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.
pnpm dev:example) to see it.Add the components
terminal npx shadcn add alert badge button empty skeleton @reui/data-grid @reui/frameInstall the glue
Install the block with
npx better-supabase add risks. The glue items are copied into your app, so edit them as you need.None: the page calls the block's client on the server.
Write the component
React and shadcn
View in the examplerisk-register.tsx "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> ); }