Qualification matrix
A table of the members who lack a required qualification, hold an expired one or will lose one soon. 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 card empty skeleton tableInstall the glue
Install the block with
npx better-supabase add qualifications. 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 examplequalification-matrix.tsx // oxlint-disable-next-line typescript/require-await -- a Server Action must be async, and refresh() returns nothing to await async function retry() { "use server"; refresh(); } const GAP_VARIANT = { missing: "destructive-light", expired: "destructive-light", expiring: "warning-light", } as const satisfies Record<QualificationGapStatus, string>; /** * Render inside `<Suspense>` for members with `qualifications.read`: each * required qualification a member lacks, has let lapse or is about to lose. */ export async function QualificationMatrix() { const [state, t, format] = await Promise.all([ getMatrix(), getExtracted("qualifications"), getFormatter(), ]); if (state.kind === "no-organization") { return null; } if (state.kind === "error") { return ( <Alert variant="destructive" data-recipe="qualification-matrix"> <TriangleAlertIcon /> <AlertTitle>{t("The qualification gaps didn't load")}</AlertTitle> <AlertDescription>{t("Try again in a moment.")}</AlertDescription> <AlertAction> <form action={retry}> <Button size="sm" variant="outline" type="submit"> {t("Retry")} </Button> </form> </AlertAction> </Alert> ); } const { types, gaps } = state.data; if (types === 0) { return ( <Empty className="border" data-recipe="qualification-matrix"> <EmptyHeader> <EmptyMedia variant="icon"> <BadgeCheckIcon /> </EmptyMedia> <EmptyTitle>{t("No qualifications yet")}</EmptyTitle> <EmptyDescription> {t( "Add a qualification type such as a forklift licence, then name the roles or teams that must hold it.", )} </EmptyDescription> </EmptyHeader> <EmptyContent> <a className="text-sm underline underline-offset-4" href="https://bettersupabase.com/docs/blocks/qualifications" > {t("Read the qualifications guide")} </a> </EmptyContent> </Empty> ); } const label = (status: QualificationGapStatus) => { switch (status) { case "missing": { return t("Missing"); } case "expired": { return t("Expired"); } case "expiring": { return t("Expiring"); } default: { const unknown: never = status; return unknown; } } }; const day = (date: string) => format.dateTime(new Date(date), { dateStyle: "medium", timeZone: "UTC" }); return ( <Card data-recipe="qualification-matrix"> <CardHeader> <CardTitle>{t("Qualification gaps")}</CardTitle> <CardDescription> {t( "Members who lack a required qualification, hold an expired one or will lose one soon.", )} </CardDescription> </CardHeader> <CardContent> {gaps.length === 0 ? ( <p className="text-sm text-muted-foreground"> {t("Every member holds the qualifications their role requires.")} </p> ) : ( <Table> <TableHeader> <TableRow> <TableHead>{t("Member")}</TableHead> <TableHead>{t("Qualification")}</TableHead> <TableHead>{t("Status")}</TableHead> <TableHead>{t("Expires")}</TableHead> </TableRow> </TableHeader> <TableBody> {gaps.map((gap) => ( <TableRow key={gap.key}> <TableCell className="font-medium"> {gap.holderTitle} </TableCell> <TableCell>{gap.typeName}</TableCell> <TableCell> <Badge variant={GAP_VARIANT[gap.status]}> {label(gap.status)} </Badge> </TableCell> <TableCell className="text-muted-foreground"> {gap.expiresOn ? day(gap.expiresOn) : "–"} </TableCell> </TableRow> ))} </TableBody> </Table> )} </CardContent> </Card> ); } export function QualificationMatrixSkeleton() { return <Skeleton className="h-64" aria-busy="true" />; }