Clean risk matrix

Signed-off-by: gearnode <bryan@frimin.fr>
This commit is contained in:
gearnode
2025-04-14 00:28:35 -07:00
parent 3711bb4bbb
commit ce6916309d
5 changed files with 335 additions and 292 deletions

View File

@@ -1,29 +1,29 @@
"use client"; import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import * as React from "react"; import { cn } from "@/lib/utils"
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "@/lib/utils"; function Switch({
className,
const Switch = React.forwardRef< ...props
React.ElementRef<typeof SwitchPrimitives.Root>, }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> return (
>(({ className, ...props }, ref) => ( <SwitchPrimitive.Root
<SwitchPrimitives.Root data-slot="switch"
className={cn( className={cn(
"peer inline-flex h-[24px] w-[44px] shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input", "peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className className
)} )}
{...props} {...props}
ref={ref}
> >
<SwitchPrimitives.Thumb <SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn( className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0" "bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)} )}
/> />
</SwitchPrimitives.Root> </SwitchPrimitive.Root>
)); )
Switch.displayName = SwitchPrimitives.Root.displayName; }
export { Switch }; export { Switch }

View File

@@ -105,11 +105,11 @@ function EditRiskViewContent({
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [inherentLikelihood, setInherentLikelihood] = const [inherentLikelihood, setInherentLikelihood] =
useState<string>("MEDIUM"); useState<string>("OCCASIONAL");
const [inherentImpact, setInherentImpact] = useState<string>("MEDIUM"); const [inherentImpact, setInherentImpact] = useState<string>("MODERATE");
const [residualLikelihood, setResidualLikelihood] = const [residualLikelihood, setResidualLikelihood] =
useState<string>("MEDIUM"); useState<string>("OCCASIONAL");
const [residualImpact, setResidualImpact] = useState<string>("MEDIUM"); const [residualImpact, setResidualImpact] = useState<string>("MODERATE");
const [treatment, setTreatment] = useState<RiskTreatment>("MITIGATED"); const [treatment, setTreatment] = useState<RiskTreatment>("MITIGATED");
const [ownerId, setOwnerId] = useState<string | null>(null); const [ownerId, setOwnerId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@@ -119,34 +119,34 @@ function EditRiskViewContent({
// Helper function to convert float to likelihood string // Helper function to convert float to likelihood string
const floatToLikelihood = (value: number): string => { const floatToLikelihood = (value: number): string => {
if (value <= 0.2) return "VERY_LOW"; if (value <= 0.2) return "IMPROBABLE";
if (value <= 0.4) return "LOW"; if (value <= 0.4) return "REMOTE";
if (value <= 0.6) return "MEDIUM"; if (value <= 0.6) return "OCCASIONAL";
if (value <= 0.8) return "HIGH"; if (value <= 0.8) return "PROBABLE";
return "VERY_HIGH"; return "FREQUENT";
}; };
// Helper function to convert float to impact string // Helper function to convert float to impact string
const floatToImpact = (value: number): string => { const floatToImpact = (value: number): string => {
if (value <= 0.2) return "VERY_LOW"; if (value <= 0.2) return "NEGLIGIBLE";
if (value <= 0.4) return "LOW"; if (value <= 0.4) return "LOW";
if (value <= 0.6) return "MEDIUM"; if (value <= 0.6) return "MODERATE";
if (value <= 0.8) return "HIGH"; if (value <= 0.8) return "SIGNIFICANT";
return "VERY_HIGH"; return "CATASTROPHIC";
}; };
// Map string values to float values // Map string values to float values
const likelihoodToFloat = (value: string): number => { const likelihoodToFloat = (value: string): number => {
switch (value) { switch (value) {
case "VERY_LOW": case "IMPROBABLE":
return 0.1; return 0.1;
case "LOW": case "REMOTE":
return 0.3; return 0.3;
case "MEDIUM": case "OCCASIONAL":
return 0.5; return 0.5;
case "HIGH": case "PROBABLE":
return 0.7; return 0.7;
case "VERY_HIGH": case "FREQUENT":
return 0.9; return 0.9;
default: default:
return 0.5; return 0.5;
@@ -155,15 +155,15 @@ function EditRiskViewContent({
const impactToFloat = (value: string): number => { const impactToFloat = (value: string): number => {
switch (value) { switch (value) {
case "VERY_LOW": case "NEGLIGIBLE":
return 0.1; return 0.1;
case "LOW": case "LOW":
return 0.3; return 0.3;
case "MEDIUM": case "MODERATE":
return 0.5; return 0.5;
case "HIGH": case "SIGNIFICANT":
return 0.7; return 0.7;
case "VERY_HIGH": case "CATASTROPHIC":
return 0.9; return 0.9;
default: default:
return 0.5; return 0.5;
@@ -308,15 +308,15 @@ function EditRiskViewContent({
value={inherentLikelihood} value={inherentLikelihood}
onValueChange={setInherentLikelihood} onValueChange={setInherentLikelihood}
> >
<SelectTrigger id="inherentLikelihood"> <SelectTrigger className="w-full">
<SelectValue placeholder="Select likelihood" /> <SelectValue placeholder="Select likelihood" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="max-h-[300px] overflow-y-auto"> <SelectContent>
<SelectItem value="VERY_LOW">Very Low</SelectItem> <SelectItem value="IMPROBABLE">Improbable</SelectItem>
<SelectItem value="LOW">Low</SelectItem> <SelectItem value="REMOTE">Remote</SelectItem>
<SelectItem value="MEDIUM">Medium</SelectItem> <SelectItem value="OCCASIONAL">Occasional</SelectItem>
<SelectItem value="HIGH">High</SelectItem> <SelectItem value="PROBABLE">Probable</SelectItem>
<SelectItem value="VERY_HIGH">Very High</SelectItem> <SelectItem value="FREQUENT">Frequent</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -327,15 +327,15 @@ function EditRiskViewContent({
value={inherentImpact} value={inherentImpact}
onValueChange={setInherentImpact} onValueChange={setInherentImpact}
> >
<SelectTrigger id="inherentImpact"> <SelectTrigger className="w-full">
<SelectValue placeholder="Select impact" /> <SelectValue placeholder="Select impact" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="max-h-[300px] overflow-y-auto"> <SelectContent>
<SelectItem value="VERY_LOW">Very Low</SelectItem> <SelectItem value="NEGLIGIBLE">Negligible</SelectItem>
<SelectItem value="LOW">Low</SelectItem> <SelectItem value="LOW">Low</SelectItem>
<SelectItem value="MEDIUM">Medium</SelectItem> <SelectItem value="MODERATE">Moderate</SelectItem>
<SelectItem value="HIGH">High</SelectItem> <SelectItem value="SIGNIFICANT">Significant</SelectItem>
<SelectItem value="VERY_HIGH">Very High</SelectItem> <SelectItem value="CATASTROPHIC">Catastrophic</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -382,15 +382,15 @@ function EditRiskViewContent({
value={residualLikelihood} value={residualLikelihood}
onValueChange={setResidualLikelihood} onValueChange={setResidualLikelihood}
> >
<SelectTrigger id="residualLikelihood"> <SelectTrigger className="w-full">
<SelectValue placeholder="Select residual likelihood" /> <SelectValue placeholder="Select likelihood" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="max-h-[300px] overflow-y-auto"> <SelectContent>
<SelectItem value="VERY_LOW">Very Low</SelectItem> <SelectItem value="IMPROBABLE">Improbable</SelectItem>
<SelectItem value="LOW">Low</SelectItem> <SelectItem value="REMOTE">Remote</SelectItem>
<SelectItem value="MEDIUM">Medium</SelectItem> <SelectItem value="OCCASIONAL">Occasional</SelectItem>
<SelectItem value="HIGH">High</SelectItem> <SelectItem value="PROBABLE">Probable</SelectItem>
<SelectItem value="VERY_HIGH">Very High</SelectItem> <SelectItem value="FREQUENT">Frequent</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -401,15 +401,15 @@ function EditRiskViewContent({
value={residualImpact} value={residualImpact}
onValueChange={setResidualImpact} onValueChange={setResidualImpact}
> >
<SelectTrigger id="residualImpact"> <SelectTrigger className="w-full">
<SelectValue placeholder="Select residual impact" /> <SelectValue placeholder="Select impact" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="max-h-[300px] overflow-y-auto"> <SelectContent>
<SelectItem value="VERY_LOW">Very Low</SelectItem> <SelectItem value="NEGLIGIBLE">Negligible</SelectItem>
<SelectItem value="LOW">Low</SelectItem> <SelectItem value="LOW">Low</SelectItem>
<SelectItem value="MEDIUM">Medium</SelectItem> <SelectItem value="MODERATE">Moderate</SelectItem>
<SelectItem value="HIGH">High</SelectItem> <SelectItem value="SIGNIFICANT">Significant</SelectItem>
<SelectItem value="VERY_HIGH">Very High</SelectItem> <SelectItem value="CATASTROPHIC">Catastrophic</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>

View File

@@ -22,7 +22,7 @@ import { RiskViewSkeleton } from "./ListRiskPage";
import { ListRiskViewPaginationQuery } from "./__generated__/ListRiskViewPaginationQuery.graphql"; import { ListRiskViewPaginationQuery } from "./__generated__/ListRiskViewPaginationQuery.graphql";
import { ListRiskView_risks$key } from "./__generated__/ListRiskView_risks.graphql"; import { ListRiskView_risks$key } from "./__generated__/ListRiskView_risks.graphql";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Link } from "react-router"; import { Link } from "react-router";
import { Plus, Trash2, Edit } from "lucide-react"; import { Plus, Trash2, Edit } from "lucide-react";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
@@ -110,10 +110,19 @@ const deleteRiskMutation = graphql`
`; `;
// Helper function to convert float values to percentage // Helper function to convert float values to percentage
// This is kept for potential future use, like tooltips
const floatToPercentage = (value: number): string => { const floatToPercentage = (value: number): string => {
return `${Math.round(value * 100)}%`; return `${Math.round(value * 100)}%`;
}; };
// Helper function to convert risk score to severity label
const riskScoreToSeverity = (score: number): string => {
if (score >= 0.75) return "Catastrophic";
if (score >= 0.5) return "Critical";
if (score >= 0.25) return "Marginal";
return "Negligible";
};
// Helper function to format treatment value // Helper function to format treatment value
const formatTreatment = (treatment: string): string => { const formatTreatment = (treatment: string): string => {
const treatmentMap: Record<string, string> = { const treatmentMap: Record<string, string> = {
@@ -182,20 +191,16 @@ function LoadBelowButton({
// Define colors for risk matrix cells // Define colors for risk matrix cells
const riskMatrixColors = { const riskMatrixColors = {
lowest: "bg-green-500 text-white", low: "bg-green-500 text-white",
low: "bg-lime-300 text-black",
medium: "bg-yellow-300 text-black", medium: "bg-yellow-300 text-black",
high: "bg-amber-400 text-white", high: "bg-red-500 text-white",
highest: "bg-red-500 text-white",
}; };
// Empty cell variants (lighter colors) // Empty cell variants (lighter colors)
const emptyRiskMatrixColors = { const emptyRiskMatrixColors = {
lowest: "bg-green-50 text-black", low: "bg-green-50 text-black",
low: "bg-lime-50 text-black",
medium: "bg-yellow-50 text-black", medium: "bg-yellow-50 text-black",
high: "bg-amber-50 text-black", high: "bg-red-50 text-black",
highest: "bg-red-50 text-black",
}; };
// Risk Matrix Component // Risk Matrix Component
@@ -213,31 +218,44 @@ function RiskMatrix({
}>; }>;
isResidual?: boolean; isResidual?: boolean;
}): JSX.Element { }): JSX.Element {
// Define likelihood and impact ranges for the 5x5 matrix // Define impact ranges for the vertical axis (rows)
const likelihoodRanges: [number, number][] = [
[0.8, 1], // Highest likelihood
[0.6, 0.8], // High likelihood
[0.4, 0.6], // Medium likelihood
[0.2, 0.4], // Low likelihood
[0, 0.2], // Lowest likelihood
];
const impactRanges: [number, number][] = [ const impactRanges: [number, number][] = [
[0, 0.2], // Lowest impact [0.8, 1], // Highest impact - top row
[0.2, 0.4], // Low impact
[0.4, 0.6], // Medium impact
[0.6, 0.8], // High impact [0.6, 0.8], // High impact
[0.8, 1], // Highest impact [0.4, 0.6], // Medium impact
[0.2, 0.4], // Low impact
[0, 0.2], // Lowest impact - bottom row
]; ];
const likelihoodLabels = ["Very High", "High", "Medium", "Low", "Very Low"]; // Define likelihood ranges for the horizontal axis (columns)
const likelihoodRanges: [number, number][] = [
[0, 0.2], // Lowest likelihood - leftmost column
[0.2, 0.4], // Low likelihood
[0.4, 0.6], // Medium likelihood
[0.6, 0.8], // High likelihood
[0.8, 1], // Highest likelihood - rightmost column
];
const impactLabels = ["Very Low", "Low", "Medium", "High", "Very High"]; const impactLabels = [
"Catastrophic",
"Significant",
"Moderate",
"Low",
"Negligible",
];
const likelihoodLabels = [
"Improbable",
"Remote",
"Occasional",
"Probable",
"Frequent",
];
// Function to get cell content with risks that fall in this cell // Function to get cell content with risks that fall in this cell
const getCellContent = ( const getCellContent = (
likelihoodRange: [number, number], impactRange: [number, number],
impactRange: [number, number] likelihoodRange: [number, number]
) => { ) => {
return risks.filter((risk) => { return risks.filter((risk) => {
const likelihood = isResidual const likelihood = isResidual
@@ -257,48 +275,41 @@ function RiskMatrix({
}; };
// Helper to determine cell color based on position in matrix // Helper to determine cell color based on position in matrix
// New matrix has rows indexed from top to bottom (0 = highest likelihood, 4 = lowest likelihood) // Matrix has rows indexed from top to bottom (0 = highest impact, 4 = lowest impact)
// and columns indexed from left to right (0 = lowest impact, 4 = highest impact) // and columns indexed from left to right (0 = lowest likelihood, 4 = highest likelihood)
const getCellColor = (row: number, col: number, isEmpty: boolean): string => { const getCellColor = (row: number, col: number, isEmpty: boolean): string => {
const colorSet = isEmpty ? emptyRiskMatrixColors : riskMatrixColors; const colorSet = isEmpty ? emptyRiskMatrixColors : riskMatrixColors;
// Top row (highest likelihood) // Top row (highest impact - Catastrophic)
if (row === 0) { if (row === 0) {
if (col === 0) return colorSet.lowest; if (col <= 1) return colorSet.medium; // Yellow for first two cells
if (col === 1) return colorSet.high; return colorSet.high; // Red for the rest
return colorSet.highest;
} }
// Second row // Second row (Significant impact)
if (row === 1) { if (row === 1) {
if (col === 0) return colorSet.lowest; if (col === 0) return colorSet.low; // Green for first cell
if (col === 1) return colorSet.medium; if (col <= 2) return colorSet.medium; // Yellow for next two
if (col === 2) return colorSet.high; return colorSet.high; // Red for the rest
return colorSet.highest;
} }
// Middle row // Middle row (Moderate impact)
if (row === 2) { if (row === 2) {
if (col === 0) return colorSet.lowest; if (col === 0) return colorSet.low; // Green for first cell
if (col === 1) return colorSet.low; if (col <= 3) return colorSet.medium; // Yellow for next three
if (col === 2) return colorSet.medium; return colorSet.high; // Red for last cell
if (col === 3) return colorSet.high;
return colorSet.highest;
} }
// Fourth row // Fourth row (Low impact)
if (row === 3) { if (row === 3) {
if (col === 0) return colorSet.lowest; if (col <= 1) return colorSet.low; // Green for first two
if (col === 1) return colorSet.low; return colorSet.medium; // Yellow for the rest
if (col === 2 || col === 3) return colorSet.medium;
return colorSet.high;
} }
// Bottom row (lowest likelihood) // Bottom row (lowest impact - Negligible)
if (row === 4) { if (row === 4) {
if (col <= 1) return colorSet.lowest; if (col <= 3) return colorSet.low; // Green for first four
if (col <= 3) return colorSet.low; return colorSet.medium; // Yellow for last cell
return colorSet.medium;
} }
return "bg-gray-100"; return "bg-gray-100";
@@ -308,15 +319,15 @@ function RiskMatrix({
const RiskCell = ({ const RiskCell = ({
rowIndex, rowIndex,
colIndex, colIndex,
likelihoodRange,
impactRange, impactRange,
likelihoodRange,
}: { }: {
rowIndex: number; rowIndex: number;
colIndex: number; colIndex: number;
likelihoodRange: [number, number];
impactRange: [number, number]; impactRange: [number, number];
likelihoodRange: [number, number];
}) => { }) => {
const cellRisks = getCellContent(likelihoodRange, impactRange); const cellRisks = getCellContent(impactRange, likelihoodRange);
const isEmpty = cellRisks.length === 0; const isEmpty = cellRisks.length === 0;
const cellColor = getCellColor(rowIndex, colIndex, isEmpty); const cellColor = getCellColor(rowIndex, colIndex, isEmpty);
@@ -360,43 +371,61 @@ function RiskMatrix({
}; };
return ( return (
<div className="space-y-2">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<div className="flex flex-col"> <div className="flex flex-col">
<div className="text-xs font-medium text-center mb-1">CONSEQUENCE</div> <div className="flex">
<table className="w-full border-collapse table-fixed"> <div
<thead> className="text-xs font-semibold flex items-center justify-center mr-2"
<tr> style={{
<th className="p-1 text-center border w-14"></th> writingMode: "vertical-rl",
{impactLabels.map((label, index) => ( transform: "rotate(180deg)",
<th alignSelf: "center",
key={index} }}
className="p-1 text-xs text-center border font-medium w-14"
> >
{label} Impact
</th> </div>
))} <table className="w-full border-collapse table-fixed">
</tr>
</thead>
<tbody> <tbody>
{likelihoodRanges.map((likelihoodRange, rowIndex) => ( {impactRanges.map((impactRange, rowIndex) => (
<tr key={rowIndex}> <tr key={rowIndex}>
<th className="p-1 text-xs text-center border font-medium w-14 h-14"> <th className="p-1 text-xs text-center border-0 font-medium w-14 h-14">
{likelihoodLabels[rowIndex]} {impactLabels[rowIndex]}
</th> </th>
{impactRanges.map((impactRange, colIndex) => ( {likelihoodRanges.map((likelihoodRange, colIndex) => (
<RiskCell <RiskCell
key={colIndex} key={colIndex}
rowIndex={rowIndex} rowIndex={rowIndex}
colIndex={colIndex} colIndex={colIndex}
likelihoodRange={likelihoodRange}
impactRange={impactRange} impactRange={impactRange}
likelihoodRange={likelihoodRange}
/> />
))} ))}
</tr> </tr>
))} ))}
</tbody> </tbody>
<tfoot>
<tr>
<th className="p-1 text-center border-0 w-14"></th>
{likelihoodLabels.map((label, index) => (
<th
key={index}
className="p-1 text-xs text-center border-t font-medium w-14"
>
{label}
</th>
))}
</tr>
</tfoot>
</table> </table>
<div className="text-xs font-medium ml-2 mt-1">LIKELIHOOD</div> </div>
<div className="flex">
<div style={{ width: "3.5rem" }}></div>
<div className="text-center text-xs font-semibold mt-2 flex-1">
Likelihood
</div>
</div>
</div>
</div> </div>
</div> </div>
); );
@@ -519,41 +548,43 @@ function ListRiskViewContent({
{/* Combined Risk Matrix with Toggle */} {/* Combined Risk Matrix with Toggle */}
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardContent className="pt-6">
<CardTitle> <div className="flex flex-col w-full">
{showResidualRisk <div className="flex justify-between items-center mb-4">
? "Residual Risk Matrix" <h3 className="text-base font-semibold">
: "Initial Risk Matrix"} {showResidualRisk ? "Residual risk" : "Current risk"}
</CardTitle> </h3>
<div className="flex items-center space-x-4 border-2 border-gray-300 rounded-lg p-3 bg-gray-50 shadow-md"> <div className="flex items-center space-x-4">
<Label <Label
htmlFor="risk-toggle" htmlFor="risk-toggle"
className={`text-sm font-semibold cursor-pointer ${ className={`text-sm font-semibold cursor-pointer ${
!showResidualRisk ? "text-primary" : "text-muted-foreground" !showResidualRisk
? "text-primary"
: "text-muted-foreground"
}`} }`}
> >
Initial Initial
</Label> </Label>
<div className="relative">
<Switch <Switch
id="risk-toggle" id="risk-toggle"
checked={showResidualRisk} checked={showResidualRisk}
onCheckedChange={setShowResidualRisk} onCheckedChange={setShowResidualRisk}
className="border-2 border-gray-400 data-[state=checked]:border-primary" className="bg-white border-2 border-gray-300 data-[state=checked]:bg-white data-[state=checked]:border-gray-300 [&_span]:bg-gray-300"
/> />
</div>
<Label <Label
htmlFor="risk-toggle" htmlFor="risk-toggle"
className={`text-sm font-semibold cursor-pointer ${ className={`text-sm font-semibold cursor-pointer ${
showResidualRisk ? "text-primary" : "text-muted-foreground" showResidualRisk
? "text-primary"
: "text-muted-foreground"
}`} }`}
> >
Residual Residual
</Label> </Label>
</div> </div>
</CardHeader> </div>
<CardContent>
<RiskMatrix risks={risks} isResidual={showResidualRisk} /> <RiskMatrix risks={risks} isResidual={showResidualRisk} />
</div>
</CardContent> </CardContent>
</Card> </Card>
@@ -617,7 +648,7 @@ function ListRiskViewContent({
to={`/organizations/${organizationId}/risks/${risk.id}`} to={`/organizations/${organizationId}/risks/${risk.id}`}
className="block p-4 h-full w-full" className="block p-4 h-full w-full"
> >
{floatToPercentage( {riskScoreToSeverity(
risk.inherentLikelihood * risk.inherentImpact risk.inherentLikelihood * risk.inherentImpact
)} )}
</Link> </Link>
@@ -628,7 +659,7 @@ function ListRiskViewContent({
className="block p-4 h-full w-full" className="block p-4 h-full w-full"
> >
{risk.residualLikelihood && risk.residualImpact {risk.residualLikelihood && risk.residualImpact
? floatToPercentage( ? riskScoreToSeverity(
risk.residualLikelihood * risk.residualImpact risk.residualLikelihood * risk.residualImpact
) )
: "Not set"} : "Not set"}
@@ -671,21 +702,6 @@ function ListRiskViewContent({
</CardContent> </CardContent>
</Card> </Card>
<LoadBelowButton
isLoading={isLoadingNext}
hasMore={hasNext}
onLoadMore={() => {
startTransition(() => {
setSearchParams((prev) => {
prev.set("after", pageInfo?.endCursor || "");
prev.delete("before");
return prev;
});
loadNext(defaultPageSize);
});
}}
/>
{/* Delete Confirmation Dialog */} {/* Delete Confirmation Dialog */}
<Dialog <Dialog
open={!!riskToDelete} open={!!riskToDelete}
@@ -717,6 +733,21 @@ function ListRiskViewContent({
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<LoadBelowButton
isLoading={isLoadingNext}
hasMore={hasNext}
onLoadMore={() => {
startTransition(() => {
setSearchParams((prev) => {
prev.set("after", pageInfo?.endCursor || "");
prev.delete("before");
return prev;
});
loadNext(defaultPageSize);
});
}}
/>
</div> </div>
</PageTemplate> </PageTemplate>
); );
@@ -740,7 +771,7 @@ export default function ListRiskView() {
last: before ? defaultPageSize : undefined, last: before ? defaultPageSize : undefined,
before: before || undefined, before: before || undefined,
}); });
}, [loadQuery, organizationId]); }, [loadQuery, organizationId, searchParams]);
if (!queryRef) { if (!queryRef) {
return <RiskViewSkeleton />; return <RiskViewSkeleton />;

View File

@@ -33,7 +33,6 @@ import PeopleSelector from "@/components/PeopleSelector";
import { User } from "lucide-react"; import { User } from "lucide-react";
import { Suspense } from "react"; import { Suspense } from "react";
import type { NewRiskViewQuery } from "./__generated__/NewRiskViewQuery.graphql"; import type { NewRiskViewQuery } from "./__generated__/NewRiskViewQuery.graphql";
import type { NewRiskViewCreateRiskMutation } from "./__generated__/NewRiskViewCreateRiskMutation.graphql";
interface RiskTemplate { interface RiskTemplate {
name: string; name: string;
@@ -89,11 +88,11 @@ function NewRiskForm({
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [inherentLikelihood, setinherentLikelihood] = const [inherentLikelihood, setinherentLikelihood] =
useState<string>("MEDIUM"); useState<string>("OCCASIONAL");
const [inherentImpact, setinherentImpact] = useState<string>("MEDIUM"); const [inherentImpact, setinherentImpact] = useState<string>("MODERATE");
const [residualLikelihood, setResidualLikelihood] = const [residualLikelihood, setResidualLikelihood] =
useState<string>("MEDIUM"); useState<string>("OCCASIONAL");
const [residualImpact, setResidualImpact] = useState<string>("MEDIUM"); const [residualImpact, setResidualImpact] = useState<string>("MODERATE");
const [treatment, setTreatment] = useState<string>("MITIGATED"); const [treatment, setTreatment] = useState<string>("MITIGATED");
const [ownerId, setOwnerId] = useState<string | null>(null); const [ownerId, setOwnerId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@@ -125,15 +124,15 @@ function NewRiskForm({
// Map string values to float values // Map string values to float values
const likelihoodToFloat = (value: string): number => { const likelihoodToFloat = (value: string): number => {
switch (value) { switch (value) {
case "VERY_LOW": case "IMPROBABLE":
return 0.1; return 0.1;
case "LOW": case "REMOTE":
return 0.3; return 0.3;
case "MEDIUM": case "OCCASIONAL":
return 0.5; return 0.5;
case "HIGH": case "PROBABLE":
return 0.7; return 0.7;
case "VERY_HIGH": case "FREQUENT":
return 0.9; return 0.9;
default: default:
return 0.5; return 0.5;
@@ -142,15 +141,15 @@ function NewRiskForm({
const impactToFloat = (value: string): number => { const impactToFloat = (value: string): number => {
switch (value) { switch (value) {
case "VERY_LOW": case "NEGLIGIBLE":
return 0.1; return 0.1;
case "LOW": case "LOW":
return 0.3; return 0.3;
case "MEDIUM": case "MODERATE":
return 0.5; return 0.5;
case "HIGH": case "SIGNIFICANT":
return 0.7; return 0.7;
case "VERY_HIGH": case "CATASTROPHIC":
return 0.9; return 0.9;
default: default:
return 0.5; return 0.5;
@@ -165,10 +164,10 @@ function NewRiskForm({
// Clear form if "Select a template" is chosen // Clear form if "Select a template" is chosen
setName(""); setName("");
setDescription(""); setDescription("");
setinherentLikelihood("MEDIUM"); setinherentLikelihood("OCCASIONAL");
setinherentImpact("MEDIUM"); setinherentImpact("MODERATE");
setResidualLikelihood("MEDIUM"); setResidualLikelihood("OCCASIONAL");
setResidualImpact("MEDIUM"); setResidualImpact("MODERATE");
setTreatment("MITIGATED"); setTreatment("MITIGATED");
return; return;
} }
@@ -197,20 +196,20 @@ function NewRiskForm({
// Helper function to convert float likelihood to string // Helper function to convert float likelihood to string
const floatTolikelihood = (value: number): string => { const floatTolikelihood = (value: number): string => {
if (value <= 0.2) return "VERY_LOW"; if (value <= 0.2) return "IMPROBABLE";
if (value <= 0.4) return "LOW"; if (value <= 0.4) return "REMOTE";
if (value <= 0.6) return "MEDIUM"; if (value <= 0.6) return "OCCASIONAL";
if (value <= 0.8) return "HIGH"; if (value <= 0.8) return "PROBABLE";
return "VERY_HIGH"; return "FREQUENT";
}; };
// Helper function to convert float impact to string // Helper function to convert float impact to string
const floatToImpact = (value: number): string => { const floatToImpact = (value: number): string => {
if (value <= 0.2) return "VERY_LOW"; if (value <= 0.2) return "NEGLIGIBLE";
if (value <= 0.4) return "LOW"; if (value <= 0.4) return "LOW";
if (value <= 0.6) return "MEDIUM"; if (value <= 0.6) return "MODERATE";
if (value <= 0.8) return "HIGH"; if (value <= 0.8) return "SIGNIFICANT";
return "VERY_HIGH"; return "CATASTROPHIC";
}; };
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
@@ -361,15 +360,15 @@ function NewRiskForm({
value={inherentLikelihood} value={inherentLikelihood}
onValueChange={setinherentLikelihood} onValueChange={setinherentLikelihood}
> >
<SelectTrigger id="inherentLikelihood"> <SelectTrigger className="w-full">
<SelectValue placeholder="Select inherentLikelihood" /> <SelectValue placeholder="Select likelihood" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="max-h-[300px] overflow-y-auto"> <SelectContent>
<SelectItem value="VERY_LOW">Very Low</SelectItem> <SelectItem value="IMPROBABLE">Improbable</SelectItem>
<SelectItem value="LOW">Low</SelectItem> <SelectItem value="REMOTE">Remote</SelectItem>
<SelectItem value="MEDIUM">Medium</SelectItem> <SelectItem value="OCCASIONAL">Occasional</SelectItem>
<SelectItem value="HIGH">High</SelectItem> <SelectItem value="PROBABLE">Probable</SelectItem>
<SelectItem value="VERY_HIGH">Very High</SelectItem> <SelectItem value="FREQUENT">Frequent</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -380,15 +379,15 @@ function NewRiskForm({
value={inherentImpact} value={inherentImpact}
onValueChange={setinherentImpact} onValueChange={setinherentImpact}
> >
<SelectTrigger id="inherentImpact"> <SelectTrigger className="w-full">
<SelectValue placeholder="Select inherentImpact" /> <SelectValue placeholder="Select impact" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="max-h-[300px] overflow-y-auto"> <SelectContent>
<SelectItem value="VERY_LOW">Very Low</SelectItem> <SelectItem value="NEGLIGIBLE">Negligible</SelectItem>
<SelectItem value="LOW">Low</SelectItem> <SelectItem value="LOW">Low</SelectItem>
<SelectItem value="MEDIUM">Medium</SelectItem> <SelectItem value="MODERATE">Moderate</SelectItem>
<SelectItem value="HIGH">High</SelectItem> <SelectItem value="SIGNIFICANT">Significant</SelectItem>
<SelectItem value="VERY_HIGH">Very High</SelectItem> <SelectItem value="CATASTROPHIC">Catastrophic</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -429,15 +428,15 @@ function NewRiskForm({
value={residualLikelihood} value={residualLikelihood}
onValueChange={setResidualLikelihood} onValueChange={setResidualLikelihood}
> >
<SelectTrigger id="residualLikelihood"> <SelectTrigger className="w-full">
<SelectValue placeholder="Select residual likelihood" /> <SelectValue placeholder="Select likelihood" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="max-h-[300px] overflow-y-auto"> <SelectContent>
<SelectItem value="VERY_LOW">Very Low</SelectItem> <SelectItem value="IMPROBABLE">Improbable</SelectItem>
<SelectItem value="LOW">Low</SelectItem> <SelectItem value="REMOTE">Remote</SelectItem>
<SelectItem value="MEDIUM">Medium</SelectItem> <SelectItem value="OCCASIONAL">Occasional</SelectItem>
<SelectItem value="HIGH">High</SelectItem> <SelectItem value="PROBABLE">Probable</SelectItem>
<SelectItem value="VERY_HIGH">Very High</SelectItem> <SelectItem value="FREQUENT">Frequent</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -448,15 +447,15 @@ function NewRiskForm({
value={residualImpact} value={residualImpact}
onValueChange={setResidualImpact} onValueChange={setResidualImpact}
> >
<SelectTrigger id="residualImpact"> <SelectTrigger className="w-full">
<SelectValue placeholder="Select residual impact" /> <SelectValue placeholder="Select impact" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="max-h-[300px] overflow-y-auto"> <SelectContent>
<SelectItem value="VERY_LOW">Very Low</SelectItem> <SelectItem value="NEGLIGIBLE">Negligible</SelectItem>
<SelectItem value="LOW">Low</SelectItem> <SelectItem value="LOW">Low</SelectItem>
<SelectItem value="MEDIUM">Medium</SelectItem> <SelectItem value="MODERATE">Moderate</SelectItem>
<SelectItem value="HIGH">High</SelectItem> <SelectItem value="SIGNIFICANT">Significant</SelectItem>
<SelectItem value="VERY_HIGH">Very High</SelectItem> <SelectItem value="CATASTROPHIC">Catastrophic</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>

View File

@@ -210,10 +210,30 @@ const deleteRiskPolicyMappingMutation = graphql`
function getRiskSeverity(likelihood: number, impact: number) { function getRiskSeverity(likelihood: number, impact: number) {
const score = likelihood * impact; const score = likelihood * impact;
if (score >= 0.75) return { level: "High", class: "bg-red-100 text-red-800" }; if (score >= 0.75)
if (score >= 0.4) return { level: "Catastrophic", class: "bg-red-100 text-red-800" };
return { level: "Medium", class: "bg-yellow-100 text-yellow-800" }; if (score >= 0.5)
return { level: "Low", class: "bg-green-100 text-green-800" }; return { level: "Critical", class: "bg-orange-100 text-orange-800" };
if (score >= 0.25)
return { level: "Marginal", class: "bg-yellow-100 text-yellow-800" };
return { level: "Negligible", class: "bg-green-100 text-green-800" };
}
// Add helper functions to convert numerical values to labels
function getLikelihoodLabel(likelihood: number): string {
if (likelihood >= 0.8) return "Very High";
if (likelihood >= 0.6) return "High";
if (likelihood >= 0.4) return "Medium";
if (likelihood >= 0.2) return "Low";
return "Very Low";
}
function getImpactLabel(impact: number): string {
if (impact >= 0.8) return "Critical";
if (impact >= 0.6) return "Major";
if (impact >= 0.4) return "Moderate";
if (impact >= 0.2) return "Minor";
return "Insignificant";
} }
function ShowRiskViewContent({ function ShowRiskViewContent({
@@ -755,13 +775,13 @@ function ShowRiskViewContent({
Likelihood Likelihood
</h3> </h3>
<p className="mt-1 text-lg"> <p className="mt-1 text-lg">
{(risk.inherentLikelihood! * 100).toFixed(0)}% {getLikelihoodLabel(risk.inherentLikelihood!)}
</p> </p>
</div> </div>
<div> <div>
<h3 className="text-sm font-medium text-secondary">Impact</h3> <h3 className="text-sm font-medium text-secondary">Impact</h3>
<p className="mt-1 text-lg"> <p className="mt-1 text-lg">
{(risk.inherentImpact! * 100).toFixed(0)}% {getImpactLabel(risk.inherentImpact!)}
</p> </p>
</div> </div>
<div> <div>
@@ -830,16 +850,12 @@ function ShowRiskViewContent({
Residual Likelihood Residual Likelihood
</h3> </h3>
<p className="mt-1 text-lg"> <p className="mt-1 text-lg">
{(risk.residualLikelihood! * 100).toFixed(0)}% {getLikelihoodLabel(risk.residualLikelihood!)}
</p> </p>
<p className="text-xs text-secondary mt-1"> <p className="text-xs text-secondary mt-1">
{risk.residualLikelihood !== risk.inherentLikelihood {risk.residualLikelihood !== risk.inherentLikelihood
? `Reduced by ${( ? `Mitigated`
(risk.inherentLikelihood! - : "No mitigation"}
risk.residualLikelihood!) *
100
).toFixed(0)}%`
: "No reduction"}
</p> </p>
</div> </div>
<div> <div>
@@ -847,15 +863,12 @@ function ShowRiskViewContent({
Residual Impact Residual Impact
</h3> </h3>
<p className="mt-1 text-lg"> <p className="mt-1 text-lg">
{(risk.residualImpact! * 100).toFixed(0)}% {getImpactLabel(risk.residualImpact!)}
</p> </p>
<p className="text-xs text-secondary mt-1"> <p className="text-xs text-secondary mt-1">
{risk.residualImpact !== risk.inherentImpact {risk.residualImpact !== risk.inherentImpact
? `Reduced by ${( ? `Mitigated`
(risk.inherentImpact! - risk.residualImpact!) * : "No mitigation"}
100
).toFixed(0)}%`
: "No reduction"}
</p> </p>
</div> </div>
<div> <div>