pdf: add new feature
Signed-off-by: Rodrigo Bertoldo de Sousa Martins <rodrigo.sousa.577@gmail.com>
This commit is contained in:
1 parent
a63aaeb655
commit
61a1631519
14 files changed
+3091
-1
No files matched your search
@@ -0,0 +1,658 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import axios from 'axios';
|
||||
import { toast } from 'react-toastify';
|
||||
import {
|
||||
DocumentIcon,
|
||||
CloudArrowUpIcon,
|
||||
ArrowsPointingInIcon,
|
||||
ScissorsIcon,
|
||||
ArrowsUpDownIcon,
|
||||
PhotoIcon,
|
||||
DocumentTextIcon,
|
||||
InformationCircleIcon,
|
||||
ArrowDownTrayIcon,
|
||||
ClockIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
interface OperationResult {
|
||||
operacaoId: string;
|
||||
outputPaths?: string[];
|
||||
outputPath?: string;
|
||||
processingTime?: number;
|
||||
reduction?: number;
|
||||
filesCreated?: number;
|
||||
text?: string;
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
type OperationType =
|
||||
| 'compress'
|
||||
| 'split'
|
||||
| 'merge'
|
||||
| 'pdfToImages'
|
||||
| 'imagesToPDF'
|
||||
| 'ocr'
|
||||
| 'info';
|
||||
|
||||
export const PDFManipulationPage: React.FC = () => {
|
||||
const [selectedOperation, setSelectedOperation] = useState<OperationType>('compress');
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [result, setResult] = useState<OperationResult | null>(null);
|
||||
const [history, setHistory] = useState<any[]>([]);
|
||||
|
||||
// Configurações para cada operação
|
||||
const [compressQuality, setCompressQuality] = useState(95);
|
||||
const [splitType, setSplitType] = useState<'pages' | 'range' | 'chunks'>('pages');
|
||||
const [splitPages, setSplitPages] = useState<string>('1,2,3');
|
||||
const [splitRanges, setSplitRanges] = useState<string>('1-5,6-10');
|
||||
const [chunkSize, setChunkSize] = useState(5);
|
||||
const [imageFormat, setImageFormat] = useState<'png' | 'jpg' | 'webp'>('png');
|
||||
const [imageQuality, setImageQuality] = useState(95);
|
||||
const [imageDensity, setImageDensity] = useState(300);
|
||||
const [ocrLanguage, setOcrLanguage] = useState('por');
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
setFiles((prev) => [...prev, ...acceptedFiles]);
|
||||
}, []);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
'application/pdf': ['.pdf'],
|
||||
'image/*': ['.png', '.jpg', '.jpeg', '.webp'],
|
||||
},
|
||||
});
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const clearFiles = () => {
|
||||
setFiles([]);
|
||||
setResult(null);
|
||||
};
|
||||
|
||||
const getToken = () => {
|
||||
return localStorage.getItem('token');
|
||||
};
|
||||
|
||||
const processFiles = async () => {
|
||||
if (files.length === 0) {
|
||||
toast.error('Selecione pelo menos um arquivo');
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessing(true);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => {
|
||||
formData.append('files', file);
|
||||
});
|
||||
|
||||
const token = getToken();
|
||||
const config = {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
};
|
||||
|
||||
let response;
|
||||
|
||||
switch (selectedOperation) {
|
||||
case 'compress':
|
||||
formData.append('quality', compressQuality.toString());
|
||||
response = await axios.post('/api/pdf/compress', formData, config);
|
||||
toast.success('PDFs comprimidos com sucesso!');
|
||||
break;
|
||||
|
||||
case 'split':
|
||||
formData.append('splitType', splitType);
|
||||
if (splitType === 'pages') {
|
||||
const pages = splitPages.split(',').map((p) => parseInt(p.trim()));
|
||||
formData.append('pages', JSON.stringify(pages));
|
||||
} else if (splitType === 'range') {
|
||||
const ranges = splitRanges.split(',').map((r) => {
|
||||
const [start, end] = r.trim().split('-').map(Number);
|
||||
return { start, end };
|
||||
});
|
||||
formData.append('ranges', JSON.stringify(ranges));
|
||||
} else {
|
||||
formData.append('chunkSize', chunkSize.toString());
|
||||
}
|
||||
response = await axios.post('/api/pdf/split', formData, config);
|
||||
toast.success('PDF dividido com sucesso!');
|
||||
break;
|
||||
|
||||
case 'merge':
|
||||
response = await axios.post('/api/pdf/merge', formData, config);
|
||||
toast.success('PDFs mesclados com sucesso!');
|
||||
break;
|
||||
|
||||
case 'pdfToImages':
|
||||
formData.append('format', imageFormat);
|
||||
formData.append('quality', imageQuality.toString());
|
||||
formData.append('density', imageDensity.toString());
|
||||
response = await axios.post('/api/pdf/pdf-to-images', formData, config);
|
||||
toast.success('PDF convertido em imagens!');
|
||||
break;
|
||||
|
||||
case 'imagesToPDF':
|
||||
formData.append('fitToPage', 'true');
|
||||
response = await axios.post('/api/pdf/images-to-pdf', formData, config);
|
||||
toast.success('Imagens convertidas em PDF!');
|
||||
break;
|
||||
|
||||
case 'ocr':
|
||||
formData.append('language', ocrLanguage);
|
||||
response = await axios.post('/api/pdf/ocr', formData, config);
|
||||
toast.success('OCR realizado com sucesso!');
|
||||
break;
|
||||
|
||||
case 'info':
|
||||
response = await axios.post('/api/pdf/info', formData, config);
|
||||
break;
|
||||
}
|
||||
|
||||
setResult(response.data);
|
||||
loadHistory();
|
||||
} catch (error: any) {
|
||||
console.error('Erro ao processar arquivos:', error);
|
||||
toast.error(error.response?.data?.message || 'Erro ao processar arquivos');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadHistory = async () => {
|
||||
try {
|
||||
const token = getToken();
|
||||
const response = await axios.get('/api/pdf/history', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
setHistory(response.data.operacoes || []);
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar histórico:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadFile = async (operacaoId: string, fileIndex: number) => {
|
||||
try {
|
||||
const token = getToken();
|
||||
const response = await axios.get(`/api/pdf/download/${operacaoId}/${fileIndex}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `arquivo-${fileIndex}.pdf`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
|
||||
toast.success('Download iniciado!');
|
||||
} catch (error) {
|
||||
console.error('Erro ao fazer download:', error);
|
||||
toast.error('Erro ao fazer download');
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
loadHistory();
|
||||
}, []);
|
||||
|
||||
const operations = [
|
||||
{
|
||||
id: 'compress' as OperationType,
|
||||
name: 'Comprimir PDF',
|
||||
icon: ArrowsPointingInIcon,
|
||||
description: 'Reduza o tamanho mantendo a qualidade',
|
||||
},
|
||||
{
|
||||
id: 'split' as OperationType,
|
||||
name: 'Dividir PDF',
|
||||
icon: ScissorsIcon,
|
||||
description: 'Separe páginas específicas',
|
||||
},
|
||||
{
|
||||
id: 'merge' as OperationType,
|
||||
name: 'Mesclar PDFs',
|
||||
icon: ArrowsUpDownIcon,
|
||||
description: 'Combine múltiplos PDFs',
|
||||
},
|
||||
{
|
||||
id: 'pdfToImages' as OperationType,
|
||||
name: 'PDF para Imagens',
|
||||
icon: PhotoIcon,
|
||||
description: 'Converta páginas em imagens',
|
||||
},
|
||||
{
|
||||
id: 'imagesToPDF' as OperationType,
|
||||
name: 'Imagens para PDF',
|
||||
icon: DocumentIcon,
|
||||
description: 'Crie PDF a partir de imagens',
|
||||
},
|
||||
{
|
||||
id: 'ocr' as OperationType,
|
||||
name: 'OCR',
|
||||
icon: DocumentTextIcon,
|
||||
description: 'Extraia texto de PDFs',
|
||||
},
|
||||
{
|
||||
id: 'info' as OperationType,
|
||||
name: 'Informações',
|
||||
icon: InformationCircleIcon,
|
||||
description: 'Obtenha detalhes do PDF',
|
||||
},
|
||||
];
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Manipulação de PDFs</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Ferramentas completas para trabalhar com arquivos PDF
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Seleção de operação */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4 mb-8">
|
||||
{operations.map((op) => (
|
||||
<button
|
||||
key={op.id}
|
||||
onClick={() => {
|
||||
setSelectedOperation(op.id);
|
||||
clearFiles();
|
||||
}}
|
||||
className={`p-4 rounded-lg border-2 transition-all ${
|
||||
selectedOperation === op.id
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<op.icon className="h-8 w-8 mx-auto mb-2" />
|
||||
<div className="text-sm font-medium">{op.name}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Área principal */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Upload de arquivos */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Arquivos</h2>
|
||||
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`border-2 border-dashed rounded-lg p-12 text-center cursor-pointer transition-colors ${
|
||||
isDragActive
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<CloudArrowUpIcon className="h-12 w-12 mx-auto text-gray-400 mb-4" />
|
||||
{isDragActive ? (
|
||||
<p className="text-blue-600">Solte os arquivos aqui...</p>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-gray-600 mb-2">
|
||||
Arraste e solte arquivos aqui, ou clique para selecionar
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
PDFs e imagens (PNG, JPG, WEBP)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Lista de arquivos */}
|
||||
{files.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h3 className="font-medium">
|
||||
Arquivos selecionados ({files.length})
|
||||
</h3>
|
||||
<button
|
||||
onClick={clearFiles}
|
||||
className="text-sm text-red-600 hover:text-red-700"
|
||||
>
|
||||
Limpar todos
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{files.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 rounded"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<DocumentIcon className="h-5 w-5 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{file.name}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{formatBytes(file.size)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeFile(index)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
Remover
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Configurações específicas */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Configurações</h2>
|
||||
|
||||
{selectedOperation === 'compress' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Qualidade ({compressQuality}%)
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="50"
|
||||
max="100"
|
||||
value={compressQuality}
|
||||
onChange={(e) => setCompressQuality(parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
95% recomendado para qualidade sem perda perceptível
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedOperation === 'split' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Tipo de divisão
|
||||
</label>
|
||||
<select
|
||||
value={splitType}
|
||||
onChange={(e) => setSplitType(e.target.value as any)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
>
|
||||
<option value="pages">Páginas específicas</option>
|
||||
<option value="range">Intervalos</option>
|
||||
<option value="chunks">Chunks (partes iguais)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{splitType === 'pages' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Páginas (separadas por vírgula)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={splitPages}
|
||||
onChange={(e) => setSplitPages(e.target.value)}
|
||||
placeholder="1,2,3,5,8"
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{splitType === 'range' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Intervalos (ex: 1-5,6-10)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={splitRanges}
|
||||
onChange={(e) => setSplitRanges(e.target.value)}
|
||||
placeholder="1-5,6-10,11-15"
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{splitType === 'chunks' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Páginas por chunk
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={chunkSize}
|
||||
onChange={(e) => setChunkSize(parseInt(e.target.value))}
|
||||
min="1"
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedOperation === 'pdfToImages' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Formato</label>
|
||||
<select
|
||||
value={imageFormat}
|
||||
onChange={(e) => setImageFormat(e.target.value as any)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
>
|
||||
<option value="png">PNG</option>
|
||||
<option value="jpg">JPG</option>
|
||||
<option value="webp">WEBP</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Qualidade ({imageQuality}%)
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="50"
|
||||
max="100"
|
||||
value={imageQuality}
|
||||
onChange={(e) => setImageQuality(parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
DPI ({imageDensity})
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="150"
|
||||
max="600"
|
||||
step="50"
|
||||
value={imageDensity}
|
||||
onChange={(e) => setImageDensity(parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedOperation === 'ocr' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Idioma</label>
|
||||
<select
|
||||
value={ocrLanguage}
|
||||
onChange={(e) => setOcrLanguage(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
>
|
||||
<option value="por">Português</option>
|
||||
<option value="eng">Inglês</option>
|
||||
<option value="spa">Espanhol</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={processFiles}
|
||||
disabled={processing || files.length === 0}
|
||||
className={`mt-6 w-full py-3 rounded-lg font-medium transition-colors ${
|
||||
processing || files.length === 0
|
||||
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{processing ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<svg
|
||||
className="animate-spin h-5 w-5 mr-2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
Processando...
|
||||
</span>
|
||||
) : (
|
||||
'Processar'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Resultado */}
|
||||
{result && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Resultado</h2>
|
||||
|
||||
{selectedOperation === 'compress' && result.reduction !== undefined && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-green-600 font-medium">
|
||||
Redução de {result.reduction.toFixed(2)}%
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
Tempo de processamento: {result.processingTime}ms
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(result.outputPaths || result.outputPath) && (
|
||||
<div className="mt-4">
|
||||
<p className="font-medium mb-2">Arquivos gerados:</p>
|
||||
{result.outputPaths?.map((path, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => downloadFile(result.operacaoId, index)}
|
||||
className="flex items-center space-x-2 w-full p-2 hover:bg-gray-50 rounded"
|
||||
>
|
||||
<ArrowDownTrayIcon className="h-5 w-5 text-blue-600" />
|
||||
<span className="text-sm">Arquivo {index + 1}</span>
|
||||
</button>
|
||||
))}
|
||||
{result.outputPath && (
|
||||
<button
|
||||
onClick={() => downloadFile(result.operacaoId, 0)}
|
||||
className="flex items-center space-x-2 w-full p-2 hover:bg-gray-50 rounded"
|
||||
>
|
||||
<ArrowDownTrayIcon className="h-5 w-5 text-blue-600" />
|
||||
<span className="text-sm">Download</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedOperation === 'ocr' && result.text && (
|
||||
<div className="mt-4">
|
||||
<p className="font-medium mb-2">Texto extraído:</p>
|
||||
<div className="bg-gray-50 p-4 rounded max-h-64 overflow-y-auto">
|
||||
<pre className="text-sm whitespace-pre-wrap">{result.text}</pre>
|
||||
</div>
|
||||
{result.confidence && (
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
Confiança: {result.confidence.toFixed(2)}%
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Histórico */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-white rounded-lg shadow p-6 sticky top-8">
|
||||
<h2 className="text-xl font-semibold mb-4">Histórico</h2>
|
||||
|
||||
{history.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm">Nenhuma operação ainda</p>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{history.map((op) => (
|
||||
<div key={op.id} className="border-b pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">
|
||||
{op.tipoOperacao.replace('_', ' ')}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded ${
|
||||
op.status === 'CONCLUIDO'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: op.status === 'ERRO'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}
|
||||
>
|
||||
{op.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center text-xs text-gray-500 mt-1">
|
||||
<ClockIcon className="h-3 w-3 mr-1" />
|
||||
{new Date(op.criadoEm).toLocaleString('pt-BR')}
|
||||
</div>
|
||||
{op.tempoProcessamento && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{op.tempoProcessamento}ms
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user