From 17852d4dbd6c6ff9936279d93b081d47f442451d Mon Sep 17 00:00:00 2001 From: admin-valentin Date: Sat, 1 Aug 2026 21:20:56 +0000 Subject: [PATCH] feat(CC-063): add Risk Register page (probability/impact matrix, mitigations, status) --- src/app/dashboard/risks/page.tsx | 218 +++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 src/app/dashboard/risks/page.tsx diff --git a/src/app/dashboard/risks/page.tsx b/src/app/dashboard/risks/page.tsx new file mode 100644 index 0000000..9bef249 --- /dev/null +++ b/src/app/dashboard/risks/page.tsx @@ -0,0 +1,218 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { apiFetch } from '../../../lib/api'; +import { useSession } from '../../../components/session-provider'; + +interface Risk { + id: string; title: string; description: string | null; + category: string | null; probability: string; impact: string; + status: string; mitigation: string | null; owner: string | null; + reviewDueAt: string | null; createdAt: string; +} + +const LEVEL_ORDER = ['low', 'medium', 'high', 'critical']; +const LEVEL_META: Record = { + low: { label: 'Scăzut', score: 1, cls: 'text-sky-600 dark:text-sky-400' }, + medium: { label: 'Mediu', score: 2, cls: 'text-signal-warn' }, + high: { label: 'Ridicat', score: 3, cls: 'text-orange-600 dark:text-orange-400' }, + critical: { label: 'Critic', score: 4, cls: 'text-signal-danger font-bold' }, +}; +const STATUS_META: Record = { + open: { label: 'Deschis', cls: 'bg-signal-warn/10 text-signal-warn' }, + mitigated: { label: 'Mitigat', cls: 'bg-primary/10 text-primary' }, + accepted: { label: 'Acceptat', cls: 'bg-sky-500/10 text-sky-700 dark:text-sky-300' }, + closed: { label: 'Închis', cls: 'bg-signal-ok/10 text-signal-ok' }, +}; + +function riskScore(probability: string, impact: string): number { + return (LEVEL_META[probability]?.score ?? 2) * (LEVEL_META[impact]?.score ?? 2); +} + +function ScoreCell({ prob, imp }: { prob: string; imp: string }) { + const score = riskScore(prob, imp); + const color = score >= 12 ? 'bg-signal-danger/80' : score >= 6 ? 'bg-signal-warn/80' : score >= 3 ? 'bg-sky-500/40' : 'bg-signal-ok/30'; + return ( +
+ {score} +
+ ); +} + +export default function RisksPage() { + const { activeTenant } = useSession(); + const tenantId = activeTenant?.tenantId ?? ''; + const qc = useQueryClient(); + const [filterStatus, setFilterStatus] = useState('open'); + const [showCreate, setShowCreate] = useState(false); + const [form, setForm] = useState({ + title: '', description: '', category: '', probability: 'medium', + impact: 'medium', mitigation: '', owner: '', + }); + + const { data: risks = [], isLoading } = useQuery({ + queryKey: ['risks', tenantId, filterStatus], + queryFn: () => apiFetch(`/v1/risks${filterStatus !== 'all' ? `?status=${filterStatus}` : ''}`, { tenantId }), + enabled: Boolean(tenantId), + staleTime: 60_000, + }); + + const { mutate: createRisk, isPending } = useMutation({ + mutationFn: () => apiFetch('/v1/risks', { method: 'POST', body: form, tenantId }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['risks', tenantId] }); + setShowCreate(false); + setForm({ title: '', description: '', category: '', probability: 'medium', impact: 'medium', mitigation: '', owner: '' }); + }, + }); + + const { mutate: closeRisk } = useMutation({ + mutationFn: (id: string) => apiFetch(`/v1/risks/${id}/close`, { method: 'POST', tenantId }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['risks', tenantId] }), + }); + + const sortedRisks = [...risks].sort((a, b) => riskScore(b.probability, b.impact) - riskScore(a.probability, a.impact)); + + return ( +
+
+
+

Registru Riscuri

+

{risks.length} riscuri {filterStatus !== 'all' ? filterStatus : ''}

+
+ +
+ + {/* Create form */} + {showCreate && ( +
+

Risc nou

+
+
+ + setForm((f) => ({ ...f, title: e.target.value }))} + placeholder="Descriere scurtă a riscului…" + className="w-full rounded-lg border bg-card px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" /> +
+ {[ + { key: 'category', label: 'Categorie', placeholder: 'financiar, legal, operational…' }, + { key: 'owner', label: 'Responsabil', placeholder: 'Nume sau echipă' }, + ].map(({ key, label, placeholder }) => ( +
+ + )[key]} + onChange={(e) => setForm((f) => ({ ...f, [key]: e.target.value }))} + placeholder={placeholder} + className="w-full rounded-lg border bg-card px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" /> +
+ ))} + {[ + { key: 'probability', label: 'Probabilitate' }, + { key: 'impact', label: 'Impact' }, + ].map(({ key, label }) => ( +
+ + +
+ ))} +
+ +