From 7a1733bf7eb35e169641be7322f55f77957aa044 Mon Sep 17 00:00:00 2001 From: Carlos Escalante Date: Wed, 10 Jun 2026 17:23:52 -0600 Subject: [PATCH] Migrate Analytics, Salarios, Pensions, ServiciosMunicipales, Proyecciones to queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/pages/Analytics.tsx | 67 ++++++++++++++------- frontend/src/pages/Pensions.tsx | 45 +++++++++----- frontend/src/pages/Proyecciones.tsx | 9 ++- frontend/src/pages/Salarios.tsx | 62 ++++++++++--------- frontend/src/pages/ServiciosMunicipales.tsx | 44 ++++++++------ 5 files changed, 139 insertions(+), 88 deletions(-) diff --git a/frontend/src/pages/Analytics.tsx b/frontend/src/pages/Analytics.tsx index a630628..65bcb3f 100644 --- a/frontend/src/pages/Analytics.tsx +++ b/frontend/src/pages/Analytics.tsx @@ -1,4 +1,5 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { PieChart, Pie, @@ -13,6 +14,7 @@ import { import { BarChart3 } from 'lucide-react'; import api from '@/lib/api'; +import ErrorState from '@/components/ErrorState'; import BillingCycleSelector from '@/components/BillingCycleSelector'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { @@ -70,29 +72,43 @@ const dailyChartConfig = { export default function Analytics() { const [cycle, setCycle] = useState<{ year: number; month: number } | null>(null); - const [byCategory, setByCategory] = useState([]); - const [trend, setTrend] = useState([]); - const [daily, setDaily] = useState([]); + const cycleParams: Record = cycle + ? { cycle_year: String(cycle.year), cycle_month: String(cycle.month) } + : {}; - useEffect(() => { - const params: Record = {}; - if (cycle) { - params.cycle_year = String(cycle.year); - params.cycle_month = String(cycle.month); - } + const byCategoryQ = useQuery({ + queryKey: ['analytics', 'by-category', cycle], + queryFn: ({ signal }) => + api + .get('/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('/analytics/monthly-trend', { signal }).then((r) => r.data), + }); + const dailyQ = useQuery({ + queryKey: ['analytics', 'daily', cycle], + queryFn: ({ signal }) => + api + .get('/analytics/daily-spending', { params: cycleParams, signal }) + .then((r) => r.data), + placeholderData: (prev) => prev, + }); - Promise.all([ - api.get('/analytics/by-category', { params }), - api.get('/analytics/monthly-trend'), - api.get('/analytics/daily-spending', { params }), - ]) - .then(([catRes, trendRes, dailyRes]) => { - setByCategory(catRes.data); - setTrend(trendRes.data); - setDaily(dailyRes.data); - }) - .catch(console.error); - }, [cycle]); + const byCategory = byCategoryQ.data ?? []; + const trend = trendQ.data ?? []; + const daily = dailyQ.data ?? []; + const anyError = byCategoryQ.isError || trendQ.isError || dailyQ.isError; + const retryAll = () => { + byCategoryQ.refetch(); + trendQ.refetch(); + dailyQ.refetch(); + }; // Build dynamic chart config for pie chart const pieChartConfig = byCategory.reduce((acc, cat, i) => { @@ -116,6 +132,13 @@ export default function Analytics() { + {anyError && ( + + )} +
{/* Spending by Category - Donut */} diff --git a/frontend/src/pages/Pensions.tsx b/frontend/src/pages/Pensions.tsx index f536db6..8f57f57 100644 --- a/frontend/src/pages/Pensions.tsx +++ b/frontend/src/pages/Pensions.tsx @@ -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 { LineChart, Line, @@ -238,9 +240,9 @@ function ChartTooltipContent({ // ─── Main Component ─────────────────────────────────────────────────────────── +const EMPTY_SNAPSHOTS: PensionSnapshot[] = []; + export default function Pensions() { - const [fundSummary, setFundSummary] = useState([]); - const [allSnapshots, setAllSnapshots] = useState([]); const [visibleFunds, setVisibleFunds] = useState>(new Set(FUND_KEYS)); const [projections, setProjections] = useState>({ FCL: { contribution: 150_000, rate: 7.5, targetAge: 35 }, @@ -254,22 +256,22 @@ export default function Pensions() { const [showManualEntry, setShowManualEntry] = useState(false); const fileInputRef = useRef(null); - const loadData = useCallback(async () => { - try { + const pensionQ = useQuery({ + queryKey: ['pensions'], + queryFn: async () => { const [summaryRes, snapshotsRes] = await Promise.all([ getPensionFundSummary(), getPensionSnapshots(), ]); - setFundSummary(summaryRes.data); - setAllSnapshots(snapshotsRes.data); - } catch { - // API not available or no data yet — use defaults - } - }, []); - - useEffect(() => { - loadData(); - }, [loadData]); + return { summary: summaryRes.data, snapshots: snapshotsRes.data }; + }, + }); + const fundSummary = pensionQ.data?.summary ?? EMPTY_SNAPSHOTS; + const allSnapshots = pensionQ.data?.snapshots ?? EMPTY_SNAPSHOTS; + const loadData = useCallback(async () => { + await pensionQ.refetch(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pensionQ.refetch]); const FUNDS = useMemo(() => applySnapshots(fundSummary), [fundSummary]); @@ -392,6 +394,12 @@ export default function Pensions() { return (
+ {pensionQ.isError && ( + pensionQ.refetch()} + /> + )} {/* ── Page Header ─────────────────────────────────────────────────── */}
@@ -849,7 +857,7 @@ export default function Pensions() { ))} {uploadResult.snapshots.length > 0 && (
- {uploadResult.snapshots.map((snap) => ( + {uploadResult.snapshots.slice(0, 10).map((snap) => (
{snap.fund} · {new Date(snap.period_start).toLocaleDateString('es-CR', { month: 'short', year: '2-digit' })} @@ -859,6 +867,11 @@ export default function Pensions() { {formatCRC(snap.saldo_final)}
))} + {uploadResult.snapshots.length > 10 && ( +

+ … y {uploadResult.snapshots.length - 10} más +

+ )}
)}
diff --git a/frontend/src/pages/Proyecciones.tsx b/frontend/src/pages/Proyecciones.tsx index 264a9f5..014721d 100644 --- a/frontend/src/pages/Proyecciones.tsx +++ b/frontend/src/pages/Proyecciones.tsx @@ -6,6 +6,7 @@ import { formatAmount } from '@/lib/format'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; +import ErrorState from '@/components/ErrorState'; import YearlyOverview from '@/components/budget/YearlyOverview'; const MIN_YEAR = 2026; @@ -19,6 +20,8 @@ export default function Proyecciones() { setSelectedMonth, projection, loading, + error, + refresh, saveBalanceOverride, clearBalanceOverride, } = useBudget(currentYear); @@ -89,7 +92,10 @@ export default function Proyecciones() { ) : projection ? ( - + ) : ( + + )} ) : null} diff --git a/frontend/src/pages/Salarios.tsx b/frontend/src/pages/Salarios.tsx index a492919..d64d432 100644 --- a/frontend/src/pages/Salarios.tsx +++ b/frontend/src/pages/Salarios.tsx @@ -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 { Landmark, RefreshCw, Hash, CalendarDays, Banknote } from 'lucide-react'; import { type Transaction, type SalariosSummary, getSalarios, getSalariosSummary } from '@/lib/api'; import { formatAmount, formatDate } from '@/lib/format'; +import ErrorState from '@/components/ErrorState'; import { DataTable } from '@/components/ui/data-table'; import { DataTableColumnHeader } from '@/components/ui/data-table-column-header'; import { Card, CardContent } from '@/components/ui/card'; @@ -11,27 +13,20 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; export default function Salarios() { - const [deposits, setDeposits] = useState([]); - const [summary, setSummary] = useState(null); - const [loading, setLoading] = useState(true); - - const fetchData = async () => { - setLoading(true); - try { + const query = useQuery({ + queryKey: ['salarios'], + queryFn: async () => { const [depRes, sumRes] = await Promise.all([ getSalarios({ limit: 500 }), getSalariosSummary(), ]); - setDeposits(depRes.data); - setSummary(sumRes.data); - } catch (e) { - console.error(e); - } finally { - setLoading(false); - } - }; - - useEffect(() => { fetchData(); }, []); + return { deposits: depRes.data, summary: sumRes.data }; + }, + }); + const deposits = query.data?.deposits ?? []; + const summary = query.data?.summary ?? null; + const loading = query.isFetching; + const fetchData = () => query.refetch(); const columns = useMemo[]>( () => [ @@ -146,18 +141,25 @@ export default function Salarios() { )} {/* Data table */} - - - - - + {query.isError ? ( + + ) : ( + + + + + + )}
); } diff --git a/frontend/src/pages/ServiciosMunicipales.tsx b/frontend/src/pages/ServiciosMunicipales.tsx index 23696fa..6e58deb 100644 --- a/frontend/src/pages/ServiciosMunicipales.tsx +++ b/frontend/src/pages/ServiciosMunicipales.tsx @@ -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 { BarChart, Bar, @@ -179,10 +181,10 @@ function ChartTooltipContent({ // ─── Main Component ────────────────────────────────────────────────────────── +const EMPTY_RECEIPTS: MunicipalReceipt[] = []; +const EMPTY_READINGS: WaterMeterReading[] = []; + export default function ServiciosMunicipales() { - const [receipts, setReceipts] = useState([]); - const [waterReadings, setWaterReadings] = useState([]); - const [loading, setLoading] = useState(true); // Chart visibility state const [hiddenMeters, setHiddenMeters] = useState>(new Set()); @@ -195,25 +197,23 @@ export default function ServiciosMunicipales() { const [uploadResults, setUploadResults] = useState([]); const fileInputRef = useRef(null); - const loadData = useCallback(async () => { - setLoading(true); - try { + const municipalQ = useQuery({ + queryKey: ['municipal'], + queryFn: async () => { const [receiptsRes, waterRes] = await Promise.all([ getMunicipalReceipts(), getWaterConsumption(24), ]); - setReceipts(receiptsRes.data); - setWaterReadings(waterRes.data); - } catch { - // API not available yet - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - loadData(); - }, [loadData]); + return { receipts: receiptsRes.data, water: waterRes.data }; + }, + }); + const receipts = municipalQ.data?.receipts ?? EMPTY_RECEIPTS; + const waterReadings = municipalQ.data?.water ?? EMPTY_READINGS; + const loading = municipalQ.isPending; + const loadData = useCallback(async () => { + await municipalQ.refetch(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [municipalQ.refetch]); // Derived data const chartData = useMemo(() => buildChartData(waterReadings), [waterReadings]); @@ -291,6 +291,12 @@ export default function ServiciosMunicipales() { return (
+ {municipalQ.isError && ( + municipalQ.refetch()} + /> + )} {/* ── Page Header ─────────────────────────────────────────────────── */}