Skip to contentVibraUI
Part of the People dashboardinstalls at /people

People dashboard

A people page: headcount, attendance, the leave queue and the average KPI, full-time against contract, the week's attendance as a stack, a work calendar, every employee in a table with a CSV export, the requests to approve or decline, the departments and an arrival heatmap.

Open the live page

Installs at /people. The page reads db.employees, db.leaves and db.events on the server: headcount is every row on the books with the remote and on-leave counts in the caption, the leave figure is every pending request, and the KPI is the payroll's mean. The week's attendance has no rows behind it, so it comes once from seeded("people-overview"), sized so the week's rate is the payroll's mean attendance to the nearest whole absence, which is why the tile and the chart print one figure; the arrival grid follows the morning's shape from the same generator. The employees table searches name and email, facets on department, and exports the rows the filters leave as CSV through lib/export. Approve and Decline call decideLeave — the dashboard's shared action, shipped once as @lib/dashboards/people/actions.ts by actions-people, which the leave page calls too — which takes only those two words and only for a pending request; a request decided elsewhere first is refused with the decision it got, as an alert above the queue. Composes AppShell, PageHeader, DashboardGrid, StatCardGroup, StatCard, MetricDelta, StackedBarChart, HeatmapGrid, PercentageBar, MiniCalendar, Widget, DataList, DataTable, StatusBadge, UserCell, Badge, Button and Callout.

Preview

Install

npx shadcn@latest add @vibra/people-overview

Needs the @vibra registry in your components.json — set it up once.

Source

app/people/page.tsx
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { DashboardGrid, DashboardGridItem } from "@/components/ui/dashboard-grid"
import { PageHeader } from "@/components/ui/page-header"

import { signOut } from "./actions"
import { Arrivals } from "./components/arrivals"
import { AttendanceChart } from "./components/attendance-chart"
import { Departments } from "./components/departments"
import { EmployeesTable } from "./components/employees-table"
import { HeadcountByType } from "./components/headcount-by-type"
import { LeaveQueue } from "./components/leave-queue"
import { PeopleStats } from "./components/people-stats"
import { WorkCalendar } from "./components/work-calendar"
import {
  TODAY,
  arrivals,
  attendanceWeek,
  calendarEvents,
  currentUser,
  departments,
  employeeRows,
  headcountByType,
  lastUpdated,
  leaveQueue,
  peopleStats,
  shellNotifications,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/people/nav"

/**
 * The people page. A server component: it reads the payroll, the leave book
 * and the calendar through `db`, works the week out, and hands plain rows to
 * the islands that hold state — the calendar, the table and the leave queue.
 */
export default function PeoplePage() {
  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.overview}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <PageHeader
        title="People"
        description="Headcount, attendance and the leave queue."
        meta={lastUpdated()}
      />

      <PeopleStats stats={peopleStats()} />

      <DashboardGrid>
        <DashboardGridItem colSpan={{ base: 12, md: 4, lg: 3 }}>
          <HeadcountByType types={headcountByType()} />
        </DashboardGridItem>
        <DashboardGridItem colSpan={{ base: 12, md: 8, lg: 5 }}>
          <AttendanceChart week={attendanceWeek()} />
        </DashboardGridItem>
        <DashboardGridItem colSpan={{ base: 12, lg: 4 }}>
          <WorkCalendar events={calendarEvents()} today={new Date(TODAY)} />
        </DashboardGridItem>

        <DashboardGridItem colSpan={{ base: 12, lg: 8 }}>
          <EmployeesTable rows={employeeRows()} />
        </DashboardGridItem>
        <DashboardGridItem colSpan={{ base: 12, lg: 4 }}>
          <LeaveQueue rows={leaveQueue()} />
        </DashboardGridItem>

        <DashboardGridItem colSpan={{ base: 12, lg: 8 }}>
          <Departments departments={departments()} />
        </DashboardGridItem>
        <DashboardGridItem colSpan={{ base: 12, lg: 4 }}>
          <Arrivals grid={arrivals()} />
        </DashboardGridItem>
      </DashboardGrid>
    </AppShell>
  )
}
app/people/data.ts
/**
 * What /hr reads. The people are `db.employees`, the queue is `db.leaves`, the
 * calendar is `db.events`. The week's attendance and the arrival grid have no
 * rows behind them, so they are generated once from `seeded("dashboard-hr")`
 * — sized from the payroll's own mean attendance, so the chart and the tile
 * above it print the same rate. "Now" is `REFERENCE_DATE`; the week is the
 * Monday-to-Friday one it falls in.
 *
 * The client islands import only the types below; `db` never crosses into the
 * browser.
 */
import { formatDate, formatNumber, formatPercent, getInitials } from "@/lib/format"
import { REFERENCE_DATE, db, seeded, type Employee, type Event, type Leave, type Member } from "@/lib/sample-data"

const DAY = 86_400_000

/** Midnight UTC on the day `date` falls on. */
const utcDay = (date: Date): number => Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())

export const TODAY = utcDay(REFERENCE_DATE)

/** Monday of the week today falls in. */
const MONDAY = TODAY - ((new Date(TODAY).getUTCDay() + 6) % 7) * DAY

const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri"]
const HOURS = ["08:00", "09:00", "10:00", "11:00", "12:00"]
/** How a morning's arrivals fall by hour: most people are in by ten. */
const HOUR_SHAPE = [0.24, 0.46, 0.19, 0.08, 0.03]

const employees = () => db.employees.all()

const mean = (values: number[]): number =>
  values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length

/** The change from `before` to `now` as a ratio; flat when there was nothing before. */
const change = (now: number, before: number): number => (before === 0 ? 0 : (now - before) / before)

export type WeekDay = { day: string; date: string; onTime: number; late: number; absent: number }

export type AttendanceWeek = {
  days: WeekDay[]
  /** The share of the week's working days that were attended. */
  rate: number
  /** The same measure over the week before, from the seeded rule. */
  lastWeekRate: number
}

type Seeded = {
  week: AttendanceWeek
  arrivals: number[][]
  typeDeltas: Record<Employee["type"], number>
}

/**
 * Everything on the page with no rows behind it, from one generator: the
 * week's absences are sized so the week's rate is the payroll's mean
 * attendance to the nearest whole absence, then spread over the five days
 * with Monday and Friday a little heavier; the arrival grid follows the
 * morning's shape; the contract-type deltas are a small drift either way.
 */
function generate(): Seeded {
  const rand = seeded("dashboard-hr")
  const staff = employees()
  const headcount = staff.length
  const attendance = mean(staff.map((row) => row.attendanceRate))
  const totalAbsent = Math.round(5 * headcount * (1 - attendance))

  const weights = [1.15, 0.9, 0.85, 0.95, 1.15].map((weight) => weight * (0.85 + rand() * 0.3))
  const total = weights.reduce((sum, weight) => sum + weight, 0)
  let assigned = 0
  const absents = weights.map((weight, index) => {
    if (index === weights.length - 1) return totalAbsent - assigned
    const share = Math.round((totalAbsent * weight) / total)
    assigned += share
    return share
  })

  const days = absents.map((absent, index) => {
    const present = headcount - absent
    const late = Math.round(present * (0.05 + rand() * 0.07))
    return {
      day: WEEKDAYS[index],
      date: new Date(MONDAY + index * DAY).toISOString().slice(0, 10),
      onTime: present - late,
      late,
      absent,
    }
  })

  const rate = 1 - totalAbsent / (5 * headcount)
  const lastWeekRate = rate - 0.03 + rand() * 0.05

  // Arrivals by hour, one column per weekday: the day's on-time and late
  // arrivals spread over the morning's shape, with a little noise per cell.
  const arrivals = HOURS.map((_, hour) =>
    days.map((day) => Math.round((day.onTime + day.late) * HOUR_SHAPE[hour] * (0.85 + rand() * 0.3)))
  )

  return {
    week: { days, rate, lastWeekRate },
    arrivals,
    typeDeltas: { fulltime: 0.01 + rand() * 0.04, contract: -0.06 + rand() * 0.1 },
  }
}

// Drawn per call: the draws are a fixed number in a fixed order whatever the
// payroll holds, so they are the same on every render, while the headcount
// they are sized to is the payroll as it stands — a hire or a leaver since the
// server started moves the week.

/** Monday to Friday of this week, and the rate over it. */
export function attendanceWeek(): AttendanceWeek {
  return generate().week
}

export type ArrivalsGrid = { rows: string[]; columns: string[]; values: number[][] }

/** When people clock in: an hour × weekday grid of arrival counts. */
export function arrivals(): ArrivalsGrid {
  return { rows: HOURS, columns: WEEKDAYS, values: generate().arrivals }
}

export type PeopleStat = {
  key: "headcount" | "attendance" | "leaves" | "kpi"
  label: string
  value: string
  /** Change against the period before, as a ratio. */
  delta?: number
  description: string
}

/** The four headline numbers. */
export function peopleStats(): PeopleStat[] {
  const staff = employees()
  const remote = staff.filter((row) => row.status === "remote").length
  const away = staff.filter((row) => row.status === "on_leave").length
  // Who was already here when the quarter opened: the payroll grows slowly,
  // so a month is too short a window to show it moving.
  const quarterStart = Date.UTC(REFERENCE_DATE.getUTCFullYear(), Math.floor(REFERENCE_DATE.getUTCMonth() / 3) * 3, 1)
  const atQuarterStart = staff.filter((row) => row.startedAt.getTime() < quarterStart).length
  const waiting = db.leaves.all().filter((leave) => leave.status === "pending").length
  const { rate, lastWeekRate } = generate().week

  return [
    {
      key: "headcount",
      label: "Headcount",
      value: formatNumber(staff.length),
      delta: change(staff.length, atQuarterStart),
      description: `${remote} remote · ${away} on leave`,
    },
    {
      key: "attendance",
      label: "Attendance",
      value: formatPercent(rate, { maximumFractionDigits: 1 }),
      delta: change(rate, lastWeekRate),
      description: "vs last week",
    },
    {
      key: "leaves",
      label: "Leave requests",
      value: formatNumber(waiting),
      description: "awaiting a decision",
    },
    {
      key: "kpi",
      label: "Average KPI",
      value: mean(staff.map((row) => row.kpi)).toFixed(1),
      description: "out of 10",
    },
  ]
}

export type TypeCount = { type: Employee["type"]; count: number; delta: number }

/** Full-time and contract headcount, each with its drift since last quarter. */
export function headcountByType(): TypeCount[] {
  const staff = employees()
  const { typeDeltas } = generate()
  return (["fulltime", "contract"] as const).map((type) => ({
    type,
    count: staff.filter((row) => row.type === type).length,
    delta: typeDeltas[type],
  }))
}

export type CalendarEvent = Pick<Event, "id" | "title" | "kind" | "startsAt" | "endsAt" | "allDay" | "location">

/** Every calendar entry, for the mini calendar to mark and list. */
export function calendarEvents(): CalendarEvent[] {
  return db.events
    .all()
    .sort((a, b) => a.startsAt.getTime() - b.startsAt.getTime())
    .map(({ id, title, kind, startsAt, endsAt, allDay, location }) => ({
      id,
      title,
      kind,
      startsAt,
      endsAt,
      ...(allDay ? { allDay } : {}),
      ...(location ? { location } : {}),
    }))
}

/** One row of the employees table. */
export type EmployeeRow = Pick<
  Employee,
  "id" | "name" | "email" | "avatarUrl" | "department" | "role" | "type" | "status" | "startedAt"
>

/** Everyone on the payroll; the table sorts and pages them itself. */
export function employeeRows(): EmployeeRow[] {
  return employees().map(({ id, name, email, avatarUrl, department, role, type, status, startedAt }) => ({
    id,
    name,
    email,
    avatarUrl,
    department,
    role,
    type,
    status,
    startedAt,
  }))
}

/** One request in the queue, with the person resolved. */
export type LeaveRow = {
  id: string
  employeeId: string
  employeeName: string
  avatarUrl?: string
  role: string
  kind: Leave["kind"]
  from: Date
  to: Date
  status: Leave["status"]
  note?: string
}

/** The requests still waiting, soonest first. */
export function leaveQueue(): LeaveRow[] {
  const byId = new Map(employees().map((row) => [row.id, row]))
  return db.leaves
    .all()
    .filter((leave) => leave.status === "pending")
    .sort((a, b) => a.from.getTime() - b.from.getTime())
    .map((leave) => {
      const employee = byId.get(leave.employeeId)
      return {
        id: leave.id,
        employeeId: leave.employeeId,
        employeeName: employee?.name ?? "A colleague",
        avatarUrl: employee?.avatarUrl,
        role: employee?.role ?? "—",
        kind: leave.kind,
        from: leave.from,
        to: leave.to,
        status: leave.status,
        ...(leave.note ? { note: leave.note } : {}),
      }
    })
}

export type DepartmentCount = { department: Employee["department"]; count: number; share: number }

/** Headcount by department, largest first, each with its share of the whole. */
export function departments(): DepartmentCount[] {
  const staff = employees()
  const counts = new Map<Employee["department"], number>()
  for (const row of staff) counts.set(row.department, (counts.get(row.department) ?? 0) + 1)
  return [...counts.entries()]
    .map(([department, count]) => ({
      department,
      count,
      share: Math.round((count / staff.length) * 100),
    }))
    .sort((a, b) => b.count - a.count)
}

const WEEK_OF = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })

/** The line under the title: the week, and when the numbers were read. */
export function lastUpdated(): string {
  return `Week of ${WEEK_OF.format(new Date(MONDAY))} · synced ${formatDate(REFERENCE_DATE, "medium", { timeZone: "UTC" })}`
}

/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
  return db.notifications
    .all()
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .slice(0, 6)
    .map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}

function ownerRow(): Member {
  return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}

/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
  const owner = ownerRow()
  return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}
app/people/actions.ts
"use server"

import { mockAuthAdapter } from "@/lib/auth-adapter"
import { type Result } from "@/lib/sample-data"

/**
 * What this page changes on its own. A server action, so the page can stay a
 * server component and still hand the shell something to call, and `Result`
 * so every caller reads one success-or-error shape. Deciding a leave request
 * is not here: the leave page does it too, so it is the dashboard's —
 * `decideLeave` in `@/lib/dashboards/people/actions`.
 */

export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}
app/people/components/arrivals.tsx
"use client"

import { formatNumber } from "@/lib/format"
import { HeatmapGrid } from "@/components/ui/heatmap-grid"
import { Widget } from "@/components/ui/widget"

import { type ArrivalsGrid } from "../data"

export type ArrivalsProps = { grid: ArrivalsGrid }

/** When people clock in: an hour × weekday grid, darker where more arrive. */
export function Arrivals({ grid }: ArrivalsProps) {
  return (
    <Widget
      data-widget="widget-people-overview-arrivals"
      title="Arrivals"
      description="When people clock in, by hour"
      className="h-full"
    >
      <HeatmapGrid
        rows={grid.rows}
        columns={grid.columns}
        values={grid.values}
        color="chart-3"
        cellSize={36}
        valueFormatter={(value) => `${formatNumber(value)} arrivals`}
        aria-label="Arrivals by hour and weekday"
      />
    </Widget>
  )
}
app/people/components/attendance-chart.tsx
"use client"

import { formatNumber, formatPercent } from "@/lib/format"
import { chartTone } from "@/components/ui/chart-core"
import { MetricDelta } from "@/components/ui/metric-delta"
import { StackedBarChart } from "@/components/ui/stacked-bar-chart"
import { Widget } from "@/components/ui/widget"

import { type AttendanceWeek } from "../data"

// Attendance is a meaning, not a category, so each series resolves to a
// semantic token: the people who were in on time, the ones who were late, and
// the ones who were not in at all. The words are in the legend; the colour
// only reinforces them.
const SERIES = [
  { key: "onTime", label: "On time", color: chartTone("positive") },
  { key: "late", label: "Late", color: chartTone("warning") },
  { key: "absent", label: "Absent", color: chartTone("negative") },
]

export type AttendanceChartProps = { week: AttendanceWeek }

/**
 * Monday to Friday as a stack of on time, late and absent, with the week's
 * rate and its move on last week above the plot. A client island only because
 * the chart is one; the series arrives computed.
 */
export function AttendanceChart({ week }: AttendanceChartProps) {
  const present = week.days.reduce((sum, day) => sum + day.onTime + day.late, 0)
  const late = week.days.reduce((sum, day) => sum + day.late, 0)
  const delta = week.lastWeekRate === 0 ? 0 : (week.rate - week.lastWeekRate) / week.lastWeekRate

  return (
    <Widget
      data-widget="widget-people-overview-attendance-chart"
      title="Attendance this week"
      description="Who was in, by day"
      className="h-full"
    >
      <div className="flex flex-col gap-4">
        <div data-slot="attendance-summary" className="flex flex-wrap items-end gap-x-6 gap-y-2">
          <div className="flex flex-col gap-0.5">
            <div className="flex items-center gap-2">
              <span className="type-numeral text-2xl">
                {formatPercent(week.rate, { maximumFractionDigits: 1 })}
              </span>
              <MetricDelta value={delta} variant="pill" size="sm" />
            </div>
            <span className="text-xs text-muted-foreground">present this week</span>
          </div>
          <div className="flex flex-col gap-0.5">
            <span className="type-numeral text-2xl">{formatNumber(present)}</span>
            <span className="text-xs text-muted-foreground">attendances over five days</span>
          </div>
          <div className="flex flex-col gap-0.5">
            <span className="type-numeral text-2xl">{formatNumber(late)}</span>
            <span className="text-xs text-muted-foreground">arrived late</span>
          </div>
        </div>
        <StackedBarChart
          data={week.days}
          index="day"
          series={SERIES}
          height={220}
          valueFormatter={(value) => formatNumber(value)}
          aria-label="Attendance by weekday"
        />
      </div>
    </Widget>
  )
}
app/people/components/departments.tsx
import { cn } from "@/lib/utils"
import { formatNumber } from "@/lib/format"
import { CHART_BG, CHART_TOKENS, PercentageBar } from "@/components/ui/percentage-bar"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { Widget } from "@/components/ui/widget"

import { type DepartmentCount } from "../data"

export type DepartmentsProps = { departments: DepartmentCount[] }

/**
 * Headcount by department: one segmented bar, then a tile per department
 * with its count and its share — the kit's small stat tiles in one divided
 * group. The tiles take the palette in the bar's own order, so a segment
 * reads back to its tile by colour.
 */
export function Departments({ departments }: DepartmentsProps) {
  return (
    <Widget
      data-widget="widget-people-overview-departments"
      title="Departments"
      description="Where the headcount sits"
      className="h-full"
    >
      <PercentageBar
        segments={departments.map((entry) => ({ label: entry.department, value: entry.count }))}
        showLegend={false}
        aria-label="Headcount by department"
      />
      <StatCardGroup columns={4} divided role="group" aria-label="Departments by headcount" className="mt-4">
        {departments.map((entry, index) => (
          <StatCard
            key={entry.department}
            size="sm"
            label={
              <span className="flex min-w-0 items-center gap-1.5">
                <span
                  aria-hidden="true"
                  className={cn("size-2 shrink-0 rounded-full", CHART_BG[CHART_TOKENS[index % CHART_TOKENS.length]])}
                />
                <span className="truncate">{entry.department}</span>
              </span>
            }
            value={formatNumber(entry.count)}
            description={`${entry.share}% of the headcount`}
          />
        ))}
      </StatCardGroup>
    </Widget>
  )
}
app/people/components/employees-table.tsx
"use client"

import * as React from "react"
import { DownloadIcon } from "lucide-react"

import { downloadText, exportFilename, rowsToCsv } from "@/lib/export"
import { formatNumber } from "@/lib/format"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import {
  DataTable,
  DataTableColumnHeader,
  type DataTableColumnDef,
  type DataTableFacet,
  type DataTableInstance,
} from "@/components/ui/data-table"
import { StatusBadge } from "@/components/ui/status-badge"
import { UserCell } from "@/components/ui/user-cell"
import { Widget } from "@/components/ui/widget"

import { type EmployeeRow } from "../data"

import { DEPARTMENTS, EMPLOYEE_STATUS_LABELS, EMPLOYEE_STATUS_MAP, TYPE_LABELS } from "./people-vocabulary"

const DEPARTMENT_FACET: DataTableFacet<EmployeeRow>[] = [
  {
    columnId: "department",
    title: "Department",
    options: DEPARTMENTS.map((department) => ({ label: department, value: department })),
  },
]

const CSV_COLUMNS = [
  { key: "id", label: "Id" },
  { key: "name", label: "Name" },
  { key: "email", label: "Email" },
  { key: "department", label: "Department" },
  { key: "role", label: "Role" },
  { key: "type", label: "Type" },
  { key: "status", label: "Status" },
  { key: "startedAt", label: "Started" },
]

const COLUMNS: DataTableColumnDef<EmployeeRow>[] = [
  {
    accessorKey: "id",
    header: ({ column }) => <DataTableColumnHeader column={column} title="ID" />,
    cell: ({ row }) => <span className="font-mono text-xs">{row.original.id}</span>,
    meta: { label: "ID" },
  },
  {
    id: "employee",
    // Name and email in one accessor, so the search box matches either.
    accessorFn: (row) => `${row.name} ${row.email}`,
    header: ({ column }) => <DataTableColumnHeader column={column} title="Employee" />,
    // Sized, so a long address truncates inside the cell rather than widening
    // the table past an eight-column card.
    size: 184,
    cell: ({ row }) => (
      <UserCell size="sm" name={row.original.name} email={row.original.email} src={row.original.avatarUrl} className="max-w-44" />
    ),
    meta: { label: "Employee" },
  },
  {
    accessorKey: "role",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Role" />,
    cell: ({ row }) => <span className="whitespace-nowrap">{row.original.role}</span>,
    meta: { label: "Role" },
  },
  {
    accessorKey: "department",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Department" />,
    cell: ({ row }) => <Badge variant="outline">{row.original.department}</Badge>,
    meta: { label: "Department" },
  },
  {
    accessorKey: "type",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Type" />,
    cell: ({ row }) => <span className="text-muted-foreground">{TYPE_LABELS[row.original.type]}</span>,
    meta: { label: "Type" },
  },
  {
    accessorKey: "status",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
    cell: ({ row }) => (
      <StatusBadge
        status={row.original.status}
        label={EMPLOYEE_STATUS_LABELS[row.original.status]}
        map={EMPLOYEE_STATUS_MAP}
      />
    ),
    meta: { label: "Status" },
  },
]

export type EmployeesTableProps = { rows: EmployeeRow[] }

/**
 * Everyone on the payroll. The export button writes the rows the filters
 * leave — every page of them, in the order they are sorted — as CSV through
 * lib/export, and says how many went out in a status line; a browser that
 * cannot save a file is told so where the button is.
 */
export function EmployeesTable({ rows }: EmployeesTableProps) {
  // The title names the table too, so a screen reader announces it by name.
  const titleId = React.useId()
  const [notice, setNotice] = React.useState("")
  const [refusal, setRefusal] = React.useState("")

  function exportCsv(table: DataTableInstance<EmployeeRow>) {
    setRefusal("")
    setNotice("")
    const shown = table.getPrePaginatedRowModel().rows.map((row) => ({
      ...row.original,
      startedAt: row.original.startedAt.toISOString().slice(0, 10),
    }))
    const written = downloadText(exportFilename("employees", "csv"), rowsToCsv(shown, CSV_COLUMNS))
    if (!written) return setRefusal("This browser cannot save the file.")
    setNotice(`Exported ${formatNumber(shown.length)} employees as CSV.`)
  }

  return (
    <Widget
      titleId={titleId}
      data-widget="widget-people-overview-employees-table"
      title="Employees"
      description="Everyone on the payroll"
      className="h-full"
    >
      <div className="flex flex-col gap-3">
        {refusal ? (
          <Callout variant="danger" role="alert">
            {refusal}
          </Callout>
        ) : null}
        <DataTable
          aria-labelledby={titleId}
          size="sm"
          columns={COLUMNS}
          data={rows}
          pageSize={10}
          searchKey="employee"
          searchPlaceholder="Search employees…"
          facets={DEPARTMENT_FACET}
          enableRowSelection={false}
          getRowId={(employee) => employee.id}
          initialSorting={[{ id: "employee", desc: false }]}
          toolbar={(table) => (
            <Button size="sm" variant="outline" onClick={() => exportCsv(table)}>
              <DownloadIcon />
              Export CSV
            </Button>
          )}
        />
        <p role="status" aria-live="polite" className="min-h-4 text-xs text-muted-foreground">
          {notice}
        </p>
      </div>
    </Widget>
  )
}
app/people/components/headcount-by-type.tsx
import { formatNumber } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { Widget } from "@/components/ui/widget"

import { type TypeCount } from "../data"

import { TYPE_LABELS } from "./people-vocabulary"

const TONES = { fulltime: "success", contract: "warning" } as const

export type HeadcountByTypeProps = { types: TypeCount[] }

/**
 * Full-time against contract, as two tinted tiles: the tone frames the tile
 * while the number stays in ink, and the delta pill says how each has moved
 * since last quarter.
 */
export function HeadcountByType({ types }: HeadcountByTypeProps) {
  return (
    <Widget
      data-widget="widget-people-overview-headcount-by-type"
      title="Headcount by type"
      description="Who is on the payroll, and how"
      className="h-full"
    >
      <div className="grid h-full grid-rows-2 gap-3">
        {types.map((entry) => (
          <StatCard
            key={entry.type}
            id={`type-${entry.type}`}
            size="sm"
            tone={TONES[entry.type]}
            label={TYPE_LABELS[entry.type]}
            value={formatNumber(entry.count)}
            delta={entry.delta}
            description="vs last quarter"
          />
        ))}
      </div>
    </Widget>
  )
}
app/people/components/leave-queue.tsx
"use client"

import * as React from "react"
import { CheckIcon, XIcon } from "lucide-react"

import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { StatusBadge } from "@/components/ui/status-badge"
import { UserCell } from "@/components/ui/user-cell"
import { Widget } from "@/components/ui/widget"

import { decideLeave, type LeaveDecision } from "@/lib/dashboards/people/actions"
import { type LeaveRow } from "../data"

import { LEAVE_KIND_LABELS, LEAVE_STATUS_MAP } from "./people-vocabulary"

/** How many requests the card shows before it counts the rest in its footer. */
const SHOWN = 6
const DAY = 86_400_000

const SHORT_DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })

/** "Sep 10 – Sep 14 · 5 days" — both ends included, in UTC. */
function span(row: LeaveRow): { dates: string; days: number } {
  const days = Math.round((row.to.getTime() - row.from.getTime()) / DAY) + 1
  return { dates: `${SHORT_DAY.format(row.from)} – ${SHORT_DAY.format(row.to)}`, days }
}

export type LeaveQueueProps = { rows: LeaveRow[] }

/**
 * The requests waiting on a decision. Both buttons write through the server
 * and report a `Result`: a decided row keeps its place and shows the pill it
 * earned, the decision lands in a status line, and a refusal — a request that
 * was decided elsewhere first — gets its own alert above the queue.
 */
export function LeaveQueue({ rows }: LeaveQueueProps) {
  const [queue, setQueue] = React.useState(rows)
  const [notice, setNotice] = React.useState("")
  const [refusal, setRefusal] = React.useState("")

  const shown = queue.slice(0, SHOWN)
  const waiting = queue.filter((row) => row.status === "pending").length
  const rest = queue.length - shown.length

  async function decide(row: LeaveRow, decision: LeaveDecision) {
    setRefusal("")
    setNotice("")
    const result = await decideLeave(row.id, decision)
    if (!result.ok) return setRefusal(result.error.message)
    setQueue((current) =>
      current.map((entry) => (entry.id === row.id ? { ...entry, status: result.data.status } : entry))
    )
    const verb = decision === "approved" ? "Approved" : "Declined"
    setNotice(`${verb} ${result.data.employeeName}'s ${LEAVE_KIND_LABELS[result.data.kind].toLowerCase()} leave.`)
  }

  return (
    <Widget
      data-widget="widget-people-overview-leave-queue"
      title="Leave requests"
      description={`${waiting} waiting on a decision`}
      footer={rest > 0 ? `${rest} more in the queue` : undefined}
      className="h-full"
    >
      <div className="flex flex-col gap-2">
        {refusal ? (
          <Callout variant="danger" role="alert">
            {refusal}
          </Callout>
        ) : null}

        <ul data-slot="leave-queue" aria-label="Leave requests" className="flex flex-col">
          {shown.map((row) => {
            const { dates, days } = span(row)
            return (
              <li key={row.id} className="flex flex-col gap-2 border-b py-3 first:pt-0 last:border-b-0 last:pb-0">
                <div className="flex items-center justify-between gap-3">
                  <UserCell size="sm" name={row.employeeName} description={row.role} src={row.avatarUrl} className="min-w-0" />
                  <Badge variant="outline" className="shrink-0">
                    {LEAVE_KIND_LABELS[row.kind]}
                  </Badge>
                </div>
                <div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 pl-9">
                  <div className="flex min-w-0 flex-col">
                    <span className="text-sm tabular-nums">{dates}</span>
                    <span className="truncate text-xs text-muted-foreground">
                      {`${days} day${days === 1 ? "" : "s"}${row.note ? ` · ${row.note}` : ""}`}
                    </span>
                  </div>
                  {row.status === "pending" ? (
                    <div className="flex items-center gap-1.5">
                      <Button
                        size="xs"
                        variant="outline"
                        aria-label={`Approve ${row.employeeName}`}
                        onClick={() => void decide(row, "approved")}
                      >
                        <CheckIcon />
                        Approve
                      </Button>
                      <Button
                        size="xs"
                        variant="ghost"
                        aria-label={`Decline ${row.employeeName}`}
                        onClick={() => void decide(row, "declined")}
                      >
                        <XIcon />
                        Decline
                      </Button>
                    </div>
                  ) : (
                    <StatusBadge status={row.status} map={LEAVE_STATUS_MAP} />
                  )}
                </div>
              </li>
            )
          })}
        </ul>
        {queue.length === 0 ? (
          <p className="py-6 text-center text-sm text-muted-foreground">Nothing waiting.</p>
        ) : null}

        <p role="status" aria-live="polite" className="min-h-4 text-xs text-muted-foreground">
          {notice}
        </p>
      </div>
    </Widget>
  )
}
app/people/components/people-stats.tsx
import { CalendarCheckIcon, ClockIcon, StarIcon, UsersIcon } from "lucide-react"

import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"

import { type PeopleStat } from "../data"

// One icon per headline, keyed by the stat rather than its position, so the
// row can be reordered without the icons following the wrong numbers.
const ICONS: Record<PeopleStat["key"], React.ReactNode> = {
  headcount: <UsersIcon />,
  attendance: <ClockIcon />,
  leaves: <CalendarCheckIcon />,
  kpi: <StarIcon />,
}

export type PeopleStatsProps = { stats: PeopleStat[] }

/** The four headline numbers. No state, so the tiles render on the server. */
export function PeopleStats({ stats }: PeopleStatsProps) {
  return (
    <StatCardGroup data-widget="widget-people-overview-people-stats" columns={4}>
      {stats.map((stat) => (
        <StatCard
          key={stat.key}
          id={stat.key}
          label={stat.label}
          value={stat.value}
          delta={stat.delta}
          description={stat.description}
          icon={ICONS[stat.key]}
        />
      ))}
    </StatCardGroup>
  )
}
app/people/components/people-vocabulary.ts
import { type StatusVariant } from "@/components/ui/status-badge"

/**
 * The words this page puts on a contract type, a status, a kind of leave and
 * a calendar entry. Vocabulary, not data — it lives beside the islands that
 * print it rather than in `data.ts`, which reads `db` and must never reach
 * the browser.
 */
export const TYPE_LABELS: Record<string, string> = {
  fulltime: "Full-time",
  contract: "Contract",
}

export const EMPLOYEE_STATUS_LABELS: Record<string, string> = {
  active: "In office",
  remote: "Remote",
  on_leave: "On leave",
}

export const EMPLOYEE_STATUS_MAP: Record<string, StatusVariant> = {
  active: "success",
  remote: "info",
  on_leave: "warning",
}

export const LEAVE_KIND_LABELS: Record<string, string> = {
  annual: "Annual",
  sick: "Sick",
  parental: "Parental",
  unpaid: "Unpaid",
}

export const LEAVE_STATUS_MAP: Record<string, StatusVariant> = {
  pending: "warning",
  approved: "success",
  declined: "danger",
}

export const EVENT_KIND_LABELS: Record<string, string> = {
  meeting: "Meeting",
  deadline: "Deadline",
  release: "Release",
  ooo: "Out of office",
}

export const EVENT_KIND_MAP: Record<string, StatusVariant> = {
  meeting: "info",
  deadline: "danger",
  release: "success",
  ooo: "neutral",
}

export const DEPARTMENTS = ["Engineering", "Design", "Sales", "Marketing", "Finance", "Support", "People"]
app/people/components/work-calendar.tsx
"use client"

import * as React from "react"

import { DataList, DataListItem } from "@/components/ui/data-list"
import { MiniCalendar, type MiniCalendarMark } from "@/components/ui/mini-calendar"
import { StatusBadge } from "@/components/ui/status-badge"
import { Widget } from "@/components/ui/widget"

import { type CalendarEvent } from "../data"

import { EVENT_KIND_LABELS, EVENT_KIND_MAP } from "./people-vocabulary"

const DAY = 86_400_000

const TIME = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", timeZone: "UTC" })
const PICKED = new Intl.DateTimeFormat("en-US", { weekday: "long", month: "long", day: "numeric", timeZone: "UTC" })

/** Midnight UTC on the day `date` falls on, as the calendar keys it. */
const dayOf = (date: Date): number => Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
const keyOf = (day: number): string => new Date(day).toISOString().slice(0, 10)

/** Every day an event touches, from the day it starts to the day it ends. */
function daysOf(event: CalendarEvent): number[] {
  const first = dayOf(event.startsAt)
  const last = Math.max(first, dayOf(event.endsAt))
  return Array.from({ length: (last - first) / DAY + 1 }, (_, index) => first + index * DAY)
}

function timeOf(event: CalendarEvent): string {
  if (event.allDay) return "All day"
  return `${TIME.format(event.startsAt)} – ${TIME.format(event.endsAt)}`
}

export type WorkCalendarProps = {
  events: CalendarEvent[]
  /** Today, at midnight UTC — the day the calendar opens on. */
  today: Date
}

/**
 * The month with a count on every day that has something on it, and the
 * picked day's entries under it. Every date is read through its UTC parts,
 * so the day under the cursor is the same day for every reader.
 */
export function WorkCalendar({ events, today }: WorkCalendarProps) {
  const [month, setMonth] = React.useState(today)
  const [picked, setPicked] = React.useState<Date | undefined>(today)

  const marks = React.useMemo(() => {
    const out: Record<string, MiniCalendarMark> = {}
    for (const event of events) {
      for (const day of daysOf(event)) {
        const key = keyOf(day)
        out[key] = { count: (out[key]?.count ?? 0) + 1 }
      }
    }
    return out
  }, [events])

  const day = picked ? dayOf(picked) : undefined
  const onDay = day === undefined ? [] : events.filter((event) => daysOf(event).includes(day))

  return (
    <Widget
      data-widget="widget-people-overview-work-calendar"
      title="Work calendar"
      description="Meetings, deadlines and time off"
      className="h-full"
    >
      <div className="flex flex-col gap-3">
        <MiniCalendar
          size="sm"
          month={month}
          onMonthChange={setMonth}
          value={picked}
          onValueChange={setPicked}
          marks={marks}
          today={today}
          className="w-full"
        />
        <p data-slot="picked-day" className="text-xs font-medium text-muted-foreground">
          {picked ? PICKED.format(picked) : "Pick a day."}
        </p>
        {onDay.length > 0 ? (
          <DataList divided>
            {onDay.map((event) => (
              <DataListItem
                key={event.id}
                title={event.title}
                description={
                  <span className="tabular-nums">
                    {event.location ? `${timeOf(event)} · ${event.location}` : timeOf(event)}
                  </span>
                }
                wrapTitle
                actions={
                  <StatusBadge
                    status={event.kind}
                    label={EVENT_KIND_LABELS[event.kind]}
                    map={EVENT_KIND_MAP}
                    size="sm"
                  />
                }
              />
            ))}
          </DataList>
        ) : (
          <p className="py-4 text-center text-sm text-muted-foreground">Nothing on this day.</p>
        )}
      </div>
    </Widget>
  )
}