From 9161a38330c2542bef1c4689b08be010adaba49a 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 Budget Planner page (target per category vs actual, over-budget alerts) --- src/app/dashboard/finance/budget/page.tsx | 167 ++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 src/app/dashboard/finance/budget/page.tsx diff --git a/src/app/dashboard/finance/budget/page.tsx b/src/app/dashboard/finance/budget/page.tsx new file mode 100644 index 0000000..0f11e00 --- /dev/null +++ b/src/app/dashboard/finance/budget/page.tsx @@ -0,0 +1,167 @@ +'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 BUDGET_CATEGORIES = [ + { id: 'software', label: 'Software / SaaS', icon: '💻' }, + { id: 'marketing', label: 'Marketing', icon: '📣' }, + { id: 'training', label: 'Training', icon: '🎓' }, + { id: 'transport', label: 'Transport', icon: '🚗' }, + { id: 'food', label: 'Masă / Cafea', icon: '☕' }, + { id: 'office', label: 'Birou / Echipament', icon: '🖥️' }, + { id: 'personal', label: 'Personal', icon: '👤' }, + { id: 'other', label: 'Altele', icon: '📦' }, +]; + +const EXPENSE_KEYWORDS: Record = { + software: ['software','saas','subscriptie','abonament','claude','openai','figma'], + marketing: ['marketing','reclama','ads','publicitate'], + training: ['training','curs','carte','educatie','conferinta'], + transport: ['transport','benzina','uber','bilet','tren','avion'], + food: ['mancare','restaurant','cafea','masa','food'], + office: ['birou','echipament','hardware','server','hosting','hetzner'], + personal: ['personal','sanatate','farmacie','sport','gym'], +}; + +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 BudgetPage() { + const { activeTenant } = useSession(); + const tenantId = activeTenant?.tenantId ?? ''; + const qc = useQueryClient(); + const [editCat, setEditCat] = useState(null); + const [editVal, setEditVal] = useState(''); + + const { data: observations = [], isLoading } = useQuery({ + queryKey: ['budget-obs', tenantId], + queryFn: () => apiFetch('/v1/observations', { tenantId }), + enabled: Boolean(tenantId), staleTime: 60_000, + }); + + const budgets = useMemo(() => { + const map: Record = {}; + for (const o of observations.filter((o) => o.subjectType === 'budget')) { + map[o.metric.replace('budget-', '')] = parseNum(o.value); + } + return map; + }, [observations]); + + const actualByCategory = useMemo(() => { + const map: Record = {}; + const thisMonthExp = observations.filter((o) => + (o.subjectType === 'expense' || o.metric.toLowerCase().includes('cheltuiala')) && + monthKey(o.observedAt ?? o.createdAt) === CURRENT_MONTH, + ); + for (const e of thisMonthExp) { + const text = `${e.metric} ${e.source ?? ''}`.toLowerCase(); + let cat = 'other'; + for (const [k, kws] of Object.entries(EXPENSE_KEYWORDS)) { + if (kws.some((kw) => text.includes(kw))) { cat = k; break; } + } + map[cat] = (map[cat] ?? 0) + parseNum(e.value); + } + return map; + }, [observations]); + + const saveBudgetMut = useMutation({ + mutationFn: ({ cat, val }: { cat: string; val: string }) => { + const existing = observations.find((o) => o.subjectType === 'budget' && o.metric === `budget-${cat}`); + if (existing) { + return apiFetch(`/v1/observations/${existing.id}`, { tenantId, method: 'PATCH', body: { value: val } }); + } + return apiFetch('/v1/observations', { tenantId, method: 'POST', body: { + metric: `budget-${cat}`, value: val, unit: '€/lună', + subjectType: 'budget', confidence: 1, observedAt: new Date().toISOString(), + }}); + }, + onSuccess: () => { qc.invalidateQueries({ queryKey: ['budget-obs', tenantId] }); setEditCat(null); }, + }); + + const totalBudget = Object.values(budgets).reduce((s, v) => s + v, 0); + const totalActual = Object.values(actualByCategory).reduce((s, v) => s + v, 0); + const overallPct = totalBudget > 0 ? (totalActual / totalBudget) * 100 : 0; + + return ( +
+
+
+

Budget Planner

+

Buget lunar vs cheltuieli luna curentă

+
+ ← Finance +
+ + {/* Overall */} +
+
+

Total lunar

+

100 ? 'text-signal-danger' : overallPct > 80 ? 'text-warn' : 'text-signal-ok'}`}> + {totalActual.toLocaleString('ro-RO')} / {totalBudget.toLocaleString('ro-RO')} € ({Math.round(overallPct)}%) +

+
+
+
100 ? 'bg-signal-danger' : overallPct > 80 ? 'bg-warn' : 'bg-signal-ok'}`} + style={{ width: `${Math.min(overallPct, 100)}%` }} /> +
+
+ + {isLoading ? ( +
Se încarcă…
+ ) : ( +
+ {BUDGET_CATEGORIES.map((cat) => { + const budget = budgets[cat.id] ?? 0; + const actual = actualByCategory[cat.id] ?? 0; + const pct = budget > 0 ? (actual / budget) * 100 : actual > 0 ? 100 : 0; + const over = budget > 0 && actual > budget; + return ( +
+
+ {cat.icon} +

{cat.label}

+ {over && DEPĂȘIT} + {editCat === cat.id ? ( +
+ setEditVal(e.target.value)} + className="w-20 rounded border bg-background px-2 py-1 text-xs text-ink focus:outline-none focus:ring-2 focus:ring-ring" /> + + +
+ ) : ( + + )} +
+
+
+
80 ? 'bg-warn' : 'bg-signal-ok'}`} + style={{ width: `${Math.min(pct, 100)}%` }} /> +
+

+ {actual.toLocaleString('ro-RO')} {budget > 0 ? `/ ${budget.toLocaleString('ro-RO')} €` : '€'} +

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

+ Bugetele sunt salvate ca observații cu subjectType: budget. Cheltuielile se preiau din luna curentă. +

+
+ ); +}