feat(CC-062): add Accountant Pack page (evidence gap view + CSV/JSON export)

This commit is contained in:
admin-valentin 2026-08-01 21:15:43 +00:00
parent ecf7cfefb1
commit fc2a532c76

View file

@ -0,0 +1,239 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '../../../lib/api';
import type { Transaction } from '../../../lib/api';
import { useSession } from '../../../components/session-provider';
interface Organization { id: string; name: string; }
const B = '/dashboard';
function fmtMoney(minorUnits: number, currency: string) {
return new Intl.NumberFormat('ro-RO', {
style: 'currency', currency, minimumFractionDigits: 0,
}).format(minorUnits / 100);
}
function downloadJson(data: unknown, filename: string) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 1000);
}
function downloadCsv(rows: string[][], filename: string) {
const csv = rows.map((r) => r.map((c) => `"${String(c).replace(/"/g, '""')}"`).join(',')).join('\n');
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = filename;
document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 1000);
}
export default function AccountantPackPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const now = new Date();
const [fromDate, setFromDate] = useState(
`${now.getFullYear()}-01-01`
);
const [toDate, setToDate] = useState(
`${now.getFullYear()}-12-31`
);
const { data: txs = [], isLoading: loadingTxs } = useQuery({
queryKey: ['accountant-txs', tenantId],
queryFn: () => apiFetch<Transaction[]>('/v1/transactions', { tenantId }),
enabled: Boolean(tenantId),
staleTime: 120_000,
});
const { data: orgs = [] } = useQuery({
queryKey: ['orgs', tenantId],
queryFn: () => apiFetch<Organization[]>('/v1/organizations', { tenantId }),
enabled: Boolean(tenantId),
staleTime: 300_000,
});
const orgMap = Object.fromEntries(orgs.map((o) => [o.id, o.name]));
// Filter by date range
const inRange = txs.filter((t) => {
const d = t.transactionDate.slice(0, 10);
return d >= fromDate && d <= toDate;
});
const missing = inRange.filter((t) => t.evidenceStatus === 'missing');
const partial = inRange.filter((t) => t.evidenceStatus === 'partial');
const complete = inRange.filter((t) => t.evidenceStatus === 'complete');
const notReq = inRange.filter((t) => t.evidenceStatus === 'not_required');
const totalAmount = (list: Transaction[], currency: string) =>
list.filter((t) => t.currency === currency)
.reduce((s, t) => s + parseFloat(t.amountMinorUnits), 0);
const currencies = [...new Set(inRange.map((t) => t.currency))];
const handleExportCsv = () => {
const headers = ['Data', 'Tip', 'Suma', 'Moneda', 'Organizatie', 'Status dovada', 'Sursa'];
const rows = inRange.map((t) => [
t.transactionDate.slice(0, 10),
t.type,
(parseFloat(t.amountMinorUnits) / 100).toFixed(2),
t.currency,
orgMap[t.organizationId] ?? t.organizationId,
t.evidenceStatus,
t.source ?? '',
]);
const ts = new Date().toISOString().slice(0, 10);
downloadCsv([headers, ...rows], `pachet-contabil-${ts}.csv`);
};
const handleExportJson = () => {
const ts = new Date().toISOString().slice(0, 10);
downloadJson({
exportedAt: new Date().toISOString(),
period: { from: fromDate, to: toDate },
summary: {
total: inRange.length,
missing: missing.length,
partial: partial.length,
complete: complete.length,
notRequired: notReq.length,
},
transactions: inRange.map((t) => ({
...t,
organizationName: orgMap[t.organizationId] ?? null,
amountMajorUnits: parseFloat(t.amountMinorUnits) / 100,
})),
}, `pachet-contabil-${ts}.json`);
};
return (
<div className="max-w-5xl space-y-6 p-6">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Pachet Contabil</h1>
<p className="text-sm text-ink-faint mt-1">
Export tranzacții cu status dovezi pentru contabilitate și audit
</p>
</div>
<div className="flex gap-2 flex-wrap">
<button onClick={handleExportCsv} className="btn btn-secondary text-xs px-3 py-2">
CSV
</button>
<button onClick={handleExportJson} className="btn btn-primary text-xs px-3 py-2">
JSON
</button>
</div>
</div>
{/* Period selector */}
<div className="card p-4 flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-xs text-ink-faint">De la</label>
<input
type="date"
value={fromDate}
onChange={(e) => setFromDate(e.target.value)}
className="rounded-lg border bg-card px-3 py-1.5 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-ink-faint">Până la</label>
<input
type="date"
value={toDate}
onChange={(e) => setToDate(e.target.value)}
className="rounded-lg border bg-card px-3 py-1.5 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<p className="text-xs text-ink-faint ml-auto">
{inRange.length} tranzacții în perioadă
</p>
</div>
{/* Evidence status summary */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{[
{ label: 'Lipsesc dovezi', count: missing.length, cls: 'text-signal-danger', bg: 'bg-signal-danger/5 border-signal-danger/20' },
{ label: 'Dovezi parțiale', count: partial.length, cls: 'text-signal-warn', bg: 'bg-signal-warn/5 border-signal-warn/20' },
{ label: 'Dovezi complete', count: complete.length, cls: 'text-signal-ok', bg: 'bg-signal-ok/5 border-signal-ok/20' },
{ label: 'Neobligatoriu', count: notReq.length, cls: 'text-ink-faint', bg: 'bg-muted/30 border-border/50' },
].map(({ label, count, cls, bg }) => (
<div key={label} className={`rounded-xl border p-4 text-center ${bg}`}>
<p className={`font-display text-3xl font-bold ${cls}`}>{count}</p>
<p className="text-xs text-ink-faint mt-1">{label}</p>
</div>
))}
</div>
{/* Totals by currency */}
{currencies.length > 0 && (
<div className="card p-5 space-y-3">
<h2 className="text-sm font-semibold text-ink">Totaluri per monedă</h2>
<div className="divide-y divide-border/50">
{currencies.map((cur) => (
<div key={cur} className="flex items-center justify-between py-3">
<div className="flex items-center gap-4">
<span className="font-mono text-xs font-bold text-ink w-10">{cur}</span>
<div className="flex gap-4 text-xs text-ink-faint">
<span className="text-signal-danger">lipsă: {fmtMoney(totalAmount(missing, cur), cur)}</span>
<span className="text-signal-warn">parțial: {fmtMoney(totalAmount(partial, cur), cur)}</span>
</div>
</div>
<span className="font-semibold text-ink">{fmtMoney(totalAmount(inRange, cur), cur)}</span>
</div>
))}
</div>
</div>
)}
{/* Missing evidence list */}
{missing.length > 0 && (
<div className="card p-5 space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-signal-danger">
Tranzacții fără dovezi ({missing.length})
</h2>
<Link href={`${B}/transactions`} className="text-xs text-bronze-deep hover:underline">
Administrează
</Link>
</div>
<div className="divide-y divide-border/50">
{missing.slice(0, 20).map((t) => (
<div key={t.id} className="flex items-center justify-between py-2.5 gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-ink capitalize">{t.type}</span>
<span className="text-[10px] text-ink-faint">
{orgMap[t.organizationId] ?? '—'}
</span>
</div>
<p className="text-[10px] text-ink-faint">{t.transactionDate.slice(0, 10)}</p>
</div>
<span className="text-xs font-semibold text-ink shrink-0">
{fmtMoney(parseFloat(t.amountMinorUnits), t.currency)}
</span>
</div>
))}
{missing.length > 20 && (
<p className="text-xs text-center text-ink-faint pt-2">
și alte {missing.length - 20} tranzacții
</p>
)}
</div>
</div>
)}
{isLoading && (
<div className="card p-8 text-center text-sm text-ink-faint">Se încarcă</div>
)}
</div>
);
}