Skip to contentVibraUI

Employees

Everyone on the payroll — searchable, filterable by department, sortable and paged — with the rows the filters leave exported as CSV; reads employeeRows().

Preview

Install

npx shadcn@latest add @vibra/widget-people-overview-employees-table

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

Source

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/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/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"]

Its page

On its page the card sits among the rest of the dashboard and shares its range and its data with them.

From the People dashboard page