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 {
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<CategorySpending[]>([]);
const [trend, setTrend] = useState<MonthlyTrend[]>([]);
const [daily, setDaily] = useState<DailySpending[]>([]);
const cycleParams: Record<string, string> = cycle
? { cycle_year: String(cycle.year), cycle_month: String(cycle.month) }
: {};
useEffect(() => {
const params: Record<string, string> = {};
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<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([
api.get<CategorySpending[]>('/analytics/by-category', { params }),
api.get<MonthlyTrend[]>('/analytics/monthly-trend'),
api.get<DailySpending[]>('/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<ChartConfig>((acc, cat, i) => {
@@ -116,6 +132,13 @@ export default function Analytics() {
<BillingCycleSelector value={cycle} onChange={setCycle} />
</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">
{/* Spending by Category - Donut */}
<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 {
LineChart,
Line,
@@ -238,9 +240,9 @@ function ChartTooltipContent({
// ─── Main Component ───────────────────────────────────────────────────────────
const EMPTY_SNAPSHOTS: PensionSnapshot[] = [];
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 [projections, setProjections] = useState<Record<FundKey, ProjectionState>>({
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<HTMLInputElement>(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 (
<div className="space-y-8">
{pensionQ.isError && (
<ErrorState
message="No se pudieron cargar los datos de pensiones"
onRetry={() => pensionQ.refetch()}
/>
)}
{/* ── Page Header ─────────────────────────────────────────────────── */}
<div className="flex items-center gap-3">
<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 && (
<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">
<span className="text-muted-foreground">
{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>
</div>
))}
{uploadResult.snapshots.length > 10 && (
<p className="text-xs text-muted-foreground">
y {uploadResult.snapshots.length - 10} más
</p>
)}
</div>
)}
</div>

View File

@@ -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 ? (
<Card>
<CardContent className="p-0">
<YearlyOverview
{error ? (
<ErrorState onRetry={refresh} />
) : (
<YearlyOverview
months={projection.months}
selectedMonth={0}
year={year}
@@ -104,6 +110,7 @@ export default function Proyecciones() {
await clearBalanceOverride(year, month);
}}
/>
)}
</CardContent>
</Card>
) : 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 { 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<Transaction[]>([]);
const [summary, setSummary] = useState<SalariosSummary | null>(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<ColumnDef<Transaction, unknown>[]>(
() => [
@@ -146,18 +141,25 @@ export default function Salarios() {
)}
{/* Data table */}
<Card>
<CardContent className="p-0">
<DataTable
columns={columns}
data={deposits}
pagination
pageSize={25}
initialSorting={[{ id: 'date', desc: true }]}
emptyMessage="No hay depósitos registrados aún."
/>
</CardContent>
</Card>
{query.isError ? (
<ErrorState
message="No se pudieron cargar los salarios"
onRetry={fetchData}
/>
) : (
<Card>
<CardContent className="p-0">
<DataTable
columns={columns}
data={deposits}
pagination
pageSize={25}
initialSorting={[{ id: 'date', desc: true }]}
emptyMessage="No hay depósitos registrados aún."
/>
</CardContent>
</Card>
)}
</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 {
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<MunicipalReceipt[]>([]);
const [waterReadings, setWaterReadings] = useState<WaterMeterReading[]>([]);
const [loading, setLoading] = useState(true);
// Chart visibility state
const [hiddenMeters, setHiddenMeters] = useState<Set<string>>(new Set());
@@ -195,25 +197,23 @@ export default function ServiciosMunicipales() {
const [uploadResults, setUploadResults] = useState<MunicipalReceiptUploadResult[]>([]);
const fileInputRef = useRef<HTMLInputElement>(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 (
<div className="space-y-8">
{municipalQ.isError && (
<ErrorState
message="No se pudieron cargar los recibos municipales"
onRetry={() => municipalQ.refetch()}
/>
)}
{/* ── Page Header ─────────────────────────────────────────────────── */}
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">