feat(CC-059): add Financial Reports page (monthly volume chart, by-type totals, evidence status)

This commit is contained in:
admin-valentin 2026-08-01 21:07:48 +00:00
parent 8a0e7a220c
commit c8412c5214

View file

@ -0,0 +1,171 @@
'use client';
import Link from 'next/link';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '../../../../lib/api';
import type { Transaction } from '../../../../lib/api';
import { useSession } from '../../../../components/session-provider';
const B = '/dashboard';
function fmtMoney(minorUnits: number, currency: string): string {
const major = minorUnits / 100;
return new Intl.NumberFormat('ro-RO', { style: 'currency', currency, minimumFractionDigits: 0 }).format(major);
}
function MonthBar({ label, value, maxVal }: { label: string; value: number; maxVal: number }) {
const pct = maxVal > 0 ? Math.round((value / maxVal) * 100) : 0;
return (
<div className="flex items-end gap-1 group cursor-default">
<div className="relative flex-1 flex flex-col items-center gap-1">
<span className="text-[10px] text-ink-faint opacity-0 group-hover:opacity-100 transition-opacity">
{value.toLocaleString('ro-RO')}
</span>
<div className="w-full bg-muted rounded-t overflow-hidden" style={{ height: '80px' }}>
<div
className="w-full bg-primary/70 rounded-t transition-all"
style={{ height: `${pct}%`, marginTop: 'auto' }}
/>
</div>
<span className="text-[9px] text-ink-faint rotate-0 whitespace-nowrap">{label}</span>
</div>
</div>
);
}
export default function FinancialReportsPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const { data: transactions = [], isLoading } = useQuery({
queryKey: ['rep-transactions', tenantId],
queryFn: () => apiFetch<Transaction[]>('/v1/transactions', { tenantId }),
enabled: Boolean(tenantId),
staleTime: 120_000,
});
// Group by currency
const currencies = [...new Set(transactions.map((t) => t.currency))];
// For each currency: total income vs total expense (by type)
// type can be anything — we assume any positive amount is 'income' unless type contains 'expense'/'cost'/'plata'/'cheltuiala'
// More pragmatically: show totals by type
function getStats(currency: string) {
const txs = transactions.filter((t) => t.currency === currency);
const byType: Record<string, number> = {};
for (const t of txs) {
const key = t.type || 'necunoscut';
byType[key] = (byType[key] || 0) + parseFloat(t.amountMinorUnits);
}
const total = txs.reduce((s, t) => s + parseFloat(t.amountMinorUnits), 0);
return { txs, byType, total };
}
// Monthly volume (last 12 months) for first currency
const primaryCurrency = currencies[0] ?? 'RON';
const now = new Date();
const months: { key: string; label: string; total: number }[] = [];
for (let i = 11; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
const label = d.toLocaleDateString('ro-RO', { month: 'short' });
const total = transactions
.filter((t) => t.currency === primaryCurrency && t.transactionDate.startsWith(key))
.reduce((s, t) => s + parseFloat(t.amountMinorUnits), 0);
months.push({ key, label, total });
}
const maxMonthly = Math.max(...months.map((m) => m.total), 1);
// Evidence status
const evByStatus: Record<string, number> = {};
for (const t of transactions) {
evByStatus[t.evidenceStatus] = (evByStatus[t.evidenceStatus] || 0) + 1;
}
return (
<div className="max-w-5xl space-y-8 p-6">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Raport Financiar</h1>
<p className="text-sm text-ink-faint mt-1">
{transactions.length} tranzacții înregistrate
</p>
</div>
{isLoading ? (
<div className="card p-12 text-center text-sm text-ink-faint">Se încarcă</div>
) : transactions.length === 0 ? (
<div className="card p-12 text-center space-y-2">
<p className="text-sm text-ink-faint">Nicio tranzacție înregistrată</p>
<Link href={`${B}/transactions`} className="text-xs text-bronze-deep hover:underline">
Adaugă prima tranzacție
</Link>
</div>
) : (
<>
{/* Per-currency stats */}
{currencies.map((currency) => {
const { txs, byType, total } = getStats(currency);
const sortedTypes = Object.entries(byType).sort((a, b) => b[1] - a[1]);
return (
<div key={currency} className="card p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-ink">
{currency} {txs.length} tranzacții
</h2>
<span className="font-display text-xl font-bold text-ink">
{fmtMoney(total, currency)}
</span>
</div>
<div className="divide-y divide-border/50">
{sortedTypes.map(([type, amount]) => (
<div key={type} className="flex items-center justify-between py-2">
<span className="text-xs text-ink-faint capitalize">{type}</span>
<span className="text-xs font-medium text-ink">{fmtMoney(amount, currency)}</span>
</div>
))}
</div>
</div>
);
})}
{/* Monthly volume chart */}
<div className="card p-6 space-y-4">
<h2 className="text-sm font-semibold text-ink">
Volum lunar {primaryCurrency} (ultimele 12 luni)
</h2>
<div className="flex items-end gap-1 h-28">
{months.map((m) => (
<MonthBar key={m.key} label={m.label} value={m.total} maxVal={maxMonthly} />
))}
</div>
</div>
{/* Evidence status */}
<div className="card p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-ink">Status dovezi</h2>
<Link href={`${B}/transactions`} className="text-xs text-bronze-deep hover:underline">
Tranzacții
</Link>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{[
{ key: 'missing', label: 'Lipsesc', color: 'text-signal-danger' },
{ key: 'partial', label: 'Parțiale', color: 'text-signal-warn' },
{ key: 'complete', label: 'Complete', color: 'text-signal-ok' },
{ key: 'not_required', label: 'Neobligatoriu', color: 'text-ink-faint' },
].map(({ key, label, color }) => (
<div key={key} className="bg-muted/30 rounded-lg p-3 text-center">
<p className={`font-display text-2xl font-bold ${color}`}>
{evByStatus[key] ?? 0}
</p>
<p className="text-xs text-ink-faint mt-0.5">{label}</p>
</div>
))}
</div>
</div>
</>
)}
</div>
);
}