Add risk charts

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Jonathan
2025-05-22 15:30:02 +02:00
committed by Sacha Al Himdani
parent fc45d2f00d
commit 08bf65d919
13 changed files with 386 additions and 23 deletions

View File

@@ -29,6 +29,7 @@ import type { FieldErrors } from "react-hook-form";
import { useMutationWithToasts } from "../../../hooks/useMutationWithToasts";
import type { FormRiskDialogMutation } from "./__generated__/FormRiskDialogMutation.graphql";
import type { FormRiskDialogUpdateRiskMutation } from "./__generated__/FormRiskDialogUpdateRiskMutation.graphql";
import { getRiskImpacts, getRiskLikelihoods } from "@probo/helpers";
type Props = {
trigger?: ReactNode;
@@ -276,11 +277,11 @@ function ImpactAndLikelihood({
placeholder={__("Select impact level")}
error={errors?.[`${prefix}Impact`]?.message}
>
<Option value="1">1 - Negligible</Option>
<Option value="2">2 - Low</Option>
<Option value="3">3 - Moderate</Option>
<Option value="4">4 - Significant</Option>
<Option value="5">5 - Catastrophic</Option>
{getRiskImpacts(__).map((i) => (
<Option key={i.value} value={i.value}>
{i.value} - {i.label}
</Option>
))}
</ControlledField>
<ControlledField
control={control}
@@ -290,11 +291,11 @@ function ImpactAndLikelihood({
placeholder={__("Select likelihood level")}
error={errors?.[`${prefix}Likelihood`]?.message}
>
<Option value="1">1 - Improbable</Option>
<Option value="2">2 - Remote</Option>
<Option value="3">3 - Occasional</Option>
<Option value="4">4 - Probable</Option>
<Option value="5">5 - Frequent</Option>
{getRiskLikelihoods(__).map((l) => (
<Option key={l.value} value={l.value}>
{l.value} - {l.label}
</Option>
))}
</ControlledField>
</Card>
</div>

View File

@@ -14,6 +14,7 @@ import {
IconTrashCan,
IconPencil,
ConfirmDialog,
RisksChart,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { IconPlusLarge } from "@probo/ui";
@@ -127,6 +128,19 @@ export default function RisksPage() {
onSuccess={() => setEditedRisk(null)}
/>
)}
<div className="grid grid-cols-2 gap-4">
<RisksChart
organizationId={organizationId}
type="inherent"
risks={risks}
/>
<RisksChart
organizationId={organizationId}
type="residual"
risks={risks}
/>
</div>
<Table>
<Thead>
<Tr>

View File

@@ -0,0 +1,20 @@
export function times<T>(n: number, cb: (i: number) => T): T[] {
return Array.from({ length: n }, (_, i) => cb(i));
}
export function groupBy<T>(
arr: T[],
key: (item: T) => string,
): Record<string, T[]> {
return arr.reduce(
(acc, item) => {
const k = key(item);
if (!acc[k]) {
acc[k] = [];
}
acc[k].push(item);
return acc;
},
{} as Record<string, T[]>,
);
}

View File

@@ -1,2 +1,5 @@
export { objectKeys } from "./object";
export { sprintf } from "./string";
export { getRiskImpacts, getRiskLikelihoods } from "./risk";
export { times, groupBy } from "./array";
export { randomInt } from "./number";

View File

@@ -0,0 +1,3 @@
export function randomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}

View File

@@ -0,0 +1,51 @@
type Translator = (s: string) => string;
export function getRiskImpacts(__: Translator) {
return [
{
value: 1,
label: __("Negligible"),
},
{
value: 2,
label: __("Low"),
},
{
value: 3,
label: __("Moderate"),
},
{
value: 4,
label: __("Significant"),
},
{
value: 5,
label: __("Catastrophic"),
},
];
}
export function getRiskLikelihoods(__: Translator) {
return [
{
value: 1,
label: __("Improbable"),
},
{
value: 2,
label: __("Remote"),
},
{
value: 3,
label: __("Occasional"),
},
{
value: 4,
label: __("Probable"),
},
{
value: 5,
label: __("Frequent"),
},
];
}

View File

@@ -37,8 +37,8 @@ const button = tv({
type Props = PropsWithChildren<
{
icon?: FC<{ size: number }>;
iconAfter?: FC<{ size: number }>;
icon?: FC<{ size: number; className?: string }>;
iconAfter?: FC<{ size: number; className?: string }>;
disabled?: boolean;
onClick?: () => void;
variant?:
@@ -71,9 +71,11 @@ export const Button = (props: Props) => {
onClick={onClick}
className={button({ ...props, empty: !children })}
>
{IconComponent && <IconComponent size={16} />}
{IconComponent && <IconComponent size={16} className="flex-none" />}
{children}
{IconAfterComponent && <IconAfterComponent size={16} />}
{IconAfterComponent && (
<IconAfterComponent size={16} className="flex-none" />
)}
</Component>
);
};

View File

@@ -31,8 +31,12 @@ export function Dropdown({ children, toggle, className }: Props) {
);
}
export function DropdownSeparator() {
return <DropdownMenu.Separator className="h-[1px] bg-border-low my-2" />;
export function DropdownSeparator({ className }: { className?: string }) {
return (
<DropdownMenu.Separator
className={clsx("h-[1px] bg-border-low my-2", className)}
/>
);
}
type DropdownItemProps = PropsWithChildren<{
@@ -86,7 +90,9 @@ export function DropdownItem({
children
) : (
<>
{IconComponent && <IconComponent size={16} />}
{IconComponent && (
<IconComponent size={16} className="flex-none" />
)}
{children}
</>
)}

View File

@@ -13,7 +13,7 @@ export function Table({ children }: PropsWithChildren) {
export function Thead({ children }: PropsWithChildren) {
return (
<thead className="text-xs text-txt-tertiary font-semibold">
<thead className="text-xs text-txt-tertiary font-semibold border-border-low border-b">
{children}
</thead>
);
@@ -30,7 +30,7 @@ export function Tr({ children, to }: PropsWithChildren<{ to?: string }>) {
<TrContext value={{ to }}>
<tr
className={clsx(
"border-border-low border",
"border-border-low border-y first:border-none last:border-none",
to && "hover:bg-subtle",
)}
>
@@ -54,7 +54,7 @@ export function Td({
}: PropsWithChildren<{ noLink?: boolean }>) {
const { to } = useContext(TrContext);
if (!to || noLink) {
return <td className="first:pl-6 last:pr-6 py-3">{children}</td>;
return <td className="first:pl-6 py-3">{children}</td>;
}
return (
<td className="first:*:pl-6 *:block last:*:pr-6 py-3">

View File

@@ -10,7 +10,7 @@ type Props = PropsWithChildren<{
export function Layout({ header, sidebar, children }: Props) {
return (
<div>
<div className="text-txt-primary bg-level-0">
<header className="absolute z-2 left-0 right-0 px-4 flex items-center border-b border-border-solid h-12 bg-level-1">
<Logo className="w-12 h-5" />
<svg
@@ -27,8 +27,10 @@ export function Layout({ header, sidebar, children }: Props) {
</header>
<div className="flex h-screen">
<Sidebar>{sidebar}</Sidebar>
<main className="p-12 pt-24 max-w-[1200px] w-full mx-auto overflow-y-auto">
{children}
<main className="overflow-y-auto w-full mt-12">
<div className="py-12 px-8 max-w-[1200px] w-full mx-auto">
{children}
</div>
</main>
</div>
<Toasts />

View File

@@ -0,0 +1,31 @@
import { randomInt, times } from "@probo/helpers";
import { RisksChart } from "./RisksChart";
import type { Meta, StoryObj } from "@storybook/react";
export default {
title: "Molecules/RisksChart",
component: RisksChart,
argTypes: {},
} satisfies Meta<typeof RisksChart>;
type Story = StoryObj<typeof RisksChart>;
export const Default: Story = {
args: {
type: "inherent",
organizationId: "1",
risks: times(20, (i) => ({
id: i.toString(),
name: `Risk ${i}`,
inherentLikelihood: randomInt(1, 5),
inherentImpact: randomInt(1, 5),
residualLikelihood: randomInt(1, 5),
residualImpact: randomInt(1, 5),
})),
},
render: (args) => (
<div style={{ maxWidth: 630 }}>
<RisksChart {...args} />
</div>
),
};

View File

@@ -0,0 +1,229 @@
import clsx from "clsx";
import { Card } from "../../Atoms/Card/Card";
import { useTranslate } from "@probo/i18n";
import { getRiskImpacts, getRiskLikelihoods, groupBy } from "@probo/helpers";
import { Fragment, useMemo } from "react";
import {
Dropdown,
DropdownItem,
DropdownSeparator,
} from "../../Atoms/Dropdown/Dropdown";
import { IconChevronRight, IconFire3 } from "../../Atoms/Icons";
import { Button } from "../../Atoms/Button/Button";
import { Link } from "react-router";
type Props = {
organizationId: string;
type: "inherent" | "residual";
risks?: Risk[];
};
type Risk = {
id: string;
name: string;
inherentLikelihood: number;
inherentImpact: number;
residualLikelihood: number;
residualImpact: number;
};
const levelColors = [
{
color: "bg-txt-success",
bg: "bg-success",
},
{
color: "bg-txt-warning",
bg: "bg-warning",
},
{
color: "bg-txt-danger",
bg: "bg-danger",
},
] as const;
const getLevel = (score: number): 0 | 1 | 2 => {
if (score >= 15) {
return 2;
}
if (score > 4) {
return 1;
}
return 0;
};
const cellKey = (impact: number, likelihood: number) =>
`${impact}-${likelihood}`;
/**
* Displays a grid of risk grouped by impact & likelihood
*/
export function RisksChart({ organizationId, type, risks }: Props) {
const { __ } = useTranslate();
const legend = [__("Low"), __("Medium"), __("High")];
const impacts = getRiskImpacts(__).reverse();
const likelihoods = getRiskLikelihoods(__);
const impactField =
type === "inherent" ? "inherentImpact" : "residualImpact";
const likelihoodField =
type === "inherent" ? "inherentLikelihood" : "residualLikelihood";
const riskMap = useMemo(() => {
return groupBy(risks ?? [], (risk) =>
cellKey(risk[impactField], risk[likelihoodField]),
);
}, [organizationId, risks]);
return (
<Card padded className="text-txt-primary">
<div className="flex justify-between items-center mb-6">
<h2 className="font-semibold text-lg">
{type === "inherent"
? __("Inherent Risk")
: __("Residual Risk")}
</h2>
<div className="flex gap-3">
{legend.map((label, i) => (
<div
key={label}
className="flex items-center gap-1 text-xs"
>
<div
className={clsx(
"size-[10px] rounded-xs",
levelColors[i].color,
)}
/>
<span>{label}</span>
</div>
))}
</div>
</div>
{/* Grid */}
<div className="flex gap-6">
<div
className="text-xs font-medium flex-none text-center"
style={{ writingMode: "sideways-lr" }}
>
{__("Impact")}
</div>
<div className="grid grid-cols-[90px_1fr_1fr_1fr_1fr_1fr] gap-1 w-full">
{impacts.map((impact) => (
<Fragment key={impact.value}>
<div className="pr-2 text-right text-xs text-txt-secondary flex items-center">
{impact.label} ({impact.value})
</div>
{likelihoods.map((likelihood) => (
<RisksChartCell
key={likelihood.value}
impact={impact.value}
likelihood={likelihood.value}
organizationId={organizationId}
risks={
riskMap[
cellKey(
impact.value,
likelihood.value,
)
]
}
/>
))}
</Fragment>
))}
{/* X axis */}
<div></div>
{likelihoods.map((likelihood) => (
<div
className="text-center text-xs text-txt-secondary mt-4"
key={likelihood.value}
>
{likelihood.label} ({likelihood.value})
{likelihood.value === 3 && (
<div className="text-xs text-txt-primary font-medium flex-none text-center mt-3">
{__("Likelihood")}
</div>
)}
</div>
))}
</div>
</div>
</Card>
);
}
function RisksChartCell({
risks,
impact,
likelihood,
organizationId,
}: {
risks?: Risk[];
impact: number;
likelihood: number;
organizationId: string;
}) {
const { __ } = useTranslate();
const level = getLevel(impact * likelihood);
const baseClass =
"flex items-center justify-center aspect-square rounded-xl text-txt-invert text-sm font-semibold";
if (!risks) {
return <div className={clsx(baseClass, levelColors[level].bg)}></div>;
}
const infos = [
{ label: __("Number of risks"), value: risks.length },
{ label: __("Impact"), value: impact },
{ label: __("Likelihood"), value: likelihood },
];
return (
<Dropdown
className="text-sm w-75 p-4 space-y-1"
toggle={
<button
className={clsx(
baseClass,
levelColors[level].color,
"cursor-pointer",
)}
>
{risks.length}
</button>
}
>
{infos.map((info) => (
<div
key={info.label}
className="flex items-center justify-between gap-4"
>
<div className="text-txt-secondary">{info.label}</div>
<div>{info.value}</div>
</div>
))}
<DropdownSeparator className="my-3" />
<div className="flex items-center justify-between gap-4">
<div className="text-txt-secondary">Risk Score</div>
<div>{impact * likelihood}</div>
</div>
<DropdownSeparator className="my-3" />
<div className="text-txt-secondary mb-1">{__("Linked Risks")}</div>
{risks.map((risk) => (
<DropdownItem key={risk.id} asChild>
<Link
to={`/organizations/${organizationId}/risks/${risk.id}`}
>
<IconFire3 size={16} className="flex-none" />
{risk.name}
<IconChevronRight
size={16}
className="flex-none ml-auto"
/>
</Link>
</DropdownItem>
))}
</Dropdown>
);
}

View File

@@ -40,6 +40,7 @@ export { Dialog, DialogContent, DialogFooter } from "./Molecules/Dialog/Dialog";
export { RiskBadge } from "./Molecules/Badge/RiskBadge";
export { SeverityBadge } from "./Molecules/Badge/SeverityBadge.tsx";
export { ConfirmDialog } from "./Molecules/Dialog/ConfirmDialog.tsx";
export { RisksChart } from "./Molecules/RisksChart/RisksChart";
// Hooks
export { useToast, Toasts } from "./Atoms/Toasts/Toasts";