feat(CC-059): add Financial Reports page (monthly volume chart, by-type totals, evidence status)
This commit is contained in:
parent
8a0e7a220c
commit
c8412c5214
1 changed files with 171 additions and 0 deletions
171
src/app/dashboard/reports/financial/page.tsx
Normal file
171
src/app/dashboard/reports/financial/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue