diff --git a/src/app/dashboard/compliance/page.tsx b/src/app/dashboard/compliance/page.tsx new file mode 100644 index 0000000..3bdb556 --- /dev/null +++ b/src/app/dashboard/compliance/page.tsx @@ -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 ( +
+
+
+ ); +} + +export default function CompliancePage() { + const { activeTenant } = useSession(); + const tenantId = activeTenant?.tenantId ?? ''; + + const { data: obligs = [] } = useQuery({ + queryKey: ['comp-obligs', tenantId], + queryFn: () => apiFetch('/v1/obligations?status=pending&limit=100', { tenantId }), + enabled: Boolean(tenantId), staleTime: 60_000, + }); + const { data: risks = [] } = useQuery({ + queryKey: ['comp-risks', tenantId], + queryFn: () => apiFetch('/v1/risks?status=open&limit=100', { tenantId }), + enabled: Boolean(tenantId), staleTime: 60_000, + }); + const { data: contracts = [] } = useQuery({ + queryKey: ['comp-contracts', tenantId], + queryFn: () => apiFetch('/v1/contracts?status=active', { tenantId }), + enabled: Boolean(tenantId), staleTime: 120_000, + }); + const { data: txMissing = [] } = useQuery({ + queryKey: ['comp-tx', tenantId], + queryFn: () => apiFetch('/v1/transactions?evidenceStatus=missing&limit=200', { tenantId }), + enabled: Boolean(tenantId), staleTime: 60_000, + }); + const { data: allTx = [] } = useQuery({ + queryKey: ['comp-tx-all', tenantId], + queryFn: () => apiFetch('/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 ( +
+
+

Dashboard Conformitate

+

Vedere consolidată a riscurilor, obligațiilor și dovezilor

+
+ + {/* Health score */} +
+
+
+

Scor Conformitate

+

{score}

+

/100 — actualizat în timp real

+
+
+
+ + ≥80 Bine +
+
+ + 60–79 Atenție +
+
+ + <60 Critic +
+
+
+ +
+ + {/* 4 summary cards */} +
+ {[ + { 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 }) => ( + +

{val}

+

{label}

+ + ))} +
+ +
+ {/* Overdue obligations */} +
+
+

+ Obligații depășite ({overdueObligs.length + urgentObligs.length}) +

+ Toate → +
+ {overdueObligs.length + urgentObligs.length === 0 ? ( +

✅ Nicio obligație urgentă

+ ) : ( +
+ {[...overdueObligs, ...urgentObligs].slice(0, 10).map((o) => { + const days = o.dueDate ? daysUntil(o.dueDate) : null; + return ( +
+ + {days !== null && days < 0 ? '🔴' : '🟡'} + +
+

{o.title}

+

+ {o.category}{days !== null ? ` · ${days < 0 ? `${Math.abs(days)}z depășit` : `${days}z rămase`}` : ''} +

+
+
+ ); + })} +
+ )} +
+ + {/* Critical & high risks */} +
+
+

+ Riscuri critice/ridicate ({criticalRisks.length + highRisks.length}) +

+ Toate → +
+ {criticalRisks.length + highRisks.length === 0 ? ( +

✅ Niciun risc critic deschis

+ ) : ( +
+ {[...criticalRisks, ...highRisks].slice(0, 10).map((r) => ( +
+ = 12 ? 'text-signal-danger' : 'text-signal-warn'}`}> + {r.riskScore ?? '?'} + +
+

{r.title}

+

{r.probability}/{r.impact}

+
+
+ ))} +
+ )} +
+ + {/* Expiring contracts */} +
+
+

+ Contracte cu termen ({expiringContracts.length}) +

+ Toate → +
+ {expiringContracts.length === 0 ? ( +

✅ Niciun contract expirant în 60 zile

+ ) : ( +
+ {expiringContracts.slice(0, 8).map((c) => { + const days = daysUntil(c.endDate!); + return ( +
+

{c.title}

+ + {days}z + +
+ ); + })} +
+ )} +
+ + {/* Evidence coverage */} +
+
+

Acoperire Dovezi

+ Centru dovezi → +
+
+
+ Acoperire totală + {evidencePct}% +
+ +
+
+
+ Lipsă dovadă + {txMissing.length} tranzacții +
+ {txMissing.slice(0, 4).map((tx) => ( +
+ {tx.description ?? tx.transactionDate} + + {(parseFloat(tx.amountMinorUnits) / 100).toFixed(0)} {tx.currency} + +
+ ))} + {txMissing.length > 4 && ( +

și alte {txMissing.length - 4}…

+ )} +
+
+
+
+ ); +}