Leave requests
The requests waiting on a decision, each approved or declined through the server where it stands, a refusal said above the queue; reads leaveQueue().
Preview
"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>
)
}Install
$
npx shadcn@latest add @vibra/widget-people-overview-leave-queueNeeds the @vibra registry in your components.json — set it up once.
Source
"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>
)
}/**
* 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 }
}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