UI recipesUI recipes
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; the qualifications block must be installed (npx better-supabase add qualifications). Its page on bettersupabase.com is /recipes/qualification-matrix.
Web, with shadcn components:
npx shadcn add alert badge button card empty skeleton table// 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" />;
}Last updated on