feat(CC-062): add Accountant Pack page (evidence gap view + CSV/JSON export)
This commit is contained in:
parent
ecf7cfefb1
commit
fc2a532c76
1 changed files with 239 additions and 0 deletions
239
src/app/dashboard/accountant-pack/page.tsx
Normal file
239
src/app/dashboard/accountant-pack/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue