Timesheet
A week of time entries per project with a running timer and totals. 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 badge button card empty skeleton tableInstall the glue
Install the block with
npx better-supabase add time-tracking. 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 screen
React and shadcn
View in the exampletimesheet.tsx /** * Render inside `<Suspense>`: the caller's week as one row per project * with hours per day and totals, a running timer, and Submit once the * week is done. `?week=YYYY-MM-DD` picks the week. */ export async function Timesheet({ searchParams, }: { searchParams: Promise<Record<string, string | string[] | undefined>>; }) { const [session, t, format, query] = await Promise.all([ getSession(), getExtracted("people"), getFormatter(), searchParams, connection(), ]); if (!can(session, "time_tracking.track")) { forbidden(); } const monday = mondayOf(query["week"]); const week = monday.toString(); const sheet = await settle(() => loadTimesheet(week)); if (sheet.kind === "no-organization") { return null; } const dayLabel = (date: string) => format.dateTime(new Date(`${date}T12:00:00Z`), { weekday: "short", day: "numeric", timeZone: "UTC", }); const range = format.dateTimeRange( new Date(`${week}T12:00:00Z`), new Date(`${monday.add({ days: 6 }).toString()}T12:00:00Z`), { day: "numeric", month: "short", year: "numeric", timeZone: "UTC" }, ); const locked = sheet.kind === "ok" && (sheet.data.status === "submitted" || sheet.data.status === "approved"); const logTime = locked ? null : <LogTimeDialog week={week} />; return ( <Card data-recipe="timesheet"> <CardHeader> <CardTitle>{t("Week of {range}", { range })}</CardTitle> <CardDescription> {t("Hours per project and day, in {zone}.", { zone: TIME_ZONE })} </CardDescription> <CardAction className="flex items-center gap-2"> <Link href={`/people/time?week=${monday.subtract({ weeks: 1 }).toString()}`} className={buttonVariants({ variant: "outline", size: "icon-sm" })} aria-label={t("Previous week")} > <ChevronLeftIcon /> </Link> <Link href={`/people/time?week=${monday.add({ weeks: 1 }).toString()}`} className={buttonVariants({ variant: "outline", size: "icon-sm" })} aria-label={t("Next week")} > <ChevronRightIcon /> </Link> </CardAction> </CardHeader> <CardContent className="flex flex-col gap-4"> {sheet.kind === "error" ? ( <PeopleLoadError title={t("The timesheet didn't load")} /> ) : ( <> <div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex items-center gap-2"> <StatusBadge status={sheet.data.status} labels={{ open: t("Open"), submitted: t("Submitted"), approved: t("Approved"), rejected: t("Rejected"), }} /> <span className="text-sm text-muted-foreground tabular-nums"> {t("{total} logged, {billable} billable", { total: hours(sheet.data.seconds), billable: hours(sheet.data.billableSeconds), })} </span> </div> <div className="flex flex-wrap items-center gap-2"> {locked ? null : ( <TimerControls running={ sheet.data.running ? { project: sheet.data.running.note ?? "", since: format.dateTime( new Date(sheet.data.running.startedAt), { timeStyle: "short" }, ), } : null } /> )} {sheet.data.rows.length > 0 ? logTime : null} {locked || sheet.data.rows.length === 0 ? null : ( <SubmitWeekButton week={week} /> )} </div> </div> {sheet.data.rows.length === 0 ? ( <Empty className="border"> <EmptyHeader> <EmptyMedia variant="icon"> <ClockIcon /> </EmptyMedia> <EmptyTitle>{t("Nothing logged this week")}</EmptyTitle> <EmptyDescription> {locked ? t("This week is locked.") : t( "Log a block of time or start the timer on a project.", )} </EmptyDescription> </EmptyHeader> {logTime ? <EmptyContent>{logTime}</EmptyContent> : null} </Empty> ) : ( <Table> <TableHeader> <TableRow> <TableHead>{t("Project")}</TableHead> {sheet.data.days.map((day) => ( <TableHead key={day.date} className="text-right"> {dayLabel(day.date)} </TableHead> ))} <TableHead className="text-right">{t("Total")}</TableHead> </TableRow> </TableHeader> <TableBody> {sheet.data.rows.map((row) => ( <TableRow key={row.id}> <TableCell> <div className="flex items-center gap-2"> <span className="text-sm font-medium"> {row.label === "" ? t("No project") : row.label} </span> {row.billable ? ( <Badge variant="outline">{t("Billable")}</Badge> ) : null} </div> </TableCell> {row.days.map((seconds, index) => ( <TableCell key={sheet.data.days[index]?.date ?? index} className="text-right tabular-nums" > {seconds === 0 ? "" : hours(seconds)} </TableCell> ))} <TableCell className="text-right font-medium tabular-nums"> {hours(row.seconds)} </TableCell> </TableRow> ))} </TableBody> <TableFooter> <TableRow> <TableCell>{t("Total")}</TableCell> {sheet.data.days.map((day) => ( <TableCell key={day.date} className="text-right tabular-nums" > {hours(day.seconds)} </TableCell> ))} <TableCell className="text-right tabular-nums"> {hours(sheet.data.seconds)} </TableCell> </TableRow> </TableFooter> </Table> )} </> )} </CardContent> </Card> ); }