mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 10:28:47 +02:00
Compare commits
5 Commits
10d9bd209f
...
3a258537fc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a258537fc | ||
|
|
41d4306e86 | ||
|
|
9907ff265e | ||
|
|
2cf19f1880 | ||
|
|
a95486481b |
@@ -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))
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
10
frontend/pnpm-lock.yaml
generated
10
frontend/pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
@@ -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>();
|
|
||||||
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>();
|
const map = new Map<string, string>();
|
||||||
data.forEach((item, i) => {
|
sorted.forEach((item, i) => map.set(item.name, rampColor(baseVar, i, sorted.length)));
|
||||||
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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,14 +154,18 @@ export default function RecurringItemsManager({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<Card>
|
||||||
columns={columns}
|
<CardContent className="p-0">
|
||||||
data={items}
|
<DataTable
|
||||||
pagination
|
columns={columns}
|
||||||
pageSize={20}
|
data={items}
|
||||||
initialSorting={[{ id: 'item_type', desc: false }]}
|
pagination
|
||||||
emptyMessage="No hay items recurrentes."
|
pageSize={20}
|
||||||
/>
|
initialSorting={[{ id: 'item_type', desc: false }]}
|
||||||
|
emptyMessage="No hay items recurrentes."
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<RecurringItemDialog
|
<RecurringItemDialog
|
||||||
open={dialogOpen}
|
open={dialogOpen}
|
||||||
|
|||||||
41
frontend/src/components/page-header.tsx
Normal file
41
frontend/src/components/page-header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
frontend/src/components/stat-tile.tsx
Normal file
96
frontend/src/components/stat-tile.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
104
frontend/src/components/ui/empty.tsx
Normal file
104
frontend/src/components/ui/empty.tsx
Normal 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,
|
||||||
|
}
|
||||||
236
frontend/src/components/ui/field.tsx
Normal file
236
frontend/src/components/ui/field.tsx
Normal 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,
|
||||||
|
}
|
||||||
156
frontend/src/components/ui/input-group.tsx
Normal file
156
frontend/src/components/ui/input-group.tsx
Normal 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,
|
||||||
|
}
|
||||||
201
frontend/src/components/ui/item.tsx
Normal file
201
frontend/src/components/ui/item.tsx
Normal 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,
|
||||||
|
}
|
||||||
26
frontend/src/components/ui/kbd.tsx
Normal file
26
frontend/src/components/ui/kbd.tsx
Normal 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 }
|
||||||
90
frontend/src/components/ui/popover.tsx
Normal file
90
frontend/src/components/ui/popover.tsx
Normal 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,
|
||||||
|
}
|
||||||
81
frontend/src/components/ui/progress.tsx
Normal file
81
frontend/src/components/ui/progress.tsx
Normal 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,
|
||||||
|
}
|
||||||
10
frontend/src/components/ui/spinner.tsx
Normal file
10
frontend/src/components/ui/spinner.tsx
Normal 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 }
|
||||||
89
frontend/src/components/ui/toggle-group.tsx
Normal file
89
frontend/src/components/ui/toggle-group.tsx
Normal 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 }
|
||||||
43
frontend/src/components/ui/toggle.tsx
Normal file
43
frontend/src/components/ui/toggle.tsx
Normal 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 }
|
||||||
@@ -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);
|
||||||
|
|||||||
32
frontend/src/lib/charts.ts
Normal file
32
frontend/src/lib/charts.ts
Normal 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))`;
|
||||||
|
}
|
||||||
@@ -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')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 = {
|
const trendChartConfig = {
|
||||||
total_crc: {
|
total_crc: { label: 'Total CRC', color: 'var(--chart-1)' },
|
||||||
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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,31 +129,32 @@ 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">
|
{cameFromProyecciones && (
|
||||||
<div className="flex items-center gap-3">
|
<Button
|
||||||
{cameFromProyecciones && (
|
variant="ghost"
|
||||||
<Button
|
size="sm"
|
||||||
variant="ghost"
|
className="-mb-4 text-muted-foreground"
|
||||||
size="sm"
|
onClick={() => navigate('/proyecciones')}
|
||||||
className="mr-4 text-muted-foreground"
|
aria-label="Volver a Proyecciones"
|
||||||
onClick={() => navigate('/proyecciones')}
|
>
|
||||||
aria-label="Volver a Proyecciones"
|
<ArrowLeft className="w-4 h-4 mr-1" aria-hidden="true" />
|
||||||
>
|
Proyecciones
|
||||||
<ArrowLeft className="w-4 h-4 mr-1" aria-hidden="true" />
|
</Button>
|
||||||
Proyecciones
|
)}
|
||||||
</Button>
|
<PageHeader
|
||||||
)}
|
icon={Calculator}
|
||||||
<Calculator className="w-6 h-6 text-primary" />
|
title="Presupuesto"
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Presupuesto</h1>
|
subtitle="Proyección y transacciones del mes"
|
||||||
</div>
|
actions={
|
||||||
<PeriodNavigator
|
<PeriodNavigator
|
||||||
label={String(year)}
|
label={String(year)}
|
||||||
onPrev={() => setYear(year - 1)}
|
onPrev={() => setYear(year - 1)}
|
||||||
onNext={() => setYear(year + 1)}
|
onNext={() => setYear(year + 1)}
|
||||||
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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,44 +37,35 @@ 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>
|
loading={q.isPending}
|
||||||
<CardDescription>
|
to="/budget"
|
||||||
Ciclo al 18 {MONTH_NAMES_ES_SHORT[CYCLE.month].toLowerCase()}
|
ariaLabel="Ver presupuesto"
|
||||||
</CardDescription>
|
>
|
||||||
</CardHeader>
|
{balance !== null && (
|
||||||
<CardContent className="space-y-1">
|
<p className="text-xs text-muted-foreground">
|
||||||
<StatValue
|
Balance proyectado:{" "}
|
||||||
loading={q.isPending}
|
<span
|
||||||
value={
|
data-sensitive
|
||||||
q.isError ? null : spend !== null ? formatAmount(spend, "CRC") : null
|
className={cn(
|
||||||
}
|
"font-mono font-medium",
|
||||||
/>
|
balance >= 0 ? "text-primary" : "text-destructive",
|
||||||
{balance !== null && (
|
)}
|
||||||
<p className="text-xs text-muted-foreground">
|
>
|
||||||
Balance proyectado:{" "}
|
{balance >= 0 ? "+" : "-"}
|
||||||
<span
|
{formatAmount(balance, "CRC")}
|
||||||
data-sensitive
|
</span>
|
||||||
className={cn(
|
</p>
|
||||||
"font-mono font-medium",
|
)}
|
||||||
balance >= 0 ? "text-primary" : "text-destructive",
|
{q.isError && (
|
||||||
)}
|
<p className="text-xs text-muted-foreground">
|
||||||
>
|
No se pudo cargar el ciclo.
|
||||||
{balance >= 0 ? "+" : "-"}
|
</p>
|
||||||
{formatAmount(balance, "CRC")}
|
)}
|
||||||
</span>
|
</StatTile>
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{q.isError && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
No se pudo cargar el ciclo.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</StatCardShell>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,45 +94,40 @@ 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>
|
? `Tasa Cero · próximo cobro ${formatShortDate(nextDate)}`
|
||||||
<CardDescription>
|
: "Tasa Cero"
|
||||||
{nextDate
|
}
|
||||||
? `Tasa Cero · próximo cobro ${formatShortDate(nextDate)}`
|
value={q.isError ? null : formatAmount(nextBatchTotal, "CRC")}
|
||||||
: "Tasa Cero"}
|
loading={q.isPending}
|
||||||
</CardDescription>
|
to="/financiamientos"
|
||||||
</CardHeader>
|
ariaLabel="Ver financiamientos"
|
||||||
<CardContent className="space-y-2">
|
>
|
||||||
<StatValue
|
{topThree.length > 0 && (
|
||||||
loading={q.isPending}
|
<ul className="space-y-0.5 text-xs text-muted-foreground">
|
||||||
value={q.isError ? null : formatAmount(nextBatchTotal, "CRC")}
|
{topThree.map((p) => (
|
||||||
/>
|
<li key={p.id} className="flex justify-between gap-2">
|
||||||
{topThree.length > 0 && (
|
<span className="truncate">{p.merchant}</span>
|
||||||
<ul className="space-y-0.5 text-xs text-muted-foreground">
|
<span data-sensitive className="font-mono shrink-0">
|
||||||
{topThree.map((p) => (
|
{formatShortDate(p.next_cuota_date!)} ·{" "}
|
||||||
<li key={p.id} className="flex justify-between gap-2">
|
{formatAmount(nextCuotaOf(p), p.currency)}
|
||||||
<span className="truncate">{p.merchant}</span>
|
</span>
|
||||||
<span data-sensitive className="font-mono shrink-0">
|
</li>
|
||||||
{formatShortDate(p.next_cuota_date!)} ·{" "}
|
))}
|
||||||
{formatAmount(nextCuotaOf(p), p.currency)}
|
</ul>
|
||||||
</span>
|
)}
|
||||||
</li>
|
{q.data && (
|
||||||
))}
|
<p className="text-xs text-muted-foreground">
|
||||||
</ul>
|
Restante total:{" "}
|
||||||
)}
|
<span data-sensitive className="font-mono font-medium">
|
||||||
{q.data && (
|
{formatAmount(q.data.total_remaining, "CRC")}
|
||||||
<p className="text-xs text-muted-foreground">
|
</span>
|
||||||
Restante total:{" "}
|
</p>
|
||||||
<span data-sensitive className="font-mono font-medium">
|
)}
|
||||||
{formatAmount(q.data.total_remaining, "CRC")}
|
</StatTile>
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</StatCardShell>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,39 +136,30 @@ 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>
|
loading={q.isPending}
|
||||||
<CardDescription>Saldo total de fondos</CardDescription>
|
to="/pensions"
|
||||||
</CardHeader>
|
ariaLabel="Ver pensiones"
|
||||||
<CardContent className="space-y-2">
|
>
|
||||||
<StatValue
|
{q.data && q.data.length > 0 && (
|
||||||
loading={q.isPending}
|
<ul className="space-y-0.5 text-xs text-muted-foreground">
|
||||||
value={
|
{q.data.map((f) => (
|
||||||
q.isError ? null : total !== null ? formatAmount(total, "CRC") : null
|
<li key={f.fund} className="flex justify-between gap-2">
|
||||||
}
|
<span>{f.fund}</span>
|
||||||
/>
|
<span data-sensitive className="font-mono">
|
||||||
{q.data && q.data.length > 0 && (
|
{formatAmount(f.saldo_final, "CRC")}
|
||||||
<ul className="space-y-0.5 text-xs text-muted-foreground">
|
</span>
|
||||||
{q.data.map((f) => (
|
</li>
|
||||||
<li key={f.fund} className="flex justify-between gap-2">
|
))}
|
||||||
<span>{f.fund}</span>
|
</ul>
|
||||||
<span data-sensitive className="font-mono">
|
)}
|
||||||
{formatAmount(f.saldo_final, "CRC")}
|
</StatTile>
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</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 />
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,20 +31,20 @@ 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)}
|
onNext={() => setYear(year + 1)}
|
||||||
onNext={() => setYear(year + 1)}
|
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 && (
|
||||||
|
|||||||
@@ -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,67 +89,48 @@ 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>
|
<Button
|
||||||
<h1 className="text-2xl font-bold font-heading">Salarios</h1>
|
variant="outline"
|
||||||
<p className="text-sm text-muted-foreground">Historial de depósitos salariales</p>
|
size="sm"
|
||||||
</div>
|
onClick={() => window.open('/api/v1/transactions/export?transaction_type=SALARY', '_blank')}
|
||||||
</div>
|
title="Descargar salarios como CSV"
|
||||||
<Button
|
>
|
||||||
variant="outline"
|
<Download className="w-4 h-4 mr-2" aria-hidden="true" />
|
||||||
size="sm"
|
CSV
|
||||||
onClick={() => window.open('/api/v1/transactions/export?transaction_type=SALARY', '_blank')}
|
</Button>
|
||||||
title="Descargar salarios como CSV"
|
<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'} />
|
||||||
<Download className="w-4 h-4 mr-2" aria-hidden="true" />
|
</Button>
|
||||||
CSV
|
</>
|
||||||
</Button>
|
}
|
||||||
<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 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">
|
<StatTile
|
||||||
<Card>
|
label="Depósitos"
|
||||||
<CardContent className="p-4">
|
value={summary ? String(summary.count) : null}
|
||||||
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
loading={query.isPending}
|
||||||
<Hash className="w-4 h-4" />
|
sensitive={false}
|
||||||
<span className="text-xs font-medium uppercase tracking-wider">Depósitos</span>
|
/>
|
||||||
</div>
|
<StatTile
|
||||||
<span className="text-2xl font-bold font-mono">{summary.count}</span>
|
label="Total acumulado"
|
||||||
</CardContent>
|
value={summary ? formatAmount(summary.total_amount, 'CRC') : null}
|
||||||
</Card>
|
loading={query.isPending}
|
||||||
<Card>
|
/>
|
||||||
<CardContent className="p-4">
|
<StatTile
|
||||||
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
label="Último depósito"
|
||||||
<Banknote className="w-4 h-4" />
|
value={summary?.latest_date ? formatDate(summary.latest_date) : null}
|
||||||
<span className="text-xs font-medium uppercase tracking-wider">Total acumulado</span>
|
loading={query.isPending}
|
||||||
</div>
|
sensitive={false}
|
||||||
<span data-sensitive className="text-2xl font-bold font-mono text-primary">
|
/>
|
||||||
{formatAmount(summary.total_amount, 'CRC')}
|
</div>
|
||||||
</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 ? (
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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,29 +49,25 @@ 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">
|
<Button
|
||||||
Estado de los flujos automáticos de datos (n8n y tipo de cambio)
|
variant="outline"
|
||||||
</p>
|
size="sm"
|
||||||
</div>
|
onClick={() => query.refetch()}
|
||||||
</div>
|
disabled={query.isFetching}
|
||||||
<Button
|
>
|
||||||
variant="outline"
|
<RefreshCw
|
||||||
size="sm"
|
className={query.isFetching ? 'w-4 h-4 mr-2 animate-spin' : 'w-4 h-4 mr-2'}
|
||||||
onClick={() => query.refetch()}
|
aria-hidden="true"
|
||||||
disabled={query.isFetching}
|
/>
|
||||||
>
|
Actualizar
|
||||||
<RefreshCw
|
</Button>
|
||||||
className={query.isFetching ? 'w-4 h-4 mr-2 animate-spin' : 'w-4 h-4 mr-2'}
|
}
|
||||||
aria-hidden="true"
|
/>
|
||||||
/>
|
|
||||||
Actualizar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{query.isError ? (
|
{query.isError ? (
|
||||||
<ErrorState
|
<ErrorState
|
||||||
@@ -75,37 +82,37 @@ 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>
|
||||||
>
|
<StatusIcon status={s.status} />
|
||||||
<CardContent className="p-4 flex items-center gap-4">
|
</ItemMedia>
|
||||||
<StatusIcon status={s.status} />
|
<ItemContent>
|
||||||
<div className="flex-1 min-w-0">
|
<ItemTitle>
|
||||||
<div className="flex items-center gap-2">
|
{s.label}
|
||||||
<span className="font-medium">{s.label}</span>
|
{s.status !== 'ok' && (
|
||||||
{s.status !== 'ok' && (
|
<Badge variant="destructive">Atención</Badge>
|
||||||
<Badge variant="destructive">Atención</Badge>
|
)}
|
||||||
)}
|
</ItemTitle>
|
||||||
</div>
|
<ItemDescription>{s.description}</ItemDescription>
|
||||||
<p className="text-sm text-muted-foreground truncate">
|
</ItemContent>
|
||||||
{s.description}
|
<div className="text-right shrink-0">
|
||||||
</p>
|
<p className="text-sm font-medium">{statusLabel(s)}</p>
|
||||||
</div>
|
<p className="text-xs text-muted-foreground">
|
||||||
<div className="text-right shrink-0">
|
{s.last_received
|
||||||
<p className="text-sm font-medium">{statusLabel(s)}</p>
|
? `Último: ${formatRelativeAge(s.last_received)}`
|
||||||
<p className="text-xs text-muted-foreground">
|
: '—'}
|
||||||
{s.last_received
|
</p>
|
||||||
? `Último: ${formatRelativeAge(s.last_received)}`
|
</div>
|
||||||
: '—'}
|
</Item>
|
||||||
</p>
|
{i < query.data.sources.length - 1 && <ItemSeparator />}
|
||||||
</div>
|
</Fragment>
|
||||||
</CardContent>
|
))}
|
||||||
</Card>
|
</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
|
||||||
|
|||||||
Reference in New Issue
Block a user