From 37e04273b99095005e7477a14094af5a689b7dac Mon Sep 17 00:00:00 2001 From: Carlos Escalante Date: Thu, 2 Apr 2026 16:38:30 -0600 Subject: [PATCH] Add clickable legend toggles and charge trend chart - Both water consumption and charge trend charts now have clickable legends to show/hide individual series - Hidden series appear dimmed in the legend - Added line chart showing charge evolution over time (one line per charge type) Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/pages/ServiciosMunicipales.tsx | 146 +++++++++++++++++++- 1 file changed, 145 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/ServiciosMunicipales.tsx b/frontend/src/pages/ServiciosMunicipales.tsx index 513bf83..c04dac9 100644 --- a/frontend/src/pages/ServiciosMunicipales.tsx +++ b/frontend/src/pages/ServiciosMunicipales.tsx @@ -2,6 +2,8 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { BarChart, Bar, + LineChart, + Line, XAxis, YAxis, CartesianGrid, @@ -99,6 +101,39 @@ function getMeterIds(readings: WaterMeterReading[]): string[] { return [...new Set(readings.map((r) => r.meter_id))].sort(); } +// ─── Charge Trend Data ─────────────────────────────────────────────────────── + +const CHARGE_COLORS = [ + '#ef4444', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16', +]; + +interface ChargeTrendPoint { + period: string; + label: string; + [chargeDetail: string]: number | string; +} + +function buildChargeTrendData(receipts: MunicipalReceipt[]): ChargeTrendPoint[] { + const sorted = [...receipts].sort((a, b) => a.period.localeCompare(b.period)); + return sorted.map((r) => { + const point: ChargeTrendPoint = { period: r.period, label: periodLabel(r.period) }; + for (const charge of r.raw_charges) { + point[charge.detail] = charge.amount; + } + return point; + }); +} + +function getChargeNames(receipts: MunicipalReceipt[]): string[] { + const names = new Set(); + for (const r of receipts) { + for (const c of r.raw_charges) { + names.add(c.detail); + } + } + return [...names]; +} + // ─── Chart Tooltip ─────────────────────────────────────────────────────────── interface TooltipEntry { @@ -149,6 +184,10 @@ export default function ServiciosMunicipales() { const [waterReadings, setWaterReadings] = useState([]); const [loading, setLoading] = useState(true); + // Chart visibility state + const [hiddenMeters, setHiddenMeters] = useState>(new Set()); + const [hiddenCharges, setHiddenCharges] = useState>(new Set()); + // Upload state const [uploadedFiles, setUploadedFiles] = useState([]); const [isDragging, setIsDragging] = useState(false); @@ -179,6 +218,8 @@ export default function ServiciosMunicipales() { // Derived data const chartData = useMemo(() => buildChartData(waterReadings), [waterReadings]); const meterIds = useMemo(() => getMeterIds(waterReadings), [waterReadings]); + const chargeTrendData = useMemo(() => buildChargeTrendData(receipts), [receipts]); + const chargeNames = useMemo(() => getChargeNames(receipts), [receipts]); const latestReceipt = receipts[0] ?? null; @@ -375,8 +416,21 @@ export default function ServiciosMunicipales() { /> } /> { + const id = e.dataKey as string; + setHiddenMeters((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + }} formatter={(value: string) => ( - + Medidor {value} )} @@ -389,6 +443,7 @@ export default function ServiciosMunicipales() { fill={meterColor(id)} radius={[3, 3, 0, 0]} maxBarSize={32} + hide={hiddenMeters.has(id)} /> ))} @@ -398,6 +453,95 @@ export default function ServiciosMunicipales() { )} + {/* ── Charge Trend Chart ─────────────────────────────────────────── */} + {chargeTrendData.length > 1 && ( +
+

+ + Evolución de Cargos +

+ + + + + + + v >= 1000 ? `₡${(v / 1000).toFixed(0)}k` : `₡${v}`} + tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }} + axisLine={false} + tickLine={false} + width={52} + /> + { + if (!active || !payload?.length) return null; + return ( +
+

{label}

+ {payload.map((entry) => ( +
+ + + {entry.name} + + + {formatCRC(entry.value as number)} + +
+ ))} +
+ ); + }} + /> + { + const name = e.dataKey as string; + setHiddenCharges((prev) => { + const next = new Set(prev); + next.has(name) ? next.delete(name) : next.add(name); + return next; + }); + }} + formatter={(value: string) => ( + {value} + )} + wrapperStyle={{ fontSize: 11 }} + /> + {chargeNames.map((name, i) => ( + + ))} +
+
+
+
+
+ )} + {/* ── Receipt History ──────────────────────────────────────────────── */}