feat(CC-091): add Budget Planner page (target per category vs actual, over-budget alerts)
This commit is contained in:
parent
e1b6301ea5
commit
9161a38330
1 changed files with 167 additions and 0 deletions
167
src/app/dashboard/finance/budget/page.tsx
Normal file
167
src/app/dashboard/finance/budget/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue