feat(CC-091): add Cashflow page (income vs expense grouped bars, running balance, monthly table)
This commit is contained in:
parent
9161a38330
commit
0bfcfc2d6b
1 changed files with 175 additions and 0 deletions
175
src/app/dashboard/finance/cashflow/page.tsx
Normal file
175
src/app/dashboard/finance/cashflow/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue