UI recipesUI recipes
Risk register
The organization's risks in a data grid with their residual band, status and next review date, the highest residual score first.
It loads its data on the server through the block's client; the risks block must be installed (npx better-supabase add risks). Its page on bettersupabase.com is /recipes/risk-register.
Web, with shadcn components:
npx shadcn add alert badge button empty skeleton @reui/data-grid @reui/frame"use client";
import type { CellContext, ColumnDef } from "@tanstack/react-table";
import type { RiskStatus } from "better-supabase/blocks/risks";
import { useTable } from "@tanstack/react-table";
import { ShieldAlertIcon, TriangleAlertIcon } from "lucide-react";
import { useExtracted, useFormatter } from "next-intl";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid";
import {
Alert,
AlertAction,
AlertDescription,
AlertTitle,
} from "@/components/reui/alert";
import { Badge } from "@/components/reui/badge";
import {
DataGrid,
DataGridContainer,
dataGridFeatures,
} from "@/components/reui/data-grid/data-grid";
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area";
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table";
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame";
import { Button } from "@/components/ui/button";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import { Skeleton } from "@/components/ui/skeleton";
import type { BandTone, Loaded, RiskRow } from "../risks-queries";
const TONE_VARIANT = {
low: "success-light",
medium: "warning-light",
high: "destructive-light",
critical: "destructive",
} as const satisfies Record<BandTone, string>;
type RiskCell = CellContext<DataGridFeatures, RiskRow>;
function NumberCell({ row }: RiskCell) {
return (
<span className="font-mono text-xs text-muted-foreground">
{row.original.number}
</span>
);
}
function TitleCell({ row }: RiskCell) {
return <span className="font-medium">{row.original.title}</span>;
}
function BandCell({ row }: RiskCell) {
const t = useExtracted("risks");
const { band, tone, score } = row.original;
return band === null || tone === null ? (
<span className="text-muted-foreground">{t("Not assessed")}</span>
) : (
<Badge variant={TONE_VARIANT[tone]}>
{t("{band} · {score, number}", { band, score: score ?? 0 })}
</Badge>
);
}
function StatusCell({ row }: RiskCell) {
const t = useExtracted("risks");
const status: RiskStatus = row.original.status;
switch (status) {
case "identified": {
return <Badge variant="secondary">{t("Identified")}</Badge>;
}
case "assessed": {
return <Badge variant="info-light">{t("Assessed")}</Badge>;
}
case "treating": {
return <Badge variant="warning-light">{t("Treating")}</Badge>;
}
case "accepted": {
return <Badge variant="success-light">{t("Accepted")}</Badge>;
}
case "closed": {
return <Badge variant="secondary">{t("Closed")}</Badge>;
}
default: {
const unknown: never = status;
return unknown;
}
}
}
function ReviewCell({ row }: RiskCell) {
const format = useFormatter();
const { nextReviewOn } = row.original;
return nextReviewOn === null ? (
<span className="text-muted-foreground">–</span>
) : (
<span className="tabular-nums">
{format.dateTime(new Date(nextReviewOn), {
dateStyle: "medium",
timeZone: "UTC",
})}
</span>
);
}
function RiskGrid({ risks }: { risks: readonly RiskRow[] }) {
const t = useExtracted("risks");
const columns: ColumnDef<DataGridFeatures, RiskRow>[] = [
{ accessorKey: "number", header: t("Number"), cell: NumberCell, size: 100 },
{ accessorKey: "title", header: t("Risk"), cell: TitleCell, size: 260 },
{ accessorKey: "category", header: t("Category"), size: 140 },
{ accessorKey: "band", header: t("Residual"), cell: BandCell, size: 150 },
{ accessorKey: "status", header: t("Status"), cell: StatusCell, size: 120 },
{
accessorKey: "nextReviewOn",
header: t("Next review"),
cell: ReviewCell,
size: 140,
},
];
const table = useTable({
features: dataGridFeatures,
columns,
data: [...risks],
getRowId: (row) => row.id,
});
return (
<DataGrid
table={table}
recordCount={risks.length}
tableLayout={{ dense: true }}
>
<DataGridContainer>
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
</DataGridContainer>
</DataGrid>
);
}
/**
* The organization's risk register: each risk with its residual band and the
* date of its next review, the highest residual score first.
*/
export function RiskRegister({ state }: { state: Loaded<readonly RiskRow[]> }) {
const t = useExtracted("risks");
const router = useRouter();
const [retrying, startRetry] = useTransition();
if (state.kind === "no-organization") {
return null;
}
return (
<Frame spacing="sm" data-recipe="risk-register">
<FrameHeader className="gap-0.5">
<FrameTitle>{t("Risk register")}</FrameTitle>
<FrameDescription>
{t(
"Every identified risk with its residual rating after controls. A review moves the next review date ahead by the risk's interval.",
)}
</FrameDescription>
</FrameHeader>
<FramePanel>
{state.kind === "error" ? (
<Alert variant="destructive">
<TriangleAlertIcon />
<AlertTitle>{t("The risk register didn't load")}</AlertTitle>
<AlertDescription>{t("Try again in a moment.")}</AlertDescription>
<AlertAction>
<Button
size="sm"
variant="outline"
type="button"
disabled={retrying}
onClick={() => {
startRetry(() => {
router.refresh();
});
}}
>
{t("Retry")}
</Button>
</AlertAction>
</Alert>
) : state.data.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<ShieldAlertIcon />
</EmptyMedia>
<EmptyTitle>{t("No risks yet")}</EmptyTitle>
<EmptyDescription>
{t(
"Register a risk with its cause and consequence, then assess it against the organization's matrix.",
)}
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<a
className="text-sm underline underline-offset-4"
href="https://bettersupabase.com/docs/blocks/risks"
>
{t("Read the risks guide")}
</a>
</EmptyContent>
</Empty>
) : (
<RiskGrid risks={state.data} />
)}
</FramePanel>
</Frame>
);
}
export function RiskRegisterSkeleton() {
return (
<Frame spacing="sm" aria-busy="true">
<FrameHeader className="gap-1">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-4 w-80" />
</FrameHeader>
<FramePanel>
<Skeleton className="h-48" />
</FramePanel>
</Frame>
);
}Last updated on