mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 08:28: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 {
|
import {
|
||||||
BarChart,
|
BarChart,
|
||||||
Bar,
|
Bar,
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
@@ -99,6 +101,39 @@ function getMeterIds(readings: WaterMeterReading[]): string[] {
|
|||||||
return [...new Set(readings.map((r) => r.meter_id))].sort();
|
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 ───────────────────────────────────────────────────────────
|
// ─── Chart Tooltip ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface TooltipEntry {
|
interface TooltipEntry {
|
||||||
@@ -149,6 +184,10 @@ export default function ServiciosMunicipales() {
|
|||||||
const [waterReadings, setWaterReadings] = useState<WaterMeterReading[]>([]);
|
const [waterReadings, setWaterReadings] = useState<WaterMeterReading[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
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
|
// Upload state
|
||||||
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
|
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
@@ -179,6 +218,8 @@ export default function ServiciosMunicipales() {
|
|||||||
// Derived data
|
// Derived data
|
||||||
const chartData = useMemo(() => buildChartData(waterReadings), [waterReadings]);
|
const chartData = useMemo(() => buildChartData(waterReadings), [waterReadings]);
|
||||||
const meterIds = useMemo(() => getMeterIds(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;
|
const latestReceipt = receipts[0] ?? null;
|
||||||
|
|
||||||
@@ -375,8 +416,21 @@ export default function ServiciosMunicipales() {
|
|||||||
/>
|
/>
|
||||||
<Tooltip content={<ChartTooltipContent />} />
|
<Tooltip content={<ChartTooltipContent />} />
|
||||||
<Legend
|
<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) => (
|
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}
|
Medidor {value}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -389,6 +443,7 @@ export default function ServiciosMunicipales() {
|
|||||||
fill={meterColor(id)}
|
fill={meterColor(id)}
|
||||||
radius={[3, 3, 0, 0]}
|
radius={[3, 3, 0, 0]}
|
||||||
maxBarSize={32}
|
maxBarSize={32}
|
||||||
|
hide={hiddenMeters.has(id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</BarChart>
|
</BarChart>
|
||||||
@@ -398,6 +453,95 @@ export default function ServiciosMunicipales() {
|
|||||||
</section>
|
</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 ──────────────────────────────────────────────── */}
|
{/* ── Receipt History ──────────────────────────────────────────────── */}
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
|
<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