Migrate Analytics, Salarios, Pensions, ServiciosMunicipales, Proyecciones to queries

Every page now goes through TanStack Query with cancellation,
placeholder data on filter changes, and a visible ErrorState with retry
instead of console.error/silent-empty rendering (FE-02, UX-02).
Analytics keeps the trend query on its own key so cycle changes don't
refetch it. Pensions/Servicios upload-triggered refreshes are bounded
by the api-level 30s timeout (FE-05); pension upload results cap at 10
rows with an overflow note (FE-19). Note: the Pensions ROI fallback
already guarded short chart data — FE-04 was already handled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-06-10 17:23:52 -06:00
parent 65dffdac06
commit 7a1733bf7e
5 changed files with 139 additions and 88 deletions

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { import {
PieChart, PieChart,
Pie, Pie,
@@ -13,6 +14,7 @@ import {
import { BarChart3 } from 'lucide-react'; import { BarChart3 } from 'lucide-react';
import api from '@/lib/api'; import api from '@/lib/api';
import ErrorState from '@/components/ErrorState';
import BillingCycleSelector from '@/components/BillingCycleSelector'; import BillingCycleSelector from '@/components/BillingCycleSelector';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { import {
@@ -70,29 +72,43 @@ const dailyChartConfig = {
export default function Analytics() { export default function Analytics() {
const [cycle, setCycle] = useState<{ year: number; month: number } | null>(null); const [cycle, setCycle] = useState<{ year: number; month: number } | null>(null);
const [byCategory, setByCategory] = useState<CategorySpending[]>([]); const cycleParams: Record<string, string> = cycle
const [trend, setTrend] = useState<MonthlyTrend[]>([]); ? { cycle_year: String(cycle.year), cycle_month: String(cycle.month) }
const [daily, setDaily] = useState<DailySpending[]>([]); : {};
useEffect(() => { const byCategoryQ = useQuery({
const params: Record<string, string> = {}; queryKey: ['analytics', 'by-category', cycle],
if (cycle) { queryFn: ({ signal }) =>
params.cycle_year = String(cycle.year); api
params.cycle_month = String(cycle.month); .get<CategorySpending[]>('/analytics/by-category', { params: cycleParams, signal })
} .then((r) => r.data),
placeholderData: (prev) => prev,
});
// The trend endpoint ignores the cycle filter — keep it on its own key so
// changing cycles doesn't refetch it.
const trendQ = useQuery({
queryKey: ['analytics', 'trend'],
queryFn: ({ signal }) =>
api.get<MonthlyTrend[]>('/analytics/monthly-trend', { signal }).then((r) => r.data),
});
const dailyQ = useQuery({
queryKey: ['analytics', 'daily', cycle],
queryFn: ({ signal }) =>
api
.get<DailySpending[]>('/analytics/daily-spending', { params: cycleParams, signal })
.then((r) => r.data),
placeholderData: (prev) => prev,
});
Promise.all([ const byCategory = byCategoryQ.data ?? [];
api.get<CategorySpending[]>('/analytics/by-category', { params }), const trend = trendQ.data ?? [];
api.get<MonthlyTrend[]>('/analytics/monthly-trend'), const daily = dailyQ.data ?? [];
api.get<DailySpending[]>('/analytics/daily-spending', { params }), const anyError = byCategoryQ.isError || trendQ.isError || dailyQ.isError;
]) const retryAll = () => {
.then(([catRes, trendRes, dailyRes]) => { byCategoryQ.refetch();
setByCategory(catRes.data); trendQ.refetch();
setTrend(trendRes.data); dailyQ.refetch();
setDaily(dailyRes.data); };
})
.catch(console.error);
}, [cycle]);
// Build dynamic chart config for pie chart // Build dynamic chart config for pie chart
const pieChartConfig = byCategory.reduce<ChartConfig>((acc, cat, i) => { const pieChartConfig = byCategory.reduce<ChartConfig>((acc, cat, i) => {
@@ -116,6 +132,13 @@ export default function Analytics() {
<BillingCycleSelector value={cycle} onChange={setCycle} /> <BillingCycleSelector value={cycle} onChange={setCycle} />
</div> </div>
{anyError && (
<ErrorState
message="No se pudieron cargar los datos de analytics"
onRetry={retryAll}
/>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Spending by Category - Donut */} {/* Spending by Category - Donut */}
<Card> <Card>

View File

@@ -1,4 +1,6 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { useState, useMemo, useCallback, useRef } from 'react';
import { useQuery } from '@tanstack/react-query';
import ErrorState from '@/components/ErrorState';
import { import {
LineChart, LineChart,
Line, Line,
@@ -238,9 +240,9 @@ function ChartTooltipContent({
// ─── Main Component ─────────────────────────────────────────────────────────── // ─── Main Component ───────────────────────────────────────────────────────────
const EMPTY_SNAPSHOTS: PensionSnapshot[] = [];
export default function Pensions() { export default function Pensions() {
const [fundSummary, setFundSummary] = useState<PensionSnapshot[]>([]);
const [allSnapshots, setAllSnapshots] = useState<PensionSnapshot[]>([]);
const [visibleFunds, setVisibleFunds] = useState<Set<FundKey>>(new Set(FUND_KEYS)); const [visibleFunds, setVisibleFunds] = useState<Set<FundKey>>(new Set(FUND_KEYS));
const [projections, setProjections] = useState<Record<FundKey, ProjectionState>>({ const [projections, setProjections] = useState<Record<FundKey, ProjectionState>>({
FCL: { contribution: 150_000, rate: 7.5, targetAge: 35 }, FCL: { contribution: 150_000, rate: 7.5, targetAge: 35 },
@@ -254,22 +256,22 @@ export default function Pensions() {
const [showManualEntry, setShowManualEntry] = useState(false); const [showManualEntry, setShowManualEntry] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const loadData = useCallback(async () => { const pensionQ = useQuery({
try { queryKey: ['pensions'],
queryFn: async () => {
const [summaryRes, snapshotsRes] = await Promise.all([ const [summaryRes, snapshotsRes] = await Promise.all([
getPensionFundSummary(), getPensionFundSummary(),
getPensionSnapshots(), getPensionSnapshots(),
]); ]);
setFundSummary(summaryRes.data); return { summary: summaryRes.data, snapshots: snapshotsRes.data };
setAllSnapshots(snapshotsRes.data); },
} catch { });
// API not available or no data yet — use defaults const fundSummary = pensionQ.data?.summary ?? EMPTY_SNAPSHOTS;
} const allSnapshots = pensionQ.data?.snapshots ?? EMPTY_SNAPSHOTS;
}, []); const loadData = useCallback(async () => {
await pensionQ.refetch();
useEffect(() => { // eslint-disable-next-line react-hooks/exhaustive-deps
loadData(); }, [pensionQ.refetch]);
}, [loadData]);
const FUNDS = useMemo(() => applySnapshots(fundSummary), [fundSummary]); const FUNDS = useMemo(() => applySnapshots(fundSummary), [fundSummary]);
@@ -392,6 +394,12 @@ export default function Pensions() {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
{pensionQ.isError && (
<ErrorState
message="No se pudieron cargar los datos de pensiones"
onRetry={() => pensionQ.refetch()}
/>
)}
{/* ── Page Header ─────────────────────────────────────────────────── */} {/* ── Page Header ─────────────────────────────────────────────────── */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center"> <div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
@@ -849,7 +857,7 @@ export default function Pensions() {
))} ))}
{uploadResult.snapshots.length > 0 && ( {uploadResult.snapshots.length > 0 && (
<div className="space-y-1 pt-1"> <div className="space-y-1 pt-1">
{uploadResult.snapshots.map((snap) => ( {uploadResult.snapshots.slice(0, 10).map((snap) => (
<div key={snap.id} className="flex items-center justify-between text-xs"> <div key={snap.id} className="flex items-center justify-between text-xs">
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{snap.fund} · {new Date(snap.period_start).toLocaleDateString('es-CR', { month: 'short', year: '2-digit' })} {snap.fund} · {new Date(snap.period_start).toLocaleDateString('es-CR', { month: 'short', year: '2-digit' })}
@@ -859,6 +867,11 @@ export default function Pensions() {
<span data-sensitive className="font-mono font-medium">{formatCRC(snap.saldo_final)}</span> <span data-sensitive className="font-mono font-medium">{formatCRC(snap.saldo_final)}</span>
</div> </div>
))} ))}
{uploadResult.snapshots.length > 10 && (
<p className="text-xs text-muted-foreground">
y {uploadResult.snapshots.length - 10} más
</p>
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -6,6 +6,7 @@ import { formatAmount } from '@/lib/format';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import ErrorState from '@/components/ErrorState';
import YearlyOverview from '@/components/budget/YearlyOverview'; import YearlyOverview from '@/components/budget/YearlyOverview';
const MIN_YEAR = 2026; const MIN_YEAR = 2026;
@@ -19,6 +20,8 @@ export default function Proyecciones() {
setSelectedMonth, setSelectedMonth,
projection, projection,
loading, loading,
error,
refresh,
saveBalanceOverride, saveBalanceOverride,
clearBalanceOverride, clearBalanceOverride,
} = useBudget(currentYear); } = useBudget(currentYear);
@@ -89,7 +92,10 @@ export default function Proyecciones() {
) : projection ? ( ) : projection ? (
<Card> <Card>
<CardContent className="p-0"> <CardContent className="p-0">
<YearlyOverview {error ? (
<ErrorState onRetry={refresh} />
) : (
<YearlyOverview
months={projection.months} months={projection.months}
selectedMonth={0} selectedMonth={0}
year={year} year={year}
@@ -104,6 +110,7 @@ export default function Proyecciones() {
await clearBalanceOverride(year, month); await clearBalanceOverride(year, month);
}} }}
/> />
)}
</CardContent> </CardContent>
</Card> </Card>
) : null} ) : null}

View File

@@ -1,9 +1,11 @@
import { useEffect, useMemo, useState } from 'react'; import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { type ColumnDef } from '@tanstack/react-table'; import { type ColumnDef } from '@tanstack/react-table';
import { Landmark, RefreshCw, Hash, CalendarDays, Banknote } from 'lucide-react'; import { Landmark, RefreshCw, Hash, CalendarDays, Banknote } from 'lucide-react';
import { type Transaction, type SalariosSummary, getSalarios, getSalariosSummary } from '@/lib/api'; import { type Transaction, type SalariosSummary, getSalarios, getSalariosSummary } from '@/lib/api';
import { formatAmount, formatDate } from '@/lib/format'; import { formatAmount, formatDate } from '@/lib/format';
import ErrorState from '@/components/ErrorState';
import { DataTable } from '@/components/ui/data-table'; import { DataTable } from '@/components/ui/data-table';
import { DataTableColumnHeader } from '@/components/ui/data-table-column-header'; import { DataTableColumnHeader } from '@/components/ui/data-table-column-header';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
@@ -11,27 +13,20 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
export default function Salarios() { export default function Salarios() {
const [deposits, setDeposits] = useState<Transaction[]>([]); const query = useQuery({
const [summary, setSummary] = useState<SalariosSummary | null>(null); queryKey: ['salarios'],
const [loading, setLoading] = useState(true); queryFn: async () => {
const fetchData = async () => {
setLoading(true);
try {
const [depRes, sumRes] = await Promise.all([ const [depRes, sumRes] = await Promise.all([
getSalarios({ limit: 500 }), getSalarios({ limit: 500 }),
getSalariosSummary(), getSalariosSummary(),
]); ]);
setDeposits(depRes.data); return { deposits: depRes.data, summary: sumRes.data };
setSummary(sumRes.data); },
} catch (e) { });
console.error(e); const deposits = query.data?.deposits ?? [];
} finally { const summary = query.data?.summary ?? null;
setLoading(false); const loading = query.isFetching;
} const fetchData = () => query.refetch();
};
useEffect(() => { fetchData(); }, []);
const columns = useMemo<ColumnDef<Transaction, unknown>[]>( const columns = useMemo<ColumnDef<Transaction, unknown>[]>(
() => [ () => [
@@ -146,18 +141,25 @@ export default function Salarios() {
)} )}
{/* Data table */} {/* Data table */}
<Card> {query.isError ? (
<CardContent className="p-0"> <ErrorState
<DataTable message="No se pudieron cargar los salarios"
columns={columns} onRetry={fetchData}
data={deposits} />
pagination ) : (
pageSize={25} <Card>
initialSorting={[{ id: 'date', desc: true }]} <CardContent className="p-0">
emptyMessage="No hay depósitos registrados aún." <DataTable
/> columns={columns}
</CardContent> data={deposits}
</Card> pagination
pageSize={25}
initialSorting={[{ id: 'date', desc: true }]}
emptyMessage="No hay depósitos registrados aún."
/>
</CardContent>
</Card>
)}
</div> </div>
); );
} }

View File

@@ -1,4 +1,6 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { useState, useCallback, useRef, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import ErrorState from '@/components/ErrorState';
import { import {
BarChart, BarChart,
Bar, Bar,
@@ -179,10 +181,10 @@ function ChartTooltipContent({
// ─── Main Component ────────────────────────────────────────────────────────── // ─── Main Component ──────────────────────────────────────────────────────────
const EMPTY_RECEIPTS: MunicipalReceipt[] = [];
const EMPTY_READINGS: WaterMeterReading[] = [];
export default function ServiciosMunicipales() { export default function ServiciosMunicipales() {
const [receipts, setReceipts] = useState<MunicipalReceipt[]>([]);
const [waterReadings, setWaterReadings] = useState<WaterMeterReading[]>([]);
const [loading, setLoading] = useState(true);
// Chart visibility state // Chart visibility state
const [hiddenMeters, setHiddenMeters] = useState<Set<string>>(new Set()); const [hiddenMeters, setHiddenMeters] = useState<Set<string>>(new Set());
@@ -195,25 +197,23 @@ export default function ServiciosMunicipales() {
const [uploadResults, setUploadResults] = useState<MunicipalReceiptUploadResult[]>([]); const [uploadResults, setUploadResults] = useState<MunicipalReceiptUploadResult[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const loadData = useCallback(async () => { const municipalQ = useQuery({
setLoading(true); queryKey: ['municipal'],
try { queryFn: async () => {
const [receiptsRes, waterRes] = await Promise.all([ const [receiptsRes, waterRes] = await Promise.all([
getMunicipalReceipts(), getMunicipalReceipts(),
getWaterConsumption(24), getWaterConsumption(24),
]); ]);
setReceipts(receiptsRes.data); return { receipts: receiptsRes.data, water: waterRes.data };
setWaterReadings(waterRes.data); },
} catch { });
// API not available yet const receipts = municipalQ.data?.receipts ?? EMPTY_RECEIPTS;
} finally { const waterReadings = municipalQ.data?.water ?? EMPTY_READINGS;
setLoading(false); const loading = municipalQ.isPending;
} const loadData = useCallback(async () => {
}, []); await municipalQ.refetch();
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { }, [municipalQ.refetch]);
loadData();
}, [loadData]);
// Derived data // Derived data
const chartData = useMemo(() => buildChartData(waterReadings), [waterReadings]); const chartData = useMemo(() => buildChartData(waterReadings), [waterReadings]);
@@ -291,6 +291,12 @@ export default function ServiciosMunicipales() {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
{municipalQ.isError && (
<ErrorState
message="No se pudieron cargar los recibos municipales"
onRetry={() => municipalQ.refetch()}
/>
)}
{/* ── Page Header ─────────────────────────────────────────────────── */} {/* ── Page Header ─────────────────────────────────────────────────── */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center"> <div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">