feat(cc-049): Finance — transaction summary with evidence breakdown
This commit is contained in:
parent
2c3408e968
commit
7ac5e98be9
1 changed files with 195 additions and 0 deletions
195
src/app/dashboard/finance/page.tsx
Normal file
195
src/app/dashboard/finance/page.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiFetch, type Transaction } from '../../../lib/api';
|
||||
import { useSession } from '../../../components/session-provider';
|
||||
|
||||
function fmt(minorUnits: string, currency: string): string {
|
||||
return new Intl.NumberFormat('ro-RO', { style: 'currency', currency }).format(
|
||||
Number(minorUnits) / 100,
|
||||
);
|
||||
}
|
||||
|
||||
export default function FinancePage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
|
||||
const { data: transactions = [], isLoading } = useQuery({
|
||||
queryKey: ['transactions-finance', tenantId],
|
||||
queryFn: () => apiFetch<Transaction[]>('/v1/transactions', { tenantId }),
|
||||
enabled: Boolean(tenantId),
|
||||
});
|
||||
|
||||
const byCurrency: Record<string, number> = {};
|
||||
for (const t of transactions)
|
||||
byCurrency[t.currency] = (byCurrency[t.currency] ?? 0) + Number(t.amountMinorUnits);
|
||||
|
||||
const byEvidence = { missing: 0, partial: 0, complete: 0 };
|
||||
for (const t of transactions)
|
||||
byEvidence[t.evidenceStatus as keyof typeof byEvidence] =
|
||||
(byEvidence[t.evidenceStatus as keyof typeof byEvidence] ?? 0) + 1;
|
||||
|
||||
const byType: Record<string, number> = {};
|
||||
for (const t of transactions) byType[t.type] = (byType[t.type] ?? 0) + 1;
|
||||
const topTypes = Object.entries(byType)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 8);
|
||||
|
||||
const evidenceBar: Record<string, string> = {
|
||||
complete: 'bg-signal-ok',
|
||||
partial: 'bg-signal-warn',
|
||||
missing: 'bg-signal-danger',
|
||||
};
|
||||
const evidenceBadge: Record<string, string> = {
|
||||
complete: 'bg-signal-ok text-white',
|
||||
partial: 'bg-signal-warn text-white',
|
||||
missing: 'bg-signal-danger text-white',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl space-y-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Finanțe</h1>
|
||||
<p className="mt-1 text-sm text-ink-faint">
|
||||
Sumar tranzacții și status evidențe contabile.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard/transactions"
|
||||
className="rounded-lg bg-bronze-deep px-4 py-2 text-sm font-medium text-white hover:opacity-90"
|
||||
>
|
||||
Gestionează tranzacțiile →
|
||||
</Link>
|
||||
</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 din modulul Tranzacții.</p>
|
||||
<Link
|
||||
href="/dashboard/transactions"
|
||||
className="mt-4 inline-block rounded-lg bg-bronze-deep px-4 py-2 text-sm font-medium text-white"
|
||||
>
|
||||
Deschide Tranzacții
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="card p-5">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-ink-faint">
|
||||
Total tranzacții
|
||||
</p>
|
||||
<p className="mt-1 font-display text-3xl font-semibold text-ink">
|
||||
{transactions.length}
|
||||
</p>
|
||||
</div>
|
||||
{Object.entries(byCurrency).map(([cur, total]) => (
|
||||
<div key={cur} className="card p-5">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-ink-faint">
|
||||
Total {cur}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-xl font-semibold text-ink">
|
||||
{fmt(String(total), cur)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<section className="card p-5">
|
||||
<h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-ink-faint">
|
||||
Status dovezi contabile
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{(['complete', 'partial', 'missing'] as const).map((status) => {
|
||||
const count = byEvidence[status];
|
||||
const pct =
|
||||
transactions.length > 0 ? (count / transactions.length) * 100 : 0;
|
||||
return (
|
||||
<div key={status}>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-sm capitalize text-ink">{status}</span>
|
||||
<span className="font-mono text-xs text-ink-faint">{count}</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-paper-sunken">
|
||||
<div
|
||||
className={`h-full rounded-full ${evidenceBar[status]}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card p-5">
|
||||
<h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-ink-faint">
|
||||
Tipuri tranzacție
|
||||
</h2>
|
||||
{topTypes.length === 0 ? (
|
||||
<p className="text-sm text-ink-faint">—</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{topTypes.map(([type, count]) => (
|
||||
<li key={type} className="flex items-center justify-between">
|
||||
<span className="capitalize text-sm text-ink">{type}</span>
|
||||
<span className="rounded-full bg-paper-sunken px-2 py-0.5 font-mono text-xs text-ink-faint">
|
||||
{count}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="card overflow-hidden p-5">
|
||||
<h2 className="mb-3 text-xs font-semibold uppercase tracking-wider text-ink-faint">
|
||||
Tranzacții recente
|
||||
</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-paper-sunken text-left">
|
||||
<th className="pb-2 pr-4 text-xs font-medium text-ink-faint">Data</th>
|
||||
<th className="pb-2 pr-4 text-xs font-medium text-ink-faint">Tip</th>
|
||||
<th className="pb-2 pr-4 text-right text-xs font-medium text-ink-faint">
|
||||
Sumă
|
||||
</th>
|
||||
<th className="pb-2 text-xs font-medium text-ink-faint">Dovezi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{transactions.slice(0, 10).map((t) => (
|
||||
<tr key={t.id} className="border-b border-paper-sunken last:border-0">
|
||||
<td className="py-2 pr-4 font-mono text-xs text-ink-faint">
|
||||
{new Date(t.transactionDate).toLocaleDateString('ro-RO')}
|
||||
</td>
|
||||
<td className="py-2 pr-4 capitalize text-ink">{t.type}</td>
|
||||
<td className="py-2 pr-4 text-right font-mono text-xs text-ink">
|
||||
{fmt(t.amountMinorUnits, t.currency)}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${evidenceBadge[t.evidenceStatus]}`}
|
||||
>
|
||||
{t.evidenceStatus}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue