mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 11:28:47 +02:00
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:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user