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.models.models import Category, Transaction, TransactionType
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
router = APIRouter(prefix="/analytics", tags=["analytics"])
@@ -178,6 +179,9 @@ def daily_spending(
.where(
Transaction.transaction_type == TransactionType.COMPRA,
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))
.order_by(func.date(Transaction.date))

View File

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

View File

@@ -63,8 +63,8 @@ importers:
specifier: ^7.6.0
version: 7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
recharts:
specifier: ^3.8.1
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)
specifier: ^3.8.0
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:
specifier: ^7.8.1
version: 7.8.2
@@ -3730,8 +3730,8 @@ packages:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'}
recharts@3.8.1:
resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==}
recharts@3.8.0:
resolution: {integrity: sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==}
engines: {node: '>=18'}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -8482,7 +8482,7 @@ snapshots:
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:
'@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

View File

@@ -1,12 +1,11 @@
import { useState } from 'react';
import { PieChart, Pie, Cell } from 'recharts';
import { type MonthlyDetail as MonthlyDetailType } from '@/lib/api';
import { formatAmount } from '@/lib/format';
import { categoricalColor, rampColor, MAX_SLICES } from '@/lib/charts';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import {
ChartContainer,
@@ -23,29 +22,6 @@ import {
Info,
} 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 }> = {
CASH: { label: 'Efectivo', icon: Banknote },
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) {
const [paletteMode, setPaletteMode] = useState<PaletteMode>('chatgpt');
if (loading || !detail) {
return (
<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">
<PieCardSkeleton titleIcon={TrendingUp} title="Ingresos" />
<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 expenseData = detail.expense_items.map((item) => ({ name: item.name, value: item.amount }));
// For ChatGPT mode: assign colors by rank (largest = darkest)
// For Gemini mode: assign colors by position (qualitative)
function buildColorMap(data: { name: string; value: number }[], colors: string[]): Map<string, string> {
if (paletteMode === 'chatgpt') {
// Sequential ramps: one hue per concept, largest slice = full token,
// smaller slices fade toward the surface (rank-ordered).
function buildRampMap(data: { name: string; value: number }[], baseVar: string): Map<string, string> {
const sorted = [...data].sort((a, b) => b.value - a.value);
const map = new Map<string, string>();
sorted.forEach((item, i) => {
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]);
});
sorted.forEach((item, i) => map.set(item.name, rampColor(baseVar, i, sorted.length)));
return map;
}
const incomeColorMap = buildColorMap(incomeData, incomeColors);
const expenseColorMap = buildColorMap(expenseData, expenseColors);
const incomeColorMap = buildRampMap(incomeData, 'var(--chart-1)');
const expenseColorMap = buildRampMap(expenseData, 'var(--chart-2)');
const incomeConfig = incomeData.reduce<ChartConfig>((acc, item) => {
acc[item.name] = { label: item.name, color: incomeColorMap.get(item.name)! };
@@ -209,16 +166,28 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
return acc;
}, {});
// CC spending by category
const ccData = (detail.cc_by_category ?? []).map((item) => ({
// CC by category: identity → categorical slots; past the 5th fold into "Otros".
const ccRaw = (detail.cc_by_category ?? []).map((item) => ({
name: item.category_name,
value: item.amount,
}));
const ccConfig = ccData.reduce<ChartConfig>((acc, item, i) => {
acc[item.name] = { label: item.name, color: ccColors[i % ccColors.length] };
const ccData =
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;
}, {});
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)
const cashTransferActuals = detail.actuals_by_source.filter(
@@ -227,27 +196,6 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
return (
<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 */}
<div className="grid gap-4 md:grid-cols-2">
{/* Income Pie */}
@@ -274,6 +222,7 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
paddingAngle={2}
strokeWidth={2}
stroke="var(--card)"
isAnimationActive={false}
>
{incomeData.map((item, i) => (
<Cell key={i} fill={incomeColorMap.get(item.name)!} />
@@ -340,6 +289,7 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
paddingAngle={2}
strokeWidth={2}
stroke="var(--card)"
isAnimationActive={false}
>
{expenseData.map((item, i) => (
<Cell key={i} fill={expenseColorMap.get(item.name)!} />
@@ -406,9 +356,10 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
paddingAngle={2}
strokeWidth={2}
stroke="var(--card)"
isAnimationActive={false}
>
{ccData.map((_, i) => (
<Cell key={i} fill={ccColors[i % ccColors.length]} />
{ccData.map((item) => (
<Cell key={item.name} fill={ccColorOf(item.name)} />
))}
</Pie>
<ChartTooltip
@@ -424,11 +375,11 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
</PieChart>
</ChartContainer>
<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
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>
</div>

View File

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

View File

@@ -8,6 +8,7 @@ import {
import { formatAmount } from '@/lib/format';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { DataTable } from '@/components/ui/data-table';
import { DataTableColumnHeader } from '@/components/ui/data-table-column-header';
import { Pencil, Plus, Trash2 } from 'lucide-react';
@@ -153,6 +154,8 @@ export default function RecurringItemsManager({
</Button>
</div>
<Card>
<CardContent className="p-0">
<DataTable
columns={columns}
data={items}
@@ -161,6 +164,8 @@ export default function RecurringItemsManager({
initialSorting={[{ id: 'item_type', desc: false }]}
emptyMessage="No hay items recurrentes."
/>
</CardContent>
</Card>
<RecurringItemDialog
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,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import ConfirmDialog from '@/components/ConfirmDialog';
import { Label } from '@/components/ui/label';
/** Mirror of the backend cuota split (services/installments.py): truncate to
@@ -92,6 +93,7 @@ export default function ConvertToInstallmentsDialog({
: tx?.date ?? new Date().toISOString();
const [numStr, setNumStr] = useState('3');
const [confirmUnconvert, setConfirmUnconvert] = useState(false);
const [firstDate, setFirstDate] = useState('');
const [saving, setSaving] = useState(false);
@@ -142,10 +144,7 @@ export default function ConvertToInstallmentsDialog({
const handleUnconvert = async () => {
if (!effectivePlan) return;
const confirmed = window.confirm(
'Se eliminarán las cuotas y la compra volverá a contar como un solo cargo. ¿Continuar?',
);
if (!confirmed) return;
setConfirmUnconvert(false);
setSaving(true);
try {
await deleteInstallmentPlan(effectivePlan.id);
@@ -248,7 +247,7 @@ export default function ConvertToInstallmentsDialog({
<Button
variant="outline"
className="text-destructive hover:text-destructive"
onClick={handleUnconvert}
onClick={() => setConfirmUnconvert(true)}
disabled={saving}
>
Deshacer Tasa Cero
@@ -273,6 +272,17 @@ export default function ConvertToInstallmentsDialog({
</div>
</DialogFooter>
</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>
);
}

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

View File

@@ -8,6 +8,7 @@ import {
} from "@copilotkit/react-core/v2";
import { useCopilotAction } from "@copilotkit/react-core";
import { Sparkles } from "lucide-react";
import { PageHeader } from '@/components/page-header';
import { SpendingSummaryCard, type SpendingSummaryArgs } from "@/components/chat/ChatCards";
const STATIC_SUGGESTIONS = {
@@ -87,13 +88,11 @@ export default function Asistente() {
// 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="mb-4">
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2" style={{ fontFamily: "var(--font-heading)" }}>
<Sparkles className="w-5 h-5 text-primary" />
Asistente
</h1>
<p className="text-sm text-muted-foreground">
Pregúntale a WealthySmart sobre tus finanzas.
</p>
<PageHeader
icon={Sparkles}
title="Asistente"
subtitle="Pregúntale a WealthySmart sobre tus finanzas."
/>
</div>
<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 { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { PageHeader } from '@/components/page-header';
import ErrorState from '@/components/ErrorState';
import PeriodNavigator from '@/components/PeriodNavigator';
import MonthlyDetail from '@/components/budget/MonthlyDetail';
@@ -128,13 +129,11 @@ export default function Budget() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{cameFromProyecciones && (
<Button
variant="ghost"
size="sm"
className="mr-4 text-muted-foreground"
className="-mb-4 text-muted-foreground"
onClick={() => navigate('/proyecciones')}
aria-label="Volver a Proyecciones"
>
@@ -142,9 +141,11 @@ export default function Budget() {
Proyecciones
</Button>
)}
<Calculator className="w-6 h-6 text-primary" />
<h1 className="text-2xl font-bold tracking-tight">Presupuesto</h1>
</div>
<PageHeader
icon={Calculator}
title="Presupuesto"
subtitle="Proyección y transacciones del mes"
actions={
<PeriodNavigator
label={String(year)}
onPrev={() => setYear(year - 1)}
@@ -152,7 +153,8 @@ export default function Budget() {
prevDisabled={year <= MIN_YEAR}
nextDisabled={year >= MAX_YEAR}
/>
</div>
}
/>
<Tabs defaultValue="overview">
<TabsList>

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { useState, useMemo, useCallback, useRef } from 'react';
import { useQuery } from '@tanstack/react-query';
import ErrorState from '@/components/ErrorState';
import { PageHeader } from '@/components/page-header';
import PensionAssumptionsDialog, { type PensionAssumptions } from '@/components/PensionAssumptionsDialog';
import {
LineChart,
@@ -103,7 +104,7 @@ const FUNDS_DEFAULT: Record<FundKey, FundDef> = {
key: 'FCL',
name: 'FCL',
fullName: 'Fondo de Capitalización Laboral',
color: '#3b82f6',
color: 'var(--chart-5)',
startBalance: 650_468,
monthlyContribution: 150_000,
annualRate: 7.5,
@@ -115,7 +116,7 @@ const FUNDS_DEFAULT: Record<FundKey, FundDef> = {
key: 'ROP',
name: 'ROP',
fullName: 'Régimen Obligatorio de Pensiones',
color: '#10b981',
color: 'var(--chart-1)',
startBalance: 18_684_765,
monthlyContribution: 120_000,
annualRate: 6.0,
@@ -127,7 +128,7 @@ const FUNDS_DEFAULT: Record<FundKey, FundDef> = {
key: 'VOL',
name: 'VOL',
fullName: 'Fondo Voluntario',
color: '#f43f5e',
color: 'var(--chart-4)',
startBalance: 2_500_381,
monthlyContribution: 400_000,
annualRate: 8.0,
@@ -480,17 +481,11 @@ export default function Pensions() {
/>
)}
{/* ── Page Header ─────────────────────────────────────────────────── */}
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
<PiggyBank className="w-5 h-5 text-primary" />
</div>
<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>
<PageHeader
icon={PiggyBank}
title="Pensiones"
subtitle="Seguimiento de aportes, rendimientos y proyecciones"
/>
{/* ── Section 1: Fund Overview Cards ──────────────────────────────── */}
<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"
style={{
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)',
}}
>
@@ -652,6 +647,7 @@ export default function Pensions() {
stroke={FUNDS[key].color}
strokeWidth={2}
dot={false}
isAnimationActive={false}
activeDot={{ r: 4 }}
/>
) : null,

View File

@@ -4,6 +4,8 @@ import { Line, LineChart, XAxis, YAxis } from 'recharts';
import { Telescope } from 'lucide-react';
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 { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -78,15 +80,11 @@ export default function Planificador() {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Telescope className="w-6 h-6 text-primary" aria-hidden="true" />
<div>
<h1 className="text-2xl font-bold tracking-tight">Planificador</h1>
<p className="text-sm text-muted-foreground">
¿Qué pasa si ahorrás un poco más cada mes?
</p>
</div>
</div>
<PageHeader
icon={Telescope}
title="Planificador"
subtitle="¿Qué pasa si ahorrás un poco más cada mes?"
/>
<Card>
<CardContent className="p-4 grid grid-cols-2 lg:grid-cols-4 gap-4">
@@ -138,41 +136,18 @@ export default function Planificador() {
</Card>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground uppercase tracking-wider">
Ritmo actual
</p>
<p data-sensitive className="text-xl font-bold font-mono">
{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>
<StatTile label="Ritmo actual" value={formatCRC(baselineEnd)} />
<StatTile label="Escenario" value={formatCRC(scenarioEnd)} />
<StatTile
label="Diferencia"
value={`+${formatCRC(scenarioEnd - baselineEnd)}`}
className="[&_span[data-sensitive]]:text-primary"
/>
</div>
<Card>
<CardHeader>
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground">
<CardTitle className="text-sm font-medium text-muted-foreground">
Crecimiento comparado
</CardTitle>
</CardHeader>
@@ -191,8 +166,8 @@ export default function Planificador() {
<ChartTooltipContent formatter={(value) => formatCRC(Number(value))} />
}
/>
<Line type="monotone" dataKey="baseline" stroke="var(--chart-2)" dot={false} strokeWidth={2} />
<Line type="monotone" dataKey="scenario" stroke="var(--chart-1)" 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} isAnimationActive={false} />
</LineChart>
</ChartContainer>
<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 { cn } from '@/lib/utils';
import { Card, CardContent } from '@/components/ui/card';
import { PageHeader } from '@/components/page-header';
import ErrorState from '@/components/ErrorState';
import PeriodNavigator from '@/components/PeriodNavigator';
import YearlyOverview from '@/components/budget/YearlyOverview';
@@ -30,12 +31,11 @@ export default function Proyecciones() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<TrendingUp className="w-6 h-6 text-primary" />
<h1 className="text-2xl font-bold tracking-tight">Proyecciones</h1>
</div>
<PageHeader
icon={TrendingUp}
title="Proyecciones"
subtitle="Presupuesto anual mes a mes"
actions={
<PeriodNavigator
label={String(year)}
onPrev={() => setYear(year - 1)}
@@ -43,7 +43,8 @@ export default function Proyecciones() {
prevDisabled={year <= MIN_YEAR}
nextDisabled={year >= MAX_YEAR}
/>
</div>
}
/>
{/* Annual summary cards */}
{projection && (

View File

@@ -1,11 +1,13 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
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 ErrorState from '@/components/ErrorState';
import { PageHeader } from '@/components/page-header';
import { StatTile } from '@/components/stat-tile';
import { DataTable } from '@/components/ui/data-table';
import { DataTableColumnHeader } from '@/components/ui/data-table-column-header';
import { Card, CardContent } from '@/components/ui/card';
@@ -87,17 +89,12 @@ export default function Salarios() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
<Landmark className="w-5 h-5 text-primary" />
</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>
<PageHeader
icon={Landmark}
title="Salarios"
subtitle="Historial de depósitos salariales"
actions={
<>
<Button
variant="outline"
size="sm"
@@ -110,44 +107,30 @@ export default function Salarios() {
<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'} />
</Button>
</div>
</>
}
/>
{/* Summary cards */}
{summary && (
{/* Summary tiles */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Hash className="w-4 h-4" />
<span className="text-xs font-medium uppercase tracking-wider">Depósitos</span>
<StatTile
label="Depósitos"
value={summary ? String(summary.count) : null}
loading={query.isPending}
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>
<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 */}
{query.isError ? (

View File

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

View File

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