feat(CC-091): add Expense Tracker page (category breakdown, monthly filter, bar chart)

This commit is contained in:
admin-valentin 2026-08-02 18:06:04 +00:00
parent 03c4f8d7e3
commit e1b6301ea5

View file

@ -0,0 +1,210 @@
'use client';
import { useMemo, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../../../../lib/api';
import { useSession } from '../../../../components/session-provider';
import Link from 'next/link';
interface Observation { id: string; metric: string; value: string; unit: string | null; subjectType: string; source: string | null; observedAt: string | null; createdAt: string; }
const EXPENSE_CATEGORIES: Record<string, { label: string; icon: string; keywords: string[] }> = {
software: { label: 'Software / SaaS', icon: '💻', keywords: ['software','saas','subscriptie','abonament','claude','openai','figma','notion'] },
marketing: { label: 'Marketing', icon: '📣', keywords: ['marketing','reclama','ads','publicitate','newsletter'] },
training: { label: 'Training / Educatie', icon: '🎓', keywords: ['training','curs','carte','educatie','conferinta','workshop'] },
transport: { label: 'Transport', icon: '🚗', keywords: ['transport','benzina','uber','bilet','tren','avion','parcare'] },
food: { label: 'Masa / Cafea', icon: '☕', keywords: ['mancare','restaurant','cafea','masa','food','lunch'] },
office: { label: 'Birou / Echipament', icon: '🖥️', keywords: ['birou','echipament','hardware','printer','server','hosting','hetzner'] },
personal: { label: 'Personal', icon: '👤', keywords: ['personal','sanatate','farmacie','sport','gym','haine'] },
other: { label: 'Altele', icon: '📦', keywords: [] },
};
const EXPENSE_METRICS = ['cheltuiala', 'expense', 'cost', 'plata', 'abonament'];
function parseNum(v: string) { return parseFloat(v.replace(/[^0-9.-]/g, '')) || 0; }
function monthKey(iso: string) { const d = new Date(iso); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; }
const CURRENT_MONTH = monthKey(new Date().toISOString());
export default function ExpensesPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const qc = useQueryClient();
const [monthFilter, setMonthFilter] = useState(CURRENT_MONTH);
const [catFilter, setCatFilter] = useState('all');
const [showAdd, setShowAdd] = useState(false);
const [form, setForm] = useState({ amount: '', category: 'software', description: '', date: new Date().toISOString().slice(0, 10) });
const { data: observations = [], isLoading } = useQuery({
queryKey: ['expenses', tenantId],
queryFn: () => apiFetch<Observation[]>('/v1/observations', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const expenses = useMemo(() =>
observations.filter((o) =>
o.subjectType === 'expense' ||
EXPENSE_METRICS.some((m) => o.metric.toLowerCase().includes(m)),
),
[observations]);
function detectCategory(o: Observation): string {
const text = `${o.metric} ${o.value} ${o.source ?? ''}`.toLowerCase();
for (const [key, cat] of Object.entries(EXPENSE_CATEGORIES)) {
if (key === 'other') continue;
if (cat.keywords.some((k) => text.includes(k))) return key;
}
return 'other';
}
const months = useMemo(() => {
const set = new Set<string>();
for (const e of expenses) set.add(monthKey(e.observedAt ?? e.createdAt));
return [...set].sort().reverse().slice(0, 12);
}, [expenses]);
const filtered = useMemo(() => {
let list = expenses.filter((e) => monthKey(e.observedAt ?? e.createdAt) === monthFilter);
if (catFilter !== 'all') list = list.filter((e) => detectCategory(e) === catFilter);
return list;
}, [expenses, monthFilter, catFilter]);
const byCategory = useMemo(() => {
const map: Record<string, number> = {};
for (const e of expenses.filter((e) => monthKey(e.observedAt ?? e.createdAt) === monthFilter)) {
const cat = detectCategory(e);
map[cat] = (map[cat] ?? 0) + parseNum(e.value);
}
return map;
}, [expenses, monthFilter]);
const monthTotal = Object.values(byCategory).reduce((s, v) => s + v, 0);
const addMut = useMutation({
mutationFn: () => apiFetch('/v1/observations', { tenantId, method: 'POST', body: {
metric: `cheltuiala-${form.category}`,
value: form.amount,
unit: '€',
subjectType: 'expense',
confidence: 1,
source: form.description || undefined,
observedAt: new Date(form.date).toISOString(),
}}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['expenses', tenantId] });
setShowAdd(false);
setForm({ amount: '', category: 'software', description: '', date: new Date().toISOString().slice(0, 10) });
},
});
function formatMonth(k: string) {
const [y, m] = k.split('-');
return new Date(parseInt(y), parseInt(m) - 1).toLocaleDateString('ro-RO', { month: 'long', year: 'numeric' });
}
return (
<div className="max-w-4xl space-y-6 p-6">
<div className="flex items-start justify-between flex-wrap gap-3">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Cheltuieli</h1>
<p className="text-sm text-ink-faint mt-1">
{formatMonth(monthFilter)} · Total: <span className="font-semibold text-signal-danger">{monthTotal.toLocaleString('ro-RO')} </span>
</p>
</div>
<div className="flex gap-2">
<Link href="/dashboard/finance" className="rounded-lg border px-3 py-1.5 text-sm text-ink hover:bg-muted/50"> Finance</Link>
<button onClick={() => setShowAdd(true)} className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90">+ Cheltuială</button>
</div>
</div>
{showAdd && (
<div className="card p-5 space-y-3">
<p className="text-sm font-semibold text-ink">Cheltuială nouă</p>
<div className="grid gap-3 sm:grid-cols-2">
<input placeholder="Sumă (ex: 49)" type="number" min="0" step="0.01" value={form.amount}
onChange={(e) => setForm((p) => ({ ...p, amount: e.target.value }))}
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
<input type="date" value={form.date}
onChange={(e) => setForm((p) => ({ ...p, date: e.target.value }))}
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
<select value={form.category} onChange={(e) => setForm((p) => ({ ...p, category: e.target.value }))}
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring">
{Object.entries(EXPENSE_CATEGORIES).map(([k, v]) => (
<option key={k} value={k}>{v.icon} {v.label}</option>
))}
</select>
<input placeholder="Descriere (ex: Claude Pro)" value={form.description}
onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))}
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
</div>
<div className="flex gap-2">
<button onClick={() => addMut.mutate()} disabled={!form.amount || addMut.isPending}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-50">
{addMut.isPending ? '…' : 'Salvează'}
</button>
<button onClick={() => setShowAdd(false)} className="rounded-lg border px-4 py-2 text-sm text-ink">Anulează</button>
</div>
</div>
)}
{/* Month selector */}
<div className="flex gap-2 overflow-x-auto pb-1">
{months.map((m) => (
<button key={m} onClick={() => setMonthFilter(m)}
className={`rounded-full px-3 py-1 text-xs whitespace-nowrap border shrink-0 ${monthFilter === m ? 'bg-primary text-white border-primary' : 'bg-background text-ink-faint border-border hover:border-primary/40'}`}>
{formatMonth(m)}
</button>
))}
</div>
{/* Category breakdown */}
{Object.keys(byCategory).length > 0 && (
<div className="card p-4 space-y-3">
<p className="text-xs font-semibold text-ink-faint uppercase tracking-wide">Pe categorii</p>
{Object.entries(byCategory).sort((a, b) => b[1] - a[1]).map(([cat, amt]) => {
const info = EXPENSE_CATEGORIES[cat];
const pct = monthTotal > 0 ? (amt / monthTotal) * 100 : 0;
return (
<button key={cat} onClick={() => setCatFilter(catFilter === cat ? 'all' : cat)}
className={`w-full space-y-1 text-left ${catFilter === cat ? 'opacity-100' : 'opacity-80 hover:opacity-100'}`}>
<div className="flex items-center justify-between">
<span className="text-xs text-ink">{info?.icon} {info?.label ?? cat}</span>
<span className="text-xs font-semibold text-signal-danger">{amt.toLocaleString('ro-RO')} </span>
</div>
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
<div className="h-full rounded-full bg-signal-danger/70" style={{ width: `${pct}%` }} />
</div>
</button>
);
})}
</div>
)}
{/* Expense list */}
{isLoading ? (
<div className="text-center text-sm text-ink-faint py-8">Se încarcă</div>
) : filtered.length === 0 ? (
<div className="card p-8 text-center space-y-2">
<p className="text-3xl">💸</p>
<p className="text-sm text-ink-faint">Nicio cheltuială pentru această perioadă.</p>
</div>
) : (
<div className="card divide-y divide-border/50">
{filtered.sort((a, b) => (b.observedAt ?? b.createdAt).localeCompare(a.observedAt ?? a.createdAt)).map((e) => {
const cat = detectCategory(e);
const info = EXPENSE_CATEGORIES[cat];
return (
<div key={e.id} className="flex items-center gap-3 p-3.5">
<span className="text-xl shrink-0">{info?.icon ?? '📦'}</span>
<div className="flex-1 min-w-0">
<p className="text-sm text-ink truncate">{e.source ?? e.metric.replace('cheltuiala-', '')}</p>
<p className="text-[10px] text-ink-faint">{new Date(e.observedAt ?? e.createdAt).toLocaleDateString('ro-RO')} · {info?.label ?? cat}</p>
</div>
<p className="text-sm font-semibold text-signal-danger shrink-0">{parseNum(e.value).toLocaleString('ro-RO')} {e.unit ?? '€'}</p>
</div>
);
})}
</div>
)}
</div>
);
}