Compare commits

...

3 Commits

Author SHA1 Message Date
Carlos Escalante
2cd0d3b2e1 Migrate all components and pages to shadcn/ui with DataTable
All checks were successful
Deploy to VPS / deploy (push) Successful in 28s
Replace custom markup across all pages and components with shadcn/ui
primitives (Dialog, Sheet, Select, Card, Tabs, etc.). Add reusable
DataTable component powered by @tanstack/react-table with sortable
column headers and client-side pagination. Introduce TransactionList
with responsive mobile cards and desktop DataTable, dashboard section
customization (DashboardSection, SectionConfigDialog), and settings
API types.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 14:45:44 -06:00
Carlos Escalante
46f2d8679c Add shadcn/ui design system, theme, and base components
Install shadcn/ui with base-nova style, tailwind-merge, tw-animate-css,
and font packages. Add oklch-based light/dark theme in index.css and
17 base UI components (button, card, dialog, table, tabs, etc.) plus
shared lib utilities (format, colors, cn) and useSettings hook.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 14:45:33 -06:00
Carlos Escalante
4d468036c6 Add user settings endpoint and exchange rate fallback APIs
Backend now stores user settings (dashboard config) in a JSONB column and
exposes CRUD via /settings/. Exchange rate service gains multiple fallback
providers (ExchangeRate-API, currency-api, FloatRates) so USD/CRC rates
stay available when BCCR is down.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 14:45:20 -06:00
46 changed files with 6815 additions and 1281 deletions

View File

@@ -0,0 +1,59 @@
from datetime import datetime
from fastapi import APIRouter, Depends
from sqlmodel import Session, select
from app.auth import get_current_user
from app.db import get_session
from app.models.models import UserSettings, UserSettingsRead, UserSettingsUpdate
router = APIRouter(prefix="/settings", tags=["settings"])
DEFAULT_SETTINGS = {
"dashboard": {
"sections": {
"crc_accounts": {"label": "CRC Accounts", "color": "primary", "cardColor": "primary", "visible": True, "order": 0, "expanded": False},
"usd_accounts": {"label": "USD Accounts", "color": "chart-1", "cardColor": "chart-1", "visible": True, "order": 1, "expanded": False},
"pension": {"label": "Pension", "color": "chart-2", "cardColor": "chart-2", "visible": True, "order": 2, "expanded": False},
"savings": {"label": "Savings", "color": "chart-3", "cardColor": "chart-3", "visible": True, "order": 3, "expanded": False},
"liabilities": {"label": "Liabilities", "color": "destructive", "cardColor": "destructive", "visible": True, "order": 4, "expanded": False},
"crypto": {"label": "Crypto", "color": "chart-4", "cardColor": "chart-4", "visible": True, "order": 5, "expanded": False},
}
}
}
@router.get("/", response_model=UserSettingsRead)
def get_settings(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
settings = session.exec(
select(UserSettings).where(UserSettings.key == "default")
).first()
if not settings:
settings = UserSettings(key="default", data=DEFAULT_SETTINGS)
session.add(settings)
session.commit()
session.refresh(settings)
return settings
@router.patch("/", response_model=UserSettingsRead)
def update_settings(
body: UserSettingsUpdate,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
settings = session.exec(
select(UserSettings).where(UserSettings.key == "default")
).first()
if not settings:
settings = UserSettings(key="default", data=body.data)
else:
settings.data = body.data
settings.updated_at = datetime.utcnow()
session.add(settings)
session.commit()
session.refresh(settings)
return settings

View File

@@ -7,6 +7,7 @@ from app.api.v1.endpoints import (
categories, categories,
exchange_rate, exchange_rate,
import_transactions, import_transactions,
settings,
tokens, tokens,
transactions, transactions,
) )
@@ -20,3 +21,4 @@ api_router.include_router(import_transactions.router)
api_router.include_router(exchange_rate.router) api_router.include_router(exchange_rate.router)
api_router.include_router(tokens.router) api_router.include_router(tokens.router)
api_router.include_router(analytics.router) api_router.include_router(analytics.router)
api_router.include_router(settings.router)

View File

@@ -2,6 +2,8 @@ import enum
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from sqlalchemy import Column
from sqlalchemy.dialects.postgresql import JSONB
from sqlmodel import Field, Relationship, SQLModel from sqlmodel import Field, Relationship, SQLModel
@@ -195,3 +197,26 @@ class APITokenRead(SQLModel):
created_at: datetime created_at: datetime
expires_at: Optional[datetime] expires_at: Optional[datetime]
is_active: bool is_active: bool
# --- User Settings ---
class UserSettings(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
key: str = Field(index=True, unique=True, default="default")
data: dict = Field(
default_factory=dict,
sa_column=Column(JSONB, nullable=False, server_default="{}"),
)
updated_at: datetime = Field(default_factory=datetime.utcnow)
class UserSettingsRead(SQLModel):
key: str
data: dict
updated_at: datetime
class UserSettingsUpdate(SQLModel):
data: dict

View File

@@ -10,7 +10,17 @@ from app.models.models import ExchangeRate
# BCCR indicators: 317 = buy, 318 = sell # BCCR indicators: 317 = buy, 318 = sell
BCCR_URL = "https://gee.bccr.fi.cr/Indicadores/Suscripciones/WS/wsindicadoreseconomicos.asmx/ObtenerIndicadoresEconomicos" BCCR_URL = "https://gee.bccr.fi.cr/Indicadores/Suscripciones/WS/wsindicadoreseconomicos.asmx/ObtenerIndicadoresEconomicos"
# Fallback APIs (no API key required, all support CRC)
EXCHANGERATE_API_URL = "https://open.er-api.com/v6/latest/USD"
CURRENCY_API_URL = "https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/usd.json"
CURRENCY_API_FALLBACK_URL = "https://latest.currency-api.pages.dev/v1/currencies/usd.json"
FLOATRATES_URL = "https://www.floatrates.com/daily/usd.json"
# Typical buy/sell spread for USD/CRC (~0.5% each side of mid-market)
_SPREAD = 0.005
_cache: dict[str, tuple[ExchangeRate, datetime]] = {} _cache: dict[str, tuple[ExchangeRate, datetime]] = {}
_last_known: ExchangeRate | None = None # survives cache expiry — always holds the last successful rate
CACHE_TTL = timedelta(hours=1) CACHE_TTL = timedelta(hours=1)
@@ -29,10 +39,7 @@ def _fetch_bccr_rate(indicator: int, date_str: str) -> float | None:
resp = httpx.get(BCCR_URL, params=params, timeout=10) resp = httpx.get(BCCR_URL, params=params, timeout=10)
resp.raise_for_status() resp.raise_for_status()
# Parse XML response
root = ET.fromstring(resp.text) root = ET.fromstring(resp.text)
# The value is in INGC011_DES_DATOS > NUM_VALOR
ns = {"": "http://ws.sdde.bccr.fi.cr"}
for datos in root.iter(): for datos in root.iter():
if datos.tag.endswith("NUM_VALOR"): if datos.tag.endswith("NUM_VALOR"):
return float(datos.text.strip().replace(",", ".")) return float(datos.text.strip().replace(",", "."))
@@ -41,14 +48,92 @@ def _fetch_bccr_rate(indicator: int, date_str: str) -> float | None:
return None return None
def _fetch_bccr() -> tuple[float, float] | None:
"""Try BCCR official API. Returns (buy, sell) or None."""
today = datetime.now().strftime("%d/%m/%Y")
buy = _fetch_bccr_rate(317, today)
sell = _fetch_bccr_rate(318, today)
if buy is not None and sell is not None:
return (buy, sell)
return None
def _mid_to_buy_sell(mid: float) -> tuple[float, float]:
"""Convert a mid-market rate to approximate buy/sell with a spread."""
return (mid * (1 - _SPREAD), mid * (1 + _SPREAD))
def _fetch_exchangerate_api() -> tuple[float, float] | None:
"""Try ExchangeRate-API (open.er-api.com). No key required."""
try:
resp = httpx.get(EXCHANGERATE_API_URL, timeout=10)
resp.raise_for_status()
data = resp.json()
if data.get("result") == "success":
crc = data["rates"].get("CRC")
if crc:
return _mid_to_buy_sell(float(crc))
except Exception:
pass
return None
def _fetch_currency_api() -> tuple[float, float] | None:
"""Try fawazahmed0/currency-api (CDN-hosted). No key required."""
for url in (CURRENCY_API_URL, CURRENCY_API_FALLBACK_URL):
try:
resp = httpx.get(url, timeout=10)
resp.raise_for_status()
data = resp.json()
crc = data.get("usd", {}).get("crc")
if crc:
return _mid_to_buy_sell(float(crc))
except Exception:
continue
return None
def _fetch_floatrates() -> tuple[float, float] | None:
"""Try FloatRates. No key required."""
try:
resp = httpx.get(FLOATRATES_URL, timeout=10)
resp.raise_for_status()
data = resp.json()
crc_data = data.get("crc")
if crc_data and "rate" in crc_data:
return _mid_to_buy_sell(float(crc_data["rate"]))
except Exception:
pass
return None
def _fetch_rate_from_apis() -> tuple[float, float] | None:
"""Try all sources in order: BCCR → ExchangeRate-API → currency-api → FloatRates."""
for fetcher in (_fetch_bccr, _fetch_exchangerate_api, _fetch_currency_api, _fetch_floatrates):
result = fetcher()
if result is not None:
return result
return None
def _remember(rate: ExchangeRate) -> ExchangeRate:
"""Store rate in both TTL cache and permanent last-known holder."""
global _last_known
_cache["current"] = (rate, datetime.utcnow())
_last_known = rate
return rate
def get_current_rate(session: Session) -> ExchangeRate | None: def get_current_rate(session: Session) -> ExchangeRate | None:
"""Get current USD/CRC rate. Uses in-memory cache + DB fallback.""" """Get current USD/CRC rate. Never returns None once a rate has been fetched."""
# Check memory cache global _last_known
# 1. Fresh memory cache (< 1 hour)
cached = _cache.get("current") cached = _cache.get("current")
if cached and datetime.utcnow() - cached[1] < CACHE_TTL: if cached and datetime.utcnow() - cached[1] < CACHE_TTL:
return cached[0] return cached[0]
# Check DB for recent rate # 2. Fresh DB rate (< 1 hour)
one_hour_ago = datetime.utcnow() - CACHE_TTL one_hour_ago = datetime.utcnow() - CACHE_TTL
db_rate = session.exec( db_rate = session.exec(
select(ExchangeRate) select(ExchangeRate)
@@ -56,31 +141,30 @@ def get_current_rate(session: Session) -> ExchangeRate | None:
.order_by(col(ExchangeRate.fetched_at).desc()) .order_by(col(ExchangeRate.fetched_at).desc())
).first() ).first()
if db_rate: if db_rate:
_cache["current"] = (db_rate, datetime.utcnow()) return _remember(db_rate)
return db_rate
# Fetch from BCCR # 3. Try all API sources
today = datetime.now().strftime("%d/%m/%Y") result = _fetch_rate_from_apis()
buy = _fetch_bccr_rate(317, today) if result is not None:
sell = _fetch_bccr_rate(318, today) buy, sell = result
rate = ExchangeRate(date=datetime.utcnow(), buy_rate=buy, sell_rate=sell)
session.add(rate)
session.commit()
session.refresh(rate)
return _remember(rate)
if buy is None or sell is None: # 4. Stale DB rate (any age)
# Fallback: return most recent DB rate regardless of age fallback = session.exec(
fallback = session.exec( select(ExchangeRate).order_by(col(ExchangeRate.fetched_at).desc())
select(ExchangeRate).order_by(col(ExchangeRate.fetched_at).desc()) ).first()
).first() if fallback:
return fallback return _remember(fallback)
rate = ExchangeRate( # 5. Last known in-memory rate (survives even if DB is empty)
date=datetime.utcnow(), if _last_known:
buy_rate=buy, return _last_known
sell_rate=sell,
) return None
session.add(rate)
session.commit()
session.refresh(rate)
_cache["current"] = (rate, datetime.utcnow())
return rate
def get_rate_history(session: Session, days: int = 30) -> list[ExchangeRate]: def get_rate_history(session: Session, days: int = 30) -> list[ExchangeRate]:

25
frontend/components.json Normal file
View File

@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default-translucent",
"menuAccent": "subtle",
"registries": {}
}

View File

@@ -9,12 +9,21 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.3.0",
"@fontsource-variable/ibm-plex-sans": "^5.2.8",
"@fontsource-variable/noto-sans": "^5.2.10",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.13.6", "axios": "^1.13.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-router-dom": "^7.12.0", "react-router-dom": "^7.12.0",
"recharts": "^3.8.0" "recharts": "^2.15.4",
"shadcn": "^4.1.0",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",

2901
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -56,6 +56,34 @@ export interface ImportResult {
errors: string[]; errors: string[];
} }
// --- User Settings ---
export interface SectionSettings {
label: string;
color: string;
cardColor: string;
visible: boolean;
order: number;
expanded: boolean;
}
export interface DashboardSettings {
sections: Record<string, SectionSettings>;
}
export interface UserSettingsData {
dashboard: DashboardSettings;
}
export interface UserSettingsResponse {
key: string;
data: UserSettingsData;
updated_at: string;
}
export const getSettings = () => api.get<UserSettingsResponse>('/settings/');
export const updateSettings = (data: UserSettingsData) => api.patch<UserSettingsResponse>('/settings/', { data });
export interface Transaction { export interface Transaction {
id: number; id: number;
amount: number; amount: number;

View File

@@ -1,6 +1,13 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Calendar, ChevronDown } from 'lucide-react'; import { Calendar } from 'lucide-react';
import api from '../api'; import api from '../api';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
export interface CycleOption { export interface CycleOption {
year: number; year: number;
@@ -22,33 +29,34 @@ export default function BillingCycleSelector({ value, onChange }: Props) {
api.get('/transactions/cycles').then((r) => setCycles(r.data)); api.get('/transactions/cycles').then((r) => setCycles(r.data));
}, []); }, []);
const selectedKey = value ? `${value.year}-${value.month}` : ''; const selectedKey = value ? `${value.year}-${value.month}` : 'all';
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-text-muted" /> <Calendar className="w-4 h-4 text-muted-foreground" />
<div className="relative"> <Select
<select value={selectedKey}
value={selectedKey} onValueChange={(val) => {
onChange={(e) => { if (val === 'all') {
if (!e.target.value) { onChange(null);
onChange(null); } else {
} else { const [y, m] = val.split('-').map(Number);
const [y, m] = e.target.value.split('-').map(Number); onChange({ year: y, month: m });
onChange({ year: y, month: m }); }
} }}
}} >
className="appearance-none bg-input-bg border border-border rounded-lg pl-3 pr-9 py-2 text-sm text-text-primary focus:outline-none focus:border-[#606C38]/50 transition-colors" <SelectTrigger className="w-[180px]">
> <SelectValue />
<option value="">All time</option> </SelectTrigger>
<SelectContent>
<SelectItem value="all">All time</SelectItem>
{cycles.map((c) => ( {cycles.map((c) => (
<option key={`${c.year}-${c.month}`} value={`${c.year}-${c.month}`}> <SelectItem key={`${c.year}-${c.month}`} value={`${c.year}-${c.month}`}>
{c.label} ({c.count}) {c.label} ({c.count})
</option> </SelectItem>
))} ))}
</select> </SelectContent>
<ChevronDown className="absolute right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted pointer-events-none" /> </Select>
</div>
</div> </div>
); );
} }

View File

@@ -1,4 +1,15 @@
import { AlertTriangle, X } from 'lucide-react'; import { AlertTriangle } from 'lucide-react';
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
AlertDialogMedia,
} from '@/components/ui/alert-dialog';
interface Props { interface Props {
title: string; title: string;
@@ -11,45 +22,26 @@ interface Props {
export default function ConfirmDialog({ title, message, confirmLabel = 'Delete', onConfirm, onCancel, loading }: Props) { export default function ConfirmDialog({ title, message, confirmLabel = 'Delete', onConfirm, onCancel, loading }: Props) {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4" onClick={onCancel}> <AlertDialog open onOpenChange={(open) => { if (!open) onCancel(); }}>
<div <AlertDialogContent>
className="bg-surface border border-border rounded-xl w-full max-w-sm animate-fade-in" <AlertDialogHeader>
onClick={(e) => e.stopPropagation()} <AlertDialogMedia className="bg-destructive/10">
> <AlertTriangle className="text-destructive" />
<div className="flex items-center justify-between px-5 py-4 border-b border-border"> </AlertDialogMedia>
<h3 className="font-semibold">{title}</h3> <AlertDialogTitle>{title}</AlertDialogTitle>
<button onClick={onCancel} className="text-text-muted hover:text-text-primary transition-colors"> <AlertDialogDescription>{message}</AlertDialogDescription>
<X className="w-5 h-5" /> </AlertDialogHeader>
</button> <AlertDialogFooter>
</div> <AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
<AlertDialogAction
<div className="px-5 py-5"> variant="destructive"
<div className="flex gap-3 items-start">
<div className="w-10 h-10 rounded-full bg-red-500/10 flex items-center justify-center flex-shrink-0">
<AlertTriangle className="w-5 h-5 text-red-500 dark:text-red-400" />
</div>
<p className="text-sm text-text-secondary pt-2">{message}</p>
</div>
</div>
<div className="flex gap-3 px-5 pb-5">
<button
type="button"
onClick={onCancel}
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium text-text-secondary border border-border hover:bg-surface-hover transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={onConfirm} onClick={onConfirm}
disabled={loading} disabled={loading}
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-red-500 hover:bg-red-600 text-white transition-colors disabled:opacity-50"
> >
{loading ? 'Deleting...' : confirmLabel} {loading ? 'Deleting...' : confirmLabel}
</button> </AlertDialogAction>
</div> </AlertDialogFooter>
</div> </AlertDialogContent>
</div> </AlertDialog>
); );
} }

View File

@@ -0,0 +1,76 @@
import { Settings } from 'lucide-react';
import type { SectionSettings } from '../api';
import { formatAmount } from '@/lib/format';
import { Card } from '@/components/ui/card';
import {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from '@/components/ui/accordion';
import { getColorClasses } from '@/lib/colors';
import { cn } from '@/lib/utils';
interface Props {
sectionId: string;
settings: SectionSettings;
total?: number;
totalCurrency?: string;
onToggleExpanded: (expanded: boolean) => void;
onOpenConfig: () => void;
children: React.ReactNode;
}
export default function DashboardSection({
sectionId,
settings,
total,
totalCurrency,
onToggleExpanded,
onOpenConfig,
children,
}: Props) {
const colors = getColorClasses(settings.color);
return (
<Card className={cn('relative overflow-hidden border-l-4', colors.borderLeft)}>
{/* Settings icon — outside accordion trigger to avoid button-in-button */}
<button
onClick={onOpenConfig}
className="absolute top-2.5 right-3 z-10 p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors cursor-pointer"
title="Section settings"
aria-label="Section settings"
>
<Settings className="w-3.5 h-3.5" />
</button>
<Accordion
value={settings.expanded ? [sectionId] : []}
onValueChange={(value: string[]) => onToggleExpanded(value.includes(sectionId))}
>
<AccordionItem value={sectionId} className="border-none">
<AccordionTrigger
className="px-4 py-3 hover:no-underline cursor-pointer"
aria-label={`Expand ${settings.label}`}
>
<div className="flex items-center justify-between w-full pr-8">
<span className="text-sm font-semibold text-foreground">
{settings.label}
</span>
{total != null && totalCurrency && (
<span className="text-sm font-bold font-mono text-foreground">
{formatAmount(total, totalCurrency)}
</span>
)}
</div>
</AccordionTrigger>
<AccordionContent>
<div className="divide-y divide-border mx-4 mb-4 rounded-lg overflow-hidden">
{children}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</Card>
);
}

View File

@@ -7,13 +7,22 @@ import {
LogOut, LogOut,
Wallet, Wallet,
Menu, Menu,
X,
Sun, Sun,
Moon, Moon,
} from 'lucide-react'; } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { useAuth } from '../AuthContext'; import { useAuth } from '../AuthContext';
import { useTheme } from '../ThemeContext'; import { useTheme } from '../ThemeContext';
import { Button } from '@/components/ui/button';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetClose,
} from '@/components/ui/sheet';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
const navItems = [ const navItems = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' }, { to: '/', icon: LayoutDashboard, label: 'Dashboard' },
@@ -34,16 +43,16 @@ export default function Layout() {
}; };
return ( return (
<div className="min-h-screen bg-surface text-text-primary"> <div className="min-h-screen bg-background text-foreground">
{/* Top bar */} {/* Top bar */}
<header className="border-b border-border backdrop-blur-sm sticky top-0 z-50 bg-surface/90"> <header className="border-b border-border backdrop-blur-sm sticky top-0 z-50 bg-background/90">
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-3 flex items-center justify-between"> <div className="max-w-7xl mx-auto px-4 sm:px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#606C38] to-[#DDA15E] flex items-center justify-center"> <div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
<Wallet className="w-4 h-4 text-[#FEFAE0]" strokeWidth={2.5} /> <Wallet className="w-4 h-4 text-primary-foreground" strokeWidth={2.5} />
</div> </div>
<span className="text-lg font-bold tracking-tight hidden sm:inline"> <span className="text-lg font-bold tracking-tight hidden sm:inline font-heading">
Wealthy<span className="text-[#606C38] dark:text-[#7a8a4a]">Smart</span> Wealthy<span className="text-primary">Smart</span>
</span> </span>
</div> </div>
@@ -55,11 +64,12 @@ export default function Layout() {
to={to} to={to}
end={to === '/'} end={to === '/'}
className={({ isActive }) => className={({ isActive }) =>
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${ cn(
'flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors',
isActive isActive
? 'bg-[#606C38]/10 text-[#606C38] dark:text-[#7a8a4a]' ? 'bg-primary/10 text-primary'
: 'text-text-muted hover:text-text-primary hover:bg-surface-hover' : 'text-muted-foreground hover:text-foreground hover:bg-muted'
}` )
} }
> >
<Icon className="w-4 h-4" /> <Icon className="w-4 h-4" />
@@ -68,59 +78,80 @@ export default function Layout() {
))} ))}
</nav> </nav>
<div className="flex items-center gap-2"> <div className="flex items-center gap-1">
<button <Button variant="ghost" size="icon" onClick={toggleTheme} title="Toggle theme" aria-label="Toggle theme">
onClick={toggleTheme}
className="p-2 rounded-lg text-text-muted hover:text-text-primary hover:bg-surface-hover transition-colors"
>
{theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />} {theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</button> </Button>
<button <Button
variant="ghost"
size="icon"
onClick={handleLogout} onClick={handleLogout}
className="hidden md:flex items-center gap-2 text-text-muted hover:text-text-secondary text-sm transition-colors" title="Sign out"
aria-label="Sign out"
className="hidden md:inline-flex"
> >
<LogOut className="w-4 h-4" /> <LogOut className="w-4 h-4" />
</button> </Button>
<button <Button
onClick={() => setMobileOpen(!mobileOpen)} variant="ghost"
className="md:hidden text-text-muted" size="icon"
onClick={() => setMobileOpen(true)}
title="Open menu"
aria-label="Open menu"
className="md:hidden"
> >
{mobileOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />} <Menu className="w-5 h-5" />
</button> </Button>
</div> </div>
</div> </div>
</header>
{/* Mobile nav */} {/* Mobile nav sheet */}
{mobileOpen && ( <Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<div className="md:hidden border-t border-border px-4 pb-4 space-y-1"> <SheetContent side="left" className="p-0">
<SheetHeader className="p-4">
<SheetTitle className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
<Wallet className="w-4 h-4 text-primary-foreground" strokeWidth={2.5} />
</div>
<span className="font-heading">
Wealthy<span className="text-primary">Smart</span>
</span>
</SheetTitle>
</SheetHeader>
<Separator />
<nav className="flex flex-col gap-1 p-4">
{navItems.map(({ to, icon: Icon, label }) => ( {navItems.map(({ to, icon: Icon, label }) => (
<NavLink <SheetClose key={to} render={<span />}>
key={to} <NavLink
to={to} to={to}
end={to === '/'} end={to === '/'}
onClick={() => setMobileOpen(false)} onClick={() => setMobileOpen(false)}
className={({ isActive }) => className={({ isActive }) =>
`flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors ${ cn(
isActive 'flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors',
? 'bg-[#606C38]/10 text-[#606C38] dark:text-[#7a8a4a]' isActive
: 'text-text-muted hover:text-text-primary hover:bg-surface-hover' ? 'bg-primary/10 text-primary'
}` : 'text-muted-foreground hover:text-foreground hover:bg-muted'
} )
> }
<Icon className="w-4 h-4" /> >
{label} <Icon className="w-4 h-4" />
</NavLink> {label}
</NavLink>
</SheetClose>
))} ))}
<Separator className="my-2" />
<button <button
onClick={handleLogout} onClick={handleLogout}
className="flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium text-text-muted hover:text-text-primary hover:bg-surface-hover w-full" className="flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted w-full"
> >
<LogOut className="w-4 h-4" /> <LogOut className="w-4 h-4" />
Sign out Sign out
</button> </button>
</div> </nav>
)} </SheetContent>
</header> </Sheet>
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-6"> <main className="max-w-7xl mx-auto px-4 sm:px-6 py-6">
<Outlet /> <Outlet />

View File

@@ -1,6 +1,24 @@
import { useState } from 'react'; import { useState } from 'react';
import { X, ClipboardPaste, CheckCircle, AlertTriangle } from 'lucide-react'; import { ClipboardPaste, CheckCircle, AlertTriangle } from 'lucide-react';
import api, { type ImportResult } from '../api'; import api, { type ImportResult } from '../api';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
interface Props { interface Props {
onClose: () => void; onClose: () => void;
@@ -28,114 +46,100 @@ export default function PasteImportModal({ onClose, onImported }: Props) {
} }
}; };
const inputClass =
'w-full bg-input-bg border border-border rounded-lg px-3 py-2.5 text-sm text-text-primary placeholder-text-faint focus:outline-none focus:border-[#606C38]/50 focus:ring-1 focus:ring-[#606C38]/20 transition-colors';
const labelClass = 'block text-xs font-medium text-text-secondary mb-1 uppercase tracking-wider';
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"> <Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
<div className="bg-surface border border-border rounded-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto"> <DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between px-5 py-4 border-b border-border"> <DialogHeader>
<div className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<ClipboardPaste className="w-4 h-4 text-[#606C38] dark:text-[#7a8a4a]" /> <ClipboardPaste className="w-4 h-4 text-primary" />
<h3 className="font-semibold">Import Bank Statement</h3> Import Bank Statement
</DialogTitle>
</DialogHeader>
{!result ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Bank</Label>
<Select value={bank} onValueChange={setBank}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="BAC">BAC</SelectItem>
<SelectItem value="BCR">BCR</SelectItem>
<SelectItem value="DAVIVIENDA">Davivienda</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Source</Label>
<Select value={source} onValueChange={setSource}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="CREDIT_CARD">Credit Card</SelectItem>
<SelectItem value="CASH">Cash</SelectItem>
<SelectItem value="TRANSFER">Transfer</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>Statement Text</Label>
<Textarea
className="h-48 font-mono text-xs resize-y"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={`Paste bank statement lines here...\n\nFormat: DD/MM/YYYY\tMERCHANT\\CITY\\COUNTRY\tAMOUNT CRC`}
/>
<p className="text-xs text-muted-foreground">
One transaction per line. Tab-separated columns.
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleImport} disabled={importing || !text.trim()}>
{importing ? 'Importing...' : 'Import'}
</Button>
</DialogFooter>
</div> </div>
<button onClick={onClose} className="text-text-muted hover:text-text-primary transition-colors"> ) : (
<X className="w-5 h-5" /> <div className="space-y-4">
</button> <Alert>
</div> <CheckCircle className="h-4 w-4 text-primary" />
<AlertTitle className="text-primary">Import Complete</AlertTitle>
<AlertDescription>
{result.imported} imported
{result.duplicates > 0 && ` · ${result.duplicates} duplicates skipped`}
</AlertDescription>
</Alert>
<div className="p-5 space-y-4"> {result.errors.length > 0 && (
{!result ? ( <Alert variant="destructive">
<> <AlertTriangle className="h-4 w-4" />
<div className="grid grid-cols-2 gap-4"> <AlertTitle>{result.errors.length} errors</AlertTitle>
<div> <AlertDescription>
<label className={labelClass}>Bank</label> <ul className="text-xs font-mono max-h-32 overflow-y-auto space-y-1 mt-1">
<select className={inputClass} value={bank} onChange={(e) => setBank(e.target.value)}>
<option value="BAC">BAC</option>
<option value="BCR">BCR</option>
<option value="DAVIVIENDA">Davivienda</option>
</select>
</div>
<div>
<label className={labelClass}>Source</label>
<select className={inputClass} value={source} onChange={(e) => setSource(e.target.value)}>
<option value="CREDIT_CARD">Credit Card</option>
<option value="CASH">Cash</option>
<option value="TRANSFER">Transfer</option>
</select>
</div>
</div>
<div>
<label className={labelClass}>Statement Text</label>
<textarea
className={`${inputClass} h-48 font-mono text-xs resize-y`}
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={`Paste bank statement lines here...\n\nFormat: DD/MM/YYYY\tMERCHANT\\CITY\\COUNTRY\tAMOUNT CRC`}
/>
<p className="text-xs text-text-faint mt-1">
One transaction per line. Tab-separated columns.
</p>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium text-text-secondary border border-border hover:bg-surface-hover transition-colors"
>
Cancel
</button>
<button
onClick={handleImport}
disabled={importing || !text.trim()}
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-[#606C38] hover:bg-[#7a8a4a] text-white dark:text-[#FEFAE0] transition-colors disabled:opacity-50"
>
{importing ? 'Importing...' : 'Import'}
</button>
</div>
</>
) : (
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 bg-[#606C38]/10 border border-[#606C38]/20 rounded-lg">
<CheckCircle className="w-5 h-5 text-[#606C38] dark:text-[#7a8a4a] flex-shrink-0" />
<div>
<p className="font-medium text-[#606C38] dark:text-[#7a8a4a]">Import Complete</p>
<p className="text-sm text-text-secondary mt-1">
{result.imported} imported
{result.duplicates > 0 && ` · ${result.duplicates} duplicates skipped`}
</p>
</div>
</div>
{result.errors.length > 0 && (
<div className="p-4 bg-amber-500/10 border border-amber-500/20 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="w-4 h-4 text-amber-500 dark:text-amber-400" />
<span className="text-sm font-medium text-amber-600 dark:text-amber-400">
{result.errors.length} errors
</span>
</div>
<ul className="text-xs text-text-secondary space-y-1 font-mono max-h-32 overflow-y-auto">
{result.errors.map((err, i) => ( {result.errors.map((err, i) => (
<li key={i}>{err}</li> <li key={i}>{err}</li>
))} ))}
</ul> </ul>
</div> </AlertDescription>
)} </Alert>
)}
<button <Button onClick={onClose} className="w-full">
onClick={onClose} Done
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium bg-[#606C38] hover:bg-[#7a8a4a] text-white dark:text-[#FEFAE0] transition-colors" </Button>
> </div>
Done )}
</button> </DialogContent>
</div> </Dialog>
)}
</div>
</div>
</div>
); );
} }

View File

@@ -0,0 +1,137 @@
import { useState } from 'react';
import type { SectionSettings } from '../api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { COLOR_OPTIONS, getColorClasses } from '@/lib/colors';
import { cn } from '@/lib/utils';
interface Props {
sectionId: string;
settings: SectionSettings;
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (sectionId: string, updated: Partial<SectionSettings>) => void;
}
function ColorSwatch({ color }: { color: string }) {
const classes = getColorClasses(color);
return (
<span className="flex items-center gap-2">
<span className={cn('w-3 h-3 rounded-full', classes.bg, classes.ring, 'ring-1')} />
{color}
</span>
);
}
export default function SectionConfigDialog({ sectionId, settings, open, onOpenChange, onSave }: Props) {
const [label, setLabel] = useState(settings.label);
const [color, setColor] = useState(settings.color);
const [cardColor, setCardColor] = useState(settings.cardColor);
const [visible, setVisible] = useState(settings.visible);
const [order, setOrder] = useState(String(settings.order));
const handleSave = () => {
onSave(sectionId, {
label,
color,
cardColor,
visible,
order: parseInt(order) || 0,
});
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Configure Section</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Label</Label>
<Input value={label} onChange={(e) => setLabel(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Section Color</Label>
<Select value={color} onValueChange={setColor}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{COLOR_OPTIONS.map((c) => (
<SelectItem key={c} value={c}>
<ColorSwatch color={c} />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Card Color</Label>
<Select value={cardColor} onValueChange={setCardColor}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{COLOR_OPTIONS.map((c) => (
<SelectItem key={c} value={c}>
<ColorSwatch color={c} />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-3">
<Label htmlFor={`visible-${sectionId}`}>Visible</Label>
<input
id={`visible-${sectionId}`}
type="checkbox"
checked={visible}
onChange={(e) => setVisible(e.target.checked)}
className="h-4 w-4 rounded border-input accent-primary cursor-pointer"
/>
</div>
<div className="space-y-2">
<Label>Order</Label>
<Input
type="number"
min="0"
max="10"
value={order}
onChange={(e) => setOrder(e.target.value)}
className="w-20"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,195 @@
import { useState, useMemo } from 'react';
import {
Plus,
Search,
Pencil,
Trash2,
TrendingUp,
TrendingDown,
ArrowLeftRight,
} from 'lucide-react';
import api, { type Transaction } from '../api';
import TransactionModal from './TransactionModal';
import ConfirmDialog from './ConfirmDialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
import { DataTable } from '@/components/ui/data-table';
import { getTransactionColumns } from '@/components/transactions/transaction-columns';
import { formatAmount } from '@/lib/format';
import { cn } from '@/lib/utils';
export interface TransactionListProps {
transactions: Transaction[];
loading: boolean;
source: 'CREDIT_CARD' | 'CASH' | 'TRANSFER';
search: string;
onSearchChange: (value: string) => void;
onRefresh: () => void;
emptyIcon?: React.ReactNode;
emptyMessage?: string;
showCategory?: boolean;
addLabel?: string;
}
export default function TransactionList({
transactions,
loading,
source,
search,
onSearchChange,
onRefresh,
emptyIcon,
emptyMessage = 'No transactions found',
showCategory = true,
addLabel = 'Add Transaction',
}: TransactionListProps) {
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Transaction | null>(null);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [deleting, setDeleting] = useState(false);
const handleEdit = (tx: Transaction) => {
setEditing(tx);
setModalOpen(true);
};
const handleDelete = async () => {
if (deleteId === null) return;
setDeleting(true);
try {
await api.delete(`/transactions/${deleteId}`);
setDeleteId(null);
onRefresh();
} finally {
setDeleting(false);
}
};
const columns = useMemo(
() => getTransactionColumns({ showCategory, onEdit: handleEdit, onDelete: (id) => setDeleteId(id) }),
[showCategory],
);
const empty = transactions.length === 0 && !loading;
return (
<>
{/* Search + Add */}
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10"
placeholder="Search merchants..."
/>
</div>
<Button onClick={() => { setEditing(null); setModalOpen(true); }}>
<Plus className="w-4 h-4" />
{addLabel}
</Button>
</div>
{/* Mobile list */}
<Card className="md:hidden">
<CardContent className="p-0 divide-y divide-border">
{empty ? (
<div className="px-5 py-16 text-center text-muted-foreground text-sm">
{emptyIcon || <ArrowLeftRight className="w-8 h-8 mx-auto mb-3 text-muted-foreground/50" />}
{emptyMessage}
</div>
) : (
transactions.map((tx) => (
<div key={tx.id} className="flex items-center gap-3 px-4 py-3">
<div
className={cn(
'w-8 h-8 rounded-lg flex items-center justify-center shrink-0',
tx.transaction_type === 'DEVOLUCION'
? 'bg-primary/10 text-primary'
: 'bg-destructive/10 text-destructive'
)}
>
{tx.transaction_type === 'DEVOLUCION' ? (
<TrendingUp className="w-4 h-4" />
) : (
<TrendingDown className="w-4 h-4" />
)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{tx.merchant}</p>
<p className="text-xs text-muted-foreground">
{new Date(tx.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
{showCategory && tx.category && (
<span className="ml-1.5 text-muted-foreground/60">{tx.category.name}</span>
)}
</p>
</div>
<span
className={cn(
'font-mono text-sm font-medium shrink-0',
tx.transaction_type === 'DEVOLUCION' && 'text-primary'
)}
>
{tx.transaction_type === 'DEVOLUCION' ? '+' : '-'}
{formatAmount(tx.amount, tx.currency)}
</span>
<div className="flex items-center gap-0.5 shrink-0">
<Button variant="ghost" size="icon" title="Edit transaction" aria-label="Edit transaction" onClick={() => handleEdit(tx)}>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
title="Delete transaction"
aria-label="Delete transaction"
onClick={() => setDeleteId(tx.id)}
className="hover:text-destructive"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
))
)}
</CardContent>
</Card>
{/* Desktop table */}
<Card className="hidden md:block">
<CardContent className="p-0">
<DataTable
columns={columns}
data={transactions}
pagination
pageSize={25}
initialSorting={[{ id: 'date', desc: true }]}
emptyMessage={emptyMessage}
/>
</CardContent>
</Card>
{/* Modals */}
{modalOpen && (
<TransactionModal
transaction={editing}
source={source}
onClose={() => setModalOpen(false)}
onSaved={onRefresh}
/>
)}
{deleteId !== null && (
<ConfirmDialog
title="Delete Transaction"
message="This transaction will be permanently deleted. This action cannot be undone."
onConfirm={handleDelete}
onCancel={() => setDeleteId(null)}
loading={deleting}
/>
)}
</>
);
}

View File

@@ -1,6 +1,24 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { X } from 'lucide-react';
import api, { type Category, type Transaction } from '../api'; import api, { type Category, type Transaction } from '../api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { AlertCircle } from 'lucide-react';
interface Props { interface Props {
transaction?: Transaction | null; transaction?: Transaction | null;
@@ -83,43 +101,35 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
} }
}; };
const inputClass =
'w-full bg-input-bg border border-border rounded-lg px-3 py-2.5 text-sm text-text-primary placeholder-text-faint focus:outline-none focus:border-[#606C38]/50 focus:ring-1 focus:ring-[#606C38]/20 transition-colors';
const labelClass = 'block text-xs font-medium text-text-secondary mb-1 uppercase tracking-wider';
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"> <Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
<div className="bg-surface border border-border rounded-xl w-full max-w-lg max-h-[90vh] overflow-y-auto"> <DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between px-5 py-4 border-b border-border"> <DialogHeader>
<h3 className="font-semibold"> <DialogTitle>
{transaction ? 'Edit Transaction' : 'New Transaction'} {transaction ? 'Edit Transaction' : 'New Transaction'}
</h3> </DialogTitle>
<button onClick={onClose} className="text-text-muted hover:text-text-primary transition-colors"> </DialogHeader>
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-5 space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{error && ( {error && (
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-500 dark:text-red-400"> <Alert variant="destructive">
{error} <AlertCircle className="h-4 w-4" />
</div> <AlertDescription>{error}</AlertDescription>
</Alert>
)} )}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="col-span-2"> <div className="col-span-2 space-y-2">
<label className={labelClass}>Merchant</label> <Label>Merchant</Label>
<input <Input
className={inputClass}
value={form.merchant} value={form.merchant}
onChange={(e) => setForm({ ...form, merchant: e.target.value })} onChange={(e) => setForm({ ...form, merchant: e.target.value })}
placeholder="e.g. AUTO MERCADO ON LINE" placeholder="e.g. AUTO MERCADO ON LINE"
required required
/> />
</div> </div>
<div> <div className="space-y-2">
<label className={labelClass}>Amount</label> <Label>Amount</Label>
<input <Input
className={inputClass}
type="number" type="number"
step="0.01" step="0.01"
value={form.amount} value={form.amount}
@@ -128,69 +138,74 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
required required
/> />
</div> </div>
<div> <div className="space-y-2">
<label className={labelClass}>Currency</label> <Label>Currency</Label>
<select <Select value={form.currency} onValueChange={(v) => setForm({ ...form, currency: v })}>
className={inputClass} <SelectTrigger className="w-full">
value={form.currency} <SelectValue />
onChange={(e) => setForm({ ...form, currency: e.target.value })} </SelectTrigger>
> <SelectContent>
<option value="CRC">CRC ()</option> <SelectItem value="CRC">CRC ()</SelectItem>
<option value="USD">USD ($)</option> <SelectItem value="USD">USD ($)</SelectItem>
</select> </SelectContent>
</Select>
</div> </div>
<div> <div className="space-y-2">
<label className={labelClass}>Date</label> <Label>Date</Label>
<input <Input
className={inputClass}
type="datetime-local" type="datetime-local"
value={form.date} value={form.date}
onChange={(e) => setForm({ ...form, date: e.target.value })} onChange={(e) => setForm({ ...form, date: e.target.value })}
required required
/> />
</div> </div>
<div> <div className="space-y-2">
<label className={labelClass}>Type</label> <Label>Type</Label>
<select <Select value={form.transaction_type} onValueChange={(v) => setForm({ ...form, transaction_type: v })}>
className={inputClass} <SelectTrigger className="w-full">
value={form.transaction_type} <SelectValue />
onChange={(e) => setForm({ ...form, transaction_type: e.target.value })} </SelectTrigger>
> <SelectContent>
<option value="COMPRA">Compra</option> <SelectItem value="COMPRA">Compra</SelectItem>
<option value="DEVOLUCION">Devolución</option> <SelectItem value="DEVOLUCION">Devolución</SelectItem>
</select> </SelectContent>
</Select>
</div> </div>
<div> <div className="space-y-2">
<label className={labelClass}>Category</label> <Label>Category</Label>
<select <Select
className={inputClass} value={form.category_id ? String(form.category_id) : 'auto'}
value={form.category_id} onValueChange={(v) => setForm({ ...form, category_id: v === 'auto' ? '' : v })}
onChange={(e) => setForm({ ...form, category_id: e.target.value })}
> >
<option value="">Auto-detect</option> <SelectTrigger className="w-full">
{categories.map((c) => ( <SelectValue />
<option key={c.id} value={c.id}> </SelectTrigger>
{c.name} <SelectContent>
</option> <SelectItem value="auto">Auto-detect</SelectItem>
))} {categories.map((c) => (
</select> <SelectItem key={c.id} value={String(c.id)}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
<div> <div className="space-y-2">
<label className={labelClass}>Bank</label> <Label>Bank</Label>
<select <Select value={form.bank} onValueChange={(v) => setForm({ ...form, bank: v })}>
className={inputClass} <SelectTrigger className="w-full">
value={form.bank} <SelectValue />
onChange={(e) => setForm({ ...form, bank: e.target.value })} </SelectTrigger>
> <SelectContent>
<option value="BAC">BAC</option> <SelectItem value="BAC">BAC</SelectItem>
<option value="BCR">BCR</option> <SelectItem value="BCR">BCR</SelectItem>
<option value="DAVIVIENDA">Davivienda</option> <SelectItem value="DAVIVIENDA">Davivienda</SelectItem>
</select> </SelectContent>
</Select>
</div> </div>
<div> <div className="space-y-2">
<label className={labelClass}>City</label> <Label>City</Label>
<input <Input
className={inputClass}
value={form.city} value={form.city}
onChange={(e) => setForm({ ...form, city: e.target.value })} onChange={(e) => setForm({ ...form, city: e.target.value })}
placeholder="SAN JOSE, Costa Rica" placeholder="SAN JOSE, Costa Rica"
@@ -198,19 +213,17 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
</div> </div>
{source === 'CREDIT_CARD' && ( {source === 'CREDIT_CARD' && (
<> <>
<div> <div className="space-y-2">
<label className={labelClass}>Card Type</label> <Label>Card Type</Label>
<input <Input
className={inputClass}
value={form.card_type} value={form.card_type}
onChange={(e) => setForm({ ...form, card_type: e.target.value })} onChange={(e) => setForm({ ...form, card_type: e.target.value })}
placeholder="MASTER" placeholder="MASTER"
/> />
</div> </div>
<div> <div className="space-y-2">
<label className={labelClass}>Card Last 4</label> <Label>Card Last 4</Label>
<input <Input
className={inputClass}
value={form.card_last4} value={form.card_last4}
onChange={(e) => setForm({ ...form, card_last4: e.target.value })} onChange={(e) => setForm({ ...form, card_last4: e.target.value })}
placeholder="6585" placeholder="6585"
@@ -219,10 +232,9 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
</div> </div>
</> </>
)} )}
<div className="col-span-2"> <div className="col-span-2 space-y-2">
<label className={labelClass}>Notes</label> <Label>Notes</Label>
<input <Input
className={inputClass}
value={form.notes} value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })} onChange={(e) => setForm({ ...form, notes: e.target.value })}
placeholder="Optional notes" placeholder="Optional notes"
@@ -230,24 +242,16 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
</div> </div>
</div> </div>
<div className="flex gap-3 pt-2"> <DialogFooter>
<button <Button type="button" variant="outline" onClick={onClose}>
type="button"
onClick={onClose}
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium text-text-secondary border border-border hover:bg-surface-hover transition-colors"
>
Cancel Cancel
</button> </Button>
<button <Button type="submit" disabled={saving}>
type="submit"
disabled={saving}
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-[#606C38] hover:bg-[#7a8a4a] text-white dark:text-[#FEFAE0] transition-colors disabled:opacity-50"
>
{saving ? 'Saving...' : transaction ? 'Update' : 'Create'} {saving ? 'Saving...' : transaction ? 'Update' : 'Create'}
</button> </Button>
</div> </DialogFooter>
</form> </form>
</div> </DialogContent>
</div> </Dialog>
); );
} }

View File

@@ -0,0 +1,137 @@
import { type ColumnDef } from '@tanstack/react-table';
import { Pencil, Trash2, TrendingDown, TrendingUp } from 'lucide-react';
import { type Transaction } from '@/api';
import { formatAmount } from '@/lib/format';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { DataTableColumnHeader } from '@/components/ui/data-table-column-header';
interface TransactionColumnOptions {
showCategory: boolean;
onEdit: (tx: Transaction) => void;
onDelete: (txId: number) => void;
}
export function getTransactionColumns({
showCategory,
onEdit,
onDelete,
}: TransactionColumnOptions): ColumnDef<Transaction, unknown>[] {
const columns: ColumnDef<Transaction, unknown>[] = [
{
accessorKey: 'date',
header: ({ column }) => <DataTableColumnHeader column={column} title="Date" />,
cell: ({ row }) => (
<span className="font-mono text-muted-foreground text-xs whitespace-nowrap">
{new Date(row.original.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</span>
),
},
{
accessorKey: 'merchant',
header: ({ column }) => <DataTableColumnHeader column={column} title="Merchant" />,
cell: ({ row }) => {
const tx = row.original;
return (
<div className="flex items-center gap-2">
<div
className={cn(
'w-6 h-6 rounded flex items-center justify-center shrink-0',
tx.transaction_type === 'DEVOLUCION'
? 'bg-primary/10 text-primary'
: 'bg-destructive/10 text-destructive',
)}
>
{tx.transaction_type === 'DEVOLUCION' ? (
<TrendingUp className="w-3 h-3" />
) : (
<TrendingDown className="w-3 h-3" />
)}
</div>
<span className="truncate">{tx.merchant}</span>
</div>
);
},
},
];
if (showCategory) {
columns.push({
accessorFn: (row) => row.category?.name ?? '',
id: 'category',
header: ({ column }) => <DataTableColumnHeader column={column} title="Category" />,
cell: ({ row }) => {
const category = row.original.category;
return category ? (
<Badge variant="secondary">{category.name}</Badge>
) : (
<span className="text-xs text-muted-foreground">&mdash;</span>
);
},
});
}
columns.push(
{
accessorKey: 'amount',
meta: { className: 'text-right' },
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Amount" className="justify-end" />
),
cell: ({ row }) => {
const tx = row.original;
return (
<span
className={cn(
'font-mono font-medium',
tx.transaction_type === 'DEVOLUCION' && 'text-primary',
)}
>
{tx.transaction_type === 'DEVOLUCION' ? '+' : '-'}
{formatAmount(tx.amount, tx.currency)}
</span>
);
},
},
{
id: 'actions',
meta: { className: 'text-right' },
size: 80,
enableSorting: false,
cell: ({ row }) => {
const tx = row.original;
return (
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
title="Edit transaction"
aria-label="Edit transaction"
onClick={() => onEdit(tx)}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
title="Delete transaction"
aria-label="Delete transaction"
onClick={() => onDelete(tx.id)}
className="hover:text-destructive"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
);
},
},
);
return columns;
}

View File

@@ -0,0 +1,72 @@
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
return (
<AccordionPrimitive.Root
data-slot="accordion"
className={cn("flex w-full flex-col", className)}
{...props}
/>
)
}
function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("not-last:border-b", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: AccordionPrimitive.Trigger.Props) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: AccordionPrimitive.Panel.Props) {
return (
<AccordionPrimitive.Panel
data-slot="accordion-content"
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
{...props}
>
<div
className={cn(
"h-(--accordion-panel-height) pt-0 pb-2.5 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
>
{children}
</div>
</AccordionPrimitive.Panel>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,187 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: AlertDialogPrimitive.Backdrop.Props) {
return (
<AlertDialogPrimitive.Backdrop
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: AlertDialogPrimitive.Popup.Props & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Popup
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm 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}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: AlertDialogPrimitive.Close.Props &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<AlertDialogPrimitive.Close
data-slot="alert-dialog-cancel"
className={cn(className)}
render={<Button variant={variant} size={size} />}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}

View File

@@ -0,0 +1,76 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"font-heading font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-2 right-2", className)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }

View File

@@ -0,0 +1,52 @@
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"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 cursor-pointer items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg 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 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,356 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium text-foreground tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@@ -0,0 +1,41 @@
import { type Column } from '@tanstack/react-table';
import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
interface DataTableColumnHeaderProps<TData, TValue> {
column: Column<TData, TValue>;
title: string;
className?: string;
}
export function DataTableColumnHeader<TData, TValue>({
column,
title,
className,
}: DataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort()) {
return <div className={cn(className)}>{title}</div>;
}
const sorted = column.getIsSorted();
return (
<Button
variant="ghost"
size="sm"
className={cn('-ml-3 h-8', className)}
onClick={() => column.toggleSorting(sorted === 'asc')}
>
{title}
{sorted === 'desc' ? (
<ArrowDown className="ml-1 h-3.5 w-3.5" />
) : sorted === 'asc' ? (
<ArrowUp className="ml-1 h-3.5 w-3.5" />
) : (
<ArrowUpDown className="ml-1 h-3.5 w-3.5 text-muted-foreground/50" />
)}
</Button>
);
}

View File

@@ -0,0 +1,128 @@
import { useState } from 'react';
import {
type ColumnDef,
type SortingState,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
pagination?: boolean;
pageSize?: number;
emptyMessage?: React.ReactNode;
initialSorting?: SortingState;
}
export function DataTable<TData, TValue>({
columns,
data,
pagination = false,
pageSize = 25,
emptyMessage = 'No results.',
initialSorting = [],
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>(initialSorting);
const table = useReactTable({
data,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
...(pagination && {
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize } },
}),
});
return (
<div>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className={(header.column.columnDef.meta as Record<string, string>)?.className}
style={{ width: header.getSize() !== 150 ? header.getSize() : undefined }}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={(cell.column.columnDef.meta as Record<string, string>)?.className}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center text-muted-foreground">
{emptyMessage}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{pagination && table.getPageCount() > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t">
<p className="text-sm text-muted-foreground">
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
</p>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,158 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 text-sm ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm 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}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,266 @@
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none 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:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95 animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!", className )}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] rounded-lg p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-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 animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!", className )}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,20 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,199 @@
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none 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 animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,25 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,136 @@
import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,80 @@
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -0,0 +1,55 @@
import { useEffect, useState, useCallback } from 'react';
import {
getSettings,
updateSettings,
type UserSettingsData,
type SectionSettings,
} from '../api';
const DEFAULT_SETTINGS: UserSettingsData = {
dashboard: {
sections: {
crc_accounts: { label: 'CRC Accounts', color: 'primary', cardColor: 'primary', visible: true, order: 0, expanded: false },
usd_accounts: { label: 'USD Accounts', color: 'chart-1', cardColor: 'chart-1', visible: true, order: 1, expanded: false },
pension: { label: 'Pension', color: 'chart-2', cardColor: 'chart-2', visible: true, order: 2, expanded: false },
savings: { label: 'Savings', color: 'chart-3', cardColor: 'chart-3', visible: true, order: 3, expanded: false },
liabilities: { label: 'Liabilities', color: 'destructive', cardColor: 'destructive', visible: true, order: 4, expanded: false },
crypto: { label: 'Crypto', color: 'chart-4', cardColor: 'chart-4', visible: true, order: 5, expanded: false },
},
},
};
export function useSettings() {
const [settings, setSettings] = useState<UserSettingsData>(DEFAULT_SETTINGS);
const [loading, setLoading] = useState(true);
useEffect(() => {
getSettings()
.then((r) => setSettings(r.data.data))
.catch(() => {}) // use defaults on error
.finally(() => setLoading(false));
}, []);
const patchSection = useCallback(
async (sectionId: string, partial: Partial<SectionSettings>) => {
setSettings((prev) => {
const updated = {
...prev,
dashboard: {
...prev.dashboard,
sections: {
...prev.dashboard.sections,
[sectionId]: { ...prev.dashboard.sections[sectionId], ...partial },
},
},
};
// Fire-and-forget save
updateSettings(updated).catch(console.error);
return updated;
});
},
[]
);
return { settings, loading, patchSection };
}

View File

@@ -1,46 +1,131 @@
@import 'tailwindcss'; @import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/noto-sans";
@import "@fontsource-variable/ibm-plex-sans";
@custom-variant dark (&:where(.dark, .dark *)); @custom-variant dark (&:is(.dark *));
@theme { :root {
--color-surface: #FFFDF5; --background: oklch(1 0 0);
--color-surface-secondary: #fefae0; --foreground: oklch(0.141 0.005 285.823);
--color-surface-card: rgba(96, 108, 56, 0.08); --card: oklch(1 0 0);
--color-surface-hover: rgba(96, 108, 56, 0.12); --card-foreground: oklch(0.141 0.005 285.823);
--color-border: rgba(96, 108, 56, 0.25); --popover: oklch(1 0 0);
--color-border-subtle: rgba(96, 108, 56, 0.15); --popover-foreground: oklch(0.141 0.005 285.823);
--color-text-primary: #283618; --primary: oklch(0.511 0.096 186.391);
--color-text-secondary: #606C38; --primary-foreground: oklch(0.984 0.014 180.72);
--color-text-muted: #8a9462; --secondary: oklch(0.967 0.001 286.375);
--color-text-faint: #c2c9a7; --secondary-foreground: oklch(0.21 0.006 285.885);
--color-input-bg: #f5f1d0; --muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.905 0.182 98.111);
--chart-2: oklch(0.795 0.184 86.047);
--chart-3: oklch(0.681 0.162 75.834);
--chart-4: oklch(0.554 0.135 66.442);
--chart-5: oklch(0.476 0.114 61.907);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.6 0.118 184.704);
--sidebar-primary-foreground: oklch(0.984 0.014 180.72);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
} }
.dark { .dark {
--color-surface: #020617; --background: oklch(0.141 0.005 285.823);
--color-surface-secondary: #0f172a; --foreground: oklch(0.985 0 0);
--color-surface-card: rgba(15, 23, 42, 0.6); --card: oklch(0.21 0.006 285.885);
--color-surface-hover: rgba(30, 41, 59, 0.3); --card-foreground: oklch(0.985 0 0);
--color-border: rgba(30, 41, 59, 0.6); --popover: oklch(0.21 0.006 285.885);
--color-border-subtle: rgba(30, 41, 59, 0.4); --popover-foreground: oklch(0.985 0 0);
--color-text-primary: #ffffff; --primary: oklch(0.437 0.078 188.216);
--color-text-secondary: #94a3b8; --primary-foreground: oklch(0.984 0.014 180.72);
--color-text-muted: #64748b; --secondary: oklch(0.274 0.006 286.033);
--color-text-faint: #334155; --secondary-foreground: oklch(0.985 0 0);
--color-input-bg: rgba(15, 23, 42, 0.8); --muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.905 0.182 98.111);
--chart-2: oklch(0.795 0.184 86.047);
--chart-3: oklch(0.681 0.162 75.834);
--chart-4: oklch(0.554 0.135 66.442);
--chart-5: oklch(0.476 0.114 61.907);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.704 0.14 182.503);
--sidebar-primary-foreground: oklch(0.277 0.046 192.524);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
} }
body { @theme inline {
background-color: var(--color-surface); --font-sans: 'Noto Sans Variable', sans-serif;
color: var(--color-text-primary); --font-heading: 'IBM Plex Sans Variable', sans-serif;
transition: background-color 0.2s, color 0.2s; --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
} }
@keyframes fade-in { @layer base {
from { opacity: 0; transform: translateY(8px); } * {
to { opacity: 1; transform: translateY(0); } @apply border-border outline-ring/50;
} }
body {
.animate-fade-in { @apply bg-background text-foreground;
animation: fade-in 0.4s ease-out both; }
html {
@apply font-sans;
}
} }

View File

@@ -0,0 +1,25 @@
export interface ColorClasses {
bg: string;
ring: string;
text: string;
borderLeft: string;
}
export const COLOR_MAP: Record<string, ColorClasses> = {
'primary': { bg: 'bg-primary/10', ring: 'ring-primary/20', text: 'text-primary', borderLeft: 'border-l-primary' },
'destructive': { bg: 'bg-destructive/10', ring: 'ring-destructive/20', text: 'text-destructive', borderLeft: 'border-l-destructive' },
'chart-1': { bg: 'bg-chart-1/10', ring: 'ring-chart-1/20', text: 'text-chart-1', borderLeft: 'border-l-chart-1' },
'chart-2': { bg: 'bg-chart-2/10', ring: 'ring-chart-2/20', text: 'text-chart-2', borderLeft: 'border-l-chart-2' },
'chart-3': { bg: 'bg-chart-3/10', ring: 'ring-chart-3/20', text: 'text-chart-3', borderLeft: 'border-l-chart-3' },
'chart-4': { bg: 'bg-chart-4/10', ring: 'ring-chart-4/20', text: 'text-chart-4', borderLeft: 'border-l-chart-4' },
'chart-5': { bg: 'bg-chart-5/10', ring: 'ring-chart-5/20', text: 'text-chart-5', borderLeft: 'border-l-chart-5' },
'accent': { bg: 'bg-accent/10', ring: 'ring-accent/20', text: 'text-accent-foreground', borderLeft: 'border-l-accent' },
'muted': { bg: 'bg-muted/50', ring: 'ring-muted', text: 'text-muted-foreground', borderLeft: 'border-l-muted' },
'secondary': { bg: 'bg-secondary/50', ring: 'ring-secondary', text: 'text-secondary-foreground', borderLeft: 'border-l-secondary' },
};
export const COLOR_OPTIONS = Object.keys(COLOR_MAP);
export function getColorClasses(colorName: string): ColorClasses {
return COLOR_MAP[colorName] ?? COLOR_MAP['primary'];
}

View File

@@ -0,0 +1,13 @@
export function formatAmount(amount: number, currency: string) {
const abs = Math.abs(amount);
if (currency === 'BTC') return abs.toFixed(8);
if (currency === 'XMR') return abs.toFixed(4);
if (currency === 'USD') {
return `$${abs.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
return `${abs.toLocaleString('es-CR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
export function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -7,8 +7,6 @@ import {
Bar, Bar,
XAxis, XAxis,
YAxis, YAxis,
Tooltip,
ResponsiveContainer,
LineChart, LineChart,
Line, Line,
} from 'recharts'; } from 'recharts';
@@ -16,7 +14,13 @@ import { BarChart3 } from 'lucide-react';
import api from '../api'; import api from '../api';
import BillingCycleSelector from '../components/BillingCycleSelector'; import BillingCycleSelector from '../components/BillingCycleSelector';
import { useTheme } from '../ThemeContext'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart';
interface CategorySpending { interface CategorySpending {
category_id: number | null; category_id: number | null;
@@ -42,18 +46,30 @@ interface DailySpending {
} }
const COLORS = [ const COLORS = [
'#606C38', '#BC6C25', '#8b5cf6', '#DDA15E', '#ef4444', 'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)',
'#ec4899', '#283618', '#f97316', '#6366f1', '#8a9462', 'oklch(0.7 0.15 30)', 'oklch(0.65 0.2 300)', 'oklch(0.6 0.15 150)',
'#e879f9', '#c2c9a7', '#fb923c', '#a78bfa', '#7a8a4a', 'oklch(0.75 0.12 60)', 'oklch(0.55 0.18 250)',
'#fbbf24',
]; ];
function formatCRC(value: number) { function formatCRC(value: number) {
return `${Math.round(value).toLocaleString('es-CR')}`; return `${Math.round(value).toLocaleString('es-CR')}`;
} }
const trendChartConfig = {
total_crc: {
label: 'Total CRC',
color: 'var(--chart-1)',
},
} satisfies ChartConfig;
const dailyChartConfig = {
total: {
label: 'Daily Spending',
color: 'var(--chart-2)',
},
} satisfies ChartConfig;
export default function Analytics() { export default function Analytics() {
const { theme } = useTheme();
const [cycle, setCycle] = useState<{ year: number; month: number } | null>(null); const [cycle, setCycle] = useState<{ year: number; month: number } | null>(null);
const [byCategory, setByCategory] = useState<CategorySpending[]>([]); const [byCategory, setByCategory] = useState<CategorySpending[]>([]);
const [trend, setTrend] = useState<MonthlyTrend[]>([]); const [trend, setTrend] = useState<MonthlyTrend[]>([]);
@@ -79,194 +95,216 @@ export default function Analytics() {
.catch(console.error); .catch(console.error);
}, [cycle]); }, [cycle]);
const tooltipStyle = { // Build dynamic chart config for pie chart
background: theme === 'dark' ? '#1e293b' : '#FEFAE0', const pieChartConfig = byCategory.reduce<ChartConfig>((acc, cat, i) => {
border: `1px solid ${theme === 'dark' ? '#334155' : 'rgba(96,108,56,0.25)'}`, acc[cat.category_name] = {
borderRadius: '8px', label: cat.category_name,
fontSize: '12px', color: COLORS[i % COLORS.length],
color: theme === 'dark' ? '#e2e8f0' : '#283618', };
}; return acc;
}, {});
const tickColor = theme === 'dark' ? '#64748b' : '#8a9462';
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"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-[#606C38] dark:text-[#7a8a4a]" /> <BarChart3 className="w-5 h-5 text-primary" />
<h1 className="text-2xl font-bold">Analytics</h1> <h1 className="text-2xl font-bold font-heading">Analytics</h1>
</div> </div>
<p className="text-sm text-text-muted mt-1">Spending breakdown and trends</p> <p className="text-sm text-muted-foreground mt-1">Spending breakdown and trends</p>
</div> </div>
<BillingCycleSelector value={cycle} onChange={setCycle} /> <BillingCycleSelector value={cycle} onChange={setCycle} />
</div> </div>
<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 */} {/* Spending by Category - Donut */}
<div className="bg-surface-card border border-border rounded-xl p-5"> <Card>
<h2 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-4"> <CardHeader>
Spending by Category <CardTitle className="text-sm uppercase tracking-wider text-muted-foreground">
</h2> Spending by Category
{byCategory.length === 0 ? ( </CardTitle>
<div className="h-64 flex items-center justify-center text-text-faint text-sm"> </CardHeader>
No data for this period <CardContent>
</div> {byCategory.length === 0 ? (
) : ( <div className="h-64 flex items-center justify-center text-muted-foreground text-sm">
<div className="flex flex-col items-center"> No data for this period
<ResponsiveContainer width="100%" height={260}>
<PieChart>
<Pie
data={byCategory}
dataKey="total"
nameKey="category_name"
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={100}
paddingAngle={2}
strokeWidth={0}
>
{byCategory.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Pie>
<Tooltip
contentStyle={tooltipStyle}
formatter={(value: any) => formatCRC(Number(value))}
/>
</PieChart>
</ResponsiveContainer>
{/* Legend */}
<div className="grid grid-cols-2 gap-x-6 gap-y-1.5 mt-2 w-full max-w-md">
{byCategory.slice(0, 10).map((cat, i) => (
<div key={cat.category_name} className="flex items-center gap-2 text-xs">
<div
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ background: COLORS[i % COLORS.length] }}
/>
<span className="text-text-secondary truncate">{cat.category_name}</span>
<span className="text-text-faint ml-auto">{cat.percentage}%</span>
</div>
))}
</div> </div>
</div> ) : (
)} <div className="flex flex-col items-center">
</div> <ChartContainer config={pieChartConfig} className="h-[260px] w-full">
<PieChart>
<Pie
data={byCategory}
dataKey="total"
nameKey="category_name"
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={100}
paddingAngle={2}
strokeWidth={0}
>
{byCategory.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Pie>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value) => formatCRC(Number(value))}
/>
}
/>
</PieChart>
</ChartContainer>
{/* Legend */}
<div className="grid grid-cols-2 gap-x-6 gap-y-1.5 mt-2 w-full max-w-md">
{byCategory.slice(0, 10).map((cat, i) => (
<div key={cat.category_name} className="flex items-center gap-2 text-xs">
<div
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ background: COLORS[i % COLORS.length] }}
/>
<span className="text-muted-foreground truncate">{cat.category_name}</span>
<span className="text-muted-foreground/60 ml-auto">{cat.percentage}%</span>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Monthly Trend - Bar */} {/* Monthly Trend - Bar */}
<div className="bg-surface-card border border-border rounded-xl p-5"> <Card>
<h2 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-4"> <CardHeader>
Monthly Spending (CRC) <CardTitle className="text-sm uppercase tracking-wider text-muted-foreground">
</h2> Monthly Spending (CRC)
{trend.length === 0 ? ( </CardTitle>
<div className="h-64 flex items-center justify-center text-text-faint text-sm"> </CardHeader>
No data <CardContent>
</div> {trend.length === 0 ? (
) : ( <div className="h-64 flex items-center justify-center text-muted-foreground text-sm">
<ResponsiveContainer width="100%" height={300}> No data
<BarChart data={trend}> </div>
<XAxis ) : (
dataKey="label" <ChartContainer config={trendChartConfig} className="h-[300px] w-full">
tick={{ fill: tickColor, fontSize: 11 }} <BarChart data={trend}>
axisLine={false} <XAxis
tickLine={false} dataKey="label"
/> axisLine={false}
<YAxis tickLine={false}
tick={{ fill: tickColor, fontSize: 11 }} />
axisLine={false} <YAxis
tickLine={false} axisLine={false}
tickFormatter={(v) => `${(v / 1000).toFixed(0)}k`} tickLine={false}
/> tickFormatter={(v) => `${(v / 1000).toFixed(0)}k`}
<Tooltip />
contentStyle={tooltipStyle} <ChartTooltip
formatter={(value: any) => formatCRC(Number(value))} content={
/> <ChartTooltipContent
<Bar dataKey="total_crc" fill="#606C38" radius={[4, 4, 0, 0]} /> formatter={(value) => formatCRC(Number(value))}
</BarChart> />
</ResponsiveContainer> }
)} />
</div> <Bar dataKey="total_crc" fill="var(--color-total_crc)" radius={[4, 4, 0, 0]} />
</BarChart>
</ChartContainer>
)}
</CardContent>
</Card>
{/* Daily Spending - Line */} {/* Daily Spending - Line */}
<div className="bg-surface-card border border-border rounded-xl p-5 lg:col-span-2"> <Card className="lg:col-span-2">
<h2 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-4"> <CardHeader>
Daily Spending <CardTitle className="text-sm uppercase tracking-wider text-muted-foreground">
</h2> Daily Spending
{daily.length === 0 ? ( </CardTitle>
<div className="h-48 flex items-center justify-center text-text-faint text-sm"> </CardHeader>
No data for this period <CardContent>
</div> {daily.length === 0 ? (
) : ( <div className="h-48 flex items-center justify-center text-muted-foreground text-sm">
<ResponsiveContainer width="100%" height={240}> No data for this period
<LineChart data={daily}> </div>
<XAxis ) : (
dataKey="date" <ChartContainer config={dailyChartConfig} className="h-[240px] w-full">
tick={{ fill: tickColor, fontSize: 10 }} <LineChart data={daily}>
axisLine={false} <XAxis
tickLine={false} dataKey="date"
tickFormatter={(v) => { axisLine={false}
const d = new Date(v); tickLine={false}
return `${d.getMonth() + 1}/${d.getDate()}`; tickFormatter={(v) => {
}} const d = new Date(v);
/> return `${d.getMonth() + 1}/${d.getDate()}`;
<YAxis }}
tick={{ fill: tickColor, fontSize: 11 }} />
axisLine={false} <YAxis
tickLine={false} axisLine={false}
tickFormatter={(v) => `${(v / 1000).toFixed(0)}k`} tickLine={false}
/> tickFormatter={(v) => `${(v / 1000).toFixed(0)}k`}
<Tooltip />
contentStyle={tooltipStyle} <ChartTooltip
formatter={(value: any) => formatCRC(Number(value))} content={
labelFormatter={(label) => new Date(label).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} <ChartTooltipContent
/> formatter={(value) => formatCRC(Number(value))}
<Line labelFormatter={(label) =>
type="monotone" new Date(label).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
dataKey="total" }
stroke="#BC6C25" />
strokeWidth={2} }
dot={{ fill: '#BC6C25', r: 3 }} />
activeDot={{ r: 5 }} <Line
/> type="monotone"
</LineChart> dataKey="total"
</ResponsiveContainer> stroke="var(--color-total)"
)} strokeWidth={2}
</div> dot={{ fill: 'var(--color-total)', r: 3 }}
activeDot={{ r: 5 }}
/>
</LineChart>
</ChartContainer>
)}
</CardContent>
</Card>
</div> </div>
{/* Top categories summary */} {/* Top categories summary */}
{byCategory.length > 0 && ( {byCategory.length > 0 && (
<div className="bg-surface-card border border-border rounded-xl p-5"> <Card>
<h2 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-4"> <CardHeader>
Top Categories <CardTitle className="text-sm uppercase tracking-wider text-muted-foreground">
</h2> Top Categories
<div className="space-y-3"> </CardTitle>
{byCategory.slice(0, 8).map((cat, i) => ( </CardHeader>
<div key={cat.category_name} className="flex items-center gap-3"> <CardContent>
<div <div className="space-y-3">
className="w-3 h-3 rounded-full flex-shrink-0" {byCategory.slice(0, 8).map((cat, i) => (
style={{ background: COLORS[i % COLORS.length] }} <div key={cat.category_name} className="flex items-center gap-3">
/>
<span className="text-sm flex-1">{cat.category_name}</span>
<span className="text-xs text-text-muted">{cat.count} txns</span>
<span className="text-sm font-mono font-medium w-32 text-right">
{formatCRC(cat.total)}
</span>
<div className="w-24 bg-surface-hover rounded-full h-1.5">
<div <div
className="h-1.5 rounded-full" className="w-3 h-3 rounded-full flex-shrink-0"
style={{ style={{ background: COLORS[i % COLORS.length] }}
width: `${cat.percentage}%`,
background: COLORS[i % COLORS.length],
}}
/> />
<span className="text-sm flex-1">{cat.category_name}</span>
<span className="text-xs text-muted-foreground">{cat.count} txns</span>
<span className="text-sm font-mono font-medium w-32 text-right">
{formatCRC(cat.total)}
</span>
<div className="w-24 bg-muted rounded-full h-1.5">
<div
className="h-1.5 rounded-full"
style={{
width: `${cat.percentage}%`,
background: COLORS[i % COLORS.length],
}}
/>
</div>
</div> </div>
</div> ))}
))} </div>
</div> </CardContent>
</div> </Card>
)} )}
</div> </div>
); );

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useState, useMemo } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { import {
ArrowRight, ArrowRight,
@@ -12,31 +12,36 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import api, { type Account, type Transaction } from '../api'; import api, { type Account, type Transaction } from '../api';
import { useSettings } from '@/hooks/useSettings';
import { formatAmount, formatDate } from '@/lib/format';
import DashboardSection from '@/components/DashboardSection';
import SectionConfigDialog from '@/components/SectionConfigDialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils';
function formatAmount(amount: number, currency: string) { // --- Section definitions ---
const abs = Math.abs(amount);
if (currency === 'BTC') return abs.toFixed(8); interface SectionDef {
if (currency === 'XMR') return abs.toFixed(4); filterFn: (a: Account) => boolean;
if (currency === 'USD') { totalCurrency: string; // empty string = no total
return `$${abs.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
return `${abs.toLocaleString('es-CR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
} }
function formatDate(dateStr: string) { const SECTION_DEFS: Record<string, SectionDef> = {
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); crc_accounts: { filterFn: (a) => a.account_type === 'BANK' && a.currency === 'CRC', totalCurrency: 'CRC' },
} usd_accounts: { filterFn: (a) => a.account_type === 'BANK' && a.currency === 'USD', totalCurrency: 'USD' },
pension: { filterFn: (a) => a.account_type === 'PENSION', totalCurrency: 'CRC' },
savings: { filterFn: (a) => a.account_type === 'SAVINGS', totalCurrency: 'CRC' },
liabilities: { filterFn: (a) => a.account_type === 'LIABILITY', totalCurrency: '' },
crypto: { filterFn: (a) => a.account_type === 'CRYPTO', totalCurrency: '' },
};
// --- Reusable card for an account balance --- const BANK_ORDER = ['BAC', 'BCR', 'DAVIVIENDA'];
function AccountCard({
account, // --- AccountRow ---
editingId,
editValue, interface AccountRowProps {
setEditValue,
startEditing,
saveBalance,
cancelEditing,
}: {
account: Account; account: Account;
editingId: number | null; editingId: number | null;
editValue: string; editValue: string;
@@ -44,20 +49,29 @@ function AccountCard({
startEditing: (a: Account) => void; startEditing: (a: Account) => void;
saveBalance: (id: number) => void; saveBalance: (id: number) => void;
cancelEditing: () => void; cancelEditing: () => void;
}) { }
const badgeLabel = account.account_type === 'CRYPTO' ? account.label : (account.bank === 'DAVIVIENDA' ? 'DAV' : account.bank);
function AccountRow({
account,
editingId,
editValue,
setEditValue,
startEditing,
saveBalance,
cancelEditing,
}: AccountRowProps) {
const isLiability = account.account_type === 'LIABILITY';
const isCrypto = account.account_type === 'CRYPTO';
const label = isCrypto ? account.label : (account.bank === 'DAVIVIENDA' ? 'DAV' : account.bank);
const isEditing = editingId === account.id; const isEditing = editingId === account.id;
return ( return (
<div className="group animate-fade-in bg-surface dark:bg-slate-900 border border-border dark:border-slate-700 rounded-xl p-5 shadow-sm dark:shadow-none hover:bg-surface-hover dark:hover:bg-slate-800/60 transition-colors h-[104px] flex flex-col justify-between"> <div className="flex items-center justify-between px-3 py-2.5 hover:bg-muted/30 transition-colors group">
<div className="flex items-center justify-end"> <span className="text-sm font-medium text-muted-foreground">{label}</span>
<span className="text-sm font-bold font-mono text-text-secondary dark:text-slate-300 bg-surface-secondary dark:bg-slate-800 px-2.5 py-0.5 rounded">
{badgeLabel}
</span>
</div>
{isEditing ? ( {isEditing ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <Input
type="number" type="number"
step="0.01" step="0.01"
value={editValue} value={editValue}
@@ -67,36 +81,40 @@ function AccountCard({
if (e.key === 'Escape') cancelEditing(); if (e.key === 'Escape') cancelEditing();
}} }}
autoFocus autoFocus
className="w-full text-2xl font-bold font-mono tracking-tight bg-input-bg border border-[#606C38]/40 rounded-lg px-2 py-1 focus:outline-none focus:border-[#606C38] transition-colors" className="text-sm font-bold font-mono tracking-tight h-auto py-1 w-40"
/> />
<button onClick={() => saveBalance(account.id)} className="p-1 text-[#606C38] dark:text-[#7a8a4a]"> <Button variant="ghost" size="icon-xs" onClick={() => saveBalance(account.id)} title="Save" aria-label="Save balance">
<Check className="w-4 h-4" /> <Check className="w-3.5 h-3.5" />
</button> </Button>
<button onClick={cancelEditing} className="p-1 text-text-muted hover:text-text-secondary"> <Button variant="ghost" size="icon-xs" onClick={cancelEditing} title="Cancel" aria-label="Cancel editing">
<X className="w-4 h-4" /> <X className="w-3.5 h-3.5" />
</button> </Button>
</div> </div>
) : ( ) : (
<div className="flex items-center gap-2 group/balance cursor-pointer" onClick={() => startEditing(account)}> <div className="flex items-center gap-1.5">
<p className="text-2xl font-bold font-mono tracking-tight"> <span className={cn('text-lg font-bold font-mono tracking-tight', isLiability && 'text-destructive')}>
{formatAmount(account.balance, account.currency)} {formatAmount(account.balance, account.currency)}
</p> </span>
<Pencil className="w-3.5 h-3.5 text-text-faint opacity-0 group-hover/balance:opacity-100 hover:text-[#606C38] dark:hover:text-[#7a8a4a] transition-all" /> <button
onClick={() => startEditing(account)}
className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground cursor-pointer"
title="Edit balance"
aria-label="Edit balance"
>
<Pencil className="w-3.5 h-3.5" />
</button>
{isLiability && account.next_payment != null && (
<span className="text-xs font-mono text-destructive/60 ml-2">
Next: {formatAmount(account.next_payment, account.currency)}
</span>
)}
</div> </div>
)} )}
</div> </div>
); );
} }
// --- Total card --- // --- Dashboard ---
function TotalCard({ total, currency }: { total: number; currency: string }) {
return (
<div className="border rounded-xl p-5 shadow-sm dark:shadow-none h-[104px] flex flex-col justify-between bg-[#fdf3e3] dark:bg-[#BC6C25]/10 border-[#e8c08a] dark:border-[#BC6C25]/20 text-[#8a5218] dark:text-[#DDA15E]">
<span className="text-xs font-bold uppercase tracking-wider opacity-80">Total</span>
<p className="text-2xl font-bold font-mono tracking-tight">{formatAmount(total, currency)}</p>
</div>
);
}
export default function Dashboard() { export default function Dashboard() {
const [accounts, setAccounts] = useState<Account[]>([]); const [accounts, setAccounts] = useState<Account[]>([]);
@@ -105,6 +123,9 @@ export default function Dashboard() {
const [editingId, setEditingId] = useState<number | null>(null); const [editingId, setEditingId] = useState<number | null>(null);
const [editValue, setEditValue] = useState(''); const [editValue, setEditValue] = useState('');
const [exchangeRate, setExchangeRate] = useState<{ buy_rate: number; sell_rate: number } | null>(null); const [exchangeRate, setExchangeRate] = useState<{ buy_rate: number; sell_rate: number } | null>(null);
const [configSection, setConfigSection] = useState<string | null>(null);
const { settings, patchSection } = useSettings();
const fetchData = async () => { const fetchData = async () => {
setLoading(true); setLoading(true);
@@ -141,222 +162,170 @@ export default function Dashboard() {
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
}; };
const cardProps = { editingId, editValue, setEditValue, startEditing, saveBalance, cancelEditing }; const rowProps = { editingId, editValue, setEditValue, startEditing, saveBalance, cancelEditing };
// Group accounts by type // Sort sections by order, filter by visible
const bankAccounts = accounts.filter((a) => a.account_type === 'BANK'); const sortedSections = useMemo(() => {
const pensionAccounts = accounts.filter((a) => a.account_type === 'PENSION'); const sections = settings.dashboard.sections;
const savingsAccounts = accounts.filter((a) => a.account_type === 'SAVINGS'); return Object.entries(sections)
const liabilityAccounts = accounts.filter((a) => a.account_type === 'LIABILITY'); .filter(([, s]) => s.visible)
const cryptoAccounts = accounts.filter((a) => a.account_type === 'CRYPTO'); .sort(([, a], [, b]) => a.order - b.order);
}, [settings]);
const bankOrder = ['BAC', 'BCR', 'DAVIVIENDA']; // Net worth calculation
const netWorthBreakdown = useMemo(() => {
// Bank totals for exchange rate combined total if (accounts.length === 0) return null;
const bankCRC = bankAccounts.filter((a) => a.currency === 'CRC').reduce((s, a) => s + a.balance, 0); let assets = 0;
const bankUSD = bankAccounts.filter((a) => a.currency === 'USD').reduce((s, a) => s + a.balance, 0); let liabilities = 0;
for (const a of accounts) {
const isLiability = a.account_type === 'LIABILITY';
let crcValue = 0;
if (a.currency === 'USD') {
crcValue = Math.abs(a.balance) * (exchangeRate?.sell_rate ?? 0);
} else if (a.currency === 'CRC') {
crcValue = Math.abs(a.balance);
}
if (isLiability) {
liabilities += crcValue;
} else {
assets += crcValue;
}
}
return { assets, liabilities, net: assets - liabilities };
}, [accounts, exchangeRate]);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold">Dashboard</h1> <h1 className="text-2xl font-bold font-heading">Dashboard</h1>
<p className="text-sm text-text-muted mt-1">Financial overview</p> <p className="text-sm text-muted-foreground mt-1">Financial overview</p>
</div> </div>
<button <Button variant="ghost" size="icon" onClick={fetchData} title="Refresh" aria-label="Refresh">
onClick={fetchData} <RefreshCw className={cn('w-4 h-4', loading && 'animate-spin')} />
className="p-2 rounded-lg text-text-muted hover:text-text-primary hover:bg-surface-hover transition-colors" </Button>
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div> </div>
{/* Bank accounts — grouped by currency */} {/* Net Worth */}
{(['CRC', 'USD'] as const).map((currency) => { {netWorthBreakdown != null && (
const accts = bankAccounts <Card>
.filter((a) => a.currency === currency) <CardContent className="px-4 py-3">
.sort((a, b) => bankOrder.indexOf(a.bank) - bankOrder.indexOf(b.bank)); <div className="flex items-center justify-between text-sm font-mono text-muted-foreground">
<span>Net <span className="text-foreground font-bold">{netWorthBreakdown.net < 0 ? '-' : ''}{formatAmount(netWorthBreakdown.net, 'CRC')}</span></span>
<div className="flex gap-4">
<span>Assets <span className="text-foreground">{formatAmount(netWorthBreakdown.assets, 'CRC')}</span></span>
<span>Liabilities <span className="text-foreground">{formatAmount(netWorthBreakdown.liabilities, 'CRC')}</span></span>
</div>
</div>
</CardContent>
</Card>
)}
{/* Account sections */}
{sortedSections.map(([sectionId, sectionSettings]) => {
const def = SECTION_DEFS[sectionId];
if (!def) return null;
let accts = accounts.filter(def.filterFn);
if (accts.length === 0) return null; if (accts.length === 0) return null;
// Sort bank accounts by bank order
if (sectionId === 'crc_accounts' || sectionId === 'usd_accounts') {
accts = accts.sort((a, b) => BANK_ORDER.indexOf(a.bank) - BANK_ORDER.indexOf(b.bank));
}
const total = accts.reduce((s, a) => s + a.balance, 0); const total = accts.reduce((s, a) => s + a.balance, 0);
return ( return (
<div key={currency} className="space-y-2"> <DashboardSection
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">{currency} Accounts</h2> key={sectionId}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> sectionId={sectionId}
{accts.map((a) => <AccountCard key={a.id} account={a} {...cardProps} />)} settings={sectionSettings}
<TotalCard total={total} currency={currency} /> total={def.totalCurrency ? total : undefined}
</div> totalCurrency={def.totalCurrency || undefined}
</div> onToggleExpanded={(expanded) => patchSection(sectionId, { expanded })}
onOpenConfig={() => setConfigSection(sectionId)}
>
{accts.map((a) => (
<AccountRow key={a.id} account={a} {...rowProps} />
))}
</DashboardSection>
); );
})} })}
{/* Pension accounts */} {/* Exchange rate */}
{pensionAccounts.length > 0 && (
<div className="space-y-2">
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">Pension</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{pensionAccounts.map((a) => <AccountCard key={a.id} account={a} {...cardProps} />)}
<TotalCard
total={pensionAccounts.reduce((s, a) => s + a.balance, 0)}
currency="CRC"
/>
</div>
</div>
)}
{/* Savings accounts */}
{savingsAccounts.length > 0 && (
<div className="space-y-2">
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">Savings</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{savingsAccounts.map((a) => <AccountCard key={a.id} account={a} {...cardProps} />)}
<TotalCard
total={savingsAccounts.reduce((s, a) => s + a.balance, 0)}
currency="CRC"
/>
</div>
</div>
)}
{/* Liabilities */}
{liabilityAccounts.length > 0 && (
<div className="space-y-2">
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">Liabilities</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{liabilityAccounts.map((account) => {
const bankShort = account.bank === 'DAVIVIENDA' ? 'DAV' : account.bank;
return (
<div
key={account.id}
className="animate-fade-in bg-red-50 dark:bg-red-500/5 border border-red-200 dark:border-red-500/20 rounded-xl p-5 shadow-sm dark:shadow-none hover:bg-red-100/50 dark:hover:bg-red-500/10 transition-colors"
>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-bold text-red-700 dark:text-red-400/80 uppercase tracking-wider">
Balance
</span>
<span className="text-sm font-bold font-mono text-red-600 dark:text-red-400 bg-red-100 dark:bg-red-500/10 px-2.5 py-0.5 rounded">
{bankShort}
</span>
</div>
<p
className="text-2xl font-bold font-mono tracking-tight text-red-700 dark:text-red-400 cursor-pointer group/balance"
onClick={() => startEditing(account)}
>
{editingId === account.id ? (
<span className="flex items-center gap-2">
<input
type="number"
step="0.01"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') saveBalance(account.id);
if (e.key === 'Escape') cancelEditing();
}}
autoFocus
className="w-full text-2xl font-bold font-mono tracking-tight bg-input-bg border border-red-500/40 rounded-lg px-2 py-1 focus:outline-none focus:border-red-500 transition-colors text-text-primary"
onClick={(e) => e.stopPropagation()}
/>
<button onClick={(e) => { e.stopPropagation(); saveBalance(account.id); }} className="p-1 text-red-500">
<Check className="w-4 h-4" />
</button>
<button onClick={(e) => { e.stopPropagation(); cancelEditing(); }} className="p-1 text-text-muted">
<X className="w-4 h-4" />
</button>
</span>
) : (
formatAmount(account.balance, account.currency)
)}
</p>
{account.next_payment != null && (
<p className="text-sm font-mono text-red-600/70 dark:text-red-400/60 mt-2">
Next payment: {formatAmount(account.next_payment, account.currency)}
</p>
)}
</div>
);
})}
</div>
</div>
)}
{/* Crypto */}
{cryptoAccounts.length > 0 && (
<div className="space-y-2">
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">Crypto</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{cryptoAccounts.map((a) => <AccountCard key={a.id} account={a} {...cardProps} />)}
</div>
</div>
)}
{/* Exchange rate + combined total */}
{exchangeRate && ( {exchangeRate && (
<div className="flex flex-col sm:flex-row gap-4"> <Card>
<div className="flex-1 bg-surface-card border border-border rounded-xl p-4"> <CardContent className="p-4">
<span className="text-xs font-medium text-text-muted uppercase tracking-wider">USD/CRC Exchange Rate</span> <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">USD/CRC Exchange Rate</span>
<div className="flex items-baseline gap-3 mt-1"> <div className="flex items-baseline gap-3 mt-1">
<span className="text-lg font-bold font-mono">Buy: {exchangeRate.buy_rate.toFixed(2)}</span> <span className="text-lg font-bold font-mono">Buy: {exchangeRate.buy_rate.toFixed(2)}</span>
<span className="text-lg font-bold font-mono text-text-secondary">Sell: {exchangeRate.sell_rate.toFixed(2)}</span> <span className="text-lg font-bold font-mono text-muted-foreground">Sell: {exchangeRate.sell_rate.toFixed(2)}</span>
</div> </div>
</div> </CardContent>
{accounts.length > 0 && ( </Card>
<div className="flex-1 bg-gradient-to-br from-violet-500/10 to-[#606C38]/5 border border-violet-500/20 rounded-xl p-4">
<span className="text-xs font-medium text-violet-600 dark:text-violet-400/80 uppercase tracking-wider">Combined Total (CRC)</span>
<p className="text-2xl font-bold font-mono tracking-tight text-violet-600 dark:text-violet-400 mt-1">
{formatAmount(bankCRC + bankUSD * exchangeRate.sell_rate, 'CRC')}
</p>
</div>
)}
</div>
)} )}
{/* Recent transactions */} {/* Recent transactions */}
<div className="bg-surface-card border border-border rounded-xl"> <Card>
<div className="flex items-center justify-between px-5 py-4 border-b border-border-subtle"> <CardHeader className="border-b flex-row items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CreditCard className="w-4 h-4 text-text-muted" /> <CreditCard className="w-4 h-4 text-muted-foreground" />
<h2 className="font-semibold text-sm">Recent Charges</h2> <CardTitle className="text-sm">Recent Charges</CardTitle>
</div> </div>
<Link <Link
to="/transactions" to="/transactions"
className="flex items-center gap-1 text-xs font-medium text-[#606C38] dark:text-[#7a8a4a] hover:text-[#4a5a2a] dark:hover:text-[#8a9462] transition-colors" className="flex items-center gap-1 text-xs font-medium text-primary hover:text-primary/80 transition-colors"
> >
View all View all
<ArrowRight className="w-3 h-3" /> <ArrowRight className="w-3 h-3" />
</Link> </Link>
</div> </CardHeader>
{recent.length === 0 && !loading ? ( <CardContent className="p-0">
<div className="px-5 py-12 text-center text-text-faint text-sm">No transactions yet. Add your first one!</div> {recent.length === 0 && !loading ? (
) : ( <div className="px-5 py-12 text-center text-muted-foreground text-sm">No transactions yet. Add your first one!</div>
<div className="divide-y divide-border-subtle"> ) : (
{recent.map((tx) => ( <div className="divide-y divide-border">
<div key={tx.id} className="flex items-center justify-between px-5 py-3.5 hover:bg-surface-hover transition-colors animate-fade-in"> {recent.map((tx) => (
<div className="flex items-center gap-3 min-w-0"> <div key={tx.id} className="flex items-center justify-between px-5 py-3.5 hover:bg-muted/50 transition-colors">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${ <div className="flex items-center gap-3 min-w-0">
tx.transaction_type === 'DEVOLUCION' ? 'bg-[#606C38]/10 text-[#606C38] dark:text-[#7a8a4a]' : 'bg-red-500/10 text-red-500 dark:text-red-400' <div className={cn(
}`}> 'w-8 h-8 rounded-lg flex items-center justify-center shrink-0',
{tx.transaction_type === 'DEVOLUCION' ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />} tx.transaction_type === 'DEVOLUCION' ? 'bg-primary/10 text-primary' : 'bg-destructive/10 text-destructive'
</div> )}>
<div className="min-w-0"> {tx.transaction_type === 'DEVOLUCION' ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
<p className="text-sm font-medium truncate">{tx.merchant}</p> </div>
<p className="text-xs text-text-muted"> <div className="min-w-0">
{formatDate(tx.date)} <p className="text-sm font-medium truncate">{tx.merchant}</p>
{tx.category && <span className="ml-2 text-text-faint">{tx.category.name}</span>} <p className="text-xs text-muted-foreground">
</p> {formatDate(tx.date)}
{tx.category && <span className="ml-2 text-muted-foreground/60">{tx.category.name}</span>}
</p>
</div>
</div> </div>
<span className={cn(
'font-mono text-sm font-medium shrink-0 ml-4',
tx.transaction_type === 'DEVOLUCION' && 'text-primary'
)}>
{tx.transaction_type === 'DEVOLUCION' ? '+' : '-'}{formatAmount(tx.amount, tx.currency)}
</span>
</div> </div>
<span className={`font-mono text-sm font-medium flex-shrink-0 ml-4 ${ ))}
tx.transaction_type === 'DEVOLUCION' ? 'text-[#606C38] dark:text-[#7a8a4a]' : '' </div>
}`}> )}
{tx.transaction_type === 'DEVOLUCION' ? '+' : '-'}{formatAmount(tx.amount, tx.currency)} </CardContent>
</span> </Card>
</div>
))} {/* Section config dialog */}
</div> {configSection && settings.dashboard.sections[configSection] && (
)} <SectionConfigDialog
</div> sectionId={configSection}
settings={settings.dashboard.sections[configSection]}
open={!!configSection}
onOpenChange={(open) => { if (!open) setConfigSection(null); }}
onSave={(id, partial) => patchSection(id, partial)}
/>
)}
</div> </div>
); );
} }

View File

@@ -4,6 +4,10 @@ import { Wallet, ArrowRight, AlertCircle } from 'lucide-react';
import { login } from '../api'; import { login } from '../api';
import { useAuth } from '../AuthContext'; import { useAuth } from '../AuthContext';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
export default function Login() { export default function Login() {
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
@@ -29,60 +33,61 @@ export default function Login() {
}; };
return ( return (
<div className="min-h-screen bg-surface flex items-center justify-center px-4"> <div className="min-h-screen bg-background flex items-center justify-center px-4">
<div className="w-full max-w-sm animate-fade-in"> <div className="w-full max-w-sm">
<div className="flex items-center justify-center gap-2.5 mb-8"> <div className="flex items-center justify-center gap-2.5 mb-8">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-[#606C38] to-[#DDA15E] flex items-center justify-center"> <div className="w-10 h-10 rounded-lg bg-primary flex items-center justify-center">
<Wallet className="w-6 h-6 text-[#FEFAE0]" strokeWidth={2.5} /> <Wallet className="w-6 h-6 text-primary-foreground" strokeWidth={2.5} />
</div> </div>
<span className="text-2xl font-bold tracking-tight"> <span className="text-2xl font-bold tracking-tight font-heading">
Wealthy<span className="text-[#606C38] dark:text-[#7a8a4a]">Smart</span> Wealthy<span className="text-primary">Smart</span>
</span> </span>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <Card>
<div> <CardHeader>
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider"> <CardTitle>Sign in</CardTitle>
Username </CardHeader>
</label> <CardContent>
<input <form onSubmit={handleSubmit} className="space-y-4">
type="text" <div className="space-y-2">
value={username} <Label htmlFor="username">Username</Label>
onChange={(e) => setUsername(e.target.value)} <Input
className="w-full bg-input-bg border border-border rounded-lg px-4 py-3 text-text-primary placeholder-text-faint focus:outline-none focus:border-[#606C38]/50 focus:ring-1 focus:ring-[#606C38]/20 transition-colors" id="username"
placeholder="Enter username" type="text"
autoFocus value={username}
/> onChange={(e) => setUsername(e.target.value)}
</div> placeholder="Enter username"
<div> autoFocus
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider"> className="h-10"
Password />
</label> </div>
<input <div className="space-y-2">
type="password" <Label htmlFor="password">Password</Label>
value={password} <Input
onChange={(e) => setPassword(e.target.value)} id="password"
className="w-full bg-input-bg border border-border rounded-lg px-4 py-3 text-text-primary placeholder-text-faint focus:outline-none focus:border-[#606C38]/50 focus:ring-1 focus:ring-[#606C38]/20 transition-colors" type="password"
placeholder="Enter password" value={password}
/> onChange={(e) => setPassword(e.target.value)}
</div> placeholder="Enter password"
className="h-10"
/>
</div>
{error && ( {error && (
<div className="flex items-center gap-2 text-red-500 dark:text-red-400 text-sm"> <div className="flex items-center gap-2 text-destructive text-sm">
<AlertCircle className="w-4 h-4" /> <AlertCircle className="w-4 h-4" />
{error} {error}
</div> </div>
)} )}
<button <Button type="submit" disabled={loading} className="w-full h-10">
type="submit" {loading ? 'Signing in...' : 'Sign in'}
disabled={loading} {!loading && <ArrowRight className="w-4 h-4" />}
className="w-full flex items-center justify-center gap-2 bg-[#606C38] hover:bg-[#7a8a4a] disabled:opacity-50 text-white dark:text-[#FEFAE0] font-semibold px-6 py-3 rounded-lg transition-colors" </Button>
> </form>
{loading ? 'Signing in...' : 'Sign in'} </CardContent>
{!loading && <ArrowRight className="w-4 h-4" />} </Card>
</button>
</form>
</div> </div>
</div> </div>
); );

View File

@@ -1,20 +1,18 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { import { Plus, ClipboardPaste } from 'lucide-react';
Plus,
Search,
Pencil,
Trash2,
TrendingUp,
TrendingDown,
ChevronDown,
ClipboardPaste,
} from 'lucide-react';
import api, { type Transaction, type Category } from '../api'; import api, { type Transaction, type Category } from '../api';
import TransactionModal from '../components/TransactionModal';
import PasteImportModal from '../components/PasteImportModal'; import PasteImportModal from '../components/PasteImportModal';
import ConfirmDialog from '../components/ConfirmDialog';
import BillingCycleSelector from '../components/BillingCycleSelector'; import BillingCycleSelector from '../components/BillingCycleSelector';
import TransactionList from '../components/TransactionList';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
function formatAmount(amount: number, currency: string) { function formatAmount(amount: number, currency: string) {
const abs = Math.abs(amount); const abs = Math.abs(amount);
@@ -30,11 +28,7 @@ export default function Transactions() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [categoryFilter, setCategoryFilter] = useState(''); const [categoryFilter, setCategoryFilter] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
const [editing, setEditing] = useState<Transaction | null>(null);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [deleting, setDeleting] = useState(false);
const [cycle, setCycle] = useState<{ year: number; month: number } | null>(null); const [cycle, setCycle] = useState<{ year: number; month: number } | null>(null);
const fetchTransactions = useCallback(async () => { const fetchTransactions = useCallback(async () => {
@@ -63,18 +57,6 @@ export default function Transactions() {
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [fetchTransactions]); }, [fetchTransactions]);
const handleDelete = async () => {
if (deleteId === null) return;
setDeleting(true);
try {
await api.delete(`/transactions/${deleteId}`);
setDeleteId(null);
fetchTransactions();
} finally {
setDeleting(false);
}
};
const totalCRC = transactions const totalCRC = transactions
.filter((tx) => tx.currency === 'CRC') .filter((tx) => tx.currency === 'CRC')
.reduce((sum, tx) => sum + (tx.transaction_type === 'DEVOLUCION' ? -tx.amount : tx.amount), 0); .reduce((sum, tx) => sum + (tx.transaction_type === 'DEVOLUCION' ? -tx.amount : tx.amount), 0);
@@ -86,187 +68,57 @@ export default function Transactions() {
<div className="space-y-5"> <div className="space-y-5">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-bold">Credit Card Transactions</h1> <h1 className="text-2xl font-bold font-heading">Credit Card Transactions</h1>
<p className="text-sm text-text-muted mt-1"> <p className="text-sm text-muted-foreground mt-1">
{transactions.length} transactions {transactions.length} transactions
{totalCRC !== 0 && ( {totalCRC !== 0 && (
<> &middot; <span className="font-mono text-text-primary">{formatAmount(totalCRC, 'CRC')}</span></> <> &middot; <span className="font-mono text-foreground">{formatAmount(totalCRC, 'CRC')}</span></>
)} )}
{totalUSD !== 0 && ( {totalUSD !== 0 && (
<> &middot; <span className="font-mono text-text-primary">{formatAmount(totalUSD, 'USD')}</span></> <> &middot; <span className="font-mono text-foreground">{formatAmount(totalUSD, 'USD')}</span></>
)} )}
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <Button variant="outline" onClick={() => setImportOpen(true)}>
onClick={() => setImportOpen(true)}
className="flex items-center gap-2 border border-border hover:bg-surface-hover text-text-secondary font-semibold px-4 py-2.5 rounded-lg text-sm transition-colors"
>
<ClipboardPaste className="w-4 h-4" /> <ClipboardPaste className="w-4 h-4" />
Import Import
</button> </Button>
<button
onClick={() => {
setEditing(null);
setModalOpen(true);
}}
className="flex items-center gap-2 bg-[#606C38] hover:bg-[#7a8a4a] text-white dark:text-[#FEFAE0] font-semibold px-4 py-2.5 rounded-lg text-sm transition-colors"
>
<Plus className="w-4 h-4" />
Add Transaction
</button>
</div> </div>
</div> </div>
{/* Billing cycle */} {/* Billing cycle */}
<BillingCycleSelector value={cycle} onChange={setCycle} /> <BillingCycleSelector value={cycle} onChange={setCycle} />
{/* Filters */} {/* Category filter */}
<div className="flex flex-col sm:flex-row gap-3"> <div className="flex items-center gap-3">
<div className="relative flex-1"> <Select
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" /> value={categoryFilter || 'all'}
<input onValueChange={(v) => setCategoryFilter(v === 'all' ? '' : v)}
value={search} >
onChange={(e) => setSearch(e.target.value)} <SelectTrigger className="w-[180px]">
className="w-full bg-input-bg border border-border rounded-lg pl-10 pr-4 py-2.5 text-sm text-text-primary placeholder-text-faint focus:outline-none focus:border-[#606C38]/50 transition-colors" <SelectValue />
placeholder="Search merchants..." </SelectTrigger>
/> <SelectContent>
</div> <SelectItem value="all">All Categories</SelectItem>
<div className="relative">
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="appearance-none bg-input-bg border border-border rounded-lg pl-4 pr-10 py-2.5 text-sm text-text-primary focus:outline-none focus:border-[#606C38]/50 transition-colors"
>
<option value="">All Categories</option>
{categories.map((c) => ( {categories.map((c) => (
<option key={c.id} value={c.id}> <SelectItem key={c.id} value={String(c.id)}>
{c.name} {c.name}
</option> </SelectItem>
))} ))}
</select> </SelectContent>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted pointer-events-none" /> </Select>
</div>
</div> </div>
{/* Table */} <TransactionList
<div className="bg-surface-card border border-border rounded-xl overflow-hidden"> transactions={transactions}
<div className="overflow-x-auto"> loading={loading}
<table className="w-full text-sm"> source="CREDIT_CARD"
<thead> search={search}
<tr className="border-b border-border-subtle"> onSearchChange={setSearch}
<th className="text-left px-5 py-3 text-xs font-medium text-text-muted uppercase tracking-wider"> onRefresh={fetchTransactions}
Date showCategory
</th> />
<th className="text-left px-5 py-3 text-xs font-medium text-text-muted uppercase tracking-wider">
Merchant
</th>
<th className="text-left px-5 py-3 text-xs font-medium text-text-muted uppercase tracking-wider hidden md:table-cell">
Category
</th>
<th className="text-right px-5 py-3 text-xs font-medium text-text-muted uppercase tracking-wider">
Amount
</th>
<th className="text-right px-5 py-3 text-xs font-medium text-text-muted uppercase tracking-wider w-20">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-border-subtle">
{transactions.map((tx) => (
<tr
key={tx.id}
className="hover:bg-surface-hover transition-colors group"
>
<td className="px-5 py-3 whitespace-nowrap">
<span className="font-mono text-text-secondary text-xs">
{new Date(tx.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</span>
</td>
<td className="px-5 py-3">
<div className="flex items-center gap-2">
<div
className={`w-6 h-6 rounded flex items-center justify-center flex-shrink-0 ${
tx.transaction_type === 'DEVOLUCION'
? 'bg-[#606C38]/10 text-[#606C38] dark:text-[#7a8a4a]'
: 'bg-red-500/10 text-red-500 dark:text-red-400'
}`}
>
{tx.transaction_type === 'DEVOLUCION' ? (
<TrendingUp className="w-3 h-3" />
) : (
<TrendingDown className="w-3 h-3" />
)}
</div>
<span className="truncate max-w-[200px] sm:max-w-none">{tx.merchant}</span>
</div>
</td>
<td className="px-5 py-3 hidden md:table-cell">
{tx.category ? (
<span className="text-xs bg-surface-hover text-text-secondary px-2 py-1 rounded">
{tx.category.name}
</span>
) : (
<span className="text-xs text-text-faint"></span>
)}
</td>
<td className="px-5 py-3 text-right whitespace-nowrap">
<span
className={`font-mono font-medium ${
tx.transaction_type === 'DEVOLUCION'
? 'text-[#606C38] dark:text-[#7a8a4a]'
: ''
}`}
>
{tx.transaction_type === 'DEVOLUCION' ? '+' : '-'}
{formatAmount(tx.amount, tx.currency)}
</span>
</td>
<td className="px-5 py-3 text-right">
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => {
setEditing(tx);
setModalOpen(true);
}}
className="p-1.5 rounded hover:bg-surface-hover text-text-muted hover:text-text-primary transition-colors"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setDeleteId(tx.id)}
className="p-1.5 rounded hover:bg-red-500/10 text-text-muted hover:text-red-500 dark:hover:text-red-400 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{transactions.length === 0 && !loading && (
<div className="px-5 py-16 text-center text-text-faint text-sm">
No transactions found
</div>
)}
</div>
{modalOpen && (
<TransactionModal
transaction={editing}
source="CREDIT_CARD"
onClose={() => setModalOpen(false)}
onSaved={fetchTransactions}
/>
)}
{importOpen && ( {importOpen && (
<PasteImportModal <PasteImportModal
@@ -274,16 +126,6 @@ export default function Transactions() {
onImported={fetchTransactions} onImported={fetchTransactions}
/> />
)} )}
{deleteId !== null && (
<ConfirmDialog
title="Delete Transaction"
message="This transaction will be permanently deleted. This action cannot be undone."
onConfirm={handleDelete}
onCancel={() => setDeleteId(null)}
loading={deleting}
/>
)}
</div> </div>
); );
} }

View File

@@ -1,17 +1,9 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { Plus, Search, Pencil, Trash2, ArrowLeftRight } from 'lucide-react'; import { ArrowLeftRight } from 'lucide-react';
import api, { type Transaction } from '../api'; import api, { type Transaction } from '../api';
import TransactionModal from '../components/TransactionModal'; import TransactionList from '../components/TransactionList';
import ConfirmDialog from '../components/ConfirmDialog'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
function formatAmount(amount: number, currency: string) {
const abs = Math.abs(amount);
if (currency === 'USD') {
return `$${abs.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
return `${abs.toLocaleString('es-CR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
type SourceTab = 'CASH' | 'TRANSFER'; type SourceTab = 'CASH' | 'TRANSFER';
@@ -20,10 +12,6 @@ export default function Transfers() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [sourceTab, setSourceTab] = useState<SourceTab>('CASH'); const [sourceTab, setSourceTab] = useState<SourceTab>('CASH');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Transaction | null>(null);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [deleting, setDeleting] = useState(false);
const fetchTransactions = useCallback(async () => { const fetchTransactions = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -42,142 +30,36 @@ export default function Transfers() {
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [fetchTransactions]); }, [fetchTransactions]);
const handleDelete = async () => {
if (deleteId === null) return;
setDeleting(true);
try {
await api.delete(`/transactions/${deleteId}`);
setDeleteId(null);
fetchTransactions();
} finally {
setDeleting(false);
}
};
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> <div>
<div> <h1 className="text-2xl font-bold font-heading">Cash & Transfers</h1>
<h1 className="text-2xl font-bold">Cash & Transfers</h1> <p className="text-sm text-muted-foreground mt-1">
<p className="text-sm text-text-muted mt-1"> Track non-credit-card expenses
Track non-credit-card expenses </p>
</p>
</div>
<button
onClick={() => {
setEditing(null);
setModalOpen(true);
}}
className="flex items-center gap-2 bg-[#606C38] hover:bg-[#7a8a4a] text-white dark:text-[#FEFAE0] font-semibold px-4 py-2.5 rounded-lg text-sm transition-colors"
>
<Plus className="w-4 h-4" />
Add {sourceTab === 'CASH' ? 'Cash Expense' : 'Transfer'}
</button>
</div> </div>
{/* Source tabs */} <Tabs value={sourceTab} onValueChange={(v) => setSourceTab(v as SourceTab)}>
<div className="flex gap-1 bg-surface-card border border-border rounded-lg p-1 w-fit"> <TabsList>
{(['CASH', 'TRANSFER'] as const).map((tab) => ( <TabsTrigger value="CASH">Cash</TabsTrigger>
<button <TabsTrigger value="TRANSFER">Transfers</TabsTrigger>
key={tab} </TabsList>
onClick={() => setSourceTab(tab)}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
sourceTab === tab
? 'bg-[#606C38]/10 text-[#606C38] dark:text-[#7a8a4a]'
: 'text-text-muted hover:text-text-primary'
}`}
>
{tab === 'CASH' ? 'Cash' : 'Transfers'}
</button>
))}
</div>
{/* Search */} <TabsContent value={sourceTab} className="mt-5 space-y-5">
<div className="relative max-w-sm"> <TransactionList
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" /> transactions={transactions}
<input loading={loading}
value={search} source={sourceTab}
onChange={(e) => setSearch(e.target.value)} search={search}
className="w-full bg-input-bg border border-border rounded-lg pl-10 pr-4 py-2.5 text-sm text-text-primary placeholder-text-faint focus:outline-none focus:border-[#606C38]/50 transition-colors" onSearchChange={setSearch}
placeholder="Search..." onRefresh={fetchTransactions}
/> showCategory={false}
</div> addLabel={sourceTab === 'CASH' ? 'Add Cash Expense' : 'Add Transfer'}
emptyIcon={<ArrowLeftRight className="w-8 h-8 mx-auto mb-3 text-muted-foreground/50" />}
{/* List */} emptyMessage={`No ${sourceTab.toLowerCase()} transactions yet`}
<div className="bg-surface-card border border-border rounded-xl divide-y divide-border-subtle"> />
{transactions.length === 0 && !loading ? ( </TabsContent>
<div className="px-5 py-16 text-center text-text-faint text-sm"> </Tabs>
<ArrowLeftRight className="w-8 h-8 mx-auto mb-3 text-text-faint" />
No {sourceTab.toLowerCase()} transactions yet
</div>
) : (
<>
{transactions.map((tx) => (
<div
key={tx.id}
className="flex items-center justify-between px-5 py-4 hover:bg-surface-hover transition-colors group"
>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{tx.merchant}</p>
<p className="text-xs text-text-muted mt-0.5">
{new Date(tx.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
{tx.category && (
<span className="ml-2 bg-surface-hover text-text-secondary px-2 py-0.5 rounded">
{tx.category.name}
</span>
)}
</p>
</div>
<div className="flex items-center gap-3 flex-shrink-0 ml-4">
<span className="font-mono text-sm font-medium">
{formatAmount(tx.amount, tx.currency)}
</span>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => {
setEditing(tx);
setModalOpen(true);
}}
className="p-1.5 rounded hover:bg-surface-hover text-text-muted hover:text-text-primary transition-colors"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setDeleteId(tx.id)}
className="p-1.5 rounded hover:bg-red-500/10 text-text-muted hover:text-red-500 dark:hover:text-red-400 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
))}
</>
)}
</div>
{modalOpen && (
<TransactionModal
transaction={editing}
source={sourceTab}
onClose={() => setModalOpen(false)}
onSaved={fetchTransactions}
/>
)}
{deleteId !== null && (
<ConfirmDialog
title="Delete Transaction"
message="This transaction will be permanently deleted. This action cannot be undone."
onConfirm={handleDelete}
onCancel={() => setDeleteId(null)}
loading={deleting}
/>
)}
</div> </div>
); );
} }