feat(CC-062): add Accountant Reports page (monthly breakdown with evidence gap tracking)
This commit is contained in:
parent
fc2a532c76
commit
12ab7ea40d
1 changed files with 206 additions and 0 deletions
206
src/app/dashboard/reports/accountant/page.tsx
Normal file
206
src/app/dashboard/reports/accountant/page.tsx
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
'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';
|
||||
|
||||
const B = '/dashboard';
|
||||
|
||||
function fmtMoney(minorUnits: number, currency: string) {
|
||||
return new Intl.NumberFormat('ro-RO', {
|
||||
style: 'currency', currency, minimumFractionDigits: 0,
|
||||
}).format(minorUnits / 100);
|
||||
}
|
||||
|
||||
export default function AccountantReportsPage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
const now = new Date();
|
||||
const [year, setYear] = useState(now.getFullYear());
|
||||
|
||||
const { data: txs = [], isLoading } = useQuery({
|
||||
queryKey: ['accountant-rep', tenantId],
|
||||
queryFn: () => apiFetch<Transaction[]>('/v1/transactions', { tenantId }),
|
||||
enabled: Boolean(tenantId),
|
||||
staleTime: 120_000,
|
||||
});
|
||||
|
||||
const yearTxs = txs.filter((t) => t.transactionDate.startsWith(String(year)));
|
||||
const currencies = [...new Set(yearTxs.map((t) => t.currency))];
|
||||
|
||||
// Monthly breakdown for primary currency
|
||||
const primaryCurrency = currencies[0] ?? 'RON';
|
||||
|
||||
// By month: totals and evidence gap count
|
||||
const months = Array.from({ length: 12 }, (_, i) => {
|
||||
const m = String(i + 1).padStart(2, '0');
|
||||
const prefix = `${year}-${m}`;
|
||||
const monthTxs = yearTxs.filter((t) => t.currency === primaryCurrency && t.transactionDate.startsWith(prefix));
|
||||
const total = monthTxs.reduce((s, t) => s + parseFloat(t.amountMinorUnits), 0);
|
||||
const gaps = monthTxs.filter((t) => t.evidenceStatus === 'missing' || t.evidenceStatus === 'partial').length;
|
||||
const label = new Date(year, i, 1).toLocaleDateString('ro-RO', { month: 'short' });
|
||||
return { m, label, total, gaps, count: monthTxs.length };
|
||||
});
|
||||
|
||||
const maxTotal = Math.max(...months.map((m) => m.total), 1);
|
||||
|
||||
// Evidence by month summary
|
||||
const evidenceByStatus = {
|
||||
missing: yearTxs.filter((t) => t.evidenceStatus === 'missing').length,
|
||||
partial: yearTxs.filter((t) => t.evidenceStatus === 'partial').length,
|
||||
complete: yearTxs.filter((t) => t.evidenceStatus === 'complete').length,
|
||||
not_required: yearTxs.filter((t) => t.evidenceStatus === 'not_required').length,
|
||||
};
|
||||
const evidenceGapRate = yearTxs.length > 0
|
||||
? Math.round(((evidenceByStatus.missing + evidenceByStatus.partial) / yearTxs.length) * 100)
|
||||
: 0;
|
||||
|
||||
// By type breakdown
|
||||
const byType: Record<string, number> = {};
|
||||
for (const t of yearTxs) {
|
||||
byType[t.type] = (byType[t.type] || 0) + parseFloat(t.amountMinorUnits);
|
||||
}
|
||||
const sortedTypes = Object.entries(byType).sort((a, b) => b[1] - a[1]).slice(0, 8);
|
||||
|
||||
const years = [...new Set(txs.map((t) => parseInt(t.transactionDate.slice(0, 4))))].sort((a, b) => b - a);
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl space-y-6 p-6">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Raport Contabil</h1>
|
||||
<p className="text-sm text-ink-faint mt-1">
|
||||
{yearTxs.length} tranzacții în {year}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-ink-faint">An</label>
|
||||
<select
|
||||
value={year}
|
||||
onChange={(e) => setYear(parseInt(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"
|
||||
>
|
||||
{years.map((y) => <option key={y} value={y}>{y}</option>)}
|
||||
{!years.includes(now.getFullYear()) && (
|
||||
<option value={now.getFullYear()}>{now.getFullYear()}</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="card p-12 text-center text-sm text-ink-faint">Se încarcă…</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Evidence health */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{[
|
||||
{ label: 'Lipsesc', count: evidenceByStatus.missing, cls: 'text-signal-danger' },
|
||||
{ label: 'Parțiale', count: evidenceByStatus.partial, cls: 'text-signal-warn' },
|
||||
{ label: 'Complete', count: evidenceByStatus.complete, cls: 'text-signal-ok' },
|
||||
{ label: 'Rata gap-uri', count: `${evidenceGapRate}%`, cls: evidenceGapRate > 20 ? 'text-signal-danger' : 'text-ink' },
|
||||
].map(({ label, count, cls }) => (
|
||||
<div key={label} className="card p-4 text-center">
|
||||
<p className={`font-display text-3xl font-bold ${cls}`}>{count}</p>
|
||||
<p className="text-xs text-ink-faint mt-1">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Monthly chart */}
|
||||
<div className="card p-6 space-y-4">
|
||||
<h2 className="text-sm font-semibold text-ink">
|
||||
Volum lunar — {primaryCurrency}
|
||||
</h2>
|
||||
<div className="grid grid-cols-12 gap-1 items-end" style={{ height: '100px' }}>
|
||||
{months.map((m) => {
|
||||
const pct = maxTotal > 0 ? (m.total / maxTotal) * 100 : 0;
|
||||
return (
|
||||
<div key={m.m} className="flex flex-col items-center gap-0.5 group">
|
||||
<div className="w-full bg-muted rounded-sm overflow-hidden" style={{ height: '80px' }}>
|
||||
<div
|
||||
className={`w-full rounded-sm transition-all ${m.gaps > 0 ? 'bg-signal-warn/70' : 'bg-primary/70'}`}
|
||||
style={{ height: `${pct}%`, marginTop: `${100 - pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[8px] text-ink-faint">{m.label}</span>
|
||||
{m.gaps > 0 && (
|
||||
<span className="text-[8px] text-signal-warn">{m.gaps}⚠</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[10px] text-ink-faint">
|
||||
<span className="inline-block w-2 h-2 rounded-sm bg-signal-warn/70 mr-1" />
|
||||
Portocaliu = luni cu dovezi lipsă/parțiale
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* By type */}
|
||||
{sortedTypes.length > 0 && (
|
||||
<div className="card p-5 space-y-3">
|
||||
<h2 className="text-sm font-semibold text-ink">Totaluri per tip</h2>
|
||||
<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-semibold text-ink">
|
||||
{fmtMoney(amount, primaryCurrency)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Monthly table */}
|
||||
<div className="card overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border/50 bg-muted/30">
|
||||
<th className="text-left p-3 font-medium text-ink-faint">Lună</th>
|
||||
<th className="text-right p-3 font-medium text-ink-faint">Tranzacții</th>
|
||||
<th className="text-right p-3 font-medium text-ink-faint">Total ({primaryCurrency})</th>
|
||||
<th className="text-right p-3 font-medium text-ink-faint">Gap-uri dovezi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/50">
|
||||
{months.filter((m) => m.count > 0).map((m) => (
|
||||
<tr key={m.m} className="hover:bg-muted/20 transition-colors">
|
||||
<td className="p-3 text-ink">{m.label} {year}</td>
|
||||
<td className="p-3 text-right text-ink-faint">{m.count}</td>
|
||||
<td className="p-3 text-right font-medium text-ink">{fmtMoney(m.total, primaryCurrency)}</td>
|
||||
<td className="p-3 text-right">
|
||||
{m.gaps > 0 ? (
|
||||
<span className="font-medium text-signal-warn">{m.gaps}</span>
|
||||
) : (
|
||||
<span className="text-signal-ok">✓</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{months.filter((m) => m.count > 0).length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="p-6 text-center text-ink-faint">
|
||||
Nicio tranzacție în {year}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Link href={`${B}/accountant-pack`} className="text-xs text-bronze-deep hover:underline">
|
||||
Descarcă pachetul complet →
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue