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