ceo-web/src/app/dashboard/compliance/page.tsx

248 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
);
}