docs(spec): Workers/Timesheets split and worker QR badges

Workers becomes a lean roster (name, employee number, position, type,
site, rate) with filters unchanged. Everything currently cluttering the
list row -- total hours, cost, the 8-week sparkline, per-employee entry
history -- moves to a new worker detail page that doesn't exist yet
(today's "detail" is a dialog). Same pattern already used for Suppliers
and Vehicles/Equipment this session: lean list, detail page carries
depth.

Timesheets keeps its flat entries table but gains a genuinely separate
upload-status section -- the timesheets document collection already has
this data, it's just currently squeezed into a per-row badge instead of
being its own thing. The duplicated Import-timesheets button (currently
on both pages) moves here only.

QR badges: encodes employee_no directly, no new PocketBase field --
every worker already has a valid code the moment they exist, with no
generation step. Explicitly a deliberate tradeoff (guessable numbers,
no revocation) the user chose for now, pending seeing how the separate
timekeeping PWA wants identity handled -- moving to a stored opaque
token later is additive, not a rework. Card layout confirmed via the
visual companion (centered, QR-forward). Both single-worker and
batch-sheet print confirmed in scope. Rendering via the qrcode npm
package + the browser's own print dialog (@media print), not a PDF
library -- CSS handles real-world card dimensions more reliably than
fighting a PDF engine's coordinate system.

The timekeeping PWA itself is explicitly out of scope -- a separate
project this spec only needs to stay compatible with.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DEls 2026-09-11 19:04:34 +02:00
parent 5f4adcba6c
commit d5e3b2ce2b
2 changed files with 179 additions and 0 deletions

2
.gitignore vendored
View file

@ -28,3 +28,5 @@ kilo.json
# Coolify production env template — placeholders only, real values stay local
.env.coolify.local
__pycache__/
.superpowers/

View file

@ -0,0 +1,177 @@
# Workers / Timesheets split, plus worker QR badges
> Splits the Workforce section's two overlapping pages into a lean
> roster (Workers) with a real detail page, and a Timesheets page that
> clearly separates "what was uploaded" from "every individual hour
> entry." Adds a printable QR badge per worker, derived from the
> existing employee number, in preparation for a separate timekeeping
> PWA that will scan these codes.
>
> Date: 2026-09-11 · Branch: `feat/workers-timesheets-qr`
---
## 1. Why
Today, `/dashboard/workforce/workers` and `/dashboard/workforce/timesheets`
both show individual `timesheet_entries` rows (date, hours), just at
different granularity — Workers nests them per-employee behind a dialog
alongside cost/hours aggregates and an 8-week sparkline; Timesheets shows
them as one flat table across everyone. Both pages also carry their own
"Import timesheets" button. This is the same category of duplication
already found and fixed once this session on the Purchase Orders /
Delivery Notes split — two pages doing overlapping jobs because a
combined page was split without deciding what each half is actually for.
Separately, MEGA is building a standalone timekeeping app — installed as
its own PWA, used to scan a worker's badge and log their time directly.
**That app is not part of this spec.** What this spec builds is the
PocketBase-adjacent groundwork it will eventually read: a QR code per
worker that a phone camera can scan, and a way to print physical badges
for the whole workforce before that app exists to consume them.
---
## 2. Workers — becomes a lean roster
`/dashboard/workforce/workers` keeps: name, employee number, position,
worker type, site, rate/hour. Filterable by site, worker type, and
position, same filters as today. **Removed from the list/row itself:**
total hours, total cost, last-30-days hours, this-month hours, the 8-week
sparkline, and the per-employee timesheet-entries list currently shown in
a `Dialog`. All of that moves to a new detail page.
### 2.1 New: worker detail page
`/dashboard/workforce/workers/[employeeId]` (does not exist today — the
current "detail" is a dialog, not a route). Shows everything removed
from the list above: total hours, total cost, last-30/this-month
windows, the 8-week sparkline, and every individual `timesheet_entries`
row for that worker, newest first. Also hosts the QR badge (§4).
This mirrors the pattern already established this session for Suppliers
(`suppliers-view.tsx` stays lean, `[supplierId]/supplier-detail-view.tsx`
carries the depth) and for Vehicles/Equipment — list pages stay scannable,
detail pages carry history.
### 2.2 The duplicated upload button
`ImportTimesheetsButton` currently appears in both pages' `Topbar`.
It moves to Timesheets only (§3) — a pure roster page has no reason to
host an upload action.
---
## 3. Timesheets — two sections, not one blended table
`/dashboard/workforce/timesheets` keeps its existing flat, filterable
`timesheet_entries` table (every entry, every worker, every site,
filterable by site and by upload-source), but gains a section above it
that doesn't exist today:
**Upload status.** A table of the `timesheets` document collection
itself (from the ingestion restructure) — one row per uploaded sheet:
filename/date, `processing_status`, `rows_created`/`rows_failed`,
`last_error`. This data already exists and is already fetched by this
page's loader (`documents` in the current `page.tsx`), but today it only
surfaces as a small badge on individual entry rows. It becomes its own
first-class section — "which sheets did I upload and did they work" is a
different question from "show me every hour entry," and both are real,
already-answerable-from-existing-data questions that deserve their own
place rather than being squeezed into one row's badge.
The two sections are visually and structurally separate — not a single
table with mixed row types.
---
## 4. QR badges
### 4.1 What the QR encodes
**No new PocketBase field.** The QR encodes `employees.employee_no`
verbatim (e.g. `MCW058` — confirmed against live production data,
already a real, structured identifier, not a placeholder). Every worker
with an employee number already "has" a QR code the instant they exist;
there is no generation step, no button, nothing that can drift out of
sync with the roster.
**This is a deliberate, acknowledged tradeoff, not an oversight.**
Employee numbers are sequential and could be guessed or typed in by
someone trying to clock in as a coworker, and a lost/stolen badge can't
be invalidated without renumbering the employee. The user has explicitly
chosen this simpler path for now, pending seeing how the (separately
developed) timekeeping app wants to handle identity — if it later needs a
stored, rotatable, opaque token instead of the employee number itself,
that is an additive schema change on top of this, not a rework: a new
`qr_token` field, a migration to backfill it, and swapping what string
gets encoded. Nothing in this spec needs to anticipate that shape now.
### 4.2 Card design
Layout, confirmed via the visual companion: **centered, QR-forward** —
company name, QR code, worker name, employee number, all centered on a
single card. Standard ID-badge dimensions (~85mm × 54mm, credit-card
sized) so it laminates cleanly.
### 4.3 Where it appears, and the two export modes
**Worker detail page (§2.1):** renders the card for that one worker, with
a "Print / Save as PDF" action.
**Workers list page (§2):** an "Export QR badges" action opens a small
dialog offering either "all currently filtered/visible workers" or a
checkbox selection of specific rows, then prints a multi-card sheet
(8–10 cards per A4/Letter page, arranged for cut-and-laminate) using the
same card design.
Both single-card and batch-sheet export are in scope — confirmed
explicitly, this is not an either/or.
### 4.4 Technical approach
QR rendering: a small, dependency-free client-side library (`qrcode` on
npm, ~20kb) — nothing like this exists in the project today, so this is
a new dependency either way, and it's the standard, minimal choice for
generating a QR as an SVG/canvas/data-URL in the browser.
Output mechanism: **the browser's own print dialog** (`window.print()`
with `@media print` CSS), not a PDF-generation library. The single-card
and batch-sheet views are two different CSS layouts (one large centered
card vs. a CSS grid of many cards at real-world mm dimensions) behind the
same print trigger. The browser's print dialog already offers "Save as
PDF," so this produces a PDF without adding `jsPDF`/`html2canvas` as
dependencies or fighting a PDF library's coordinate system to get
physical card dimensions right — CSS `@media print` with `mm`/`in` units
is the more reliable tool for that than a PDF library's own layout
engine.
---
## 5. What's explicitly out of scope
- **The timekeeping PWA itself.** A separate application, a separate
project, not designed here. This spec's only obligation to it is that
the QR payload (the employee number) is something that app can scan
and look up against the same `employees` collection.
- **Moving to a stored/opaque QR token.** Deliberately deferred per §4.1
— revisit once the timekeeping app's actual requirements are known.
- **Auto-generation of anything.** Because the QR is derived, not stored,
there is nothing to auto-generate on worker creation — every worker
already has a valid QR the moment they're created with an
`employee_no`. The only generation event in this spec is rendering the
printable artifact on demand, not a data-creation step.
- **Any change to the `employees` PocketBase schema.** None needed for
this spec.
---
## 6. Testing
| Area | What must be covered |
| --- | --- |
| Worker detail page | Renders the aggregates (hours, cost, sparkline, entries) that used to live in the list-row dialog, for a worker with entries and for a worker with none. |
| Timesheets upload-status section | Renders `timesheets` document rows with correct `processing_status`; a document collection returning zero rows doesn't break the page. |
| QR encoding | The rendered QR's payload is exactly `employee_no`, no prefix/suffix added silently. |
| Batch export selection | "All visible" respects the list's current site/type/position filters; explicit checkbox selection overrides it. |
| Print CSS | Manual verification only (this is a `@media print` concern vitest can't exercise) — confirm via the browser's print preview that single-card and multi-card layouts both render at the intended physical dimensions before considering this done. |