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);
// 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);
setUploadResults([]);
const results: MunicipalReceiptUploadResult[] = [];
for (const file of uploadedFiles) {
try {
const { data } = await uploadMunicipalReceipt(uploadedFile);
setUploadResult(data);
setUploadedFile(null);
await loadData();
const { data } = await uploadMunicipalReceipt(file);
results.push(data);
} catch (err) {
setUploadResult({
results.push({
imported: 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,
});
} finally {
setIsUploading(false);
}
}
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">
{/* 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">{uploadedFile.name}</p>
<p className="text-xs text-muted-foreground">{formatFileSize(uploadedFile.size)}</p>
<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={() => setUploadedFile(null)}
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 ${uploadedFile.name}`}
aria-label={`Eliminar ${file.name}`}
>
<X className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
</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 && (
{/* Upload results */}
{uploadResults.length > 0 && (
<div className="space-y-2">
{uploadResults.map((result, i) => (
<div
key={i}
className={[
'rounded-lg border p-4 space-y-2',
uploadResult.errors.length > 0 && !uploadResult.receipt
'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">
{uploadResult.receipt ? (
<CheckCircle2 className="w-4 h-4 text-emerald-500" />
{result.receipt ? (
<CheckCircle2 className="w-4 h-4 text-emerald-500 flex-shrink-0" />
) : (
<AlertTriangle className="w-4 h-4 text-amber-500" />
<AlertTriangle className="w-4 h-4 text-amber-500 flex-shrink-0" />
)}
<span className="text-sm font-medium">
{uploadResult.imported > 0 && 'Recibo importado'}
{uploadResult.updated > 0 && 'Recibo actualizado'}
{!uploadResult.receipt && 'Error al procesar'}
{result.imported > 0 && 'Recibo importado'}
{result.updated > 0 && 'Recibo actualizado'}
{!result.receipt && 'Error al procesar'}
</span>
</div>
{uploadResult.receipt && (
<div className="flex items-center justify-between text-xs">
{result.receipt && (
<div className="flex items-center justify-between text-xs pl-6">
<span className="text-muted-foreground">
{periodLabel(uploadResult.receipt.period)}
{periodLabel(result.receipt.period)}
</span>
<span data-sensitive className="font-mono font-medium">
{formatCRC(uploadResult.receipt.total)}
{formatCRC(result.receipt.total)}
</span>
</div>
)}
{uploadResult.errors.map((err, i) => (
<p key={i} className="text-xs text-destructive">{err}</p>
{result.errors.map((err, j) => (
<p key={j} className="text-xs text-destructive pl-6">{err}</p>
))}
</div>
))}
</div>
)}