feat(CC-063): add Risk Register page (probability/impact matrix, mitigations, status)
This commit is contained in:
parent
365b17d71b
commit
17852d4dbd
1 changed files with 218 additions and 0 deletions
218
src/app/dashboard/risks/page.tsx
Normal file
218
src/app/dashboard/risks/page.tsx
Normal file
|
|
@ -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<string, { label: string; score: number; cls: string }> = {
|
||||
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<string, { label: string; cls: string }> = {
|
||||
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 (
|
||||
<div className={`w-8 h-8 rounded flex items-center justify-center text-xs font-bold ${color} text-white`}>
|
||||
{score}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<Risk[]>(`/v1/risks${filterStatus !== 'all' ? `?status=${filterStatus}` : ''}`, { tenantId }),
|
||||
enabled: Boolean(tenantId),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const { mutate: createRisk, isPending } = useMutation({
|
||||
mutationFn: () => apiFetch<Risk>('/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 (
|
||||
<div className="max-w-5xl space-y-6 p-6">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Registru Riscuri</h1>
|
||||
<p className="text-sm text-ink-faint mt-1">{risks.length} riscuri {filterStatus !== 'all' ? filterStatus : ''}</p>
|
||||
</div>
|
||||
<button onClick={() => setShowCreate(true)} className="btn btn-primary text-xs px-4 py-2">
|
||||
+ Risc nou
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create form */}
|
||||
{showCreate && (
|
||||
<div className="card p-5 space-y-4 border-primary/30">
|
||||
<h2 className="text-sm font-semibold text-ink">Risc nou</h2>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label className="text-xs text-ink-faint block mb-1">Titlu *</label>
|
||||
<input value={form.title} onChange={(e) => 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" />
|
||||
</div>
|
||||
{[
|
||||
{ key: 'category', label: 'Categorie', placeholder: 'financiar, legal, operational…' },
|
||||
{ key: 'owner', label: 'Responsabil', placeholder: 'Nume sau echipă' },
|
||||
].map(({ key, label, placeholder }) => (
|
||||
<div key={key}>
|
||||
<label className="text-xs text-ink-faint block mb-1">{label}</label>
|
||||
<input value={(form as Record<string, string>)[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" />
|
||||
</div>
|
||||
))}
|
||||
{[
|
||||
{ key: 'probability', label: 'Probabilitate' },
|
||||
{ key: 'impact', label: 'Impact' },
|
||||
].map(({ key, label }) => (
|
||||
<div key={key}>
|
||||
<label className="text-xs text-ink-faint block mb-1">{label}</label>
|
||||
<select value={(form as Record<string, string>)[key]}
|
||||
onChange={(e) => setForm((f) => ({ ...f, [key]: e.target.value }))}
|
||||
className="w-full rounded-lg border bg-card px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
{LEVEL_ORDER.map((l) => <option key={l} value={l}>{LEVEL_META[l].label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
<div className="sm:col-span-2">
|
||||
<label className="text-xs text-ink-faint block mb-1">Plan de mitigare</label>
|
||||
<textarea value={form.mitigation}
|
||||
onChange={(e) => setForm((f) => ({ ...f, mitigation: e.target.value }))}
|
||||
rows={2} placeholder="Pași concreți pentru reducerea riscului…"
|
||||
className="w-full rounded-lg border bg-card px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
</div>
|
||||
</div>
|
||||
{form.probability && form.impact && (
|
||||
<div className="flex items-center gap-2 text-xs text-ink-faint">
|
||||
<ScoreCell prob={form.probability} imp={form.impact} />
|
||||
<span>Scor risc: {riskScore(form.probability, form.impact)}/16</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => createRisk()} disabled={isPending || !form.title.trim()}
|
||||
className="btn btn-primary text-xs px-4 py-2 disabled:opacity-50">
|
||||
{isPending ? 'Se salvează…' : 'Salvează'}
|
||||
</button>
|
||||
<button onClick={() => setShowCreate(false)} className="text-xs text-ink-faint hover:text-ink">Anulează</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status filter */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{['open', 'mitigated', 'accepted', 'closed', 'all'].map((s) => (
|
||||
<button key={s} onClick={() => setFilterStatus(s)}
|
||||
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
|
||||
filterStatus === s ? 'bg-primary text-white border-primary' : 'bg-card text-ink-faint border-border hover:border-primary/40'
|
||||
}`}>
|
||||
{s === 'all' ? 'Toate' : STATUS_META[s]?.label ?? s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Risk list */}
|
||||
{isLoading ? (
|
||||
<div className="card p-8 text-center text-sm text-ink-faint">Se încarcă…</div>
|
||||
) : sortedRisks.length === 0 ? (
|
||||
<div className="card p-12 text-center space-y-2">
|
||||
<p className="text-3xl">🛡️</p>
|
||||
<p className="text-sm text-ink-faint">Niciun risc înregistrat în această categorie.</p>
|
||||
<button onClick={() => setShowCreate(true)} className="text-xs text-bronze-deep hover:underline">Adaugă primul risc →</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sortedRisks.map((risk) => {
|
||||
const status = STATUS_META[risk.status] ?? STATUS_META.open;
|
||||
const now = new Date();
|
||||
const isDue = risk.reviewDueAt && new Date(risk.reviewDueAt) < now;
|
||||
return (
|
||||
<div key={risk.id} className="card p-4 space-y-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<ScoreCell prob={risk.probability} imp={risk.impact} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="text-sm font-semibold text-ink">{risk.title}</p>
|
||||
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${status.cls}`}>
|
||||
{status.label}
|
||||
</span>
|
||||
{risk.category && (
|
||||
<span className="text-[10px] text-ink-faint px-1.5 py-0.5 rounded-full border border-border/50">
|
||||
{risk.category}
|
||||
</span>
|
||||
)}
|
||||
{isDue && (
|
||||
<span className="text-[10px] text-signal-danger font-medium">⏰ Review în restanță</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-ink-faint mt-0.5">
|
||||
<span>Probabilitate: <strong className={LEVEL_META[risk.probability]?.cls}>{LEVEL_META[risk.probability]?.label}</strong></span>
|
||||
<span>Impact: <strong className={LEVEL_META[risk.impact]?.cls}>{LEVEL_META[risk.impact]?.label}</strong></span>
|
||||
{risk.owner && <span>Responsabil: {risk.owner}</span>}
|
||||
</div>
|
||||
{risk.mitigation && (
|
||||
<p className="text-xs text-ink-faint mt-1 line-clamp-1">↳ {risk.mitigation}</p>
|
||||
)}
|
||||
</div>
|
||||
{risk.status === 'open' && (
|
||||
<button onClick={() => closeRisk(risk.id)}
|
||||
className="shrink-0 text-[10px] text-ink-faint border border-border/50 rounded px-2 py-1 hover:text-ink hover:border-ink/30">
|
||||
Închide
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue