mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 09:08:46 +02:00
Analytics: date-range filter + fix category endpoint Decimal crash
by-category and daily-spending accept start_date/end_date (range wins over cycle); Analytics gets a two-month calendar range picker beside the cycle selector. Fixes a latent float/Decimal TypeError that 500'd spending_by_category whenever it had data — the category donut had never rendered. Regression + range tests added. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2419,6 +2419,8 @@ export interface operations {
|
||||
query?: {
|
||||
cycle_year?: number | null;
|
||||
cycle_month?: number | null;
|
||||
start_date?: string | null;
|
||||
end_date?: string | null;
|
||||
};
|
||||
header?: {
|
||||
authorization?: string | null;
|
||||
@@ -2455,6 +2457,8 @@ export interface operations {
|
||||
query?: {
|
||||
cycle_year?: number | null;
|
||||
cycle_month?: number | null;
|
||||
start_date?: string | null;
|
||||
end_date?: string | null;
|
||||
};
|
||||
header?: {
|
||||
authorization?: string | null;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { type DateRange } from 'react-day-picker';
|
||||
import {
|
||||
PieChart,
|
||||
Pie,
|
||||
@@ -11,7 +12,7 @@ import {
|
||||
LineChart,
|
||||
Line,
|
||||
} from 'recharts';
|
||||
import { BarChart3, ChartPie } from 'lucide-react';
|
||||
import { BarChart3, CalendarRange, ChartPie, X } from 'lucide-react';
|
||||
|
||||
import api, { getRateHistory } from '@/lib/api';
|
||||
import { categoricalColor, MAX_SLICES } from '@/lib/charts';
|
||||
@@ -19,6 +20,13 @@ import ErrorState from '@/components/ErrorState';
|
||||
import BillingCycleSelector from '@/components/BillingCycleSelector';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
@@ -110,15 +118,26 @@ const dailyChartConfig = {
|
||||
|
||||
export default function Analytics() {
|
||||
const [cycle, setCycle] = useState<{ year: number; month: number } | null>(null);
|
||||
const cycleParams: Record<string, string> = cycle
|
||||
? { cycle_year: String(cycle.year), cycle_month: String(cycle.month) }
|
||||
: {};
|
||||
const [range, setRange] = useState<DateRange | undefined>();
|
||||
|
||||
const toISODate = (d: Date) => d.toISOString().slice(0, 10);
|
||||
const nextDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1);
|
||||
const rangeActive = !!(range?.from && range?.to);
|
||||
// Range takes precedence over the billing-cycle filter (end is exclusive).
|
||||
const periodParams: Record<string, string> = rangeActive
|
||||
? {
|
||||
start_date: toISODate(range!.from!),
|
||||
end_date: toISODate(nextDay(range!.to!)),
|
||||
}
|
||||
: cycle
|
||||
? { cycle_year: String(cycle.year), cycle_month: String(cycle.month) }
|
||||
: {};
|
||||
|
||||
const byCategoryQ = useQuery({
|
||||
queryKey: ['analytics', 'by-category', cycle],
|
||||
queryKey: ['analytics', 'by-category', cycle, range],
|
||||
queryFn: ({ signal }) =>
|
||||
api
|
||||
.get<CategorySpending[]>('/analytics/by-category', { params: cycleParams, signal })
|
||||
.get<CategorySpending[]>('/analytics/by-category', { params: periodParams, signal })
|
||||
.then((r) => r.data),
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
@@ -130,10 +149,10 @@ export default function Analytics() {
|
||||
api.get<MonthlyTrend[]>('/analytics/monthly-trend', { signal }).then((r) => r.data),
|
||||
});
|
||||
const dailyQ = useQuery({
|
||||
queryKey: ['analytics', 'daily', cycle],
|
||||
queryKey: ['analytics', 'daily', cycle, range],
|
||||
queryFn: ({ signal }) =>
|
||||
api
|
||||
.get<DailySpending[]>('/analytics/daily-spending', { params: cycleParams, signal })
|
||||
.get<DailySpending[]>('/analytics/daily-spending', { params: periodParams, signal })
|
||||
.then((r) => r.data),
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
@@ -173,7 +192,55 @@ export default function Analytics() {
|
||||
icon={BarChart3}
|
||||
title="Analytics"
|
||||
subtitle="Desglose y tendencias de gasto"
|
||||
actions={<BillingCycleSelector value={cycle} onChange={setCycle} />}
|
||||
actions={
|
||||
<>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={rangeActive ? 'text-foreground' : 'text-muted-foreground'}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CalendarRange className="w-4 h-4 mr-2" aria-hidden />
|
||||
{rangeActive
|
||||
? `${range!.from!.toLocaleDateString('es-CR', { day: 'numeric', month: 'short' })} – ${range!.to!.toLocaleDateString('es-CR', { day: 'numeric', month: 'short' })}`
|
||||
: 'Rango'}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="end">
|
||||
<Calendar
|
||||
mode="range"
|
||||
selected={range}
|
||||
onSelect={(r) => {
|
||||
setRange(r);
|
||||
if (r?.from && r?.to) setCycle(null);
|
||||
}}
|
||||
numberOfMonths={2}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{rangeActive && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setRange(undefined)}
|
||||
title="Quitar rango"
|
||||
aria-label="Quitar rango de fechas"
|
||||
>
|
||||
<X className="w-4 h-4" aria-hidden />
|
||||
</Button>
|
||||
)}
|
||||
<BillingCycleSelector
|
||||
value={cycle}
|
||||
onChange={(c) => {
|
||||
setCycle(c);
|
||||
if (c) setRange(undefined);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{anyError && (
|
||||
|
||||
Reference in New Issue
Block a user