mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 14:08:47 +02:00
Add municipal receipt module and convert navbar to sidebar
All checks were successful
Deploy to VPS / deploy (push) Successful in 58s
All checks were successful
Deploy to VPS / deploy (push) Successful in 58s
- New module: Municipalidad de Belén receipt extraction via pdftotext+regex - Backend: MunicipalReceipt + WaterMeterReading models, upload/list/detail/water-consumption endpoints - Auto-creates budget Transaction on upload (duplicate-safe via reference) - Frontend: ServiciosMunicipales page with summary cards, water consumption bar chart, receipt history, PDF upload - Convert top navbar to left sidebar with section headers (General, Finanzas, Servicios) - Desktop: fixed 220px sidebar, mobile: sheet overlay - Grouped nav: Dashboard | Presupuesto, Salarios, Pensiones, Analytics | Municipalidad Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
637
frontend/src/pages/ServiciosMunicipales.tsx
Normal file
637
frontend/src/pages/ServiciosMunicipales.tsx
Normal file
@@ -0,0 +1,637 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import {
|
||||
Droplets,
|
||||
Upload,
|
||||
X,
|
||||
FileText,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
Receipt,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
CalendarDays,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion';
|
||||
import {
|
||||
uploadMunicipalReceipt,
|
||||
getMunicipalReceipts,
|
||||
getWaterConsumption,
|
||||
type MunicipalReceipt,
|
||||
type MunicipalReceiptUploadResult,
|
||||
type WaterMeterReading,
|
||||
} from '@/api';
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const METER_COLORS: Record<string, string> = {
|
||||
'7335': '#3b82f6',
|
||||
'7345': '#10b981',
|
||||
'9345': '#f59e0b',
|
||||
};
|
||||
|
||||
const MONTH_NAMES_ES = [
|
||||
'Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun',
|
||||
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic',
|
||||
];
|
||||
|
||||
const DEFAULT_METER_COLOR = '#8b5cf6';
|
||||
|
||||
// ─── Utilities ───────────────────────────────────────────────────────────────
|
||||
|
||||
const formatCRC = (amount: number): string =>
|
||||
`₡${amount.toLocaleString('es-CR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
|
||||
function periodLabel(period: string): string {
|
||||
const [yearStr, monthStr] = period.split('-');
|
||||
const monthIdx = parseInt(monthStr, 10) - 1;
|
||||
return `${MONTH_NAMES_ES[monthIdx]} ${yearStr.slice(2)}`;
|
||||
}
|
||||
|
||||
function meterColor(meterId: string): string {
|
||||
return METER_COLORS[meterId] ?? DEFAULT_METER_COLOR;
|
||||
}
|
||||
|
||||
// ─── Chart Data ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface ChartPoint {
|
||||
period: string;
|
||||
label: string;
|
||||
[meterId: string]: number | string;
|
||||
}
|
||||
|
||||
function buildChartData(readings: WaterMeterReading[]): ChartPoint[] {
|
||||
const byPeriod = new Map<string, Record<string, number>>();
|
||||
|
||||
for (const r of readings) {
|
||||
if (!byPeriod.has(r.period)) byPeriod.set(r.period, {});
|
||||
byPeriod.get(r.period)![r.meter_id] = r.consumption_m3;
|
||||
}
|
||||
|
||||
const sorted = Array.from(byPeriod.keys()).sort();
|
||||
return sorted.map((period) => ({
|
||||
period,
|
||||
label: periodLabel(period),
|
||||
...byPeriod.get(period)!,
|
||||
}));
|
||||
}
|
||||
|
||||
function getMeterIds(readings: WaterMeterReading[]): string[] {
|
||||
return [...new Set(readings.map((r) => r.meter_id))].sort();
|
||||
}
|
||||
|
||||
// ─── Chart Tooltip ───────────────────────────────────────────────────────────
|
||||
|
||||
interface TooltipEntry {
|
||||
name: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: TooltipEntry[];
|
||||
label?: string;
|
||||
}) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const total = payload.reduce((sum, e) => sum + e.value, 0);
|
||||
return (
|
||||
<div className="bg-popover border border-border rounded-lg p-3 shadow-lg text-sm min-w-[180px]">
|
||||
<p className="font-semibold mb-2 text-foreground">{label}</p>
|
||||
{payload.map((entry) => (
|
||||
<div key={entry.name} className="flex items-center justify-between gap-4 py-0.5">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||
style={{ background: entry.color }}
|
||||
/>
|
||||
<span className="text-muted-foreground">Medidor {entry.name}</span>
|
||||
</span>
|
||||
<span className="font-mono font-medium text-foreground">{entry.value} m³</span>
|
||||
</div>
|
||||
))}
|
||||
<Separator className="my-1.5" />
|
||||
<div className="flex justify-between text-xs font-medium">
|
||||
<span className="text-muted-foreground">Total</span>
|
||||
<span className="font-mono">{total} m³</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ──────────────────────────────────────────────────────────
|
||||
|
||||
export default function ServiciosMunicipales() {
|
||||
const [receipts, setReceipts] = useState<MunicipalReceipt[]>([]);
|
||||
const [waterReadings, setWaterReadings] = useState<WaterMeterReading[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Upload state
|
||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadResult, setUploadResult] = useState<MunicipalReceiptUploadResult | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [receiptsRes, waterRes] = await Promise.all([
|
||||
getMunicipalReceipts(),
|
||||
getWaterConsumption(24),
|
||||
]);
|
||||
setReceipts(receiptsRes.data);
|
||||
setWaterReadings(waterRes.data);
|
||||
} catch {
|
||||
// API not available yet
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// Derived data
|
||||
const chartData = useMemo(() => buildChartData(waterReadings), [waterReadings]);
|
||||
const meterIds = useMemo(() => getMeterIds(waterReadings), [waterReadings]);
|
||||
|
||||
const latestReceipt = receipts[0] ?? null;
|
||||
|
||||
const avgMonthly = useMemo(() => {
|
||||
if (receipts.length === 0) return 0;
|
||||
const sum = receipts.reduce((s, r) => s + r.total, 0);
|
||||
return sum / receipts.length;
|
||||
}, [receipts]);
|
||||
|
||||
const currentConsumption = useMemo(() => {
|
||||
if (chartData.length === 0) return { total: 0, prev: 0 };
|
||||
const latest = chartData[chartData.length - 1];
|
||||
const prev = chartData.length >= 2 ? chartData[chartData.length - 2] : null;
|
||||
const sumValues = (point: ChartPoint) =>
|
||||
meterIds.reduce((s, id) => s + ((point[id] as number) || 0), 0);
|
||||
return {
|
||||
total: sumValues(latest),
|
||||
prev: prev ? sumValues(prev) : 0,
|
||||
};
|
||||
}, [chartData, meterIds]);
|
||||
|
||||
const consumptionDelta = currentConsumption.prev > 0
|
||||
? currentConsumption.total - currentConsumption.prev
|
||||
: 0;
|
||||
|
||||
// Upload handlers
|
||||
const handleFile = 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 handleUpload = async () => {
|
||||
if (!uploadedFile) 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);
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* ── Page Header ─────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Droplets className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold font-heading">Servicios Municipales</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Municipalidad de Belén — recibos y consumo de agua
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Summary Cards ───────────────────────────────────────────────── */}
|
||||
<section className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<Receipt className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
|
||||
Último recibo
|
||||
</span>
|
||||
</div>
|
||||
<p data-sensitive className="text-lg font-bold font-mono">
|
||||
{latestReceipt ? formatCRC(latestReceipt.total) : '—'}
|
||||
</p>
|
||||
{latestReceipt && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{periodLabel(latestReceipt.period)}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<CalendarDays className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
|
||||
Promedio mensual
|
||||
</span>
|
||||
</div>
|
||||
<p data-sensitive className="text-lg font-bold font-mono">
|
||||
{receipts.length > 0 ? formatCRC(avgMonthly) : '—'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{receipts.length} {receipts.length === 1 ? 'recibo' : 'recibos'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<Droplets className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
|
||||
Consumo actual
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold font-mono">
|
||||
{currentConsumption.total > 0 ? `${currentConsumption.total} m³` : '—'}
|
||||
</p>
|
||||
{chartData.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{periodLabel(chartData[chartData.length - 1].period)}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
{consumptionDelta <= 0 ? (
|
||||
<TrendingDown className="w-3.5 h-3.5 text-emerald-500" />
|
||||
) : (
|
||||
<TrendingUp className="w-3.5 h-3.5 text-amber-500" />
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
|
||||
Variación
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className={`text-lg font-bold font-mono ${
|
||||
consumptionDelta <= 0 ? 'text-emerald-500' : 'text-amber-500'
|
||||
}`}
|
||||
>
|
||||
{currentConsumption.prev > 0
|
||||
? `${consumptionDelta > 0 ? '+' : ''}${consumptionDelta} m³`
|
||||
: '—'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
vs mes anterior
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* ── Water Consumption Chart ─────────────────────────────────────── */}
|
||||
{chartData.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
|
||||
<Droplets className="w-4 h-4" />
|
||||
Consumo de Agua (m³)
|
||||
</h2>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<BarChart data={chartData} margin={{ top: 4, right: 8, left: 8, bottom: 4 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }}
|
||||
axisLine={{ stroke: 'var(--border)' }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={32}
|
||||
unit=" m³"
|
||||
/>
|
||||
<Tooltip content={<ChartTooltipContent />} />
|
||||
<Legend
|
||||
formatter={(value: string) => (
|
||||
<span style={{ fontSize: 12, color: 'var(--foreground)' }}>
|
||||
Medidor {value}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
{meterIds.map((id) => (
|
||||
<Bar
|
||||
key={id}
|
||||
dataKey={id}
|
||||
name={id}
|
||||
fill={meterColor(id)}
|
||||
radius={[3, 3, 0, 0]}
|
||||
maxBarSize={32}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Receipt History ──────────────────────────────────────────────── */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
|
||||
<Receipt className="w-4 h-4" />
|
||||
Historial de Recibos
|
||||
</h2>
|
||||
{loading && receipts.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-8 flex items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
||||
Cargando...
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : receipts.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
<Receipt className="w-8 h-8 mx-auto mb-2 opacity-40" />
|
||||
<p className="text-sm">No hay recibos aún. Sube un PDF para comenzar.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Accordion>
|
||||
{receipts.map((receipt) => (
|
||||
<AccordionItem key={receipt.id} value={String(receipt.id)}>
|
||||
<AccordionTrigger className="px-4 py-3 hover:no-underline">
|
||||
<div className="flex items-center justify-between w-full pr-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{periodLabel(receipt.period)}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground hidden sm:inline">
|
||||
Vence{' '}
|
||||
{new Date(receipt.due_date).toLocaleDateString('es-CR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<span data-sensitive className="font-mono font-bold text-sm">
|
||||
{formatCRC(receipt.total)}
|
||||
</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-4 pb-4">
|
||||
<div className="space-y-3">
|
||||
{/* Charges breakdown */}
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/50">
|
||||
<th className="text-left px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Detalle
|
||||
</th>
|
||||
<th className="text-right px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Monto
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{receipt.raw_charges.map((charge, i) => (
|
||||
<tr key={i} className="border-t border-border">
|
||||
<td className="px-3 py-2 text-foreground">{charge.detail}</td>
|
||||
<td data-sensitive className="px-3 py-2 text-right font-mono">
|
||||
{formatCRC(charge.amount)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
{receipt.interests > 0 && (
|
||||
<tr className="border-t border-border bg-muted/30">
|
||||
<td className="px-3 py-1.5 text-xs text-muted-foreground">Intereses</td>
|
||||
<td data-sensitive className="px-3 py-1.5 text-right font-mono text-xs">
|
||||
{formatCRC(receipt.interests)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{receipt.iva > 0 && (
|
||||
<tr className="border-t border-border bg-muted/30">
|
||||
<td className="px-3 py-1.5 text-xs text-muted-foreground">IVA</td>
|
||||
<td data-sensitive className="px-3 py-1.5 text-right font-mono text-xs">
|
||||
{formatCRC(receipt.iva)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr className="border-t-2 border-border font-bold">
|
||||
<td className="px-3 py-2">Total</td>
|
||||
<td data-sensitive className="px-3 py-2 text-right font-mono">
|
||||
{formatCRC(receipt.total)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Meta info */}
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>Cuenta: {receipt.account}</span>
|
||||
<span>Finca: {receipt.finca}</span>
|
||||
<span>
|
||||
Fecha:{' '}
|
||||
{new Date(receipt.receipt_date).toLocaleDateString('es-CR')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── PDF Upload ──────────────────────────────────────────────────── */}
|
||||
<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
|
||||
</h2>
|
||||
<Card>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
|
||||
onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }}
|
||||
onDrop={(e) => { e.preventDefault(); setIsDragging(false); handleFile(e.dataTransfer.files); }}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === 'Enter' && fileInputRef.current?.click()}
|
||||
aria-label="Seleccionar archivo PDF"
|
||||
className={[
|
||||
'border-2 border-dashed rounded-lg p-8',
|
||||
'flex flex-col items-center justify-center gap-3',
|
||||
'cursor-pointer transition-colors select-none',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
isDragging
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/50 hover:bg-muted/30',
|
||||
].join(' ')}
|
||||
>
|
||||
<Upload
|
||||
className={`w-8 h-8 ${isDragging ? 'text-primary' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium">
|
||||
{isDragging
|
||||
? 'Suelta el archivo aquí'
|
||||
: 'Arrastra el PDF aquí o toca para seleccionar'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Solo archivos PDF · Recibo Municipal de Belén
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
className="hidden"
|
||||
onChange={(e) => handleFile(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>
|
||||
</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}
|
||||
className="w-full"
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{isUploading ? 'Extrayendo datos...' : 'Subir 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>
|
||||
</div>
|
||||
)}
|
||||
{uploadResult.errors.map((err, i) => (
|
||||
<p key={i} className="text-xs text-destructive">{err}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user