mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 08:48:48 +02:00
Add clickable legend toggles and charge trend chart
All checks were successful
Deploy to VPS / deploy (push) Successful in 14s
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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user