feat(cc-051): Cash Flow page — monthly tx grouped by currency
This commit is contained in:
parent
7c7d3fc4c9
commit
5d2d870c4f
1 changed files with 165 additions and 0 deletions
165
src/app/dashboard/cash-flow/page.tsx
Normal file
165
src/app/dashboard/cash-flow/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue