604 lines
26 KiB
TypeScript
604 lines
26 KiB
TypeScript
import React, { useState, useMemo } from 'react';
|
||
import { Risk, Project } from '../../types';
|
||
import { getUserEmail } from '../../lib/formatters';
|
||
import { AlertTriangle, ShieldAlert, CheckCircle2, ChevronRight, X, Info } from 'lucide-react';
|
||
import { HelpTooltip } from './HelpTooltip';
|
||
|
||
interface RiskHeatmapMatrixProps {
|
||
risks: Risk[];
|
||
projects?: Project[];
|
||
compact?: boolean;
|
||
onSelectRisk?: (risk: Risk) => void;
|
||
title?: string;
|
||
subtitle?: string;
|
||
}
|
||
|
||
// Standard 5-point likelihood and impact levels
|
||
const LIKELIHOOD_LEVELS = [
|
||
{ val: 1, label: 'Rare', short: '1 - Rare' },
|
||
{ val: 2, label: 'Unlikely', short: '2 - Unlikely' },
|
||
{ val: 3, label: 'Possible', short: '3 - Possible' },
|
||
{ val: 4, label: 'Likely', short: '4 - Likely' },
|
||
{ val: 5, label: 'Almost Certain', short: '5 - Almost Certain' },
|
||
];
|
||
|
||
const IMPACT_LEVELS = [
|
||
{ val: 5, label: 'Severe', short: '5 - Severe' },
|
||
{ val: 4, label: 'Critical', short: '4 - Critical' },
|
||
{ val: 3, label: 'Major', short: '3 - Major' },
|
||
{ val: 2, label: 'Moderate', short: '2 - Moderate' },
|
||
{ val: 1, label: 'Minor', short: '1 - Minor' },
|
||
];
|
||
|
||
export const getCellSeverity = (score: number) => {
|
||
if (score >= 15) {
|
||
return {
|
||
name: 'Critical',
|
||
bgClass: 'bg-rose-950/70 border-rose-600/70 text-rose-100 hover:bg-rose-900/90',
|
||
badgeClass: 'bg-rose-500/20 text-rose-300 border-rose-500/40',
|
||
textColor: 'text-rose-400',
|
||
};
|
||
}
|
||
if (score >= 10) {
|
||
return {
|
||
name: 'High',
|
||
bgClass: 'bg-orange-950/60 border-orange-700/60 text-orange-200 hover:bg-orange-900/80',
|
||
badgeClass: 'bg-orange-500/20 text-orange-300 border-orange-500/40',
|
||
textColor: 'text-orange-400',
|
||
};
|
||
}
|
||
if (score >= 5) {
|
||
return {
|
||
name: 'Moderate',
|
||
bgClass: 'bg-amber-950/50 border-amber-800/50 text-amber-300 hover:bg-amber-900/70',
|
||
badgeClass: 'bg-amber-500/20 text-amber-300 border-amber-500/40',
|
||
textColor: 'text-amber-400',
|
||
};
|
||
}
|
||
return {
|
||
name: 'Low',
|
||
bgClass: 'bg-emerald-950/40 border-emerald-800/40 text-emerald-300 hover:bg-emerald-900/60',
|
||
badgeClass: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40',
|
||
textColor: 'text-emerald-400',
|
||
};
|
||
};
|
||
|
||
export const RiskHeatmapMatrix: React.FC<RiskHeatmapMatrixProps> = ({
|
||
risks,
|
||
projects = [],
|
||
compact = false,
|
||
onSelectRisk,
|
||
title,
|
||
subtitle,
|
||
}) => {
|
||
const [matrixMode, setMatrixMode] = useState<'5x5' | '3x3'>('5x5');
|
||
const [selectedCell, setSelectedCell] = useState<{
|
||
impactLabel: string;
|
||
likelihoodLabel: string;
|
||
score: number;
|
||
risks: Risk[];
|
||
} | null>(null);
|
||
|
||
// Map project names for easy lookup
|
||
const projectMap = useMemo(() => {
|
||
const map: Record<string, string> = {};
|
||
projects.forEach((p) => {
|
||
map[String(p.id)] = p.name;
|
||
});
|
||
return map;
|
||
}, [projects]);
|
||
|
||
// Pre-calculate risk counts by exact coordinates (1-5)
|
||
const matrix5x5 = useMemo(() => {
|
||
const grid: Record<string, Risk[]> = {};
|
||
for (let imp = 1; imp <= 5; imp++) {
|
||
for (let lik = 1; lik <= 5; lik++) {
|
||
grid[`${imp}-${lik}`] = [];
|
||
}
|
||
}
|
||
|
||
risks.forEach((r) => {
|
||
const imp = Math.min(5, Math.max(1, Number(r.impact) || 1));
|
||
const lik = Math.min(5, Math.max(1, Number(r.likelihood) || 1));
|
||
grid[`${imp}-${lik}`].push(r);
|
||
});
|
||
|
||
return grid;
|
||
}, [risks]);
|
||
|
||
// Pre-calculate 3x3 summary matrix
|
||
const matrix3x3 = useMemo(() => {
|
||
const buckets: Record<string, Risk[]> = {
|
||
'high-low': [],
|
||
'high-med': [],
|
||
'high-high': [],
|
||
'med-low': [],
|
||
'med-med': [],
|
||
'med-high': [],
|
||
'low-low': [],
|
||
'low-med': [],
|
||
'low-high': [],
|
||
};
|
||
|
||
risks.forEach((r) => {
|
||
const imp = Number(r.impact) || 1;
|
||
const lik = Number(r.likelihood) || 1;
|
||
|
||
const impKey = imp >= 4 ? 'high' : imp === 3 ? 'med' : 'low';
|
||
const likKey = lik >= 4 ? 'high' : lik === 3 ? 'med' : 'low';
|
||
|
||
buckets[`${impKey}-${likKey}`].push(r);
|
||
});
|
||
|
||
return buckets;
|
||
}, [risks]);
|
||
|
||
// Overall severity summary counters
|
||
const summaryCounters = useMemo(() => {
|
||
let low = 0;
|
||
let moderate = 0;
|
||
let high = 0;
|
||
let critical = 0;
|
||
|
||
risks.forEach((r) => {
|
||
const score = (Number(r.impact) || 1) * (Number(r.likelihood) || 1);
|
||
if (score >= 15) critical++;
|
||
else if (score >= 10) high++;
|
||
else if (score >= 5) moderate++;
|
||
else low++;
|
||
});
|
||
|
||
return { low, moderate, high, critical };
|
||
}, [risks]);
|
||
|
||
const handleCellClick = (impactLabel: string, likelihoodLabel: string, score: number, cellRisks: Risk[]) => {
|
||
if (cellRisks.length === 0) {
|
||
if (selectedCell) setSelectedCell(null);
|
||
return;
|
||
}
|
||
setSelectedCell({
|
||
impactLabel,
|
||
likelihoodLabel,
|
||
score,
|
||
risks: cellRisks,
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* Header & Controls */}
|
||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||
<div>
|
||
<div className="flex items-center gap-2">
|
||
{title && <h3 className="text-sm font-semibold text-white">{title}</h3>}
|
||
<HelpTooltip
|
||
title="Risk Heatmap Guidance"
|
||
content="Visualizes project risk exposure based on ISO 31000 standards. Severity score = Likelihood (1–5) × Impact (1–5). Click any populated cell to drill into registered risks."
|
||
size="sm"
|
||
placement="bottom"
|
||
/>
|
||
</div>
|
||
{subtitle && <p className="text-xs text-slate-400 mt-0.5">{subtitle}</p>}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 self-end sm:self-auto">
|
||
{/* Mode Switcher */}
|
||
<div className="flex items-center bg-slate-900/90 p-0.5 rounded-lg border border-slate-700/80 text-[11px]">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setMatrixMode('5x5');
|
||
setSelectedCell(null);
|
||
}}
|
||
className={`px-2.5 py-1 rounded-md font-medium transition cursor-pointer ${
|
||
matrixMode === '5x5' ? 'bg-blue-600 text-white shadow-xs' : 'text-slate-400 hover:text-white'
|
||
}`}
|
||
>
|
||
5×5 Exact Matrix
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setMatrixMode('3x3');
|
||
setSelectedCell(null);
|
||
}}
|
||
className={`px-2.5 py-1 rounded-md font-medium transition cursor-pointer ${
|
||
matrixMode === '3x3' ? 'bg-blue-600 text-white shadow-xs' : 'text-slate-400 hover:text-white'
|
||
}`}
|
||
>
|
||
3×3 Summary
|
||
</button>
|
||
</div>
|
||
<HelpTooltip
|
||
title="Matrix Resolution Switcher"
|
||
content="Toggle between 5×5 detailed granular risk distribution and 3×3 executive aggregated categories (Low, Medium, High)."
|
||
size="sm"
|
||
placement="left"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Summary Score Badges */}
|
||
<div className="grid grid-cols-4 gap-2">
|
||
<div className="bg-rose-500/10 border border-rose-500/30 p-2 rounded-xl text-center">
|
||
<span className="text-[10px] text-rose-400 font-semibold block uppercase">Critical (15–25)</span>
|
||
<span className="text-base font-bold text-rose-200">{summaryCounters.critical}</span>
|
||
</div>
|
||
<div className="bg-orange-500/10 border border-orange-500/30 p-2 rounded-xl text-center">
|
||
<span className="text-[10px] text-orange-400 font-semibold block uppercase">High (10–14)</span>
|
||
<span className="text-base font-bold text-orange-200">{summaryCounters.high}</span>
|
||
</div>
|
||
<div className="bg-amber-500/10 border border-amber-500/30 p-2 rounded-xl text-center">
|
||
<span className="text-[10px] text-amber-400 font-semibold block uppercase">Moderate (5–9)</span>
|
||
<span className="text-base font-bold text-amber-200">{summaryCounters.moderate}</span>
|
||
</div>
|
||
<div className="bg-emerald-500/10 border border-emerald-500/30 p-2 rounded-xl text-center">
|
||
<span className="text-[10px] text-emerald-400 font-semibold block uppercase">Low (1–4)</span>
|
||
<span className="text-base font-bold text-emerald-200">{summaryCounters.low}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 5x5 Matrix Mode */}
|
||
{matrixMode === '5x5' && (
|
||
<div className="bg-[#090d16]/80 p-3 rounded-2xl border border-slate-800/90 overflow-x-auto">
|
||
<div className="min-w-[480px]">
|
||
{/* Top axis label: Likelihood */}
|
||
<div className="text-center text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-2 pl-24">
|
||
Likelihood / Probability →
|
||
</div>
|
||
|
||
<div className="flex">
|
||
{/* Left Y-Axis Label: Impact */}
|
||
<div className="w-6 flex items-center justify-center -mr-2">
|
||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest -rotate-90 whitespace-nowrap">
|
||
↑ Impact / Severity
|
||
</span>
|
||
</div>
|
||
|
||
{/* Grid with Row Headers */}
|
||
<div className="flex-1">
|
||
{/* Column Headers */}
|
||
<div className="grid grid-cols-6 gap-1.5 mb-1.5 text-center">
|
||
<div className="text-[10px] font-semibold text-slate-500 flex items-center justify-end pr-2">
|
||
Impact \ Lik.
|
||
</div>
|
||
{LIKELIHOOD_LEVELS.map((lik) => (
|
||
<div key={lik.val} className="text-[10px] font-semibold text-slate-400 bg-slate-900/60 py-1 rounded-md">
|
||
{lik.short}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Rows (Impact 5 down to 1) */}
|
||
<div className="space-y-1.5">
|
||
{IMPACT_LEVELS.map((imp) => (
|
||
<div key={imp.val} className="grid grid-cols-6 gap-1.5">
|
||
{/* Row Header */}
|
||
<div className="text-[10px] font-semibold text-slate-400 bg-slate-900/60 rounded-md flex items-center justify-end pr-2">
|
||
{imp.short}
|
||
</div>
|
||
|
||
{/* 5 Cells */}
|
||
{LIKELIHOOD_LEVELS.map((lik) => {
|
||
const cellRisks = matrix5x5[`${imp.val}-${lik.val}`] || [];
|
||
const score = imp.val * lik.val;
|
||
const sev = getCellSeverity(score);
|
||
const hasRisks = cellRisks.length > 0;
|
||
const isSelected =
|
||
selectedCell?.impactLabel === imp.label && selectedCell?.likelihoodLabel === lik.label;
|
||
|
||
return (
|
||
<button
|
||
key={lik.val}
|
||
type="button"
|
||
onClick={() => handleCellClick(imp.label, lik.label, score, cellRisks)}
|
||
className={`relative rounded-xl border p-2 text-center transition-all cursor-pointer ${
|
||
sev.bgClass
|
||
} ${isSelected ? 'ring-2 ring-blue-400 scale-[1.03] z-10 shadow-lg' : ''} ${
|
||
hasRisks ? 'shadow-xs font-bold' : 'opacity-70 hover:opacity-100'
|
||
}`}
|
||
title={`Impact: ${imp.label} (${imp.val}) × Likelihood: ${lik.label} (${lik.val}) = Score ${score} (${sev.name})`}
|
||
>
|
||
{/* Score small indicator */}
|
||
<span className="text-[9px] block text-slate-400/80 font-mono">
|
||
{score}
|
||
</span>
|
||
|
||
{/* Risk count or empty dash */}
|
||
{hasRisks ? (
|
||
<div className="mt-0.5 flex items-center justify-center gap-1">
|
||
<span className="h-1.5 w-1.5 rounded-full bg-white animate-pulse"></span>
|
||
<span className="text-xs font-extrabold text-white px-1.5 py-0.2 rounded-full bg-black/40 border border-white/20">
|
||
{cellRisks.length} {cellRisks.length === 1 ? 'Risk' : 'Risks'}
|
||
</span>
|
||
</div>
|
||
) : (
|
||
<span className="text-xs text-slate-600 block mt-0.5">—</span>
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 3x3 Summary Mode */}
|
||
{matrixMode === '3x3' && (
|
||
<div className="bg-[#090d16]/80 p-3 rounded-2xl border border-slate-800/90 overflow-x-auto">
|
||
<div className="min-w-[440px]">
|
||
{/* Top axis label: Likelihood */}
|
||
<div className="text-center text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-2 pl-24">
|
||
Likelihood / Probability →
|
||
</div>
|
||
|
||
<div className="flex">
|
||
{/* Left Y-Axis Label: Impact */}
|
||
<div className="w-6 flex items-center justify-center -mr-2">
|
||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest -rotate-90 whitespace-nowrap">
|
||
↑ Impact / Severity
|
||
</span>
|
||
</div>
|
||
|
||
{/* 3x3 Table */}
|
||
<div className="flex-1">
|
||
{/* Column Headers */}
|
||
<div className="grid grid-cols-4 gap-2 mb-2 text-center">
|
||
<div className="text-[10px] font-semibold text-slate-500 flex items-center justify-end pr-2">
|
||
Impact \ Lik.
|
||
</div>
|
||
<div className="text-[10px] font-semibold text-slate-300 bg-slate-900/60 py-1.5 rounded-lg">
|
||
Low (1–2 Rare/Unlikely)
|
||
</div>
|
||
<div className="text-[10px] font-semibold text-slate-300 bg-slate-900/60 py-1.5 rounded-lg">
|
||
Medium (3 Possible)
|
||
</div>
|
||
<div className="text-[10px] font-semibold text-slate-300 bg-slate-900/60 py-1.5 rounded-lg">
|
||
High (4–5 Likely/Certain)
|
||
</div>
|
||
</div>
|
||
|
||
{/* Row 1: High Impact (4-5) */}
|
||
<div className="grid grid-cols-4 gap-2 mb-2">
|
||
<div className="text-[10px] font-semibold text-slate-300 bg-slate-900/60 rounded-lg flex items-center justify-end pr-2">
|
||
High (4–5 Major/Crit.)
|
||
</div>
|
||
|
||
{/* High Imp - Low Lik */}
|
||
{(() => {
|
||
const cRisks = matrix3x3['high-low'];
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => handleCellClick('High (4-5)', 'Low (1-2)', 8, cRisks)}
|
||
className="bg-amber-950/50 border border-amber-800/60 p-3 rounded-xl text-center hover:bg-amber-900/70 transition cursor-pointer"
|
||
>
|
||
<span className="text-[10px] font-semibold text-amber-400 block">Moderate Severity</span>
|
||
<span className="text-xs font-bold text-white mt-1 block">
|
||
{cRisks.length} {cRisks.length === 1 ? 'Risk' : 'Risks'}
|
||
</span>
|
||
</button>
|
||
);
|
||
})()}
|
||
|
||
{/* High Imp - Med Lik */}
|
||
{(() => {
|
||
const cRisks = matrix3x3['high-med'];
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => handleCellClick('High (4-5)', 'Medium (3)', 12, cRisks)}
|
||
className="bg-orange-950/60 border border-orange-700/60 p-3 rounded-xl text-center hover:bg-orange-900/80 transition cursor-pointer"
|
||
>
|
||
<span className="text-[10px] font-semibold text-orange-400 block">High Severity</span>
|
||
<span className="text-xs font-bold text-white mt-1 block">
|
||
{cRisks.length} {cRisks.length === 1 ? 'Risk' : 'Risks'}
|
||
</span>
|
||
</button>
|
||
);
|
||
})()}
|
||
|
||
{/* High Imp - High Lik */}
|
||
{(() => {
|
||
const cRisks = matrix3x3['high-high'];
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => handleCellClick('High (4-5)', 'High (4-5)', 20, cRisks)}
|
||
className="bg-rose-950/80 border border-rose-600 p-3 rounded-xl text-center hover:bg-rose-900 transition cursor-pointer shadow-md"
|
||
>
|
||
<span className="text-[10px] font-bold text-rose-300 block">Critical Severity</span>
|
||
<span className="text-xs font-extrabold text-white mt-1 block">
|
||
{cRisks.length} {cRisks.length === 1 ? 'Risk' : 'Risks'}
|
||
</span>
|
||
</button>
|
||
);
|
||
})()}
|
||
</div>
|
||
|
||
{/* Row 2: Medium Impact (3) */}
|
||
<div className="grid grid-cols-4 gap-2 mb-2">
|
||
<div className="text-[10px] font-semibold text-slate-300 bg-slate-900/60 rounded-lg flex items-center justify-end pr-2">
|
||
Medium (3 Moderate)
|
||
</div>
|
||
|
||
{/* Med Imp - Low Lik */}
|
||
{(() => {
|
||
const cRisks = matrix3x3['med-low'];
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => handleCellClick('Medium (3)', 'Low (1-2)', 3, cRisks)}
|
||
className="bg-emerald-950/40 border border-emerald-800/40 p-3 rounded-xl text-center hover:bg-emerald-900/60 transition cursor-pointer"
|
||
>
|
||
<span className="text-[10px] font-semibold text-emerald-400 block">Low Severity</span>
|
||
<span className="text-xs font-bold text-white mt-1 block">
|
||
{cRisks.length} {cRisks.length === 1 ? 'Risk' : 'Risks'}
|
||
</span>
|
||
</button>
|
||
);
|
||
})()}
|
||
|
||
{/* Med Imp - Med Lik */}
|
||
{(() => {
|
||
const cRisks = matrix3x3['med-med'];
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => handleCellClick('Medium (3)', 'Medium (3)', 9, cRisks)}
|
||
className="bg-amber-950/50 border border-amber-800/50 p-3 rounded-xl text-center hover:bg-amber-900/70 transition cursor-pointer"
|
||
>
|
||
<span className="text-[10px] font-semibold text-amber-400 block">Moderate Severity</span>
|
||
<span className="text-xs font-bold text-white mt-1 block">
|
||
{cRisks.length} {cRisks.length === 1 ? 'Risk' : 'Risks'}
|
||
</span>
|
||
</button>
|
||
);
|
||
})()}
|
||
|
||
{/* Med Imp - High Lik */}
|
||
{(() => {
|
||
const cRisks = matrix3x3['med-high'];
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => handleCellClick('Medium (3)', 'High (4-5)', 12, cRisks)}
|
||
className="bg-orange-950/60 border border-orange-700/60 p-3 rounded-xl text-center hover:bg-orange-900/80 transition cursor-pointer"
|
||
>
|
||
<span className="text-[10px] font-semibold text-orange-400 block">High Severity</span>
|
||
<span className="text-xs font-bold text-white mt-1 block">
|
||
{cRisks.length} {cRisks.length === 1 ? 'Risk' : 'Risks'}
|
||
</span>
|
||
</button>
|
||
);
|
||
})()}
|
||
</div>
|
||
|
||
{/* Row 3: Low Impact (1-2) */}
|
||
<div className="grid grid-cols-4 gap-2">
|
||
<div className="text-[10px] font-semibold text-slate-300 bg-slate-900/60 rounded-lg flex items-center justify-end pr-2">
|
||
Low (1–2 Minor)
|
||
</div>
|
||
|
||
{/* Low Imp - Low Lik */}
|
||
{(() => {
|
||
const cRisks = matrix3x3['low-low'];
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => handleCellClick('Low (1-2)', 'Low (1-2)', 1, cRisks)}
|
||
className="bg-slate-900/80 border border-slate-800 p-3 rounded-xl text-center hover:bg-slate-800/80 transition cursor-pointer"
|
||
>
|
||
<span className="text-[10px] font-semibold text-slate-400 block">Trivial Severity</span>
|
||
<span className="text-xs font-bold text-slate-300 mt-1 block">
|
||
{cRisks.length} {cRisks.length === 1 ? 'Risk' : 'Risks'}
|
||
</span>
|
||
</button>
|
||
);
|
||
})()}
|
||
|
||
{/* Low Imp - Med Lik */}
|
||
{(() => {
|
||
const cRisks = matrix3x3['low-med'];
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => handleCellClick('Low (1-2)', 'Medium (3)', 3, cRisks)}
|
||
className="bg-emerald-950/40 border border-emerald-800/40 p-3 rounded-xl text-center hover:bg-emerald-900/60 transition cursor-pointer"
|
||
>
|
||
<span className="text-[10px] font-semibold text-emerald-400 block">Low Severity</span>
|
||
<span className="text-xs font-bold text-white mt-1 block">
|
||
{cRisks.length} {cRisks.length === 1 ? 'Risk' : 'Risks'}
|
||
</span>
|
||
</button>
|
||
);
|
||
})()}
|
||
|
||
{/* Low Imp - High Lik */}
|
||
{(() => {
|
||
const cRisks = matrix3x3['low-high'];
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => handleCellClick('Low (1-2)', 'High (4-5)', 8, cRisks)}
|
||
className="bg-amber-950/50 border border-amber-800/50 p-3 rounded-xl text-center hover:bg-amber-900/70 transition cursor-pointer"
|
||
>
|
||
<span className="text-[10px] font-semibold text-amber-400 block">Moderate Severity</span>
|
||
<span className="text-xs font-bold text-white mt-1 block">
|
||
{cRisks.length} {cRisks.length === 1 ? 'Risk' : 'Risks'}
|
||
</span>
|
||
</button>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Selected Cell Inspector (Drill-down) */}
|
||
{selectedCell && selectedCell.risks.length > 0 && (
|
||
<div className="bg-[#0f1523] border border-blue-500/40 p-4 rounded-2xl shadow-xl animate-in fade-in slide-in-from-top-2 duration-200">
|
||
<div className="flex items-center justify-between pb-3 border-b border-slate-800">
|
||
<div className="flex items-center gap-2">
|
||
<div className="p-1 rounded-md bg-blue-500/20 text-blue-400">
|
||
<Info className="w-4 h-4" />
|
||
</div>
|
||
<div>
|
||
<h4 className="text-xs font-bold text-white">
|
||
Risks in Box: Impact [{selectedCell.impactLabel}] × Likelihood [{selectedCell.likelihoodLabel}]
|
||
</h4>
|
||
<p className="text-[10px] text-slate-400">
|
||
Residual Score: <strong className="text-blue-300">{selectedCell.score} / 25</strong> • Total Items: {selectedCell.risks.length}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelectedCell(null)}
|
||
className="p-1 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition"
|
||
>
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="mt-3 space-y-2 max-h-56 overflow-y-auto pr-1 divide-y divide-slate-800/60">
|
||
{selectedCell.risks.map((rk) => (
|
||
<div
|
||
key={rk.id}
|
||
onClick={() => onSelectRisk && onSelectRisk(rk)}
|
||
className={`pt-2 first:pt-0 ${onSelectRisk ? 'cursor-pointer hover:bg-slate-800/30 p-2 rounded-xl transition' : ''}`}
|
||
>
|
||
<div className="flex items-center justify-between gap-2">
|
||
<span className="font-semibold text-xs text-white line-clamp-1">{rk.name}</span>
|
||
<span className="text-[10px] font-mono text-slate-400 shrink-0">
|
||
Project #{rk.projectId} {projectMap[String(rk.projectId)] ? `(${projectMap[String(rk.projectId)]})` : ''}
|
||
</span>
|
||
</div>
|
||
{rk.description && (
|
||
<p className="text-[11px] text-slate-400 line-clamp-2 mt-0.5">{rk.description}</p>
|
||
)}
|
||
<div className="flex items-center gap-3 mt-1 text-[10px] text-slate-400">
|
||
<span>Likelihood: <strong className="text-slate-200">{rk.likelihood}</strong></span>
|
||
<span>Impact: <strong className="text-slate-200">{rk.impact}</strong></span>
|
||
<span>Owner: <strong className="text-slate-300">{getUserEmail(rk.owner)}</strong></span>
|
||
</div>
|
||
{rk.mitigationAction && (
|
||
<div className="mt-1 text-[10px] bg-slate-900/80 p-1.5 rounded-lg text-slate-300 border border-slate-800">
|
||
<strong className="text-blue-400">Mitigation: </strong>{rk.mitigationAction}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|