feat(CC-066): add Evidence Center page (coverage %, mark complete, link URL)

This commit is contained in:
admin-valentin 2026-08-02 12:09:09 +00:00
parent 203ab390a4
commit 9d35476169

View file

@ -0,0 +1,177 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../../../lib/api';
import { useSession } from '../../../components/session-provider';
interface Transaction {
id: string; description: string | null; amountMinorUnits: string; currency: string;
transactionDate: string; counterparty: string | null; evidenceStatus: string;
evidenceUrl: string | null; category: string | null;
}
interface Document { id: string; filename: string; classification: string; createdAt: string; }
const EV_META: Record<string, { label: string; cls: string; icon: string }> = {
missing: { label: 'Lipsă', cls: 'text-signal-danger bg-signal-danger/10', icon: '❌' },
partial: { label: 'Parțial', cls: 'text-signal-warn bg-signal-warn/10', icon: '⚠' },
complete: { label: 'Complet', cls: 'text-signal-ok bg-signal-ok/10', icon: '✓' },
not_required: { label: 'N/A', cls: 'text-ink-faint bg-muted', icon: '—' },
};
function fmtMoney(minorUnits: string, currency: string) {
try { return new Intl.NumberFormat('ro-RO', { style: 'currency', currency, maximumFractionDigits: 2 }).format(parseFloat(minorUnits) / 100); }
catch { return `${currency} ${(parseFloat(minorUnits) / 100).toFixed(2)}`; }
}
export default function EvidencePage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const qc = useQueryClient();
const [filterStatus, setFilterStatus] = useState('missing');
const [linkTarget, setLinkTarget] = useState<string | null>(null);
const [evidenceUrl, setEvidenceUrl] = useState('');
const { data: transactions = [], isLoading: txLoading } = useQuery({
queryKey: ['transactions-ev', tenantId, filterStatus],
queryFn: () => apiFetch<Transaction[]>(`/v1/transactions?evidenceStatus=${filterStatus}&limit=100`, { tenantId }),
enabled: Boolean(tenantId),
staleTime: 30_000,
});
const { data: docs = [] } = useQuery({
queryKey: ['docs-ev', tenantId],
queryFn: () => apiFetch<Document[]>('/v1/documents?limit=50', { tenantId }),
enabled: Boolean(tenantId),
staleTime: 120_000,
});
const patchMut = useMutation({
mutationFn: ({ id, evidenceStatus, evidenceUrl }: { id: string; evidenceStatus: string; evidenceUrl?: string }) =>
apiFetch(`/v1/transactions/${id}`, { tenantId, method: 'PATCH', body: { evidenceStatus, ...(evidenceUrl ? { evidenceUrl } : {}) } }),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['transactions-ev', tenantId] }); setLinkTarget(null); setEvidenceUrl(''); },
});
const { data: allTx = [] } = useQuery({
queryKey: ['transactions-stats', tenantId],
queryFn: () => apiFetch<Transaction[]>('/v1/transactions?limit=500', { tenantId }),
enabled: Boolean(tenantId),
staleTime: 120_000,
});
const stats: Record<string, number> = {};
for (const t of allTx) stats[t.evidenceStatus] = (stats[t.evidenceStatus] || 0) + 1;
const totalGap = (stats.missing || 0) + (stats.partial || 0);
const totalTx = allTx.length;
const coveragePct = totalTx > 0 ? Math.round(((stats.complete || 0) + (stats.not_required || 0)) / totalTx * 100) : 100;
return (
<div className="max-w-5xl space-y-6 p-6">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Centru de Dovezi</h1>
<p className="text-sm text-ink-faint mt-1">
Acoperire: <span className={coveragePct >= 80 ? 'text-signal-ok font-medium' : 'text-signal-warn font-medium'}>{coveragePct}%</span>
{totalGap > 0 && ` · ${totalGap} tranzacții fără dovezi complete`}
</p>
</div>
{/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{Object.entries(EV_META).map(([status, m]) => (
<button key={status} onClick={() => setFilterStatus(status)}
className={`rounded-xl border p-4 text-center transition-all ${filterStatus === status ? 'ring-2 ring-primary' : ''} ${m.cls.split(' ')[1]}`}>
<p className={`font-display text-3xl font-bold ${m.cls.split(' ')[0]}`}>
{stats[status] ?? 0}
</p>
<p className="text-xs text-ink-faint mt-1">{m.label}</p>
</button>
))}
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Transaction list */}
<div className="lg:col-span-2 space-y-3">
<h2 className="text-sm font-semibold text-ink">
{EV_META[filterStatus]?.label} ({transactions.length})
</h2>
{txLoading ? (
<div className="card p-8 text-center text-sm text-ink-faint">Se încarcă</div>
) : transactions.length === 0 ? (
<div className="card p-8 text-center space-y-2">
<p className="text-2xl"></p>
<p className="text-sm text-ink-faint">Nicio tranzacție cu status &quot;{EV_META[filterStatus]?.label}&quot;</p>
</div>
) : (
<div className="card divide-y divide-border/50">
{transactions.map((tx) => (
<div key={tx.id} className="p-4 space-y-2">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium text-ink truncate">{tx.description ?? tx.counterparty ?? '—'}</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs font-mono font-semibold text-ink">{fmtMoney(tx.amountMinorUnits, tx.currency)}</span>
<span className="text-[10px] text-ink-faint">{tx.transactionDate}</span>
{tx.category && <span className="text-[10px] text-ink-faint/60">{tx.category}</span>}
</div>
</div>
<div className="flex gap-1 shrink-0">
{filterStatus !== 'complete' && filterStatus !== 'not_required' && (
<>
{linkTarget === tx.id ? (
<div className="flex gap-1 items-center">
<input placeholder="URL dovadă (opțional)" value={evidenceUrl}
onChange={(e) => setEvidenceUrl(e.target.value)}
className="rounded border px-2 py-1 text-xs bg-card text-ink w-40 focus:outline-none focus:ring-1 focus:ring-ring" />
<button onClick={() => patchMut.mutate({ id: tx.id, evidenceStatus: 'complete', evidenceUrl: evidenceUrl || undefined })}
className="rounded border px-2 py-1 text-[10px] text-signal-ok hover:bg-signal-ok/5"></button>
<button onClick={() => setLinkTarget(null)} className="text-[10px] text-ink-faint hover:text-ink px-1"></button>
</div>
) : (
<button onClick={() => setLinkTarget(tx.id)}
className="rounded border px-2 py-1 text-[10px] text-bronze-deep hover:bg-primary/5 transition-colors">
Marchează complet
</button>
)}
{filterStatus === 'missing' && (
<button onClick={() => patchMut.mutate({ id: tx.id, evidenceStatus: 'not_required' })}
className="rounded border px-2 py-1 text-[10px] text-ink-faint hover:bg-muted transition-colors">
N/A
</button>
)}
</>
)}
</div>
</div>
{tx.evidenceUrl && (
<a href={tx.evidenceUrl} target="_blank" rel="noopener noreferrer"
className="text-xs text-bronze-deep hover:underline truncate block">
🔗 {tx.evidenceUrl}
</a>
)}
</div>
))}
</div>
)}
</div>
{/* Recent documents panel */}
<div className="space-y-3">
<h2 className="text-sm font-semibold text-ink">Documente recente</h2>
<div className="card divide-y divide-border/50">
{docs.slice(0, 15).map((d) => (
<div key={d.id} className="p-3">
<p className="text-xs font-medium text-ink truncate">{d.filename}</p>
<p className="text-[10px] text-ink-faint mt-0.5 uppercase">{d.classification}</p>
</div>
))}
{docs.length === 0 && (
<p className="p-4 text-xs text-center text-ink-faint">Niciun document în bibliotecă</p>
)}
</div>
</div>
</div>
</div>
);
}