From e1b6301ea53146e5023f42c7feb8fd49bc34158d Mon Sep 17 00:00:00 2001 From: admin-valentin Date: Sun, 2 Aug 2026 18:06:04 +0000 Subject: [PATCH] feat(CC-091): add Expense Tracker page (category breakdown, monthly filter, bar chart) --- src/app/dashboard/finance/expenses/page.tsx | 210 ++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 src/app/dashboard/finance/expenses/page.tsx diff --git a/src/app/dashboard/finance/expenses/page.tsx b/src/app/dashboard/finance/expenses/page.tsx new file mode 100644 index 0000000..e9534ac --- /dev/null +++ b/src/app/dashboard/finance/expenses/page.tsx @@ -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 = { + 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('/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(); + 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 = {}; + 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 ( +
+
+
+

Cheltuieli

+

+ {formatMonth(monthFilter)} · Total: {monthTotal.toLocaleString('ro-RO')} € +

+
+
+ ← Finance + +
+
+ + {showAdd && ( +
+

Cheltuială nouă

+
+ 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" /> + 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" /> + + 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" /> +
+
+ + +
+
+ )} + + {/* Month selector */} +
+ {months.map((m) => ( + + ))} +
+ + {/* Category breakdown */} + {Object.keys(byCategory).length > 0 && ( +
+

Pe categorii

+ {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 ( + + ); + })} +
+ )} + + {/* Expense list */} + {isLoading ? ( +
Se încarcă…
+ ) : filtered.length === 0 ? ( +
+

💸

+

Nicio cheltuială pentru această perioadă.

+
+ ) : ( +
+ {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 ( +
+ {info?.icon ?? '📦'} +
+

{e.source ?? e.metric.replace('cheltuiala-', '')}

+

{new Date(e.observedAt ?? e.createdAt).toLocaleDateString('ro-RO')} · {info?.label ?? cat}

+
+

{parseNum(e.value).toLocaleString('ro-RO')} {e.unit ?? '€'}

+
+ ); + })} +
+ )} +
+ ); +}