Add clickable legend toggles and charge trend chart
All checks were successful
Deploy to VPS / deploy (push) Successful in 14s

- 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) <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-04-02 16:38:30 -06:00
parent c005956458
commit 37e04273b9

View File

@@ -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<string>();
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<WaterMeterReading[]>([]);
const [loading, setLoading] = useState(true);
// Chart visibility state
const [hiddenMeters, setHiddenMeters] = useState<Set<string>>(new Set());
const [hiddenCharges, setHiddenCharges] = useState<Set<string>>(new Set());
// Upload state
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
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() {
/>
<Tooltip content={<ChartTooltipContent />} />
<Legend
onClick={(e) => {
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) => (
<span style={{ fontSize: 12, color: 'var(--foreground)' }}>
<span style={{
fontSize: 12,
color: hiddenMeters.has(value) ? 'var(--muted-foreground)' : 'var(--foreground)',
opacity: hiddenMeters.has(value) ? 0.4 : 1,
cursor: 'pointer',
}}>
Medidor {value}
</span>
)}
@@ -389,6 +443,7 @@ export default function ServiciosMunicipales() {
fill={meterColor(id)}
radius={[3, 3, 0, 0]}
maxBarSize={32}
hide={hiddenMeters.has(id)}
/>
))}
</BarChart>
@@ -398,6 +453,95 @@ export default function ServiciosMunicipales() {
</section>
)}
{/* ── Charge Trend Chart ─────────────────────────────────────────── */}
{chargeTrendData.length > 1 && (
<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" />
Evolución de Cargos
</h2>
<Card>
<CardContent className="p-4">
<ResponsiveContainer width="100%" height={320}>
<LineChart data={chargeTrendData} 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
tickFormatter={(v: number) => v >= 1000 ? `${(v / 1000).toFixed(0)}k` : `${v}`}
tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }}
axisLine={false}
tickLine={false}
width={52}
/>
<Tooltip
content={({ active, payload, label }) => {
if (!active || !payload?.length) return null;
return (
<div className="bg-popover border border-border rounded-lg p-3 shadow-lg text-sm min-w-[220px]">
<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 text-xs">{entry.name}</span>
</span>
<span data-sensitive className="font-mono font-medium text-foreground text-xs">
{formatCRC(entry.value as number)}
</span>
</div>
))}
</div>
);
}}
/>
<Legend
onClick={(e) => {
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) => (
<span style={{
fontSize: 11,
color: hiddenCharges.has(value) ? 'var(--muted-foreground)' : 'var(--foreground)',
opacity: hiddenCharges.has(value) ? 0.4 : 1,
cursor: 'pointer',
}}>{value}</span>
)}
wrapperStyle={{ fontSize: 11 }}
/>
{chargeNames.map((name, i) => (
<Line
key={name}
type="monotone"
dataKey={name}
name={name}
stroke={CHARGE_COLORS[i % CHARGE_COLORS.length]}
strokeWidth={2}
dot={{ r: 3 }}
activeDot={{ r: 5 }}
connectNulls
hide={hiddenCharges.has(name)}
/>
))}
</LineChart>
</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">