feat(CC-091): add Cashflow page (income vs expense grouped bars, running balance, monthly table)

This commit is contained in:
admin-valentin 2026-08-02 18:06:05 +00:00
parent 9161a38330
commit 0bfcfc2d6b

View file

@ -0,0 +1,175 @@
'use client';
import { useMemo } from 'react';
import { useQuery } 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; observedAt: string | null; createdAt: string; }
interface Contract { id: string; value: number | null; status: string; createdAt: string; }
const INCOME_TYPES = ['venit','income','revenue','incasare','factura'];
const EXPENSE_TYPES = ['cheltuiala','expense','cost','plata'];
const MONTHS_LABELS = ['Ian','Feb','Mar','Apr','Mai','Iun','Iul','Aug','Sep','Oct','Nov','Dec'];
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')}`; }
function shortLabel(k: string) { const [,m] = k.split('-'); return MONTHS_LABELS[parseInt(m) - 1]; }
const LAST_12 = Array.from({ length: 12 }, (_, i) => {
const d = new Date(); d.setDate(1); d.setMonth(d.getMonth() - (11 - i));
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
});
export default function CashflowPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const { data: observations = [], isLoading: loadObs } = useQuery({
queryKey: ['cf-obs', tenantId],
queryFn: () => apiFetch<Observation[]>('/v1/observations', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const { data: contracts = [], isLoading: loadCt } = useQuery({
queryKey: ['cf-contracts', tenantId],
queryFn: () => apiFetch<Contract[]>('/v1/contracts?limit=500', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const { incomeByMonth, expenseByMonth } = useMemo(() => {
const inc: Record<string, number> = {};
const exp: Record<string, number> = {};
for (const o of observations) {
const key = monthKey(o.observedAt ?? o.createdAt);
const metric = o.metric.toLowerCase();
const val = parseNum(o.value);
if (INCOME_TYPES.some((t) => metric.includes(t)) || o.subjectType === 'income') {
inc[key] = (inc[key] ?? 0) + val;
} else if (EXPENSE_TYPES.some((t) => metric.includes(t)) || o.subjectType === 'expense') {
exp[key] = (exp[key] ?? 0) + val;
}
}
for (const c of contracts.filter((c) => c.status === 'signed' || c.status === 'completed')) {
if (!c.value) continue;
const key = monthKey(c.createdAt);
inc[key] = (inc[key] ?? 0) + c.value;
}
return { incomeByMonth: inc, expenseByMonth: exp };
}, [observations, contracts]);
const rows = LAST_12.map((k) => ({
key: k,
label: shortLabel(k),
income: incomeByMonth[k] ?? 0,
expense: expenseByMonth[k] ?? 0,
net: (incomeByMonth[k] ?? 0) - (expenseByMonth[k] ?? 0),
}));
const maxVal = Math.max(...rows.map((r) => Math.max(r.income, r.expense)), 1);
const totalNet = rows.reduce((s, r) => s + r.net, 0);
const totalInc = rows.reduce((s, r) => s + r.income, 0);
const totalExp = rows.reduce((s, r) => s + r.expense, 0);
let running = 0;
const runningBalances = rows.map((r) => { running += r.net; return running; });
const maxRun = Math.max(...runningBalances.map(Math.abs), 1);
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">Cashflow</h1>
<p className="text-sm text-ink-faint mt-1">Intrări vs ieșiri · ultimele 12 luni</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>
{/* KPI row */}
<div className="grid gap-4 sm:grid-cols-3">
{[
{ label: 'Total intrări', value: totalInc, color: 'text-signal-ok' },
{ label: 'Total ieșiri', value: totalExp, color: 'text-signal-danger' },
{ label: 'Net 12 luni', value: totalNet, color: totalNet >= 0 ? 'text-signal-ok' : 'text-signal-danger' },
].map((k) => (
<div key={k.label} className="card p-4 space-y-1">
<p className="text-xs text-ink-faint">{k.label}</p>
<p className={`text-2xl font-bold font-display ${k.color}`}>
{k.value >= 0 ? '+' : ''}{k.value.toLocaleString('ro-RO')}
</p>
</div>
))}
</div>
{/* Grouped bar chart */}
{!loadObs && !loadCt && (
<div className="card p-5 space-y-4">
<div className="flex gap-4 text-xs text-ink-faint">
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded bg-signal-ok inline-block" />Intrări</span>
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded bg-signal-danger/70 inline-block" />Ieșiri</span>
</div>
<div className="flex items-end gap-1.5 h-40">
{rows.map((r) => (
<div key={r.key} className="flex-1 flex flex-col items-center gap-0.5 min-w-0">
<div className="w-full flex items-end gap-0.5" style={{ height: '120px' }}>
<div className="flex-1 rounded-t bg-signal-ok/70 hover:bg-signal-ok transition-colors"
style={{ height: `${(r.income / maxVal) * 100}%` }} title={`Intrări: ${r.income.toLocaleString()}`} />
<div className="flex-1 rounded-t bg-signal-danger/60 hover:bg-signal-danger/80 transition-colors"
style={{ height: `${(r.expense / maxVal) * 100}%` }} title={`Ieșiri: ${r.expense.toLocaleString()}`} />
</div>
<p className="text-[9px] text-ink-faint">{r.label}</p>
</div>
))}
</div>
</div>
)}
{/* Running balance */}
<div className="card p-5 space-y-3">
<p className="text-sm font-semibold text-ink">Balanță cumulată</p>
<div className="flex items-end gap-1 h-20">
{runningBalances.map((bal, i) => {
const pct = (Math.abs(bal) / maxRun) * 100;
return (
<div key={i} className="flex-1 flex flex-col items-center justify-end min-w-0" style={{ height: '80px' }}>
<div className={`w-full rounded ${bal >= 0 ? 'bg-signal-ok/60' : 'bg-signal-danger/60'}`}
style={{ height: `${Math.max(pct, 2)}%` }}
title={`${LAST_12[i]}: ${bal.toLocaleString()}`} />
</div>
);
})}
</div>
<div className="flex justify-between text-[9px] text-ink-faint">
{rows.map((r) => <span key={r.key}>{r.label}</span>)}
</div>
</div>
{/* Monthly table */}
<div className="card overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/30">
<tr>
<th className="px-4 py-2.5 text-left text-xs font-medium text-ink-faint">Lună</th>
<th className="px-4 py-2.5 text-right text-xs font-medium text-ink-faint">Intrări</th>
<th className="px-4 py-2.5 text-right text-xs font-medium text-ink-faint">Ieșiri</th>
<th className="px-4 py-2.5 text-right text-xs font-medium text-ink-faint">Net</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{[...rows].reverse().map((r) => (
<tr key={r.key} className="hover:bg-muted/20">
<td className="px-4 py-2.5 text-ink">{r.key}</td>
<td className="px-4 py-2.5 text-right text-signal-ok">{r.income > 0 ? `+${r.income.toLocaleString('ro-RO')}` : '—'}</td>
<td className="px-4 py-2.5 text-right text-signal-danger">{r.expense > 0 ? `${r.expense.toLocaleString('ro-RO')}` : '—'}</td>
<td className={`px-4 py-2.5 text-right font-semibold ${r.net >= 0 ? 'text-signal-ok' : 'text-signal-danger'}`}>
{r.net >= 0 ? '+' : ''}{r.net.toLocaleString('ro-RO')}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}