feat(CC-067): add Compliance Dashboard (aggregated health score + 4 panels)

This commit is contained in:
admin-valentin 2026-08-02 12:13:01 +00:00
parent f3a90cee73
commit 84c50f68cf

View file

@ -0,0 +1,248 @@
'use client';
import Link from 'next/link';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '../../../lib/api';
import { useSession } from '../../../components/session-provider';
interface Obligation { id: string; title: string; status: string; category: string; dueDate: string | null; riskLevel: string | null; }
interface Risk { id: string; title: string; status: string; probability: string; impact: string; riskScore: number | null; }
interface Contract { id: string; title: string; status: string; endDate: string | null; contractType: string; }
interface Transaction { id: string; description: string | null; amountMinorUnits: string; currency: string; evidenceStatus: string; transactionDate: string; }
const B = '/dashboard';
function daysUntil(d: string) { return Math.ceil((new Date(d).getTime() - Date.now()) / 86400_000); }
function ScoreBar({ value, max = 100, cls = '' }: { value: number; max?: number; cls?: string }) {
const pct = Math.min(100, Math.round((value / max) * 100));
return (
<div className="h-2 rounded-full bg-muted/50">
<div className={`h-2 rounded-full transition-all ${cls}`} style={{ width: `${pct}%` }} />
</div>
);
}
export default function CompliancePage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const { data: obligs = [] } = useQuery({
queryKey: ['comp-obligs', tenantId],
queryFn: () => apiFetch<Obligation[]>('/v1/obligations?status=pending&limit=100', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const { data: risks = [] } = useQuery({
queryKey: ['comp-risks', tenantId],
queryFn: () => apiFetch<Risk[]>('/v1/risks?status=open&limit=100', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const { data: contracts = [] } = useQuery({
queryKey: ['comp-contracts', tenantId],
queryFn: () => apiFetch<Contract[]>('/v1/contracts?status=active', { tenantId }),
enabled: Boolean(tenantId), staleTime: 120_000,
});
const { data: txMissing = [] } = useQuery({
queryKey: ['comp-tx', tenantId],
queryFn: () => apiFetch<Transaction[]>('/v1/transactions?evidenceStatus=missing&limit=200', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const { data: allTx = [] } = useQuery({
queryKey: ['comp-tx-all', tenantId],
queryFn: () => apiFetch<Transaction[]>('/v1/transactions?limit=500', { tenantId }),
enabled: Boolean(tenantId), staleTime: 120_000,
});
const overdueObligs = obligs.filter((o) => o.dueDate && daysUntil(o.dueDate) < 0);
const urgentObligs = obligs.filter((o) => o.dueDate && daysUntil(o.dueDate) >= 0 && daysUntil(o.dueDate) <= 30);
const criticalRisks = risks.filter((r) => (r.riskScore ?? 0) >= 12);
const highRisks = risks.filter((r) => (r.riskScore ?? 0) >= 6 && (r.riskScore ?? 0) < 12);
const expiringContracts = contracts.filter((c) => c.endDate && daysUntil(c.endDate) <= 60);
const evidencePct = allTx.length > 0
? Math.round(allTx.filter((t) => t.evidenceStatus === 'complete' || t.evidenceStatus === 'not_required').length / allTx.length * 100)
: 100;
// Compliance health score (0-100)
const score = Math.max(0, 100
- overdueObligs.length * 15
- criticalRisks.length * 10
- highRisks.length * 5
- expiringContracts.filter((c) => daysUntil(c.endDate!) <= 14).length * 8
- Math.round((100 - evidencePct) * 0.3)
);
const scoreColor = score >= 80 ? 'text-signal-ok' : score >= 60 ? 'text-signal-warn' : 'text-signal-danger';
const scoreBarCls = score >= 80 ? 'bg-signal-ok' : score >= 60 ? 'bg-signal-warn' : 'bg-signal-danger';
return (
<div className="max-w-5xl space-y-6 p-6">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Dashboard Conformitate</h1>
<p className="text-sm text-ink-faint mt-1">Vedere consolidată a riscurilor, obligațiilor și dovezilor</p>
</div>
{/* Health score */}
<div className="card p-6">
<div className="flex items-end justify-between mb-3">
<div>
<h2 className="text-xs font-semibold uppercase tracking-wider text-ink-faint mb-1">Scor Conformitate</h2>
<p className={`font-display text-5xl font-bold ${scoreColor}`}>{score}</p>
<p className="text-xs text-ink-faint mt-1">/100 actualizat în timp real</p>
</div>
<div className="text-right space-y-1">
<div className="flex items-center gap-2 justify-end">
<span className="w-2 h-2 rounded-full bg-signal-ok" />
<span className="text-xs text-ink-faint">80 Bine</span>
</div>
<div className="flex items-center gap-2 justify-end">
<span className="w-2 h-2 rounded-full bg-signal-warn" />
<span className="text-xs text-ink-faint">6079 Atenție</span>
</div>
<div className="flex items-center gap-2 justify-end">
<span className="w-2 h-2 rounded-full bg-signal-danger" />
<span className="text-xs text-ink-faint">&lt;60 Critic</span>
</div>
</div>
</div>
<ScoreBar value={score} cls={scoreBarCls} />
</div>
{/* 4 summary cards */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{[
{ label: 'Obligații scadente', val: overdueObligs.length, cls: 'text-signal-danger', link: `${B}/obligations`, warn: overdueObligs.length > 0 },
{ label: 'Riscuri critice', val: criticalRisks.length, cls: 'text-signal-danger', link: `${B}/risks`, warn: criticalRisks.length > 0 },
{ label: 'Contracte expirate', val: expiringContracts.length, cls: 'text-signal-warn', link: `${B}/contracts`, warn: expiringContracts.length > 0 },
{ label: 'Acoperire dovezi', val: `${evidencePct}%`, cls: evidencePct >= 80 ? 'text-signal-ok' : 'text-signal-warn', link: `${B}/evidence`, warn: evidencePct < 80 },
].map(({ label, val, cls, link, warn }) => (
<Link key={label} href={link}
className={`card p-4 text-center hover:border-primary/40 transition-colors ${warn ? 'bg-signal-danger/5' : ''}`}>
<p className={`font-display text-3xl font-bold ${cls}`}>{val}</p>
<p className="text-xs text-ink-faint mt-1">{label}</p>
</Link>
))}
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Overdue obligations */}
<div className="card p-5 space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-ink">
Obligații <span className="text-signal-danger">depășite</span> ({overdueObligs.length + urgentObligs.length})
</h2>
<Link href={`${B}/obligations`} className="text-xs text-bronze-deep hover:underline">Toate </Link>
</div>
{overdueObligs.length + urgentObligs.length === 0 ? (
<p className="text-xs text-center text-ink-faint py-4"> Nicio obligație urgentă</p>
) : (
<div className="divide-y divide-border/50 max-h-64 overflow-y-auto">
{[...overdueObligs, ...urgentObligs].slice(0, 10).map((o) => {
const days = o.dueDate ? daysUntil(o.dueDate) : null;
return (
<div key={o.id} className="flex items-start gap-3 py-2.5">
<span className={`text-lg ${days !== null && days < 0 ? 'text-signal-danger' : 'text-signal-warn'}`}>
{days !== null && days < 0 ? '🔴' : '🟡'}
</span>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-ink truncate">{o.title}</p>
<p className="text-[10px] text-ink-faint mt-0.5">
{o.category}{days !== null ? ` · ${days < 0 ? `${Math.abs(days)}z depășit` : `${days}z rămase`}` : ''}
</p>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Critical & high risks */}
<div className="card p-5 space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-ink">
Riscuri <span className="text-signal-danger">critice/ridicate</span> ({criticalRisks.length + highRisks.length})
</h2>
<Link href={`${B}/risks`} className="text-xs text-bronze-deep hover:underline">Toate </Link>
</div>
{criticalRisks.length + highRisks.length === 0 ? (
<p className="text-xs text-center text-ink-faint py-4"> Niciun risc critic deschis</p>
) : (
<div className="divide-y divide-border/50 max-h-64 overflow-y-auto">
{[...criticalRisks, ...highRisks].slice(0, 10).map((r) => (
<div key={r.id} className="flex items-start gap-3 py-2.5">
<span className={`font-bold text-sm ${(r.riskScore ?? 0) >= 12 ? 'text-signal-danger' : 'text-signal-warn'}`}>
{r.riskScore ?? '?'}
</span>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-ink truncate">{r.title}</p>
<p className="text-[10px] text-ink-faint mt-0.5">{r.probability}/{r.impact}</p>
</div>
</div>
))}
</div>
)}
</div>
{/* Expiring contracts */}
<div className="card p-5 space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-ink">
Contracte cu termen ({expiringContracts.length})
</h2>
<Link href={`${B}/contracts`} className="text-xs text-bronze-deep hover:underline">Toate </Link>
</div>
{expiringContracts.length === 0 ? (
<p className="text-xs text-center text-ink-faint py-4"> Niciun contract expirant în 60 zile</p>
) : (
<div className="divide-y divide-border/50">
{expiringContracts.slice(0, 8).map((c) => {
const days = daysUntil(c.endDate!);
return (
<div key={c.id} className="flex items-center justify-between py-2.5 gap-3">
<p className="text-xs font-medium text-ink truncate flex-1">{c.title}</p>
<span className={`text-[10px] font-semibold shrink-0 ${days <= 14 ? 'text-signal-danger' : 'text-signal-warn'}`}>
{days}z
</span>
</div>
);
})}
</div>
)}
</div>
{/* Evidence coverage */}
<div className="card p-5 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-ink">Acoperire Dovezi</h2>
<Link href={`${B}/evidence`} className="text-xs text-bronze-deep hover:underline">Centru dovezi </Link>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-ink-faint">Acoperire totală</span>
<span className={`font-semibold ${scoreColor}`}>{evidencePct}%</span>
</div>
<ScoreBar value={evidencePct} cls={scoreBarCls} />
</div>
<div className="space-y-2">
<div className="flex justify-between text-xs">
<span className="text-signal-danger">Lipsă dovadă</span>
<span className="font-mono font-medium text-ink">{txMissing.length} tranzacții</span>
</div>
{txMissing.slice(0, 4).map((tx) => (
<div key={tx.id} className="flex items-center justify-between py-1 border-t border-border/30">
<span className="text-[10px] text-ink-faint truncate flex-1">{tx.description ?? tx.transactionDate}</span>
<span className="text-[10px] font-mono text-signal-danger shrink-0 ml-2">
{(parseFloat(tx.amountMinorUnits) / 100).toFixed(0)} {tx.currency}
</span>
</div>
))}
{txMissing.length > 4 && (
<p className="text-[10px] text-ink-faint text-center">și alte {txMissing.length - 4}</p>
)}
</div>
</div>
</div>
</div>
);
}