mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 12:08: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 {
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user