feat(cc-051): Cash Flow page — monthly tx grouped by currency

This commit is contained in:
admin-valentin 2026-08-01 10:09:27 +00:00
parent 7c7d3fc4c9
commit 5d2d870c4f

View file

@ -0,0 +1,165 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '../../../lib/api';
import type { Transaction } from '../../../lib/api';
import { useSession } from '../../../components/session-provider';
type MonthKey = string; // 'YYYY-MM'
interface MonthBucket {
income: number;
expense: number;
net: number;
}
function monthKey(dateStr: string): MonthKey {
return dateStr.slice(0, 7);
}
function fmtMonth(key: MonthKey): string {
const [y, m] = key.split('-');
return new Date(Number(y), Number(m) - 1).toLocaleDateString('ro-RO', { month: 'short', year: 'numeric' });
}
function fmtAmount(minor: number, currency: string): string {
return new Intl.NumberFormat('ro-RO', { style: 'currency', currency, maximumFractionDigits: 0 }).format(minor / 100);
}
export default function CashFlowPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const { data: transactions = [], isLoading } = useQuery({
queryKey: ['transactions-cashflow', tenantId],
queryFn: () => apiFetch<Transaction[]>('/v1/transactions', { tenantId }),
enabled: Boolean(tenantId),
staleTime: 60_000,
});
// Group by currency then by month
const currencies = [...new Set(transactions.map((t) => t.currency))].sort();
function buildMonthlyFlow(currency: string) {
const byMonth = new Map<MonthKey, MonthBucket>();
for (const t of transactions) {
if (t.currency !== currency) continue;
const key = monthKey(t.transactionDate);
const bucket = byMonth.get(key) ?? { income: 0, expense: 0, net: 0 };
const amt = Number(t.amountMinorUnits);
if (t.type === 'income' || t.type === 'revenue') {
bucket.income += amt;
} else {
bucket.expense += amt;
}
bucket.net = bucket.income - bucket.expense;
byMonth.set(key, bucket);
}
return [...byMonth.entries()].sort((a, b) => a[0].localeCompare(b[0]));
}
// Running totals per currency
const totals = currencies.map((cur) => {
const months = buildMonthlyFlow(cur);
const totalIncome = months.reduce((s, [, b]) => s + b.income, 0);
const totalExpense = months.reduce((s, [, b]) => s + b.expense, 0);
return { currency: cur, totalIncome, totalExpense, net: totalIncome - totalExpense, months };
});
const maxAbsNet = Math.max(...totals.flatMap((t) => t.months.map(([, b]) => Math.abs(b.net))), 1);
return (
<div className="max-w-5xl space-y-8">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Cash Flow</h1>
<p className="mt-1 text-sm text-ink-faint">
Flux financiar lunar pe valute {' '}
{isLoading ? '…' : `${transactions.length} tranzacții`}
</p>
</div>
{isLoading ? (
<p className="text-sm text-ink-faint">Se încarcă</p>
) : transactions.length === 0 ? (
<div className="card p-10 text-center">
<p className="text-sm font-medium text-ink">Nicio tranzacție înregistrată</p>
<p className="mt-1 text-xs text-ink-faint">Adaugă tranzacții pentru a vedea fluxul de numerar.</p>
</div>
) : (
<>
{/* Summary cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{totals.map(({ currency, totalIncome, totalExpense, net }) => (
<div key={currency} className="card p-5">
<p className="text-xs font-medium uppercase tracking-wider text-ink-faint">{currency}</p>
<div className="mt-3 space-y-1.5">
<div className="flex justify-between text-sm">
<span className="text-ink-faint">Venituri</span>
<span className="font-mono text-signal-ok">{fmtAmount(totalIncome, currency)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-ink-faint">Cheltuieli</span>
<span className="font-mono text-signal-danger">{fmtAmount(totalExpense, currency)}</span>
</div>
<div className="border-t border-paper-sunken pt-1.5 flex justify-between text-sm font-semibold">
<span className="text-ink">Net</span>
<span className={`font-mono ${net >= 0 ? 'text-signal-ok' : 'text-signal-danger'}`}>
{fmtAmount(net, currency)}
</span>
</div>
</div>
</div>
))}
</div>
{/* Monthly breakdown per currency */}
{totals.map(({ currency, months }) => (
<section key={currency} className="card overflow-hidden">
<div className="border-b border-paper-sunken px-5 py-3">
<h2 className="text-sm font-semibold text-ink">Lunar · {currency}</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-paper-sunken text-left">
<th className="px-5 py-2.5 text-xs font-medium text-ink-faint">Lună</th>
<th className="px-5 py-2.5 text-right text-xs font-medium text-signal-ok">Venituri</th>
<th className="px-5 py-2.5 text-right text-xs font-medium text-signal-danger">Cheltuieli</th>
<th className="px-5 py-2.5 text-right text-xs font-medium text-ink-faint">Net</th>
<th className="px-5 py-2.5 text-xs font-medium text-ink-faint w-[120px]">Balanță</th>
</tr>
</thead>
<tbody>
{months.map(([key, bucket]) => {
const barPct = (Math.abs(bucket.net) / maxAbsNet) * 100;
const barColor = bucket.net >= 0 ? 'bg-signal-ok' : 'bg-signal-danger';
return (
<tr key={key} className="border-b border-paper-sunken last:border-0 hover:bg-paper-sunken/40">
<td className="px-5 py-2.5 text-sm text-ink">{fmtMonth(key)}</td>
<td className="px-5 py-2.5 text-right font-mono text-xs text-signal-ok">
{fmtAmount(bucket.income, currency)}
</td>
<td className="px-5 py-2.5 text-right font-mono text-xs text-signal-danger">
{fmtAmount(bucket.expense, currency)}
</td>
<td className={`px-5 py-2.5 text-right font-mono text-xs font-semibold ${bucket.net >= 0 ? 'text-signal-ok' : 'text-signal-danger'}`}>
{fmtAmount(bucket.net, currency)}
</td>
<td className="px-5 py-2.5">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-paper-sunken">
<div className={`h-full rounded-full ${barColor}`} style={{ width: `${barPct}%` }} />
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
))}
</>
)}
</div>
);
}