busy-office-ui
Menu

Pattern: timesheet

Who uses it: anyone who books time — a consultant, a field engineer, a project team member. How often: daily in passing, then once at period end to submit. What "done" looks like: every day accounted for, the period total reconciles against its target, and the whole period moves into approval as ONE unit. The column axis is fixed — it is the period's days — and both row totals (per project) and day totals are always visible, because the question "did Wednesday add up?" is asked as often as "how much went to this project?". Not for editing arbitrary fields across rows — a grid whose columns are a record's own attributes is editable grid; the fixed calendar axis and the reconcile-then-submit gate are what make this its own screen.

The screen — one week

Week 34 · 17–23 August 2026 Draft
Hours booked per project per day, week 34
Project / task Mon 17Tue 18Wed 19Thu 20Fri 21Sat 22Sun 23 Total
PRJ-4471 · Hydraulics rollout 34.00
PRJ-1180 · Vendor onboarding 4.00
INT-0002 · Internal · training 2.00
Day total 8.008.008.008.008.000.000.00 40.00 / 40

Cells are seamless numeric inputs — the grid reads as a sheet, not as a wall of boxes. Every cell carries a full accessible name ("PRJ-4471, Wednesday 19 August, hours"), because a screen-reader user landing mid-grid has no visual row/column context to fall back on. Empty means no time booked and renders empty, not 0.00: a typed zero is a statement ("I worked none on this"), a blank is the absence of one.

Two totals, because two questions

Row totals answer "how much went to this project this week"; day totals answer "does Wednesday add up". Both are always on screen — the references that ship only one force the reader to add up a row or a column in their head, which is exactly where a wrong timesheet comes from. The grand total carries its target beside it (40.00 / 40), so the gap that blocks submission is visible before the reader tries.

Markup

<!-- The column axis is the PERIOD's days and does not change;
     rows are what time is booked to. Row totals AND day totals are both
     shown, and the grand total is checked against the period target before
     Submit enables. -->
<table class="bo-data-table">
  <thead>
    <tr>
      <th scope="col">Project / task</th>
      <th scope="col" class="bo-data-table__col--numeric">Mon 17</th>
      …one per day…
      <th scope="col" class="bo-data-table__col--numeric">Total</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">PRJ-4471 · Hydraulics rollout</th>
      <td class="bo-data-table__col--numeric">
        <input class="bo-input bo-input--seamless bo-input--numeric" type="number"
               step="0.25" min="0" max="24" value="8" data-sum-col="d0"
               aria-label="PRJ-4471, Monday 17 August, hours" />
      </td>
      …one cell per day…
      <td class="bo-data-table__col--numeric"><span data-sum-of="row">30.00</span></td>
    </tr>
  </tbody>
  <tfoot>
    <tr>
      <th scope="row">Day total</th>
      <td class="bo-data-table__col--numeric"><span data-sum-of="d0">8.00</span></td>
      …
      <td class="bo-data-table__col--numeric"><strong>40.00</strong> / 40</td>
    </tr>
  </tfoot>
</table>

Anatomy

  1. Period header — which period, its state (badge: Draft / Submitted / Approved / Returned), and the period actions.
  2. Copy last week — a button that seeds the recurring rows; present in every reference, because retyping the same five projects weekly is the pattern's main friction.
  3. The grid — a data table: fixed day columns, one row per booking target, seamless numeric inputs in the cells.
  4. Row totals — the last column, per project.
  5. Day totals + grand total vs target — the <tfoot>; the grand total is what the submit gate reads.
  6. Submit — moves the WHOLE period into approval; the approver sees this same grid, read-only.

Data contract

RequestReturns / on failure
PATCH /timesheet/:period/cellone cell, saved as typed; returns the affected row total, day total and grand total as one fragment — never the whole grid, which would blow away the caret mid-entry
POST /timesheet/:period/copythe rows (not the hours) of the previous period; returns the re-rendered grid
POST /timesheet/:period/submit422 with the failing rule named when validation does not pass (under target, a day over its cap, a closed cost object) — the disabled button is a courtesy, the server is the gate
POST … on a submitted period409 — re-render read-only with who approved and when; a submitted period is not editable in place

States

StateWhat ships
Draft, incompleteSubmit disabled; the grand total shows the gap against target (36.00 / 40) so the reason is on screen, not in a tooltip
Draft, reconciledSubmit enabled; the target text flips to a success tone — two channels, since tone alone cannot carry it
A day over its capthe offending cell gets aria-invalid + a message; the day total is marked, not just the cell — the rule is about the day
Submittedthe whole grid renders read-only with a Submitted badge; editing requires the approver to return it
Returnededitable again, with the approver's reason shown above the grid — a returned period without a reason is an audit problem
Approvedread-only permanently; corrections go through an adjustment, never a silent edit
Empty (new period)the grid renders with no rows and "Copy last week" as the primary action — an empty timesheet's first job is getting its rows
No JSfully functional: the cells are real inputs in a real form, and Save recomputes every total server-side. Without JS the totals update on save rather than on each keystroke — the numbers are never browser-computed truth

Components used

complexity 3 of 4

↑↓ move Enter open Esc close