mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 14:08:47 +02:00
Support multi-file upload for municipal receipts
All checks were successful
Deploy to VPS / deploy (push) Successful in 15s
All checks were successful
Deploy to VPS / deploy (push) Successful in 15s
Upload panel now accepts multiple PDFs at once (drag-drop or file picker), shows a file queue with individual remove buttons, and displays per-file results after processing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -150,10 +150,10 @@ export default function ServiciosMunicipales() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Upload state
|
||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
||||
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadResult, setUploadResult] = useState<MunicipalReceiptUploadResult | null>(null);
|
||||
const [uploadResults, setUploadResults] = useState<MunicipalReceiptUploadResult[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
@@ -205,34 +205,41 @@ export default function ServiciosMunicipales() {
|
||||
: 0;
|
||||
|
||||
// Upload handlers
|
||||
const handleFile = useCallback((files: FileList | null) => {
|
||||
const handleFiles = useCallback((files: FileList | null) => {
|
||||
if (!files) return;
|
||||
const pdf = Array.from(files).find((f) => f.type === 'application/pdf');
|
||||
if (pdf) {
|
||||
setUploadedFile(pdf);
|
||||
setUploadResult(null);
|
||||
const pdfs = Array.from(files).filter((f) => f.type === 'application/pdf');
|
||||
if (pdfs.length > 0) {
|
||||
setUploadedFiles((prev) => [...prev, ...pdfs]);
|
||||
setUploadResults([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setUploadedFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!uploadedFile) return;
|
||||
if (uploadedFiles.length === 0) return;
|
||||
setIsUploading(true);
|
||||
setUploadResult(null);
|
||||
try {
|
||||
const { data } = await uploadMunicipalReceipt(uploadedFile);
|
||||
setUploadResult(data);
|
||||
setUploadedFile(null);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
setUploadResult({
|
||||
imported: 0,
|
||||
updated: 0,
|
||||
errors: [err instanceof Error ? err.message : 'Error al subir archivo'],
|
||||
receipt: null,
|
||||
});
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setUploadResults([]);
|
||||
const results: MunicipalReceiptUploadResult[] = [];
|
||||
for (const file of uploadedFiles) {
|
||||
try {
|
||||
const { data } = await uploadMunicipalReceipt(file);
|
||||
results.push(data);
|
||||
} catch (err) {
|
||||
results.push({
|
||||
imported: 0,
|
||||
updated: 0,
|
||||
errors: [`${file.name}: ${err instanceof Error ? err.message : 'Error al subir'}`],
|
||||
receipt: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
setUploadResults(results);
|
||||
setUploadedFiles([]);
|
||||
await loadData();
|
||||
setIsUploading(false);
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
@@ -511,7 +518,7 @@ export default function ServiciosMunicipales() {
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
|
||||
<FileText className="w-4 h-4" />
|
||||
Subir Recibo
|
||||
Subir Recibos
|
||||
</h2>
|
||||
<Card>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
@@ -519,12 +526,12 @@ export default function ServiciosMunicipales() {
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
|
||||
onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }}
|
||||
onDrop={(e) => { e.preventDefault(); setIsDragging(false); handleFile(e.dataTransfer.files); }}
|
||||
onDrop={(e) => { e.preventDefault(); setIsDragging(false); handleFiles(e.dataTransfer.files); }}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === 'Enter' && fileInputRef.current?.click()}
|
||||
aria-label="Seleccionar archivo PDF"
|
||||
aria-label="Seleccionar archivos PDF"
|
||||
className={[
|
||||
'border-2 border-dashed rounded-lg p-8',
|
||||
'flex flex-col items-center justify-center gap-3',
|
||||
@@ -541,11 +548,11 @@ export default function ServiciosMunicipales() {
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium">
|
||||
{isDragging
|
||||
? 'Suelta el archivo aquí'
|
||||
: 'Arrastra el PDF aquí o toca para seleccionar'}
|
||||
? 'Suelta los archivos aquí'
|
||||
: 'Arrastra PDFs aquí o toca para seleccionar'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Solo archivos PDF · Recibo Municipal de Belén
|
||||
Solo archivos PDF · Múltiples archivos soportados
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -554,34 +561,48 @@ export default function ServiciosMunicipales() {
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => handleFile(e.target.files)}
|
||||
onChange={(e) => handleFiles(e.target.files)}
|
||||
/>
|
||||
|
||||
{/* Selected file */}
|
||||
{uploadedFile && (
|
||||
<div className="flex items-center justify-between gap-3 p-2.5 rounded-lg bg-muted/50 border border-border">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{uploadedFile.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatFileSize(uploadedFile.size)}</p>
|
||||
</div>
|
||||
{/* File list */}
|
||||
{uploadedFiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground font-medium">
|
||||
{uploadedFiles.length}{' '}
|
||||
{uploadedFiles.length === 1 ? 'archivo en cola' : 'archivos en cola'}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{uploadedFiles.map((file, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between gap-3 p-2.5 rounded-lg bg-muted/50 border border-border"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{file.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatFileSize(file.size)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeFile(i)}
|
||||
className="flex-shrink-0 p-1 rounded hover:bg-destructive/10 hover:text-destructive transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={`Eliminar ${file.name}`}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setUploadedFile(null)}
|
||||
className="flex-shrink-0 p-1 rounded hover:bg-destructive/10 hover:text-destructive transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={`Eliminar ${uploadedFile.name}`}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={!uploadedFile || isUploading}
|
||||
disabled={uploadedFiles.length === 0 || isUploading}
|
||||
className="w-full"
|
||||
>
|
||||
{isUploading ? (
|
||||
@@ -589,43 +610,48 @@ export default function ServiciosMunicipales() {
|
||||
) : (
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{isUploading ? 'Extrayendo datos...' : 'Subir Recibo'}
|
||||
{isUploading ? 'Extrayendo datos...' : `Subir ${uploadedFiles.length > 1 ? `${uploadedFiles.length} Recibos` : 'Recibo'}`}
|
||||
</Button>
|
||||
|
||||
{/* Upload result */}
|
||||
{uploadResult && (
|
||||
<div
|
||||
className={[
|
||||
'rounded-lg border p-4 space-y-2',
|
||||
uploadResult.errors.length > 0 && !uploadResult.receipt
|
||||
? 'border-destructive/50 bg-destructive/5'
|
||||
: 'border-emerald-500/50 bg-emerald-500/5',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{uploadResult.receipt ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-500" />
|
||||
) : (
|
||||
<AlertTriangle className="w-4 h-4 text-amber-500" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{uploadResult.imported > 0 && 'Recibo importado'}
|
||||
{uploadResult.updated > 0 && 'Recibo actualizado'}
|
||||
{!uploadResult.receipt && 'Error al procesar'}
|
||||
</span>
|
||||
</div>
|
||||
{uploadResult.receipt && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{periodLabel(uploadResult.receipt.period)}
|
||||
</span>
|
||||
<span data-sensitive className="font-mono font-medium">
|
||||
{formatCRC(uploadResult.receipt.total)}
|
||||
</span>
|
||||
{/* Upload results */}
|
||||
{uploadResults.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{uploadResults.map((result, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={[
|
||||
'rounded-lg border p-3 space-y-1',
|
||||
result.errors.length > 0 && !result.receipt
|
||||
? 'border-destructive/50 bg-destructive/5'
|
||||
: 'border-emerald-500/50 bg-emerald-500/5',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{result.receipt ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-500 flex-shrink-0" />
|
||||
) : (
|
||||
<AlertTriangle className="w-4 h-4 text-amber-500 flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{result.imported > 0 && 'Recibo importado'}
|
||||
{result.updated > 0 && 'Recibo actualizado'}
|
||||
{!result.receipt && 'Error al procesar'}
|
||||
</span>
|
||||
</div>
|
||||
{result.receipt && (
|
||||
<div className="flex items-center justify-between text-xs pl-6">
|
||||
<span className="text-muted-foreground">
|
||||
{periodLabel(result.receipt.period)}
|
||||
</span>
|
||||
<span data-sensitive className="font-mono font-medium">
|
||||
{formatCRC(result.receipt.total)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{result.errors.map((err, j) => (
|
||||
<p key={j} className="text-xs text-destructive pl-6">{err}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{uploadResult.errors.map((err, i) => (
|
||||
<p key={i} className="text-xs text-destructive">{err}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user