feat(CC-091): add Budget Planner page (target per category vs actual, over-budget alerts)

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

View file

@ -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<string, string[]> = {
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<string | null>(null);
const [editVal, setEditVal] = useState('');
const { data: observations = [], isLoading } = useQuery({
queryKey: ['budget-obs', tenantId],
queryFn: () => apiFetch<Observation[]>('/v1/observations', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const budgets = useMemo(() => {
const map: Record<string, number> = {};
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<string, number> = {};
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 (
<div className="max-w-3xl 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">Budget Planner</h1>
<p className="text-sm text-ink-faint mt-1">Buget lunar vs cheltuieli luna curentă</p>
</div>
<Link href="/dashboard/finance" className="rounded-lg border px-3 py-1.5 text-sm text-ink hover:bg-muted/50"> Finance</Link>
</div>
{/* Overall */}
<div className="card p-5 space-y-3">
<div className="flex justify-between items-center">
<p className="text-sm font-semibold text-ink">Total lunar</p>
<p className={`text-sm font-bold ${overallPct > 100 ? 'text-signal-danger' : overallPct > 80 ? 'text-warn' : 'text-signal-ok'}`}>
{totalActual.toLocaleString('ro-RO')} / {totalBudget.toLocaleString('ro-RO')} ({Math.round(overallPct)}%)
</p>
</div>
<div className="h-3 rounded-full bg-muted overflow-hidden">
<div className={`h-full rounded-full transition-all ${overallPct > 100 ? 'bg-signal-danger' : overallPct > 80 ? 'bg-warn' : 'bg-signal-ok'}`}
style={{ width: `${Math.min(overallPct, 100)}%` }} />
</div>
</div>
{isLoading ? (
<div className="text-center text-sm text-ink-faint py-8">Se încarcă</div>
) : (
<div className="space-y-3">
{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 (
<div key={cat.id} className={`card p-4 space-y-2 ${over ? 'border-signal-danger/30' : ''}`}>
<div className="flex items-center gap-2">
<span className="text-lg shrink-0">{cat.icon}</span>
<p className="flex-1 text-sm font-medium text-ink">{cat.label}</p>
{over && <span className="text-[10px] font-bold text-signal-danger">DEPĂȘIT</span>}
{editCat === cat.id ? (
<div className="flex items-center gap-1 shrink-0">
<input type="number" min="0" step="10" value={editVal}
onChange={(e) => 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" />
<button onClick={() => saveBudgetMut.mutate({ cat: cat.id, val: editVal })}
className="rounded bg-primary px-2 py-1 text-xs text-white"></button>
<button onClick={() => setEditCat(null)} className="rounded border px-2 py-1 text-xs text-ink"></button>
</div>
) : (
<button onClick={() => { setEditCat(cat.id); setEditVal(String(budget)); }}
className="shrink-0 text-xs text-ink-faint hover:text-primary">
{budget > 0 ? `${budget}` : '+ buget'}
</button>
)}
</div>
<div className="flex items-center gap-3">
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
<div className={`h-full rounded-full ${over ? 'bg-signal-danger' : pct > 80 ? 'bg-warn' : 'bg-signal-ok'}`}
style={{ width: `${Math.min(pct, 100)}%` }} />
</div>
<p className="text-xs text-ink-faint shrink-0 tabular-nums">
{actual.toLocaleString('ro-RO')} {budget > 0 ? `/ ${budget.toLocaleString('ro-RO')}` : '€'}
</p>
</div>
</div>
);
})}
</div>
)}
<p className="text-[10px] text-ink-faint text-center">
Bugetele sunt salvate ca observații cu <code>subjectType: budget</code>. Cheltuielile se preiau din luna curentă.
</p>
</div>
);
}