feat(CC-091): add Expense Tracker page (category breakdown, monthly filter, bar chart)
This commit is contained in:
parent
03c4f8d7e3
commit
e1b6301ea5
1 changed files with 210 additions and 0 deletions
210
src/app/dashboard/finance/expenses/page.tsx
Normal file
210
src/app/dashboard/finance/expenses/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue