Compare commits

..

5 Commits

Author SHA1 Message Date
Carlos Escalante
3a258537fc P2 tail: cuota progress bars, Sync item rows, dialog polish
All checks were successful
Deploy to VPS / test (push) Successful in 1m29s
Deploy to VPS / deploy (push) Successful in 2m19s
Financiamientos shows cuota progress as Progress bars; Sincronización
uses a single Card of Item rows; Items Recurrentes table wrapped in a
Card like every other table; both window.confirm sites replaced with
the app's ConfirmDialog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 13:22:15 -06:00
Carlos Escalante
41d4306e86 Standardize page headers and chart tokens across all pages
PageHeader on Budget, Proyecciones, Pensiones, Planificador,
Municipalidad, Sincronización, Financiamientos, Asistente. Pensiones
fund identity and Municipalidad meter/charge series now use the
validated chart tokens (color-mix for translucent fills). Planificador
stat cards on StatTile; deterministic chart rendering everywhere.
Municipalidad page title now matches its nav/breadcrumb label.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 12:53:30 -06:00
Carlos Escalante
9907ff265e Budget donuts: sequential ramps + categorical slots, Paleta toggle retired
lib/charts.ts centralizes the color system: fixed categorical slots
(never cycled, >5 folds into Otros, 'Sin categoría' reads neutral) and
color-mix ramps that stay correct in dark mode. Animations off so
charts render deterministically.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 12:47:20 -06:00
Carlos Escalante
2cf19f1880 Analytics: validated palette, bars for daily spend, Empty states, es-CR
Categories fold into 'Otros' past 5 slots (fixed hue order, never
cycled); daily spending excludes future-dated Tasa Cero cuotas; rate
chart gets a legend; PageHeader with the cycle selector as action.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 12:43:57 -06:00
Carlos Escalante
a95486481b UI foundations: PageHeader, StatTile, validated chart palette, registry primitives
- PageHeader + StatTile standardize the four header and three stat-tile
  variants found in the audit; Inicio and Salarios migrated as proof.
- chart tokens replaced with a teal-anchored categorical scale, CVD-
  ordered and validated (six checks, light + dark surfaces).
- Registry adds: empty, field, item, spinner, progress, toggle-group,
  input-group, popover, kbd (+ toggle dep).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 12:23:21 -06:00
32 changed files with 1732 additions and 618 deletions

View File

@@ -10,6 +10,7 @@ from app.auth import get_current_user
from app.db import get_session from app.db import get_session
from app.models.models import Category, Transaction, TransactionType from app.models.models import Category, Transaction, TransactionType
from app.services.budget_projection import NOT_INSTALLMENT_ANCHOR, get_cycle_range from app.services.budget_projection import NOT_INSTALLMENT_ANCHOR, get_cycle_range
from app.timeutil import utcnow
from app.services.exchange_rate import get_converted_amount_expr from app.services.exchange_rate import get_converted_amount_expr
router = APIRouter(prefix="/analytics", tags=["analytics"]) router = APIRouter(prefix="/analytics", tags=["analytics"])
@@ -178,6 +179,9 @@ def daily_spending(
.where( .where(
Transaction.transaction_type == TransactionType.COMPRA, Transaction.transaction_type == TransactionType.COMPRA,
NOT_INSTALLMENT_ANCHOR, NOT_INSTALLMENT_ANCHOR,
# Tasa Cero generates future-dated cuotas; daily spending is
# about money already spent.
Transaction.date <= utcnow(),
) )
.group_by(func.date(Transaction.date)) .group_by(func.date(Transaction.date))
.order_by(func.date(Transaction.date)) .order_by(func.date(Transaction.date))

View File

@@ -30,7 +30,7 @@
"react": "19.2.5", "react": "19.2.5",
"react-dom": "19.2.5", "react-dom": "19.2.5",
"react-router-dom": "^7.6.0", "react-router-dom": "^7.6.0",
"recharts": "^3.8.1", "recharts": "^3.8.0",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",

View File

@@ -63,8 +63,8 @@ importers:
specifier: ^7.6.0 specifier: ^7.6.0
version: 7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) version: 7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
recharts: recharts:
specifier: ^3.8.1 specifier: ^3.8.0
version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@18.3.1)(react@19.2.5)(redux@5.0.1) version: 3.8.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@18.3.1)(react@19.2.5)(redux@5.0.1)
rxjs: rxjs:
specifier: ^7.8.1 specifier: ^7.8.1
version: 7.8.2 version: 7.8.2
@@ -3730,8 +3730,8 @@ packages:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'} engines: {node: '>= 12.13.0'}
recharts@3.8.1: recharts@3.8.0:
resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==} resolution: {integrity: sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -8482,7 +8482,7 @@ snapshots:
real-require@0.2.0: {} real-require@0.2.0: {}
recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@18.3.1)(react@19.2.5)(redux@5.0.1): recharts@3.8.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@18.3.1)(react@19.2.5)(redux@5.0.1):
dependencies: dependencies:
'@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1))(react@19.2.5) '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1))(react@19.2.5)
clsx: 2.1.1 clsx: 2.1.1

View File

@@ -1,12 +1,11 @@
import { useState } from 'react';
import { PieChart, Pie, Cell } from 'recharts'; import { PieChart, Pie, Cell } from 'recharts';
import { type MonthlyDetail as MonthlyDetailType } from '@/lib/api'; import { type MonthlyDetail as MonthlyDetailType } from '@/lib/api';
import { formatAmount } from '@/lib/format'; import { formatAmount } from '@/lib/format';
import { categoricalColor, rampColor, MAX_SLICES } from '@/lib/charts';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { import {
ChartContainer, ChartContainer,
@@ -23,29 +22,6 @@ import {
Info, Info,
} from 'lucide-react'; } from 'lucide-react';
type PaletteMode = 'chatgpt' | 'gemini';
const PALETTES: Record<PaletteMode, { income: string[]; expense: string[]; cc: string[] }> = {
chatgpt: {
// Pure green scale, darkest → lightest (assigned by rank)
income: ['#14532D', '#16A34A', '#4ADE80', '#BBF7D0'],
// Pure amber scale, darkest → lightest (assigned by rank)
expense: ['#92400E', '#B45309', '#D97706', '#F59E0B', '#FCD34D'],
// Warm-to-cool alternating for CC categories
cc: ['#B45309', '#2563EB', '#DC2626', '#16A34A', '#7C3AED',
'#D97706', '#0F766E', '#DB2777', '#EA580C', '#4F46E5'],
},
gemini: {
// Qualitative greens: dark green, mint, pale green, forest
income: ['#2D6A4F', '#52B788', '#B7E4C7', '#1B4332'],
// Terracotta, slate blue, sage, sand — diverse hues
expense: ['#E07A5F', '#3D405B', '#81B29A', '#F2CC8F', '#D56B4E', '#2E344A', '#6A9E85', '#E5B87A'],
// Pastel/muted diverse for CC categories
cc: ['#6366F1', '#EC4899', '#14B8A6', '#F97316', '#8B5CF6',
'#06B6D4', '#EF4444', '#10B981', '#F59E0B', '#3B82F6'],
},
};
const SOURCE_LABELS: Record<string, { label: string; icon: typeof Banknote }> = { const SOURCE_LABELS: Record<string, { label: string; icon: typeof Banknote }> = {
CASH: { label: 'Efectivo', icon: Banknote }, CASH: { label: 'Efectivo', icon: Banknote },
TRANSFER: { label: 'Transferencias', icon: ArrowLeftRight }, TRANSFER: { label: 'Transferencias', icon: ArrowLeftRight },
@@ -91,16 +67,9 @@ function PieCardSkeleton({ titleIcon: TitleIcon, title }: { titleIcon: typeof Tr
} }
export default function MonthlyDetail({ detail, loading, onNavigateToTransactions }: MonthlyDetailProps) { export default function MonthlyDetail({ detail, loading, onNavigateToTransactions }: MonthlyDetailProps) {
const [paletteMode, setPaletteMode] = useState<PaletteMode>('chatgpt');
if (loading || !detail) { if (loading || !detail) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-end gap-1">
<span className="text-xs text-muted-foreground mr-1">Paleta:</span>
<Skeleton className="h-6 w-16" />
<Skeleton className="h-6 w-16" />
</div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<PieCardSkeleton titleIcon={TrendingUp} title="Ingresos" /> <PieCardSkeleton titleIcon={TrendingUp} title="Ingresos" />
<PieCardSkeleton titleIcon={TrendingDown} title="Egresos Fijos" /> <PieCardSkeleton titleIcon={TrendingDown} title="Egresos Fijos" />
@@ -172,32 +141,20 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
); );
} }
const { income: incomeColors, expense: expenseColors, cc: ccColors } = PALETTES[paletteMode];
const incomeData = detail.income_items.map((item) => ({ name: item.name, value: item.amount })); const incomeData = detail.income_items.map((item) => ({ name: item.name, value: item.amount }));
const expenseData = detail.expense_items.map((item) => ({ name: item.name, value: item.amount })); const expenseData = detail.expense_items.map((item) => ({ name: item.name, value: item.amount }));
// For ChatGPT mode: assign colors by rank (largest = darkest) // Sequential ramps: one hue per concept, largest slice = full token,
// For Gemini mode: assign colors by position (qualitative) // smaller slices fade toward the surface (rank-ordered).
function buildColorMap(data: { name: string; value: number }[], colors: string[]): Map<string, string> { function buildRampMap(data: { name: string; value: number }[], baseVar: string): Map<string, string> {
if (paletteMode === 'chatgpt') {
const sorted = [...data].sort((a, b) => b.value - a.value); const sorted = [...data].sort((a, b) => b.value - a.value);
const map = new Map<string, string>(); const map = new Map<string, string>();
sorted.forEach((item, i) => { sorted.forEach((item, i) => map.set(item.name, rampColor(baseVar, i, sorted.length)));
map.set(item.name, colors[Math.min(i, colors.length - 1)]);
});
return map;
}
// Gemini: positional
const map = new Map<string, string>();
data.forEach((item, i) => {
map.set(item.name, colors[i % colors.length]);
});
return map; return map;
} }
const incomeColorMap = buildColorMap(incomeData, incomeColors); const incomeColorMap = buildRampMap(incomeData, 'var(--chart-1)');
const expenseColorMap = buildColorMap(expenseData, expenseColors); const expenseColorMap = buildRampMap(expenseData, 'var(--chart-2)');
const incomeConfig = incomeData.reduce<ChartConfig>((acc, item) => { const incomeConfig = incomeData.reduce<ChartConfig>((acc, item) => {
acc[item.name] = { label: item.name, color: incomeColorMap.get(item.name)! }; acc[item.name] = { label: item.name, color: incomeColorMap.get(item.name)! };
@@ -209,16 +166,28 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
return acc; return acc;
}, {}); }, {});
// CC spending by category // CC by category: identity → categorical slots; past the 5th fold into "Otros".
const ccData = (detail.cc_by_category ?? []).map((item) => ({ const ccRaw = (detail.cc_by_category ?? []).map((item) => ({
name: item.category_name, name: item.category_name,
value: item.amount, value: item.amount,
})); }));
const ccConfig = ccData.reduce<ChartConfig>((acc, item, i) => { const ccData =
acc[item.name] = { label: item.name, color: ccColors[i % ccColors.length] }; ccRaw.length <= MAX_SLICES
? ccRaw
: [
...ccRaw.slice(0, MAX_SLICES),
{
name: 'Otros',
value: ccRaw.slice(MAX_SLICES).reduce((s, c) => s + c.value, 0),
},
];
const ccColorOf = (name: string) =>
categoricalColor(ccData.findIndex((c) => c.name === name), name);
const ccConfig = ccData.reduce<ChartConfig>((acc, item) => {
acc[item.name] = { label: item.name, color: ccColorOf(item.name) };
return acc; return acc;
}, {}); }, {});
const ccTotal = ccData.reduce((sum, item) => sum + item.value, 0); const ccTotal = ccRaw.reduce((sum, item) => sum + item.value, 0);
// Filter actuals to only cash and transfer (no credit card) // Filter actuals to only cash and transfer (no credit card)
const cashTransferActuals = detail.actuals_by_source.filter( const cashTransferActuals = detail.actuals_by_source.filter(
@@ -227,27 +196,6 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Palette Toggle */}
<div className="flex items-center justify-end gap-1">
<span className="text-xs text-muted-foreground mr-1">Paleta:</span>
<Button
variant={paletteMode === 'chatgpt' ? 'default' : 'outline'}
size="sm"
className="h-6 text-xs px-2"
onClick={() => setPaletteMode('chatgpt')}
>
ChatGPT
</Button>
<Button
variant={paletteMode === 'gemini' ? 'default' : 'outline'}
size="sm"
className="h-6 text-xs px-2"
onClick={() => setPaletteMode('gemini')}
>
Gemini
</Button>
</div>
{/* Pie Charts */} {/* Pie Charts */}
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
{/* Income Pie */} {/* Income Pie */}
@@ -274,6 +222,7 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
paddingAngle={2} paddingAngle={2}
strokeWidth={2} strokeWidth={2}
stroke="var(--card)" stroke="var(--card)"
isAnimationActive={false}
> >
{incomeData.map((item, i) => ( {incomeData.map((item, i) => (
<Cell key={i} fill={incomeColorMap.get(item.name)!} /> <Cell key={i} fill={incomeColorMap.get(item.name)!} />
@@ -340,6 +289,7 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
paddingAngle={2} paddingAngle={2}
strokeWidth={2} strokeWidth={2}
stroke="var(--card)" stroke="var(--card)"
isAnimationActive={false}
> >
{expenseData.map((item, i) => ( {expenseData.map((item, i) => (
<Cell key={i} fill={expenseColorMap.get(item.name)!} /> <Cell key={i} fill={expenseColorMap.get(item.name)!} />
@@ -406,9 +356,10 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
paddingAngle={2} paddingAngle={2}
strokeWidth={2} strokeWidth={2}
stroke="var(--card)" stroke="var(--card)"
isAnimationActive={false}
> >
{ccData.map((_, i) => ( {ccData.map((item) => (
<Cell key={i} fill={ccColors[i % ccColors.length]} /> <Cell key={item.name} fill={ccColorOf(item.name)} />
))} ))}
</Pie> </Pie>
<ChartTooltip <ChartTooltip
@@ -424,11 +375,11 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
</PieChart> </PieChart>
</ChartContainer> </ChartContainer>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 w-full md:w-1/2"> <div className="grid grid-cols-2 gap-x-4 gap-y-1 w-full md:w-1/2">
{ccData.map((item, i) => ( {ccData.map((item) => (
<div key={item.name} className="flex items-center gap-1.5 text-xs"> <div key={item.name} className="flex items-center gap-1.5 text-xs">
<div <div
className="w-2 h-2 rounded-full shrink-0" className="w-2 h-2 rounded-full shrink-0"
style={{ background: ccColors[i % ccColors.length] }} style={{ background: ccColorOf(item.name) }}
/> />
<span className="truncate text-muted-foreground">{item.name}</span> <span className="truncate text-muted-foreground">{item.name}</span>
</div> </div>

View File

@@ -25,6 +25,7 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Plus, Trash2 } from 'lucide-react'; import { Plus, Trash2 } from 'lucide-react';
import ConfirmDialog from '@/components/ConfirmDialog';
const TYPE_OPTIONS: { value: RecurringItemType; label: string }[] = [ const TYPE_OPTIONS: { value: RecurringItemType; label: string }[] = [
{ value: 'INCOME', label: 'Ingreso' }, { value: 'INCOME', label: 'Ingreso' },
@@ -73,11 +74,13 @@ export default function RecurringItemDialog({
const snapshot = () => const snapshot = () =>
JSON.stringify([name, amount, itemType, frequency, dayOfMonth, monthOfYear, overrides, notes]); JSON.stringify([name, amount, itemType, frequency, dayOfMonth, monthOfYear, overrides, notes]);
const [confirmDiscard, setConfirmDiscard] = useState(false);
const handleOpenChange = (next: boolean) => { const handleOpenChange = (next: boolean) => {
if (!next && !saving && snapshot() !== baseline) { if (!next && !saving && snapshot() !== baseline) {
// Closing with unsaved edits — confirm before discarding (UX-06). // Closing with unsaved edits — confirm before discarding (UX-06).
const discard = window.confirm('Hay cambios sin guardar. ¿Descartarlos?'); setConfirmDiscard(true);
if (!discard) return; return;
} }
onOpenChange(next); onOpenChange(next);
}; };
@@ -338,6 +341,19 @@ export default function RecurringItemDialog({
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
{confirmDiscard && (
<ConfirmDialog
title="Cambios sin guardar"
message="Hay cambios sin guardar. ¿Descartarlos?"
confirmLabel="Descartar"
onConfirm={() => {
setConfirmDiscard(false);
onOpenChange(false);
}}
onCancel={() => setConfirmDiscard(false)}
/>
)}
</Dialog> </Dialog>
); );
} }

View File

@@ -8,6 +8,7 @@ import {
import { formatAmount } from '@/lib/format'; import { formatAmount } from '@/lib/format';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
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 { Pencil, Plus, Trash2 } from 'lucide-react'; import { Pencil, Plus, Trash2 } from 'lucide-react';
@@ -153,6 +154,8 @@ export default function RecurringItemsManager({
</Button> </Button>
</div> </div>
<Card>
<CardContent className="p-0">
<DataTable <DataTable
columns={columns} columns={columns}
data={items} data={items}
@@ -161,6 +164,8 @@ export default function RecurringItemsManager({
initialSorting={[{ id: 'item_type', desc: false }]} initialSorting={[{ id: 'item_type', desc: false }]}
emptyMessage="No hay items recurrentes." emptyMessage="No hay items recurrentes."
/> />
</CardContent>
</Card>
<RecurringItemDialog <RecurringItemDialog
open={dialogOpen} open={dialogOpen}

View File

@@ -0,0 +1,41 @@
import type { LucideIcon } from "lucide-react";
/** Standard page header: icon tile + title + optional subtitle on the left,
* page-level actions (period navigators, CSV, refresh…) on the right.
* Every page uses this — the breadcrumb in the shell header stays the only
* other place a page identifies itself. */
export function PageHeader({
icon: Icon,
title,
subtitle,
actions,
}: {
icon: LucideIcon;
title: string;
subtitle?: string;
actions?: React.ReactNode;
}) {
return (
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Icon className="size-5" aria-hidden />
</div>
<div>
<h1
className="text-2xl font-bold tracking-tight"
style={{ fontFamily: "var(--font-heading)" }}
>
{title}
</h1>
{subtitle && (
<p className="text-sm text-muted-foreground">{subtitle}</p>
)}
</div>
</div>
{actions && (
<div className="flex items-center gap-2">{actions}</div>
)}
</div>
);
}

View File

@@ -0,0 +1,96 @@
import { Link } from "react-router-dom";
import { cn } from "@/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
/** Stat-tile contract (dataviz skill): label · value · optional delta ·
* optional context/detail rows. Values are pre-formatted by the caller
* (formatAmount etc.) and blur under privacy mode via data-sensitive. */
export interface StatTileProps {
label: string;
/** Small line under the label, e.g. "Ciclo al 18 jul". */
description?: string;
/** Pre-formatted headline value. null renders an em dash. */
value: string | null;
loading?: boolean;
/** Signed, pre-formatted delta plus whether it's good news (colors it). */
delta?: { text: string; positive: boolean };
/** Muted line(s) or detail rows under the value. */
children?: React.ReactNode;
/** Makes the whole tile a link (adds hover border + focus ring). */
to?: string;
ariaLabel?: string;
/** Set false for non-monetary values that shouldn't blur (counts, dates). */
sensitive?: boolean;
className?: string;
}
export function StatTile({
label,
description,
value,
loading = false,
delta,
children,
to,
ariaLabel,
sensitive = true,
className,
}: StatTileProps) {
const card = (
<Card
className={cn(
"h-full",
to && "transition-colors hover:border-primary/40 cursor-pointer",
className,
)}
>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{label}
</CardTitle>
{description && (
<p className="text-xs text-muted-foreground/80">{description}</p>
)}
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-baseline gap-2">
{loading ? (
<Skeleton className="h-8 w-32" />
) : (
<span
{...(sensitive ? { "data-sensitive": true } : {})}
className="text-2xl font-bold font-mono"
>
{value ?? "—"}
</span>
)}
{!loading && delta && (
<span
data-sensitive
className={cn(
"text-xs font-mono font-medium",
delta.positive ? "text-primary" : "text-destructive",
)}
>
{delta.text}
</span>
)}
</div>
{children}
</CardContent>
</Card>
);
if (!to) return card;
return (
<Link
to={to}
aria-label={ariaLabel ?? label}
className="block h-full rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{card}
</Link>
);
}

View File

@@ -20,6 +20,7 @@ import {
DialogFooter, DialogFooter,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import ConfirmDialog from '@/components/ConfirmDialog';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
/** Mirror of the backend cuota split (services/installments.py): truncate to /** Mirror of the backend cuota split (services/installments.py): truncate to
@@ -92,6 +93,7 @@ export default function ConvertToInstallmentsDialog({
: tx?.date ?? new Date().toISOString(); : tx?.date ?? new Date().toISOString();
const [numStr, setNumStr] = useState('3'); const [numStr, setNumStr] = useState('3');
const [confirmUnconvert, setConfirmUnconvert] = useState(false);
const [firstDate, setFirstDate] = useState(''); const [firstDate, setFirstDate] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -142,10 +144,7 @@ export default function ConvertToInstallmentsDialog({
const handleUnconvert = async () => { const handleUnconvert = async () => {
if (!effectivePlan) return; if (!effectivePlan) return;
const confirmed = window.confirm( setConfirmUnconvert(false);
'Se eliminarán las cuotas y la compra volverá a contar como un solo cargo. ¿Continuar?',
);
if (!confirmed) return;
setSaving(true); setSaving(true);
try { try {
await deleteInstallmentPlan(effectivePlan.id); await deleteInstallmentPlan(effectivePlan.id);
@@ -248,7 +247,7 @@ export default function ConvertToInstallmentsDialog({
<Button <Button
variant="outline" variant="outline"
className="text-destructive hover:text-destructive" className="text-destructive hover:text-destructive"
onClick={handleUnconvert} onClick={() => setConfirmUnconvert(true)}
disabled={saving} disabled={saving}
> >
Deshacer Tasa Cero Deshacer Tasa Cero
@@ -273,6 +272,17 @@ export default function ConvertToInstallmentsDialog({
</div> </div>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
{confirmUnconvert && (
<ConfirmDialog
title="Deshacer Tasa Cero"
message="Se eliminarán las cuotas y la compra volverá a contar como un solo cargo. Esta acción no se puede deshacer."
confirmLabel="Deshacer plan"
loading={saving}
onConfirm={handleUnconvert}
onCancel={() => setConfirmUnconvert(false)}
/>
)}
</Dialog> </Dialog>
); );
} }

View File

@@ -0,0 +1,104 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
variant: "default",
},
}
)
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn(
"font-heading text-sm font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}

View File

@@ -0,0 +1,236 @@
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
horizontal:
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"last:mt-0 nth-last-2:-mt-1",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}

View File

@@ -0,0 +1,156 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: "button" | "submit" | "reset"
}) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}

View File

@@ -0,0 +1,201 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
role="list"
data-slot="item-group"
className={cn(
"group/item-group flex w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2",
className
)}
{...props}
/>
)
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn("my-2", className)}
{...props}
/>
)
}
const itemVariants = cva(
"group/item flex w-full flex-wrap items-center rounded-lg border text-sm transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-muted",
{
variants: {
variant: {
default: "border-transparent",
outline: "border-border",
muted: "border-transparent bg-muted/50",
},
size: {
default: "gap-2.5 px-3 py-2.5",
sm: "gap-2.5 px-3 py-2.5",
xs: "gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Item({
className,
variant = "default",
size = "default",
render,
...props
}: useRender.ComponentProps<"div"> & VariantProps<typeof itemVariants>) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(itemVariants({ variant, size, className })),
},
props
),
render,
state: {
slot: "item",
variant,
size,
},
})
}
const itemMediaVariants = cva(
"flex shrink-0 items-center justify-center gap-2 group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start [&_svg]:pointer-events-none",
{
variants: {
variant: {
default: "bg-transparent",
icon: "[&_svg:not([class*='size-'])]:size-4",
image:
"size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
}
)
function ItemMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
)
}
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-content"
className={cn(
"flex flex-1 flex-col gap-1 group-data-[size=xs]/item:gap-0 [&+[data-slot=item-content]]:flex-none",
className
)}
{...props}
/>
)
}
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-title"
className={cn(
"line-clamp-1 flex w-fit items-center gap-2 text-sm leading-snug font-medium underline-offset-4",
className
)}
{...props}
/>
)
}
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="item-description"
className={cn(
"line-clamp-2 text-left text-sm leading-normal font-normal text-muted-foreground group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-actions"
className={cn("flex items-center gap-2", className)}
{...props}
/>
)
}
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-header"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-footer"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
}

View File

@@ -0,0 +1,26 @@
import { cn } from "@/lib/utils"
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 [&_svg:not([class*='size-'])]:size-3",
className
)}
{...props}
/>
)
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
data-slot="kbd-group"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
export { Kbd, KbdGroup }

View File

@@ -0,0 +1,90 @@
"use client"
import * as React from "react"
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import { cn } from "@/lib/utils"
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
)
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-0.5 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
return (
<PopoverPrimitive.Title
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: PopoverPrimitive.Description.Props) {
return (
<PopoverPrimitive.Description
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}

View File

@@ -0,0 +1,81 @@
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
import { cn } from "@/lib/utils"
function Progress({
className,
children,
value,
...props
}: ProgressPrimitive.Root.Props) {
return (
<ProgressPrimitive.Root
value={value}
data-slot="progress"
className={cn("flex flex-wrap gap-3", className)}
{...props}
>
{children}
<ProgressTrack>
<ProgressIndicator />
</ProgressTrack>
</ProgressPrimitive.Root>
)
}
function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
return (
<ProgressPrimitive.Track
className={cn(
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
className
)}
data-slot="progress-track"
{...props}
/>
)
}
function ProgressIndicator({
className,
...props
}: ProgressPrimitive.Indicator.Props) {
return (
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className={cn("h-full bg-primary transition-all", className)}
{...props}
/>
)
}
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
return (
<ProgressPrimitive.Label
className={cn("text-sm font-medium", className)}
data-slot="progress-label"
{...props}
/>
)
}
function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
return (
<ProgressPrimitive.Value
className={cn(
"ml-auto text-sm text-muted-foreground tabular-nums",
className
)}
data-slot="progress-value"
{...props}
/>
)
}
export {
Progress,
ProgressTrack,
ProgressIndicator,
ProgressLabel,
ProgressValue,
}

View File

@@ -0,0 +1,10 @@
import { cn } from "@/lib/utils"
import { Loader2Icon } from "lucide-react"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon data-slot="spinner" role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />
)
}
export { Spinner }

View File

@@ -0,0 +1,89 @@
"use client"
import * as React from "react"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}
>({
size: "default",
variant: "default",
spacing: 2,
orientation: "horizontal",
})
function ToggleGroup({
className,
variant,
size,
spacing = 2,
orientation = "horizontal",
children,
...props
}: ToggleGroupPrimitive.Props &
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}) {
return (
<ToggleGroupPrimitive
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
data-orientation={orientation}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
className
)}
{...props}
>
<ToggleGroupContext.Provider
value={{ variant, size, spacing, orientation }}
>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive>
)
}
function ToggleGroupItem({
className,
children,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<TogglePrimitive
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</TogglePrimitive>
)
}
export { ToggleGroup, ToggleGroupItem }

View File

@@ -0,0 +1,43 @@
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-muted",
},
size: {
default:
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }

View File

@@ -28,11 +28,11 @@
--border: oklch(0.92 0.004 286.32); --border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32); --input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067); --ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.55 0.16 145); --chart-1: oklch(0.55 0.15 187);
--chart-2: oklch(0.62 0.19 25); --chart-2: oklch(0.62 0.14 78);
--chart-3: oklch(0.58 0.14 250); --chart-3: oklch(0.50 0.14 300);
--chart-4: oklch(0.68 0.15 80); --chart-4: oklch(0.56 0.18 27);
--chart-5: oklch(0.52 0.13 320); --chart-5: oklch(0.55 0.13 252);
--radius: 0.625rem; --radius: 0.625rem;
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823); --sidebar-foreground: oklch(0.141 0.005 285.823);
@@ -65,11 +65,11 @@
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938); --ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.60 0.16 145); --chart-1: oklch(0.62 0.11 187);
--chart-2: oklch(0.67 0.19 25); --chart-2: oklch(0.66 0.13 78);
--chart-3: oklch(0.63 0.14 250); --chart-3: oklch(0.60 0.14 300);
--chart-4: oklch(0.73 0.15 80); --chart-4: oklch(0.64 0.16 27);
--chart-5: oklch(0.57 0.13 320); --chart-5: oklch(0.64 0.13 252);
--sidebar: oklch(0.21 0.006 285.885); --sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.704 0.14 182.503); --sidebar-primary: oklch(0.704 0.14 182.503);

View File

@@ -0,0 +1,32 @@
/** Shared chart color system (see dataviz standard).
*
* - Categorical (identity): the five validated --chart-N slots, assigned in
* fixed order, NEVER cycled — anything past the 5th folds into "Otros".
* - Sequential (magnitude within one concept, e.g. income sources ranked by
* size): one hue ramped toward the surface via color-mix, which stays
* correct in both light and dark mode.
*/
export const SERIES_VARS = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
] as const;
export const OTROS_COLOR = "var(--muted-foreground)";
export const MAX_SLICES = SERIES_VARS.length;
export function categoricalColor(index: number, name?: string): string {
if (name === "Otros" || name === "Sin categoría") return OTROS_COLOR;
return SERIES_VARS[Math.min(index, SERIES_VARS.length - 1)];
}
/** Rank-ordered shade of one hue: rank 0 (largest) is the full token, later
* ranks fade toward the surface but never below 35% so they keep chroma. */
export function rampColor(baseVar: string, rank: number, count: number): string {
if (count <= 1 || rank <= 0) return baseVar;
const pct = Math.round(100 - (rank / Math.max(count - 1, 1)) * 65);
return `color-mix(in oklab, ${baseVar} ${pct}%, var(--background))`;
}

View File

@@ -11,14 +11,25 @@ import {
LineChart, LineChart,
Line, Line,
} from 'recharts'; } from 'recharts';
import { BarChart3 } from 'lucide-react'; import { BarChart3, ChartPie } from 'lucide-react';
import api, { getRateHistory } from '@/lib/api'; import api, { getRateHistory } from '@/lib/api';
import { categoricalColor, MAX_SLICES } from '@/lib/charts';
import ErrorState from '@/components/ErrorState'; import ErrorState from '@/components/ErrorState';
import BillingCycleSelector from '@/components/BillingCycleSelector'; import BillingCycleSelector from '@/components/BillingCycleSelector';
import { PageHeader } from '@/components/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty';
import { import {
ChartContainer, ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip, ChartTooltip,
ChartTooltipContent, ChartTooltipContent,
type ChartConfig, type ChartConfig,
@@ -47,32 +58,54 @@ interface DailySpending {
count: number; count: number;
} }
const COLORS = [
'#B45309', '#16A34A', '#2563EB', '#DC2626', '#7C3AED',
'#D97706', '#0F766E', '#DB2777', '#EA580C', '#4F46E5',
];
function formatCRC(value: number) { function formatCRC(value: number) {
return `${Math.round(value).toLocaleString('es-CR')}`; return `${Math.round(value).toLocaleString('es-CR')}`;
} }
const trendChartConfig = { /** Top 5 categories keep their own slice; the rest aggregate into "Otros". */
total_crc: { function foldCategories(cats: CategorySpending[]) {
label: 'Total CRC', if (cats.length <= MAX_SLICES) return cats;
color: 'var(--chart-1)', const head = cats.slice(0, MAX_SLICES);
const rest = cats.slice(MAX_SLICES);
return [
...head,
{
category_id: null,
category_name: 'Otros',
total: rest.reduce((s, c) => s + c.total, 0),
count: rest.reduce((s, c) => s + c.count, 0),
percentage: +rest.reduce((s, c) => s + c.percentage, 0).toFixed(1),
}, },
];
}
function ChartEmpty({ description }: { description: string }) {
return (
<Empty className="h-56">
<EmptyHeader>
<EmptyMedia variant="icon">
<ChartPie />
</EmptyMedia>
<EmptyTitle>Sin datos</EmptyTitle>
<EmptyDescription>{description}</EmptyDescription>
</EmptyHeader>
</Empty>
);
}
const trendChartConfig = {
total_crc: { label: 'Total CRC', color: 'var(--chart-1)' },
} satisfies ChartConfig; } satisfies ChartConfig;
const rateChartConfig = { const rateChartConfig = {
sell_rate: { label: 'Venta', color: 'var(--chart-1)' }, sell_rate: { label: 'Venta', color: 'var(--chart-1)' },
buy_rate: { label: 'Compra', color: 'var(--chart-2)' }, buy_rate: { label: 'Compra', color: 'var(--chart-5)' },
} satisfies ChartConfig; } satisfies ChartConfig;
const dailyChartConfig = { const dailyChartConfig = {
total: { total: { label: 'Gasto diario', color: 'var(--chart-1)' },
label: 'Gasto Diario',
color: 'var(--chart-2)',
},
} satisfies ChartConfig; } satisfies ChartConfig;
export default function Analytics() { export default function Analytics() {
@@ -106,6 +139,7 @@ export default function Analytics() {
}); });
const byCategory = byCategoryQ.data ?? []; const byCategory = byCategoryQ.data ?? [];
const folded = foldCategories(byCategory);
const trend = trendQ.data ?? []; const trend = trendQ.data ?? [];
const daily = dailyQ.data ?? []; const daily = dailyQ.data ?? [];
const ratesQ = useQuery({ const ratesQ = useQuery({
@@ -125,27 +159,22 @@ export default function Analytics() {
dailyQ.refetch(); dailyQ.refetch();
}; };
// Build dynamic chart config for pie chart const pieChartConfig = folded.reduce<ChartConfig>((acc, cat, i) => {
const pieChartConfig = byCategory.reduce<ChartConfig>((acc, cat, i) => {
acc[cat.category_name] = { acc[cat.category_name] = {
label: cat.category_name, label: cat.category_name,
color: COLORS[i % COLORS.length], color: categoricalColor(i, cat.category_name),
}; };
return acc; return acc;
}, {}); }, {});
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> <PageHeader
<div> icon={BarChart3}
<div className="flex items-center gap-2"> title="Analytics"
<BarChart3 className="w-5 h-5 text-primary" /> subtitle="Desglose y tendencias de gasto"
<h1 className="text-2xl font-bold font-heading">Analytics</h1> actions={<BillingCycleSelector value={cycle} onChange={setCycle} />}
</div> />
<p className="text-sm text-muted-foreground mt-1">Desglose y tendencias de gasto</p>
</div>
<BillingCycleSelector value={cycle} onChange={setCycle} />
</div>
{anyError && ( {anyError && (
<ErrorState <ErrorState
@@ -155,24 +184,22 @@ export default function Analytics() {
)} )}
<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 */} {/* Gasto por categoría — donut */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground"> <CardTitle className="text-sm font-medium text-muted-foreground">
Spending by Category Gasto por categoría
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{byCategory.length === 0 ? ( {folded.length === 0 ? (
<div className="h-64 flex items-center justify-center text-muted-foreground text-sm"> <ChartEmpty description="Sin gastos en este período." />
Sin datos para este período
</div>
) : ( ) : (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<ChartContainer data-sensitive config={pieChartConfig} className="h-[260px] w-full"> <ChartContainer data-sensitive config={pieChartConfig} className="h-[260px] w-full">
<PieChart> <PieChart>
<Pie <Pie
data={byCategory} data={folded}
dataKey="total" dataKey="total"
nameKey="category_name" nameKey="category_name"
cx="50%" cx="50%"
@@ -180,10 +207,12 @@ export default function Analytics() {
innerRadius={60} innerRadius={60}
outerRadius={100} outerRadius={100}
paddingAngle={2} paddingAngle={2}
strokeWidth={0} strokeWidth={2}
stroke="var(--background)"
isAnimationActive={false}
> >
{byCategory.map((_, i) => ( {folded.map((cat, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} /> <Cell key={cat.category_name} fill={categoricalColor(i, cat.category_name)} />
))} ))}
</Pie> </Pie>
<ChartTooltip <ChartTooltip
@@ -198,11 +227,11 @@ export default function Analytics() {
{/* Legend */} {/* Legend */}
<div className="grid grid-cols-2 gap-x-6 gap-y-1.5 mt-2 w-full max-w-md"> <div className="grid grid-cols-2 gap-x-6 gap-y-1.5 mt-2 w-full max-w-md">
{byCategory.slice(0, 10).map((cat, i) => ( {folded.map((cat, i) => (
<div key={cat.category_name} className="flex items-center gap-2 text-xs"> <div key={cat.category_name} className="flex items-center gap-2 text-xs">
<div <div
className="w-2.5 h-2.5 rounded-full flex-shrink-0" className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ background: COLORS[i % COLORS.length] }} style={{ background: categoricalColor(i, cat.category_name) }}
/> />
<span className="text-muted-foreground truncate">{cat.category_name}</span> <span className="text-muted-foreground truncate">{cat.category_name}</span>
<span data-sensitive className="text-muted-foreground/60 ml-auto">{cat.percentage}%</span> <span data-sensitive className="text-muted-foreground/60 ml-auto">{cat.percentage}%</span>
@@ -214,26 +243,20 @@ export default function Analytics() {
</CardContent> </CardContent>
</Card> </Card>
{/* Monthly Trend - Bar */} {/* Gasto mensual — barras por ciclo */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground"> <CardTitle className="text-sm font-medium text-muted-foreground">
Monthly Spending (CRC) Gasto mensual por ciclo (CRC)
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{trend.length === 0 ? ( {trend.length === 0 ? (
<div className="h-64 flex items-center justify-center text-muted-foreground text-sm"> <ChartEmpty description="Todavía no hay ciclos con datos." />
No data
</div>
) : ( ) : (
<ChartContainer data-sensitive config={trendChartConfig} className="h-[300px] w-full"> <ChartContainer data-sensitive config={trendChartConfig} className="h-[300px] w-full">
<BarChart data={trend}> <BarChart data={trend}>
<XAxis <XAxis dataKey="label" axisLine={false} tickLine={false} />
dataKey="label"
axisLine={false}
tickLine={false}
/>
<YAxis <YAxis
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
@@ -246,36 +269,39 @@ export default function Analytics() {
/> />
} }
/> />
<Bar dataKey="total_crc" fill="var(--color-total_crc)" radius={[4, 4, 0, 0]} /> <Bar
dataKey="total_crc"
fill="var(--color-total_crc)"
radius={[4, 4, 0, 0]}
isAnimationActive={false}
/>
</BarChart> </BarChart>
</ChartContainer> </ChartContainer>
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* Daily Spending - Line */} {/* Gasto diario — barras */}
<Card className="lg:col-span-2"> <Card className="lg:col-span-2">
<CardHeader> <CardHeader>
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground"> <CardTitle className="text-sm font-medium text-muted-foreground">
Daily Spending Gasto diario
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{daily.length === 0 ? ( {daily.length === 0 ? (
<div className="h-48 flex items-center justify-center text-muted-foreground text-sm"> <ChartEmpty description="Sin gastos en este período." />
Sin datos para este período
</div>
) : ( ) : (
<ChartContainer data-sensitive config={dailyChartConfig} className="h-[240px] w-full"> <ChartContainer data-sensitive config={dailyChartConfig} className="h-[240px] w-full">
<LineChart data={daily}> <BarChart data={daily}>
<XAxis <XAxis
dataKey="date" dataKey="date"
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
tickFormatter={(v) => { minTickGap={24}
const d = new Date(v); tickFormatter={(v) =>
return `${d.getMonth() + 1}/${d.getDate()}`; new Date(v).toLocaleDateString('es-CR', { month: 'short', day: 'numeric' })
}} }
/> />
<YAxis <YAxis
axisLine={false} axisLine={false}
@@ -292,36 +318,34 @@ export default function Analytics() {
/> />
} }
/> />
<Line <Bar
type="monotone"
dataKey="total" dataKey="total"
stroke="var(--color-total)" fill="var(--color-total)"
strokeWidth={2} radius={[2, 2, 0, 0]}
dot={{ fill: 'var(--color-total)', r: 3 }} isAnimationActive={false}
activeDot={{ r: 5 }}
/> />
</LineChart> </BarChart>
</ChartContainer> </ChartContainer>
)} )}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Top categories summary */} {/* Categorías principales */}
{byCategory.length > 0 && ( {byCategory.length > 0 && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground"> <CardTitle className="text-sm font-medium text-muted-foreground">
Top Categories Categorías principales
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-3"> <div className="space-y-3">
{byCategory.slice(0, 8).map((cat, i) => ( {folded.map((cat, i) => (
<div key={cat.category_name} className="flex items-center gap-3"> <div key={cat.category_name} className="flex items-center gap-3">
<div <div
className="w-3 h-3 rounded-full flex-shrink-0" className="w-3 h-3 rounded-full flex-shrink-0"
style={{ background: COLORS[i % COLORS.length] }} style={{ background: categoricalColor(i, cat.category_name) }}
/> />
<span className="text-sm flex-1">{cat.category_name}</span> <span className="text-sm flex-1">{cat.category_name}</span>
<span data-sensitive className="text-xs text-muted-foreground">{cat.count} txns</span> <span data-sensitive className="text-xs text-muted-foreground">{cat.count} txns</span>
@@ -332,8 +356,8 @@ export default function Analytics() {
<div <div
className="h-1.5 rounded-full" className="h-1.5 rounded-full"
style={{ style={{
width: `${cat.percentage}%`, width: `${Math.min(100, cat.percentage)}%`,
background: COLORS[i % COLORS.length], background: categoricalColor(i, cat.category_name),
}} }}
/> />
</div> </div>
@@ -343,28 +367,25 @@ export default function Analytics() {
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Exchange rate history */}
{/* Tipo de cambio */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground"> <CardTitle className="text-sm font-medium text-muted-foreground">
Tipo de Cambio USD/CRC (90 días) Tipo de cambio USD/CRC (90 días)
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{(ratesQ.data?.length ?? 0) < 2 ? ( {(ratesQ.data?.length ?? 0) < 2 ? (
<div className="h-40 flex items-center justify-center text-muted-foreground text-sm"> <ChartEmpty description="Sin historial suficiente." />
Sin historial suficiente
</div>
) : ( ) : (
<ChartContainer <ChartContainer config={rateChartConfig} className="h-[200px] w-full">
config={rateChartConfig}
className="h-[200px] w-full"
>
<LineChart data={ratesQ.data}> <LineChart data={ratesQ.data}>
<XAxis <XAxis
dataKey="day" dataKey="day"
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
minTickGap={24}
tickFormatter={(v) => tickFormatter={(v) =>
new Date(v).toLocaleDateString('es-CR', { new Date(v).toLocaleDateString('es-CR', {
month: 'short', month: 'short',
@@ -385,19 +406,22 @@ export default function Analytics() {
/> />
} }
/> />
<ChartLegend content={<ChartLegendContent />} />
<Line <Line
type="monotone" type="monotone"
dataKey="sell_rate" dataKey="sell_rate"
stroke="var(--chart-1)" stroke="var(--color-sell_rate)"
dot={false} dot={false}
strokeWidth={2} strokeWidth={2}
isAnimationActive={false}
/> />
<Line <Line
type="monotone" type="monotone"
dataKey="buy_rate" dataKey="buy_rate"
stroke="var(--chart-2)" stroke="var(--color-buy_rate)"
dot={false} dot={false}
strokeWidth={2} strokeWidth={2}
isAnimationActive={false}
/> />
</LineChart> </LineChart>
</ChartContainer> </ChartContainer>

View File

@@ -8,6 +8,7 @@ import {
} from "@copilotkit/react-core/v2"; } from "@copilotkit/react-core/v2";
import { useCopilotAction } from "@copilotkit/react-core"; import { useCopilotAction } from "@copilotkit/react-core";
import { Sparkles } from "lucide-react"; import { Sparkles } from "lucide-react";
import { PageHeader } from '@/components/page-header';
import { SpendingSummaryCard, type SpendingSummaryArgs } from "@/components/chat/ChatCards"; import { SpendingSummaryCard, type SpendingSummaryArgs } from "@/components/chat/ChatCards";
const STATIC_SUGGESTIONS = { const STATIC_SUGGESTIONS = {
@@ -87,13 +88,11 @@ export default function Asistente() {
// max-w wrapper are all min-h-0 flex columns) — no vh math. // max-w wrapper are all min-h-0 flex columns) — no vh math.
<div className="flex flex-1 min-h-0 flex-col"> <div className="flex flex-1 min-h-0 flex-col">
<div className="mb-4"> <div className="mb-4">
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2" style={{ fontFamily: "var(--font-heading)" }}> <PageHeader
<Sparkles className="w-5 h-5 text-primary" /> icon={Sparkles}
Asistente title="Asistente"
</h1> subtitle="Pregúntale a WealthySmart sobre tus finanzas."
<p className="text-sm text-muted-foreground"> />
Pregúntale a WealthySmart sobre tus finanzas.
</p>
</div> </div>
<div className="flex-1 min-h-0 rounded-xl border border-border overflow-hidden bg-card"> <div className="flex-1 min-h-0 rounded-xl border border-border overflow-hidden bg-card">

View File

@@ -12,6 +12,7 @@ import {
import { useBudget } from '@/hooks/useBudget'; import { useBudget } from '@/hooks/useBudget';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { PageHeader } from '@/components/page-header';
import ErrorState from '@/components/ErrorState'; import ErrorState from '@/components/ErrorState';
import PeriodNavigator from '@/components/PeriodNavigator'; import PeriodNavigator from '@/components/PeriodNavigator';
import MonthlyDetail from '@/components/budget/MonthlyDetail'; import MonthlyDetail from '@/components/budget/MonthlyDetail';
@@ -128,13 +129,11 @@ export default function Budget() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{cameFromProyecciones && ( {cameFromProyecciones && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="mr-4 text-muted-foreground" className="-mb-4 text-muted-foreground"
onClick={() => navigate('/proyecciones')} onClick={() => navigate('/proyecciones')}
aria-label="Volver a Proyecciones" aria-label="Volver a Proyecciones"
> >
@@ -142,9 +141,11 @@ export default function Budget() {
Proyecciones Proyecciones
</Button> </Button>
)} )}
<Calculator className="w-6 h-6 text-primary" /> <PageHeader
<h1 className="text-2xl font-bold tracking-tight">Presupuesto</h1> icon={Calculator}
</div> title="Presupuesto"
subtitle="Proyección y transacciones del mes"
actions={
<PeriodNavigator <PeriodNavigator
label={String(year)} label={String(year)}
onPrev={() => setYear(year - 1)} onPrev={() => setYear(year - 1)}
@@ -152,7 +153,8 @@ export default function Budget() {
prevDisabled={year <= MIN_YEAR} prevDisabled={year <= MIN_YEAR}
nextDisabled={year >= MAX_YEAR} nextDisabled={year >= MAX_YEAR}
/> />
</div> }
/>
<Tabs defaultValue="overview"> <Tabs defaultValue="overview">
<TabsList> <TabsList>

View File

@@ -9,6 +9,7 @@ import {
import { formatAmount } from '@/lib/format'; import { formatAmount } from '@/lib/format';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { import {
Table, Table,
@@ -19,6 +20,7 @@ import {
TableRow, TableRow,
} from '@/components/ui/table'; } from '@/components/ui/table';
import ErrorState from '@/components/ErrorState'; import ErrorState from '@/components/ErrorState';
import { PageHeader } from '@/components/page-header';
import ConvertToInstallmentsDialog from '@/components/transactions/ConvertToInstallmentsDialog'; import ConvertToInstallmentsDialog from '@/components/transactions/ConvertToInstallmentsDialog';
function formatDate(iso: string) { function formatDate(iso: string) {
@@ -29,8 +31,20 @@ function formatDate(iso: string) {
}); });
} }
function progressLabel(plan: InstallmentPlan) { function CuotasProgress({ plan }: { plan: InstallmentPlan }) {
return `${String(plan.cuotas_billed).padStart(3, '0')}/${String(plan.num_installments).padStart(3, '0')}`; const pct = (plan.cuotas_billed / plan.num_installments) * 100;
return (
<div className="flex items-center gap-2">
<Progress
value={pct}
className="w-16"
aria-label={`${plan.cuotas_billed} de ${plan.num_installments} cuotas pagadas`}
/>
<span className="font-mono text-xs text-muted-foreground whitespace-nowrap">
{plan.cuotas_billed}/{plan.num_installments}
</span>
</div>
);
} }
export default function Financiamientos() { export default function Financiamientos() {
@@ -86,7 +100,9 @@ export default function Financiamientos() {
<TableCell className="text-muted-foreground whitespace-nowrap"> <TableCell className="text-muted-foreground whitespace-nowrap">
{formatDate(plan.purchase_date)} {formatDate(plan.purchase_date)}
</TableCell> </TableCell>
<TableCell className="font-mono text-xs">{progressLabel(plan)}</TableCell> <TableCell>
<CuotasProgress plan={plan} />
</TableCell>
<TableCell className="text-right font-mono" data-sensitive> <TableCell className="text-right font-mono" data-sensitive>
{formatAmount(plan.installment_amount, plan.currency)} {formatAmount(plan.installment_amount, plan.currency)}
</TableCell> </TableCell>
@@ -104,14 +120,11 @@ export default function Financiamientos() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-3"> <PageHeader
<Layers className="w-6 h-6 text-primary" /> icon={Layers}
<h1 className="text-2xl font-bold tracking-tight">Financiamientos</h1> title="Financiamientos"
</div> subtitle="Compras Tasa Cero en cuotas sin intereses — las cuotas futuras ya cuentan en el presupuesto de sus meses."
<p className="text-sm text-muted-foreground -mt-4"> />
Compras Tasa Cero pagadas en cuotas mensuales sin intereses. Las cuotas
futuras ya cuentan en el presupuesto de sus meses.
</p>
{/* Summary */} {/* Summary */}
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">

View File

@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { ArrowRight, Sparkles } from "lucide-react"; import { ArrowRight, Home, Sparkles } from "lucide-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
@@ -16,61 +16,15 @@ import {
formatShortDate, formatShortDate,
} from "@/lib/dates"; } from "@/lib/dates";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PageHeader } from "@/components/page-header";
import { StatTile } from "@/components/stat-tile";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
const CYCLE = currentBudgetCycle(); const CYCLE = currentBudgetCycle();
function StatCardShell({
to,
ariaLabel,
children,
}: {
to: string;
ariaLabel: string;
children: React.ReactNode;
}) {
return (
<Link
to={to}
aria-label={ariaLabel}
className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Card className="h-full transition-colors hover:border-primary/40 cursor-pointer">
{children}
</Card>
</Link>
);
}
function StatValue({
value,
loading,
className,
}: {
value: string | null;
loading: boolean;
className?: string;
}) {
if (loading) return <Skeleton className="h-8 w-36" />;
return (
<span
data-sensitive
className={cn("text-2xl font-bold font-mono", className)}
>
{value ?? "—"}
</span>
);
}
function GastoDelCicloCard() { function GastoDelCicloCard() {
const q = useQuery({ const q = useQuery({
queryKey: ["budget", "month", CYCLE.year, CYCLE.month], queryKey: ["budget", "month", CYCLE.year, CYCLE.month],
@@ -83,22 +37,14 @@ function GastoDelCicloCard() {
const balance = q.data?.net_balance ?? null; const balance = q.data?.net_balance ?? null;
return ( return (
<StatCardShell to="/budget" ariaLabel="Ver presupuesto"> <StatTile
<CardHeader className="pb-2"> label="Gasto del ciclo"
<CardTitle className="text-sm font-medium text-muted-foreground"> description={`Ciclo al 18 ${MONTH_NAMES_ES_SHORT[CYCLE.month].toLowerCase()}`}
Gasto del ciclo value={q.isError || spend === null ? null : formatAmount(spend, "CRC")}
</CardTitle>
<CardDescription>
Ciclo al 18 {MONTH_NAMES_ES_SHORT[CYCLE.month].toLowerCase()}
</CardDescription>
</CardHeader>
<CardContent className="space-y-1">
<StatValue
loading={q.isPending} loading={q.isPending}
value={ to="/budget"
q.isError ? null : spend !== null ? formatAmount(spend, "CRC") : null ariaLabel="Ver presupuesto"
} >
/>
{balance !== null && ( {balance !== null && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Balance proyectado:{" "} Balance proyectado:{" "}
@@ -119,8 +65,7 @@ function GastoDelCicloCard() {
No se pudo cargar el ciclo. No se pudo cargar el ciclo.
</p> </p>
)} )}
</CardContent> </StatTile>
</StatCardShell>
); );
} }
@@ -149,22 +94,18 @@ function ProximasCuotasCard() {
const topThree = upcoming.slice(0, 3); const topThree = upcoming.slice(0, 3);
return ( return (
<StatCardShell to="/financiamientos" ariaLabel="Ver financiamientos"> <StatTile
<CardHeader className="pb-2"> label="Próximas cuotas"
<CardTitle className="text-sm font-medium text-muted-foreground"> description={
Próximas cuotas nextDate
</CardTitle>
<CardDescription>
{nextDate
? `Tasa Cero · próximo cobro ${formatShortDate(nextDate)}` ? `Tasa Cero · próximo cobro ${formatShortDate(nextDate)}`
: "Tasa Cero"} : "Tasa Cero"
</CardDescription> }
</CardHeader>
<CardContent className="space-y-2">
<StatValue
loading={q.isPending}
value={q.isError ? null : formatAmount(nextBatchTotal, "CRC")} value={q.isError ? null : formatAmount(nextBatchTotal, "CRC")}
/> loading={q.isPending}
to="/financiamientos"
ariaLabel="Ver financiamientos"
>
{topThree.length > 0 && ( {topThree.length > 0 && (
<ul className="space-y-0.5 text-xs text-muted-foreground"> <ul className="space-y-0.5 text-xs text-muted-foreground">
{topThree.map((p) => ( {topThree.map((p) => (
@@ -186,8 +127,7 @@ function ProximasCuotasCard() {
</span> </span>
</p> </p>
)} )}
</CardContent> </StatTile>
</StatCardShell>
); );
} }
@@ -196,25 +136,17 @@ function PensionCard() {
queryKey: ["pension-fund-summary"], queryKey: ["pension-fund-summary"],
queryFn: () => getPensionFundSummary().then((r) => r.data), queryFn: () => getPensionFundSummary().then((r) => r.data),
}); });
const total = q.data const total = q.data ? q.data.reduce((s, f) => s + f.saldo_final, 0) : null;
? q.data.reduce((s, f) => s + f.saldo_final, 0)
: null;
return ( return (
<StatCardShell to="/pensions" ariaLabel="Ver pensiones"> <StatTile
<CardHeader className="pb-2"> label="Pensión"
<CardTitle className="text-sm font-medium text-muted-foreground"> description="Saldo total de fondos"
Pensión value={q.isError || total === null ? null : formatAmount(total, "CRC")}
</CardTitle>
<CardDescription>Saldo total de fondos</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<StatValue
loading={q.isPending} loading={q.isPending}
value={ to="/pensions"
q.isError ? null : total !== null ? formatAmount(total, "CRC") : null ariaLabel="Ver pensiones"
} >
/>
{q.data && q.data.length > 0 && ( {q.data && q.data.length > 0 && (
<ul className="space-y-0.5 text-xs text-muted-foreground"> <ul className="space-y-0.5 text-xs text-muted-foreground">
{q.data.map((f) => ( {q.data.map((f) => (
@@ -227,8 +159,7 @@ function PensionCard() {
))} ))}
</ul> </ul>
)} )}
</CardContent> </StatTile>
</StatCardShell>
); );
} }
@@ -327,17 +258,11 @@ function RecentTransactionsCard() {
export default function Inicio() { export default function Inicio() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <PageHeader
<h1 icon={Home}
className="text-2xl font-bold tracking-tight" title="Inicio"
style={{ fontFamily: "var(--font-heading)" }} subtitle="Tu panorama financiero de hoy."
> />
Inicio
</h1>
<p className="text-sm text-muted-foreground">
Tu panorama financiero de hoy.
</p>
</div>
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-3">
<GastoDelCicloCard /> <GastoDelCicloCard />

View File

@@ -1,6 +1,7 @@
import { useState, useMemo, useCallback, useRef } from 'react'; import { useState, useMemo, useCallback, useRef } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import ErrorState from '@/components/ErrorState'; import ErrorState from '@/components/ErrorState';
import { PageHeader } from '@/components/page-header';
import PensionAssumptionsDialog, { type PensionAssumptions } from '@/components/PensionAssumptionsDialog'; import PensionAssumptionsDialog, { type PensionAssumptions } from '@/components/PensionAssumptionsDialog';
import { import {
LineChart, LineChart,
@@ -103,7 +104,7 @@ const FUNDS_DEFAULT: Record<FundKey, FundDef> = {
key: 'FCL', key: 'FCL',
name: 'FCL', name: 'FCL',
fullName: 'Fondo de Capitalización Laboral', fullName: 'Fondo de Capitalización Laboral',
color: '#3b82f6', color: 'var(--chart-5)',
startBalance: 650_468, startBalance: 650_468,
monthlyContribution: 150_000, monthlyContribution: 150_000,
annualRate: 7.5, annualRate: 7.5,
@@ -115,7 +116,7 @@ const FUNDS_DEFAULT: Record<FundKey, FundDef> = {
key: 'ROP', key: 'ROP',
name: 'ROP', name: 'ROP',
fullName: 'Régimen Obligatorio de Pensiones', fullName: 'Régimen Obligatorio de Pensiones',
color: '#10b981', color: 'var(--chart-1)',
startBalance: 18_684_765, startBalance: 18_684_765,
monthlyContribution: 120_000, monthlyContribution: 120_000,
annualRate: 6.0, annualRate: 6.0,
@@ -127,7 +128,7 @@ const FUNDS_DEFAULT: Record<FundKey, FundDef> = {
key: 'VOL', key: 'VOL',
name: 'VOL', name: 'VOL',
fullName: 'Fondo Voluntario', fullName: 'Fondo Voluntario',
color: '#f43f5e', color: 'var(--chart-4)',
startBalance: 2_500_381, startBalance: 2_500_381,
monthlyContribution: 400_000, monthlyContribution: 400_000,
annualRate: 8.0, annualRate: 8.0,
@@ -480,17 +481,11 @@ export default function Pensions() {
/> />
)} )}
{/* ── Page Header ─────────────────────────────────────────────────── */} {/* ── Page Header ─────────────────────────────────────────────────── */}
<div className="flex items-center gap-3"> <PageHeader
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center"> icon={PiggyBank}
<PiggyBank className="w-5 h-5 text-primary" /> title="Pensiones"
</div> subtitle="Seguimiento de aportes, rendimientos y proyecciones"
<div> />
<h1 className="text-2xl font-bold font-heading">Pensiones</h1>
<p className="text-sm text-muted-foreground">
Seguimiento de aportes, rendimientos y proyecciones
</p>
</div>
</div>
{/* ── Section 1: Fund Overview Cards ──────────────────────────────── */} {/* ── Section 1: Fund Overview Cards ──────────────────────────────── */}
<section className="space-y-3"> <section className="space-y-3">
@@ -606,7 +601,7 @@ export default function Pensions() {
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all border cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all border cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
style={{ style={{
borderColor: fund.color, borderColor: fund.color,
background: active ? fund.color + '22' : 'transparent', background: active ? `color-mix(in oklab, ${fund.color} 13%, transparent)` : 'transparent',
color: active ? fund.color : 'var(--muted-foreground)', color: active ? fund.color : 'var(--muted-foreground)',
}} }}
> >
@@ -652,6 +647,7 @@ export default function Pensions() {
stroke={FUNDS[key].color} stroke={FUNDS[key].color}
strokeWidth={2} strokeWidth={2}
dot={false} dot={false}
isAnimationActive={false}
activeDot={{ r: 4 }} activeDot={{ r: 4 }}
/> />
) : null, ) : null,

View File

@@ -4,6 +4,8 @@ import { Line, LineChart, XAxis, YAxis } from 'recharts';
import { Telescope } from 'lucide-react'; import { Telescope } from 'lucide-react';
import { getYearlyProjection } from '@/lib/api'; import { getYearlyProjection } from '@/lib/api';
import { PageHeader } from '@/components/page-header';
import { StatTile } from '@/components/stat-tile';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -78,15 +80,11 @@ export default function Planificador() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-3"> <PageHeader
<Telescope className="w-6 h-6 text-primary" aria-hidden="true" /> icon={Telescope}
<div> title="Planificador"
<h1 className="text-2xl font-bold tracking-tight">Planificador</h1> subtitle="¿Qué pasa si ahorrás un poco más cada mes?"
<p className="text-sm text-muted-foreground"> />
¿Qué pasa si ahorrás un poco más cada mes?
</p>
</div>
</div>
<Card> <Card>
<CardContent className="p-4 grid grid-cols-2 lg:grid-cols-4 gap-4"> <CardContent className="p-4 grid grid-cols-2 lg:grid-cols-4 gap-4">
@@ -138,41 +136,18 @@ export default function Planificador() {
</Card> </Card>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Card> <StatTile label="Ritmo actual" value={formatCRC(baselineEnd)} />
<CardContent className="p-4"> <StatTile label="Escenario" value={formatCRC(scenarioEnd)} />
<p className="text-xs text-muted-foreground uppercase tracking-wider"> <StatTile
Ritmo actual label="Diferencia"
</p> value={`+${formatCRC(scenarioEnd - baselineEnd)}`}
<p data-sensitive className="text-xl font-bold font-mono"> className="[&_span[data-sensitive]]:text-primary"
{formatCRC(baselineEnd)} />
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground uppercase tracking-wider">
Escenario
</p>
<p data-sensitive className="text-xl font-bold font-mono text-primary">
{formatCRC(scenarioEnd)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground uppercase tracking-wider">
Diferencia
</p>
<p data-sensitive className="text-xl font-bold font-mono text-emerald-500">
+{formatCRC(scenarioEnd - baselineEnd)}
</p>
</CardContent>
</Card>
</div> </div>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground"> <CardTitle className="text-sm font-medium text-muted-foreground">
Crecimiento comparado Crecimiento comparado
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@@ -191,8 +166,8 @@ export default function Planificador() {
<ChartTooltipContent formatter={(value) => formatCRC(Number(value))} /> <ChartTooltipContent formatter={(value) => formatCRC(Number(value))} />
} }
/> />
<Line type="monotone" dataKey="baseline" stroke="var(--chart-2)" dot={false} strokeWidth={2} /> <Line type="monotone" dataKey="baseline" stroke="var(--chart-2)" dot={false} strokeWidth={2} isAnimationActive={false} />
<Line type="monotone" dataKey="scenario" stroke="var(--chart-1)" dot={false} strokeWidth={2} /> <Line type="monotone" dataKey="scenario" stroke="var(--chart-1)" dot={false} strokeWidth={2} isAnimationActive={false} />
</LineChart> </LineChart>
</ChartContainer> </ChartContainer>
<p className="text-xs text-muted-foreground mt-2"> <p className="text-xs text-muted-foreground mt-2">

View File

@@ -5,6 +5,7 @@ import { useBudget } from '@/hooks/useBudget';
import { formatAmount } from '@/lib/format'; import { formatAmount } from '@/lib/format';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { PageHeader } from '@/components/page-header';
import ErrorState from '@/components/ErrorState'; import ErrorState from '@/components/ErrorState';
import PeriodNavigator from '@/components/PeriodNavigator'; import PeriodNavigator from '@/components/PeriodNavigator';
import YearlyOverview from '@/components/budget/YearlyOverview'; import YearlyOverview from '@/components/budget/YearlyOverview';
@@ -30,12 +31,11 @@ export default function Proyecciones() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} <PageHeader
<div className="flex items-center justify-between"> icon={TrendingUp}
<div className="flex items-center gap-3"> title="Proyecciones"
<TrendingUp className="w-6 h-6 text-primary" /> subtitle="Presupuesto anual mes a mes"
<h1 className="text-2xl font-bold tracking-tight">Proyecciones</h1> actions={
</div>
<PeriodNavigator <PeriodNavigator
label={String(year)} label={String(year)}
onPrev={() => setYear(year - 1)} onPrev={() => setYear(year - 1)}
@@ -43,7 +43,8 @@ export default function Proyecciones() {
prevDisabled={year <= MIN_YEAR} prevDisabled={year <= MIN_YEAR}
nextDisabled={year >= MAX_YEAR} nextDisabled={year >= MAX_YEAR}
/> />
</div> }
/>
{/* Annual summary cards */} {/* Annual summary cards */}
{projection && ( {projection && (

View File

@@ -1,11 +1,13 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query'; 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, Download } from 'lucide-react'; import { Landmark, RefreshCw, Download } from 'lucide-react';
import { type Transaction, type SalariosSummary, getSalarios, getSalariosSummary } from '@/lib/api'; import { type Transaction, getSalarios, getSalariosSummary } from '@/lib/api';
import { formatAmount, formatDate } from '@/lib/format'; import { formatAmount, formatDate } from '@/lib/format';
import ErrorState from '@/components/ErrorState'; import ErrorState from '@/components/ErrorState';
import { PageHeader } from '@/components/page-header';
import { StatTile } from '@/components/stat-tile';
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';
@@ -87,17 +89,12 @@ export default function Salarios() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} <PageHeader
<div className="flex items-center justify-between"> icon={Landmark}
<div className="flex items-center gap-3"> title="Salarios"
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center"> subtitle="Historial de depósitos salariales"
<Landmark className="w-5 h-5 text-primary" /> actions={
</div> <>
<div>
<h1 className="text-2xl font-bold font-heading">Salarios</h1>
<p className="text-sm text-muted-foreground">Historial de depósitos salariales</p>
</div>
</div>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -110,44 +107,30 @@ export default function Salarios() {
<Button variant="ghost" size="icon" onClick={fetchData} title="Refresh" aria-label="Refresh"> <Button variant="ghost" size="icon" onClick={fetchData} title="Refresh" aria-label="Refresh">
<RefreshCw className={loading ? 'w-4 h-4 animate-spin' : 'w-4 h-4'} /> <RefreshCw className={loading ? 'w-4 h-4 animate-spin' : 'w-4 h-4'} />
</Button> </Button>
</div> </>
}
/>
{/* Summary cards */} {/* Summary tiles */}
{summary && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Card> <StatTile
<CardContent className="p-4"> label="Depósitos"
<div className="flex items-center gap-2 text-muted-foreground mb-1"> value={summary ? String(summary.count) : null}
<Hash className="w-4 h-4" /> loading={query.isPending}
<span className="text-xs font-medium uppercase tracking-wider">Depósitos</span> sensitive={false}
/>
<StatTile
label="Total acumulado"
value={summary ? formatAmount(summary.total_amount, 'CRC') : null}
loading={query.isPending}
/>
<StatTile
label="Último depósito"
value={summary?.latest_date ? formatDate(summary.latest_date) : null}
loading={query.isPending}
sensitive={false}
/>
</div> </div>
<span className="text-2xl font-bold font-mono">{summary.count}</span>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Banknote className="w-4 h-4" />
<span className="text-xs font-medium uppercase tracking-wider">Total acumulado</span>
</div>
<span data-sensitive className="text-2xl font-bold font-mono text-primary">
{formatAmount(summary.total_amount, 'CRC')}
</span>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<CalendarDays className="w-4 h-4" />
<span className="text-xs font-medium uppercase tracking-wider">Último depósito</span>
</div>
<span className="text-2xl font-bold font-mono">
{summary.latest_date ? formatDate(summary.latest_date) : '—'}
</span>
</CardContent>
</Card>
</div>
)}
{/* Data table */} {/* Data table */}
{query.isError ? ( {query.isError ? (

View File

@@ -2,6 +2,7 @@ import { useState, useCallback, useRef, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { MONTH_NAMES_ES_SHORT as MONTH_NAMES_ES } from '@/lib/dates'; import { MONTH_NAMES_ES_SHORT as MONTH_NAMES_ES } from '@/lib/dates';
import MeterLabelsDialog from '@/components/MeterLabelsDialog'; import MeterLabelsDialog from '@/components/MeterLabelsDialog';
import { PageHeader } from '@/components/page-header';
import ErrorState from '@/components/ErrorState'; import ErrorState from '@/components/ErrorState';
import { import {
BarChart, BarChart,
@@ -51,13 +52,13 @@ import {
// ─── Constants ─────────────────────────────────────────────────────────────── // ─── Constants ───────────────────────────────────────────────────────────────
const METER_COLORS: Record<string, string> = { const METER_COLORS: Record<string, string> = {
'7335': '#3b82f6', '7335': 'var(--chart-5)',
'7345': '#10b981', '7345': 'var(--chart-1)',
'9345': '#f59e0b', '9345': 'var(--chart-2)',
}; };
const DEFAULT_METER_COLOR = '#8b5cf6'; const DEFAULT_METER_COLOR = 'var(--chart-3)';
// ─── Utilities ─────────────────────────────────────────────────────────────── // ─── Utilities ───────────────────────────────────────────────────────────────
@@ -105,8 +106,11 @@ function getMeterIds(readings: WaterMeterReading[]): string[] {
// ─── Charge Trend Data ─────────────────────────────────────────────────────── // ─── Charge Trend Data ───────────────────────────────────────────────────────
// Fixed categorical slots (validated palette); series beyond the 5th read
// muted — never cycle hues.
const CHARGE_COLORS = [ const CHARGE_COLORS = [
'#ef4444', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16', 'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)',
'var(--chart-5)', 'var(--muted-foreground)',
]; ];
interface ChargeTrendPoint { interface ChargeTrendPoint {
@@ -306,17 +310,11 @@ export default function ServiciosMunicipales() {
/> />
)} )}
{/* ── Page Header ─────────────────────────────────────────────────── */} {/* ── Page Header ─────────────────────────────────────────────────── */}
<div className="flex items-center gap-3"> <PageHeader
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center"> icon={Droplets}
<Droplets className="w-5 h-5 text-primary" /> title="Municipalidad"
</div> subtitle="Municipalidad de Belén — recibos y consumo de agua"
<div> />
<h1 className="text-2xl font-bold font-heading">Servicios Municipales</h1>
<p className="text-sm text-muted-foreground">
Municipalidad de Belén recibos y consumo de agua
</p>
</div>
</div>
{/* ── Summary Cards ───────────────────────────────────────────────── */} {/* ── Summary Cards ───────────────────────────────────────────────── */}
<section className="grid grid-cols-2 sm:grid-cols-4 gap-3"> <section className="grid grid-cols-2 sm:grid-cols-4 gap-3">
@@ -546,7 +544,7 @@ export default function ServiciosMunicipales() {
type="monotone" type="monotone"
dataKey={name} dataKey={name}
name={name} name={name}
stroke={CHARGE_COLORS[i % CHARGE_COLORS.length]} stroke={CHARGE_COLORS[Math.min(i, CHARGE_COLORS.length - 1)]}
strokeWidth={2} strokeWidth={2}
dot={{ r: 3 }} dot={{ r: 3 }}
activeDot={{ r: 5 }} activeDot={{ r: 5 }}

View File

@@ -1,3 +1,4 @@
import { Fragment } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { import {
AlertTriangle, AlertTriangle,
@@ -9,8 +10,18 @@ import {
import { getSyncStatus, type SyncSource } from '@/lib/api'; import { getSyncStatus, type SyncSource } from '@/lib/api';
import { formatRelativeAge } from '@/lib/dates'; import { formatRelativeAge } from '@/lib/dates';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import {
Item,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemSeparator,
ItemTitle,
} from '@/components/ui/item';
import { PageHeader } from '@/components/page-header';
import ErrorState from '@/components/ErrorState'; import ErrorState from '@/components/ErrorState';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
@@ -38,16 +49,11 @@ export default function SyncStatus() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <PageHeader
<div className="flex items-center gap-3"> icon={RefreshCw}
<RefreshCw className="w-6 h-6 text-primary" aria-hidden="true" /> title="Sincronización"
<div> subtitle="Estado de los flujos automáticos de datos (n8n y tipo de cambio)"
<h1 className="text-2xl font-bold tracking-tight">Sincronización</h1> actions={
<p className="text-sm text-muted-foreground">
Estado de los flujos automáticos de datos (n8n y tipo de cambio)
</p>
</div>
</div>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -60,7 +66,8 @@ export default function SyncStatus() {
/> />
Actualizar Actualizar
</Button> </Button>
</div> }
/>
{query.isError ? ( {query.isError ? (
<ErrorState <ErrorState
@@ -75,26 +82,23 @@ export default function SyncStatus() {
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{query.data.sources.map((s) => ( <Card className="py-2">
<Card <ItemGroup>
key={s.key} {query.data.sources.map((s, i) => (
className={ <Fragment key={s.key}>
s.status !== 'ok' ? 'border-amber-500/40' : undefined <Item>
} <ItemMedia>
>
<CardContent className="p-4 flex items-center gap-4">
<StatusIcon status={s.status} /> <StatusIcon status={s.status} />
<div className="flex-1 min-w-0"> </ItemMedia>
<div className="flex items-center gap-2"> <ItemContent>
<span className="font-medium">{s.label}</span> <ItemTitle>
{s.label}
{s.status !== 'ok' && ( {s.status !== 'ok' && (
<Badge variant="destructive">Atención</Badge> <Badge variant="destructive">Atención</Badge>
)} )}
</div> </ItemTitle>
<p className="text-sm text-muted-foreground truncate"> <ItemDescription>{s.description}</ItemDescription>
{s.description} </ItemContent>
</p>
</div>
<div className="text-right shrink-0"> <div className="text-right shrink-0">
<p className="text-sm font-medium">{statusLabel(s)}</p> <p className="text-sm font-medium">{statusLabel(s)}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -103,9 +107,12 @@ export default function SyncStatus() {
: '—'} : '—'}
</p> </p>
</div> </div>
</CardContent> </Item>
</Card> {i < query.data.sources.length - 1 && <ItemSeparator />}
</Fragment>
))} ))}
</ItemGroup>
</Card>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Si una fuente aparece atrasada, revisá el flujo correspondiente en Si una fuente aparece atrasada, revisá el flujo correspondiente en
n8n un cambio de formato en los correos del banco es la causa más n8n un cambio de formato en los correos del banco es la causa más