RegistryUI recipes
Hiring board
Candidates for an opening as cards in interview stage columns.
It loads its data on the server through the block's client; the hiring block must be installed (npx better-supabase add hiring). Its page on bettersupabase.com is /recipes/hiring-board.
Web, with shadcn components:
npx shadcn add badge button card empty skeleton/**
* Render inside `<Suspense>`: the candidates of one opening as cards in its
* stage columns. Dragging a card to Hired or Rejected closes the application;
* to another open stage, moves it. `?job=` picks the opening.
*/
export async function HiringBoard({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const [session, t, query] = await Promise.all([
getSession(),
getExtracted("people"),
searchParams,
]);
if (!can(session, "hiring.read")) {
forbidden();
}
const board = await settle(loadHiringBoard);
if (board.kind === "no-organization") {
return null;
}
const canManage = can(session, "hiring.manage");
const canWrite = can(session, "hiring.write");
const jobs = board.kind === "ok" ? board.data.jobs : [];
const job = jobs.find((item) => item.id === query["job"]) ?? jobs[0];
const cards =
board.kind === "ok" && job
? board.data.cards.filter((card) => card.jobId === job.id)
: [];
const createOpening = canManage ? <CreateOpeningDialog /> : null;
return (
<Card data-recipe="hiring-board">
<CardHeader>
<CardTitle>{job?.title ?? t("Hiring")}</CardTitle>
<CardDescription>
{t("Drag a candidate to the next stage, or to Hired or Rejected.")}
</CardDescription>
{jobs.length > 0 || board.kind === "error" ? (
<CardAction className="flex items-center gap-2">
{job && canWrite && board.kind === "ok" && cards.length > 0 ? (
<AddCandidateDialog
jobId={job.id}
candidates={board.data.candidates}
/>
) : null}
{createOpening}
</CardAction>
) : null}
</CardHeader>
<CardContent className="flex flex-col gap-4">
{board.kind === "error" ? (
<PeopleLoadError title={t("The hiring board didn't load")} />
) : job === undefined ? (
<Empty className="border">
<EmptyHeader>
<EmptyMedia variant="icon">
<BriefcaseIcon />
</EmptyMedia>
<EmptyTitle>{t("No openings yet")}</EmptyTitle>
<EmptyDescription>
{canManage
? t("Create an opening, then add candidates to it.")
: t("Ask a hiring manager to create an opening.")}
</EmptyDescription>
</EmptyHeader>
{createOpening ? (
<EmptyContent>{createOpening}</EmptyContent>
) : null}
</Empty>
) : (
<>
{jobs.length > 1 ? (
<nav
aria-label={t("Openings")}
className="flex flex-wrap items-center gap-2"
>
{jobs.map((item) => (
<Link
key={item.id}
href={`/people/hiring?job=${item.id}`}
aria-current={item.id === job.id ? "page" : undefined}
className={buttonVariants({
variant: item.id === job.id ? "secondary" : "ghost",
size: "sm",
})}
>
{item.title}
{item.status === "open" ? null : (
<Badge variant="outline">
{item.status === "draft" ? t("Draft") : t("Closed")}
</Badge>
)}
</Link>
))}
</nav>
) : null}
{cards.length === 0 ? (
<Empty className="border">
<EmptyHeader>
<EmptyMedia variant="icon">
<UserPlusIcon />
</EmptyMedia>
<EmptyTitle>{t("No candidates yet")}</EmptyTitle>
<EmptyDescription>
{t("Candidates start in the first stage of the pipeline.")}
</EmptyDescription>
</EmptyHeader>
{canWrite ? (
<EmptyContent>
<AddCandidateDialog
jobId={job.id}
candidates={board.data.candidates}
/>
</EmptyContent>
) : null}
</Empty>
) : (
<HiringKanban
key={cards
.map((card) => `${card.id}:${card.stageId}`)
.join(",")}
stages={board.data.stages}
cards={cards}
canMove={canWrite}
/>
)}
</>
)}
</CardContent>
</Card>
);
}Last updated on