Home/ Cookbook/ Approvals/ Leave-request workflow

Build a leave-request approval workflow

By the end of this recipe you'll have an end-to-end leave-request flow: employee submits, manager sees in an inbox with filter chips, opens a drawer with full context — balance, conflicts, history — approves or rejects, batch-approves a Monday-morning queue, and gets a 5-second undo window before anything is sent.

Approvals 2 primitives · 3 blocks · 2 patterns ~45 min React · @corelithzw/react

Overview

Three views, one state machine on the manager side: pendingapproved | rejected | needs-info. The drawer carries enough context that the manager almost never has to leave it.

Leave is the highest-volume HR workflow most SMBs run, and most apps get it wrong by hiding the things a manager needs at the decision moment — current balance, who else is off that week, the employee's request history. The cost of getting it wrong is a manager who copies the request into a spreadsheet, decides there, and lets the inbox rot. This recipe puts all of that context inside the drawer so the decision happens in one tap.

Batch-approve is for Monday morning: ten weekend requests, all from the same shift, all non-conflicting — select-all, approve-all, one action. Undo lives on the toast for 5 seconds because that's the window in which "wait, no" actually happens; after that, the manager is committed and the employee gets notified.

Skip this recipe if approvals in your domain need a multi-signer chain (e.g. CFO + CEO), an SLA timer or a per-policy escalation tree — those need a proper workflow engine. This recipe assumes one manager, one decision, one notification.

What you'll build

Three screens. Inbox, drawer with context, post-decision undo toast.

Leave inbox
All · 8 Pending · 5 Mine · 2
Tendai N.
Mon 9–Wed 11
Pending
Farai M.
Fri 13
Pending
Sipho D.
Mon 16–Fri 20
Approved
Chipo R.
Wed 11
Rejected
01 Inbox — filter chips + status badges
Tendai N.
DatesMon 9 — Wed 11 Jun (3 days) Balance after7 days · was 10
⚠ Farai M. is also off Wed 11.
History3 approved · 0 rejected · last Apr
Reject
Approve
02 Drawer — full context for one decision
Leave inbox
All · 7 Pending · 4
Tendai N.
Mon 9–Wed 11
Approved
Farai M.
Fri 13
Pending
Approved Tendai N. Undo · 4s
03 Decided — toast with 5s undo

You can see this live in

Portal demo that wires this approval workflow in context. Open the manager inbox to act on a request.

Required pieces

Everything this recipe pulls from @corelithzw/react. Open any reference page for props, variants and the do/don't.

@corelithzw/react exports used here: Stack, FilterChips, RowCard, EmptyState, Button, Alert, Drawer, BottomSheet, Toast, useToast. useToast is what carries the 5-second undo window — every recipe in this batch uses it the same way.

Step-by-step build

01

Model the request as a state machine, not a status string

The four states — pending, approved, rejected, needs-info — drive every part of the UI. Encoding them as a discriminated union means the drawer can render the right buttons for the current state with exhaustive type-checking, and the inbox filter chips can count states by name instead of by magic strings.

Code-only step

'pending' → REJECT(reason) → 'rejected'

'pending' → ASK_INFO(question) → 'needs-info'

Approved/rejected are terminal. needs-info returns to pending when the employee replies.

Switch to the Code tab to see the snippet.

Step 1 · State machine@corelithzw/react
02

Render the inbox with filter chips + status-tinted RowCards

One FilterChips at the top, one RowCard per request. Tinting the badge — not the whole row — lets the inbox stay scannable while still calling out where work is needed.

All · 8 Pending · 5 Mine · 2
Tendai N.
Mon 9 – Wed 11 Jun
Pending
Farai M.
Fri 13 Jun
Pending
Sipho D.
Mon 16 – Fri 20 Jun
Approved
Step 2 · Inbox
03

Open the drawer with full decision context

The drawer shows everything the manager would otherwise have to go look up: dates, days, balance after the leave, conflicts with teammates, history. The conflict warning uses Alert tone="warning" because it's an "I-still-might-approve-this" signal, not a hard block.

Tendai N.
Dates
Mon 9 — Wed 11 Jun (3 days)
Balance after
7 days (was 10)
Farai M. is also off Wed 11.
History
3 approved · 0 rejected · last Apr
Reject
Approve
Step 3 · Drawer
04

Approve optimistically with a 5-second undo window

Update local state immediately and fire the API call after a 5-second timer so the manager can undo without server churn. If the timer expires, post to the API and notify the employee; if the user clicks Undo, cancel the timer and revert the local state — no network calls at all.

Approved Tendai N. Undo · 4s
Step 4 · Undo toast
05

Batch-approve from the inbox header

Multi-select with a checkbox column reveals a sticky action bar — "Approve all 4" with a single confirm. Each request still passes through the same approve-with-undo helper, so individual undo works even after a batch.

4 selected · 1 with conflicts Clear Approve all 4
Step 5 · Batch

Final composition

The whole LeaveInbox, assembled. Filter chips, multi-select, drawer with context, optimistic decide with 5-second undo, batch approve.

All · 8 Pending · 5
Tendai N. · Mon 9 – Wed 11Pending
Farai M. · Fri 13Pending
Sipho D. · Mon 16 – Fri 20Approved
Approved Tendai N. Undo · 4s
LeaveInbox.tsx@corelithzw/react

Variations

Three forks. The state machine doesn't change.

Mobile bottom-sheet

On phones the side drawer becomes a bottom sheet. The body and footer markup are identical; only the wrapper swaps.

// Phone: BottomSheet, desktop: Drawer
import { useMediaQuery } from '@corelithzw/react';
const isPhone = useMediaQuery('(max-width: 720px)');
const Shell = isPhone ? BottomSheet : Drawer;
<Shell open onClose={...} title={...}>
  {/* same body */}
</Shell>

Reject with required reason

For an audited workflow, force a non-empty reason. The reject button opens a small prompt before the optimistic call fires.

// Inline reject prompt
const [why, setWhy] = useState('');
<Button disabled={!why}
  onClick={() => decide(
    { kind: 'REJECT', reason: why }, opened)}>
  Reject — needs reason
</Button>

Auto-approve under N days

Short requests (≤1 day, sufficient balance, no conflicts) can skip the inbox entirely. Surface them as a daily digest instead.

// Pre-filter on the server, render a digest card
if (r.days <= 1
    && r.conflicts.length === 0
    && r.employee.balance > r.days) {
  decide({ kind: 'APPROVE' }, r);
  // ↑ runs without the manager ever opening it
}

Accessibility

What this recipe takes care of for you.

  • Filter chips are a radio group. FilterChips renders an aria-radiogroup; arrow keys move the selection, Space commits it. Tab leaves the group at the next element.
  • Each request row is one focusable target. The row is the link, the selection checkbox is a sibling control with its own label — Shift+click extends selection; Space toggles when focused.
  • Drawer manages focus, then returns it. Opening the drawer moves focus to the first heading, traps Tab inside, and on close returns focus to the row that opened it. Esc closes without a confirm prompt.
  • The conflict warning is an alert region. Alert tone="warning" with role="status" so a screen reader announces "Farai M. is also off Wed 11" the moment the drawer opens.
  • Undo is keyboard-reachable. The toast traps the next Tab so the user lands on Undo, not on the underlying inbox. After the 5-second window the toast dismisses and focus returns to the inbox.
  • Batch action bar announces the count. aria-label="4 selected" updates live, and the conflict suffix ("· 1 with conflicts") is read in the same breath.
  • Optimistic state is reversible until the timer fires. Screen-reader users hear "Approved Tendai N. Undo available for 5 seconds" — no surprise once it commits.