Support multi-file upload for municipal receipts
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:
Carlos Escalante
2026-04-02 16:23:14 -06:00
parent 8f775e5531
commit c005956458

View File

@@ -150,10 +150,10 @@ export default function ServiciosMunicipales() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// Upload state // Upload state
const [uploadedFile, setUploadedFile] = useState<File | null>(null); const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = 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 fileInputRef = useRef<HTMLInputElement>(null);
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
@@ -205,34 +205,41 @@ export default function ServiciosMunicipales() {
: 0; : 0;
// Upload handlers // Upload handlers
const handleFile = useCallback((files: FileList | null) => { const handleFiles = useCallback((files: FileList | null) => {
if (!files) return; if (!files) return;
const pdf = Array.from(files).find((f) => f.type === 'application/pdf'); const pdfs = Array.from(files).filter((f) => f.type === 'application/pdf');
if (pdf) { if (pdfs.length > 0) {
setUploadedFile(pdf); setUploadedFiles((prev) => [...prev, ...pdfs]);
setUploadResult(null); setUploadResults([]);
} }
}, []); }, []);
const removeFile = (index: number) => {
setUploadedFiles((prev) => prev.filter((_, i) => i !== index));
};
const handleUpload = async () => { const handleUpload = async () => {
if (!uploadedFile) return; if (uploadedFiles.length === 0) return;
setIsUploading(true); setIsUploading(true);
setUploadResult(null); setUploadResults([]);
try { const results: MunicipalReceiptUploadResult[] = [];
const { data } = await uploadMunicipalReceipt(uploadedFile); for (const file of uploadedFiles) {
setUploadResult(data); try {
setUploadedFile(null); const { data } = await uploadMunicipalReceipt(file);
await loadData(); results.push(data);
} catch (err) { } catch (err) {
setUploadResult({ results.push({
imported: 0, imported: 0,
updated: 0, updated: 0,
errors: [err instanceof Error ? err.message : 'Error al subir archivo'], errors: [`${file.name}: ${err instanceof Error ? err.message : 'Error al subir'}`],
receipt: null, receipt: null,
}); });
} finally { }
setIsUploading(false);
} }
setUploadResults(results);
setUploadedFiles([]);
await loadData();
setIsUploading(false);
}; };
const formatFileSize = (bytes: number): string => { const formatFileSize = (bytes: number): string => {
@@ -511,7 +518,7 @@ export default function ServiciosMunicipales() {
<section className="space-y-3"> <section className="space-y-3">
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider"> <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" /> <FileText className="w-4 h-4" />
Subir Recibo Subir Recibos
</h2> </h2>
<Card> <Card>
<CardContent className="p-6 space-y-4"> <CardContent className="p-6 space-y-4">
@@ -519,12 +526,12 @@ export default function ServiciosMunicipales() {
<div <div
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }} onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }} 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()} onClick={() => fileInputRef.current?.click()}
role="button" role="button"
tabIndex={0} tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && fileInputRef.current?.click()} onKeyDown={(e) => e.key === 'Enter' && fileInputRef.current?.click()}
aria-label="Seleccionar archivo PDF" aria-label="Seleccionar archivos PDF"
className={[ className={[
'border-2 border-dashed rounded-lg p-8', 'border-2 border-dashed rounded-lg p-8',
'flex flex-col items-center justify-center gap-3', 'flex flex-col items-center justify-center gap-3',
@@ -541,11 +548,11 @@ export default function ServiciosMunicipales() {
<div className="text-center"> <div className="text-center">
<p className="text-sm font-medium"> <p className="text-sm font-medium">
{isDragging {isDragging
? 'Suelta el archivo aquí' ? 'Suelta los archivos aquí'
: 'Arrastra el PDF aquí o toca para seleccionar'} : 'Arrastra PDFs aquí o toca para seleccionar'}
</p> </p>
<p className="text-xs text-muted-foreground mt-1"> <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> </p>
</div> </div>
</div> </div>
@@ -554,34 +561,48 @@ export default function ServiciosMunicipales() {
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept="application/pdf" accept="application/pdf"
multiple
className="hidden" className="hidden"
onChange={(e) => handleFile(e.target.files)} onChange={(e) => handleFiles(e.target.files)}
/> />
{/* Selected file */} {/* File list */}
{uploadedFile && ( {uploadedFiles.length > 0 && (
<div className="flex items-center justify-between gap-3 p-2.5 rounded-lg bg-muted/50 border border-border"> <div className="space-y-2">
<div className="flex items-center gap-2.5 min-w-0"> <p className="text-sm text-muted-foreground font-medium">
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" /> {uploadedFiles.length}{' '}
<div className="min-w-0"> {uploadedFiles.length === 1 ? 'archivo en cola' : 'archivos en cola'}
<p className="text-sm font-medium truncate">{uploadedFile.name}</p> </p>
<p className="text-xs text-muted-foreground">{formatFileSize(uploadedFile.size)}</p> <div className="space-y-1.5">
</div> {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> </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> </div>
)} )}
{/* Submit */} {/* Submit */}
<Button <Button
onClick={handleUpload} onClick={handleUpload}
disabled={!uploadedFile || isUploading} disabled={uploadedFiles.length === 0 || isUploading}
className="w-full" className="w-full"
> >
{isUploading ? ( {isUploading ? (
@@ -589,43 +610,48 @@ export default function ServiciosMunicipales() {
) : ( ) : (
<Upload className="w-4 h-4 mr-2" /> <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> </Button>
{/* Upload result */} {/* Upload results */}
{uploadResult && ( {uploadResults.length > 0 && (
<div <div className="space-y-2">
className={[ {uploadResults.map((result, i) => (
'rounded-lg border p-4 space-y-2', <div
uploadResult.errors.length > 0 && !uploadResult.receipt key={i}
? 'border-destructive/50 bg-destructive/5' className={[
: 'border-emerald-500/50 bg-emerald-500/5', 'rounded-lg border p-3 space-y-1',
].join(' ')} result.errors.length > 0 && !result.receipt
> ? 'border-destructive/50 bg-destructive/5'
<div className="flex items-center gap-2"> : 'border-emerald-500/50 bg-emerald-500/5',
{uploadResult.receipt ? ( ].join(' ')}
<CheckCircle2 className="w-4 h-4 text-emerald-500" /> >
) : ( <div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-amber-500" /> {result.receipt ? (
)} <CheckCircle2 className="w-4 h-4 text-emerald-500 flex-shrink-0" />
<span className="text-sm font-medium"> ) : (
{uploadResult.imported > 0 && 'Recibo importado'} <AlertTriangle className="w-4 h-4 text-amber-500 flex-shrink-0" />
{uploadResult.updated > 0 && 'Recibo actualizado'} )}
{!uploadResult.receipt && 'Error al procesar'} <span className="text-sm font-medium">
</span> {result.imported > 0 && 'Recibo importado'}
</div> {result.updated > 0 && 'Recibo actualizado'}
{uploadResult.receipt && ( {!result.receipt && 'Error al procesar'}
<div className="flex items-center justify-between text-xs"> </span>
<span className="text-muted-foreground"> </div>
{periodLabel(uploadResult.receipt.period)} {result.receipt && (
</span> <div className="flex items-center justify-between text-xs pl-6">
<span data-sensitive className="font-mono font-medium"> <span className="text-muted-foreground">
{formatCRC(uploadResult.receipt.total)} {periodLabel(result.receipt.period)}
</span> </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> </div>
)}
{uploadResult.errors.map((err, i) => (
<p key={i} className="text-xs text-destructive">{err}</p>
))} ))}
</div> </div>
)} )}