diff --git a/docs/superpowers/plans/2026-09-11-workers-timesheets-qr.md b/docs/superpowers/plans/2026-09-11-workers-timesheets-qr.md new file mode 100644 index 0000000..55688e5 --- /dev/null +++ b/docs/superpowers/plans/2026-09-11-workers-timesheets-qr.md @@ -0,0 +1,2095 @@ +# Workers / Timesheets split + worker QR badges — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Split the overlapping Workers/Timesheets pages into a lean Workers roster + a real `/workers/[employeeId]` detail page, add an "upload status" section to Timesheets, and add printable QR badges (single + batch) derived from `employees.employee_no`. + +**Architecture:** Two pure-logic helper modules (`src/lib/workforce-hours.ts` for the date-bucketing math already in the codebase but duplicated across pages, and `src/lib/worker-qr.ts` for the one-line QR-payload decision) get extracted with tests. Everything else is React Server Component loaders + client view components, following the exact pattern already used for Suppliers (`suppliers-view.tsx` lean list + `[supplierId]/supplier-detail-view.tsx` for depth). QR badges render via a shared `` client component (uses the `qrcode` npm package to draw a data-URL image) and print via the app's existing `printing-answer` / `print-target` CSS mechanism (already used in `chat-view.tsx`), not a new print system. + +**Tech Stack:** Next.js 16 App Router, TypeScript, PocketBase, `qrcode` (new dependency) + `@types/qrcode`, existing shadcn-style UI components, vitest for pure-function tests. + +**Spec:** `docs/superpowers/specs/2026-09-11-workers-timesheets-qr-design.md` + +--- + +## File Structure + +| File | Change | +| --- | --- | +| `package.json` | Add `qrcode` + `@types/qrcode` | +| `src/lib/worker-qr.ts` | **Create** — the one function deciding the QR payload | +| `src/lib/worker-qr.test.ts` | **Create** | +| `src/lib/workforce-hours.ts` | **Create** — extracted date-bucketing helpers, now shared by the landing page and the detail page | +| `src/lib/workforce-hours.test.ts` | **Create** | +| `src/app/dashboard/workforce/workers/page.tsx` | **Modify** — loader drops all timesheet fetching; roster only | +| `src/app/dashboard/workforce/workers/employees-view.tsx` | **Modify** — becomes a lean, selectable roster; charts/KPIs/dialog removed | +| `src/components/worker-qr-card.tsx` | **Create** — the printable badge card (single source of truth for both print modes) | +| `src/components/worker-qr-batch-dialog.tsx` | **Create** — "all visible" vs "selected" batch print dialog | +| `src/app/dashboard/workforce/workers/[employeeId]/page.tsx` | **Create** — detail loader | +| `src/app/dashboard/workforce/workers/[employeeId]/worker-detail-view.tsx` | **Create** — aggregates, timesheet history, single-badge print | +| `src/app/dashboard/workforce/workforce-charts.tsx` | **Create** — `WorkforceHoursChart` + `TopWorkersCostChart`, moved verbatim out of `employees-view.tsx` | +| `src/app/dashboard/workforce/page.tsx` | **Modify** — gains the two charts + Total cost/Idle-30d KPIs | +| `src/app/dashboard/workforce/timesheets/page.tsx` | **Modify** — fetches full `timesheets` document fields, not just `id,processing_status` | +| `src/app/dashboard/workforce/timesheets/timesheets-view.tsx` | **Modify** — new "Upload status" section above the existing Entries table | +| `src/app/globals.css` | **Modify** — `.worker-qr-card` / `.worker-qr-badge-grid` styles | + +--- + +### Task 1: Add the `qrcode` dependency + +**Files:** +- Modify: `package.json` + +- [ ] **Step 1: Install the packages** + +```bash +npm install qrcode +npm install -D @types/qrcode +``` + +- [ ] **Step 2: Verify `package.json` now lists both** + +```bash +grep -n "qrcode" package.json +``` +Expected: one line under `"dependencies"` for `"qrcode"`, one line under `"devDependencies"` for `"@types/qrcode"`. + +- [ ] **Step 3: Commit** + +```bash +git add package.json package-lock.json +git commit -m "chore(workforce): add qrcode dependency for worker badges" +``` + +--- + +### Task 2: QR payload decision (`worker-qr.ts`) + +This is deliberately its own file: the spec (§4.1) explains that the QR encodes `employee_no` verbatim as a considered tradeoff, not an oversight, and that a future move to a stored opaque token only requires changing this one function. Isolating it means that migration touches one file, not every call site. + +**Files:** +- Create: `src/lib/worker-qr.ts` +- Test: `src/lib/worker-qr.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// src/lib/worker-qr.test.ts +import { describe, expect, it } from "vitest"; +import { buildWorkerQrPayload } from "./worker-qr"; + +describe("buildWorkerQrPayload", () => { + it("returns the employee number verbatim, no prefix or suffix", () => { + expect(buildWorkerQrPayload("MCW058")).toBe("MCW058"); + }); + + it("does not alter case or whitespace", () => { + expect(buildWorkerQrPayload(" MCW058 ")).toBe(" MCW058 "); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/worker-qr.test.ts` +Expected: FAIL — `Cannot find module './worker-qr'` + +- [ ] **Step 3: Write the implementation** + +```typescript +// src/lib/worker-qr.ts +/** + * The QR payload IS the employee number, verbatim — a deliberate, + * acknowledged tradeoff (see spec §4.1), not a placeholder. Isolating the + * decision here means a future move to a stored, rotatable token only + * requires changing this function's body, not every call site. + */ +export function buildWorkerQrPayload(employeeNo: string): string { + return employeeNo; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/worker-qr.test.ts` +Expected: PASS (2/2) + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/worker-qr.ts src/lib/worker-qr.test.ts +git commit -m "feat(workforce): add worker QR payload helper" +``` + +--- + +### Task 3: Extract shared workforce date-math (`workforce-hours.ts`) + +`buildWeeklyHours`, `buildWeeklyTrend`, `sumHoursInWindow`, and the Monday-anchoring logic currently live only inside `workers/page.tsx`, duplicated in spirit by `workforce/page.tsx`'s own `startOfIsoWeekUtc`. Both the Workforce landing page (Task 9) and the new worker detail page (Task 8) need this math, so it moves to a shared, tested module first. + +**Files:** +- Create: `src/lib/workforce-hours.ts` +- Test: `src/lib/workforce-hours.test.ts` + +- [ ] **Step 1: Write the failing tests** + +```typescript +// src/lib/workforce-hours.test.ts +import { describe, expect, it } from "vitest"; +import { + MS_PER_DAY, + buildWeeklyHours, + buildWeeklyTrend, + startOfIsoWeekUtc, + sumHoursInWindow, +} from "./workforce-hours"; + +// Wednesday, 2026-09-09 (UTC) — Monday of that week is 2026-09-07. +const NOW = new Date("2026-09-09T12:00:00.000Z"); + +describe("startOfIsoWeekUtc", () => { + it("anchors to Monday of the current week", () => { + expect(new Date(startOfIsoWeekUtc(NOW)).toISOString().slice(0, 10)).toBe( + "2026-09-07", + ); + }); +}); + +describe("sumHoursInWindow", () => { + const list = [ + { date: "2026-09-01", hours: 3 }, + { date: "2026-09-05", hours: 5 }, + { date: "2026-09-10", hours: 8 }, + ]; + + it("sums hours within [start, end)", () => { + const start = Date.UTC(2026, 8, 1); + const end = Date.UTC(2026, 8, 6); + expect(sumHoursInWindow(list, start, end)).toBe(8); + }); + + it("excludes the end boundary", () => { + const start = Date.UTC(2026, 8, 5); + const end = Date.UTC(2026, 8, 5); + expect(sumHoursInWindow(list, start, end)).toBe(0); + }); + + it("ignores unparseable dates instead of throwing", () => { + const bad = [{ date: "not-a-date", hours: 99 }]; + expect(sumHoursInWindow(bad, 0, Date.UTC(2030, 0, 1))).toBe(0); + }); +}); + +describe("buildWeeklyTrend", () => { + it("buckets hours into the correct week, oldest first", () => { + const timesheets = [ + { date: "2026-09-07", hours: 10 }, // this week (Mon) + { date: "2026-08-31", hours: 4 }, // last week (Mon) + ]; + const trend = buildWeeklyTrend(timesheets, NOW, 2); + expect(trend).toHaveLength(2); + expect(trend[0].weekStart).toBe("2026-08-31"); + expect(trend[0].hours).toBe(4); + expect(trend[1].weekStart).toBe("2026-09-07"); + expect(trend[1].hours).toBe(10); + }); + + it("returns all-zero buckets when there are no timesheets", () => { + const trend = buildWeeklyTrend([], NOW, 3); + expect(trend.every((b) => b.hours === 0)).toBe(true); + }); +}); + +describe("buildWeeklyHours", () => { + it("builds a per-employee weekly array, oldest first", () => { + const map = new Map([ + [ + "emp1", + [ + { date: "2026-09-07", hours: 6 }, + { date: "2026-08-31", hours: 2 }, + ], + ], + ]); + const result = buildWeeklyHours(map, NOW, 2); + expect(result.get("emp1")).toEqual([2, 6]); + }); + + it("defaults to zeros for an employee with no entries in range", () => { + const map = new Map([["emp1", []]]); + const result = buildWeeklyHours(map, NOW, 2); + expect(result.get("emp1")).toEqual([0, 0]); + }); +}); + +it("exports the day-in-ms constant", () => { + expect(MS_PER_DAY).toBe(86_400_000); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run src/lib/workforce-hours.test.ts` +Expected: FAIL — `Cannot find module './workforce-hours'` + +- [ ] **Step 3: Write the implementation** + +```typescript +// src/lib/workforce-hours.ts +export interface WeeklyTrendPoint { + /** YYYY-MM-DD, Monday of the week */ + weekStart: string; + hours: number; +} + +export const MS_PER_DAY = 86_400_000; + +/** UTC midnight of the Monday that starts `now`'s ISO week. */ +export function startOfIsoWeekUtc(now: Date): number { + const todayUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); + const dayOfWeek = new Date(todayUtc).getUTCDay(); // 0=Sun..6=Sat + const mondayOffset = (dayOfWeek + 6) % 7; // days since Monday + return todayUtc - mondayOffset * MS_PER_DAY; +} + +/** + * Build a `weekCount`-week hours sparkline per employee, oldest first. + * Weeks with no entries show as 0. + */ +export function buildWeeklyHours( + timesheetsByEmployee: Map, + now: Date, + weekCount = 8, +): Map { + const result = new Map(); + const todayUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); + const thisMonday = startOfIsoWeekUtc(now); + const startMonday = thisMonday - (weekCount - 1) * 7 * MS_PER_DAY; + + for (const [empId, list] of timesheetsByEmployee) { + const weekly = new Array(weekCount).fill(0); + for (const t of list) { + const d = new Date(t.date); + if (Number.isNaN(d.getTime())) continue; + const dayUtc = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); + if (dayUtc < startMonday || dayUtc > todayUtc + MS_PER_DAY) continue; + const idx = Math.floor((dayUtc - startMonday) / (7 * MS_PER_DAY)); + if (idx >= 0 && idx < weekCount) weekly[idx] += t.hours; + } + result.set(empId, weekly); + } + return result; +} + +/** + * Sum total hours for each of the last `weekCount` weeks across ALL + * employees, oldest first. Used for the workforce-wide trend chart. + */ +export function buildWeeklyTrend( + timesheets: { date: string; hours: number }[], + now: Date, + weekCount = 12, +): WeeklyTrendPoint[] { + const todayUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); + const thisMonday = startOfIsoWeekUtc(now); + const startMonday = thisMonday - (weekCount - 1) * 7 * MS_PER_DAY; + + const buckets: WeeklyTrendPoint[] = []; + for (let i = 0; i < weekCount; i++) { + const weekStart = new Date(startMonday + i * 7 * MS_PER_DAY); + buckets.push({ weekStart: weekStart.toISOString().slice(0, 10), hours: 0 }); + } + for (const t of timesheets) { + const d = new Date(t.date); + if (Number.isNaN(d.getTime())) continue; + const dayUtc = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); + if (dayUtc < startMonday || dayUtc > todayUtc + MS_PER_DAY) continue; + const idx = Math.floor((dayUtc - startMonday) / (7 * MS_PER_DAY)); + if (idx >= 0 && idx < weekCount) buckets[idx].hours += t.hours; + } + return buckets; +} + +/** Sum hours in the half-open window [startUtc, endUtc). */ +export function sumHoursInWindow( + list: { date: string; hours: number }[], + startUtc: number, + endUtc: number, +): number { + let total = 0; + for (const t of list) { + const d = new Date(t.date); + if (Number.isNaN(d.getTime())) continue; + const dayUtc = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); + if (dayUtc >= startUtc && dayUtc < endUtc) total += t.hours; + } + return total; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run src/lib/workforce-hours.test.ts` +Expected: PASS (all cases) + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/workforce-hours.ts src/lib/workforce-hours.test.ts +git commit -m "refactor(workforce): extract shared weekly-hours math with tests" +``` + +--- + +### Task 4: Slim the Workers page loader + +The lean roster (Task 5) needs no timesheet data at all — a load-bearing simplification the spec calls for. This removes the entire `buildWeeklyHours`/`buildWeeklyTrend`/`sumHoursInWindow` block from this file (it now lives in `workforce-hours.ts`, consumed by Tasks 8–9, not here). + +**Files:** +- Modify: `src/app/dashboard/workforce/workers/page.tsx` + +- [ ] **Step 1: Replace the file** + +```tsx +// src/app/dashboard/workforce/workers/page.tsx +import { pbAdmin } from "@/lib/pb"; +import { Topbar } from "@/components/layout/sidebar"; +import { EmployeesView, type EmployeeRow } from "./employees-view"; + +export const dynamic = "force-dynamic"; + +export default async function EmployeesPage() { + const pb = await pbAdmin(); + + const [employees, sites] = await Promise.all([ + pb.collection("employees").getFullList({ sort: "name", expand: "site" }), + pb.collection("sites").getFullList({ sort: "name" }), + ]); + + const siteById = new Map(sites.map((s) => [s.id, s])); + + const rows: EmployeeRow[] = employees.map((e) => ({ + id: e.id, + name: e.name, + employeeNo: e.employee_no, + position: e.position, + workerType: e.worker_type, + ratePerHour: e.rate_per_hour || 0, + projectCode: e.expand?.site?.project_code ?? siteById.get(e.site)?.project_code ?? "", + })); + + const projectCodes = Array.from(new Set(rows.map((r) => r.projectCode))).sort(); + const positions = Array.from( + new Set(rows.map((r) => r.position).filter((p): p is string => !!p)), + ).sort(); + + return ( + <> + + + + ); +} +``` + +Note: `ImportTimesheetsButton` is intentionally dropped from this page's `Topbar` per spec §2.2 — it now lives only on Timesheets. + +- [ ] **Step 2: Commit** + +This file won't compile cleanly until Task 5 updates `employees-view.tsx` to match the new `EmployeeRow` shape — commit both together: + +```bash +git add src/app/dashboard/workforce/workers/page.tsx +git commit -m "refactor(workers): slim page loader to roster-only data" --allow-empty +``` + +(The `--allow-empty` guard is only needed if your tool runs Task 4 and 5 as separate commits and typecheck would fail in between; if running them as one commit, skip the flag and combine with Task 5's `git add`.) + +--- + +### Task 5: Slim `EmployeesView` to a lean, selectable roster + +Removes: the KPI strip, both charts, the CSV export's cost/hours columns, the `Dialog` detail drawer, the sparkline, and the permanent/casual section grouping (redundant now that a "Type" column + filter show the same thing per-row — dropping it also keeps "select all visible" a single flat operation instead of a two-section one). Adds: a leftmost checkbox column, an "Export QR badges" button wired to the new batch dialog (Task 7), and row-click navigation to the new detail route (Task 8). + +**Files:** +- Modify: `src/app/dashboard/workforce/workers/employees-view.tsx` + +- [ ] **Step 1: Replace the file** + +```tsx +// src/app/dashboard/workforce/workers/employees-view.tsx +"use client"; + +import * as React from "react"; +import { useRouter } from "next/navigation"; +import { Download, Filter, QrCode, Search, Users } from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Pagination } from "@/components/ui/pagination"; +import { formatCurrency } from "@/lib/utils"; +import { WorkerQrBatchDialog } from "@/components/worker-qr-batch-dialog"; + +export interface EmployeeRow { + id: string; + name: string; + employeeNo: string; + position: string; + workerType: "permanent" | "casual"; + ratePerHour: number; + projectCode: string; +} + +type WorkerFilter = "all" | "permanent" | "casual"; + +const PAGE_SIZE = 25; + +export function EmployeesView({ + rows, + projectCodes, + positions, +}: { + rows: EmployeeRow[]; + projectCodes: string[]; + positions: string[]; +}) { + const router = useRouter(); + const [workerFilter, setWorkerFilter] = React.useState("all"); + const [projectFilter, setProjectFilter] = React.useState("all"); + const [positionFilter, setPositionFilter] = React.useState("all"); + const [search, setSearch] = React.useState(""); + const [page, setPage] = React.useState(1); + const [selected, setSelected] = React.useState>(new Set()); + const [batchDialogOpen, setBatchDialogOpen] = React.useState(false); + + const filtered = React.useMemo(() => { + const q = search.trim().toLowerCase(); + return rows.filter((r) => { + if (workerFilter !== "all" && r.workerType !== workerFilter) return false; + if (projectFilter !== "all" && r.projectCode !== projectFilter) return false; + if (positionFilter !== "all" && r.position !== positionFilter) return false; + if (q) { + const hay = `${r.name} ${r.employeeNo}`.toLowerCase(); + if (!hay.includes(q)) return false; + } + return true; + }); + }, [rows, workerFilter, projectFilter, positionFilter, search]); + + const sorted = React.useMemo( + () => [...filtered].sort((a, b) => a.name.localeCompare(b.name)), + [filtered], + ); + + const pageCount = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE)); + const pageRows = React.useMemo( + () => sorted.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), + [sorted, page], + ); + + React.useEffect(() => { + setPage(1); + }, [workerFilter, projectFilter, positionFilter, search]); + + const filtersActive = + workerFilter !== "all" || + projectFilter !== "all" || + positionFilter !== "all" || + search !== ""; + + const allFilteredSelected = sorted.length > 0 && sorted.every((r) => selected.has(r.id)); + + function toggleSelectAll() { + setSelected(allFilteredSelected ? new Set() : new Set(sorted.map((r) => r.id))); + } + + function toggleSelectOne(id: string) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + const selectedRows = React.useMemo( + () => rows.filter((r) => selected.has(r.id)), + [rows, selected], + ); + + return ( +
+ + +
+ + setSearch(e.target.value)} + placeholder="Search by name or employee number…" + className="pl-8" + aria-label="Search workers" + /> +
+ + setWorkerFilter(v as WorkerFilter)} + options={[ + { value: "all", label: "All workers" }, + { value: "permanent", label: "Permanent only" }, + { value: "casual", label: "Casual only" }, + ]} + /> + + ({ value: c, label: c })), + ]} + /> + + {positions.length > 0 && ( + ({ value: p, label: p })), + ]} + /> + )} + + {filtersActive && ( + + )} + + + + +
+
+ + + + {sorted.length === 0 ? ( +
+ {rows.length === 0 + ? "No workers yet." + : "No workers match the current filters."} +
+ ) : ( + + + + + + + Worker + Position + Project + Type + Rate / hr + + + + {pageRows.map((r) => ( + router.push(`/dashboard/workforce/workers/${r.id}`)} + > + e.stopPropagation()}> + toggleSelectOne(r.id)} + aria-label={`Select ${r.name}`} + /> + + +
+ + + +
+
{r.name}
+
+ {r.employeeNo} +
+
+
+
+ {r.position || "—"} + + + {r.projectCode || "—"} + + + + + {r.workerType} + + + + {formatCurrency(r.ratePerHour)} + +
+ ))} +
+
+ )} +
+ {sorted.length > PAGE_SIZE && ( +
+ +
+ )} +
+ + +
+ ); +} + +function SelectFilter({ + icon: Icon, + label, + value, + onChange, + options, +}: { + icon: React.ComponentType<{ className?: string }>; + label: string; + value: string; + onChange: (v: string) => void; + options: { value: string; label: string }[]; +}) { + return ( +
+ + {label} + + +
+ ); +} + +/** Export the currently-filtered roster as a CSV file. Browser-only. */ +function exportCsv(rows: EmployeeRow[]) { + const headers = [ + "name", + "employee_no", + "worker_type", + "position", + "project_code", + "rate_per_hour", + ]; + const lines = [headers.join(",")]; + for (const r of rows) { + lines.push( + [ + csvCell(r.name), + csvCell(r.employeeNo), + r.workerType, + csvCell(r.position), + csvCell(r.projectCode), + r.ratePerHour.toFixed(2), + ].join(","), + ); + } + const blob = new Blob([lines.join("\n")], { type: "text/csv;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `workers-${new Date().toISOString().slice(0, 10)}.csv`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +function csvCell(s: string): string { + if (s == null) return ""; + const needsQuotes = /[",\n]/.test(s); + const escaped = s.replace(/"/g, '""'); + return needsQuotes ? `"${escaped}"` : escaped; +} +``` + +This won't typecheck yet — it imports `WorkerQrBatchDialog`, built in Task 7. That's expected; Tasks 5–7 land together before the next typecheck gate (see Task 7 Step 3). + +- [ ] **Step 2: Commit alongside Task 4** + +```bash +git add src/app/dashboard/workforce/workers/page.tsx src/app/dashboard/workforce/workers/employees-view.tsx +git commit -m "refactor(workers): slim roster page — drop KPIs, charts, dialog, sections" +``` + +--- + +### Task 6: `WorkerQrCard` — the printable badge + +Single source of truth for badge markup, used by both the detail page (Task 8) and the batch dialog (Task 7). Layout matches the confirmed visual companion choice ("A — Centered, QR-forward"). + +**Files:** +- Create: `src/components/worker-qr-card.tsx` + +- [ ] **Step 1: Write the component** + +```tsx +// src/components/worker-qr-card.tsx +"use client"; + +import * as React from "react"; +import QRCode from "qrcode"; +import { buildWorkerQrPayload } from "@/lib/worker-qr"; +import { cn } from "@/lib/utils"; + +export interface WorkerQrCardProps { + name: string; + employeeNo: string; + className?: string; +} + +export function WorkerQrCard({ name, employeeNo, className }: WorkerQrCardProps) { + const [qrDataUrl, setQrDataUrl] = React.useState(null); + + React.useEffect(() => { + let cancelled = false; + QRCode.toDataURL(buildWorkerQrPayload(employeeNo), { margin: 1, width: 240 }).then( + (url) => { + if (!cancelled) setQrDataUrl(url); + }, + ); + return () => { + cancelled = true; + }; + }, [employeeNo]); + + return ( +
+
Mega Infrastructure
+ {qrDataUrl ? ( + // eslint-disable-next-line @next/next/no-img-element -- data URL, no next/image benefit + {`QR + ) : ( +
+ )} +
{name}
+
{employeeNo}
+
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/components/worker-qr-card.tsx +git commit -m "feat(workforce): add printable worker QR badge card" +``` + +--- + +### Task 7: Badge CSS + batch export dialog + +Adds the physical (mm-based) card sizing shared by both print modes, then the dialog that lets a Workers-list user choose "all visible" or their checkbox selection and print a multi-card sheet. Reuses the app's existing `printing-answer` / `print-target` mechanism (see `chat-view.tsx`'s `printAnswer`) rather than inventing a new print system. + +**Files:** +- Modify: `src/app/globals.css` +- Create: `src/components/worker-qr-batch-dialog.tsx` + +- [ ] **Step 1: Append badge CSS to `globals.css`** + +Add this block at the end of `src/app/globals.css` (after the existing `printing-answer` block): + +```css + +/* Worker QR badge cards — used by both the single-print button on a + worker's detail page and the batch export dialog on the Workers list. + Sized to standard ID-badge/credit-card dimensions so a printed sheet + can be cut and laminated directly. */ +.worker-qr-card { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + width: 85mm; + height: 54mm; + padding: 6mm; + border: 1px solid hsl(var(--border)); + border-radius: 10px; + background: hsl(var(--card)); + box-sizing: border-box; + text-align: center; + break-inside: avoid; +} +.worker-qr-card__brand { + font-size: 9px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: hsl(var(--muted-foreground)); +} +.worker-qr-card__qr { + width: 26mm; + height: 26mm; + object-fit: contain; +} +.worker-qr-card__name { + font-size: 13px; + font-weight: 700; +} +.worker-qr-card__number { + font-size: 11px; + font-family: ui-monospace, monospace; + color: #E08A3C; +} + +/* Batch sheet: 2 cards per row, as many rows as fit. */ +.worker-qr-badge-grid { + display: grid; + grid-template-columns: repeat(2, 85mm); + grid-auto-rows: 54mm; + gap: 4mm; +} + +/* The batch grid isn't part of the normal page layout — it exists only + to be printed. Keep it off-canvas on screen; the .print-target rule + in the printing-answer block above (position: absolute; inset: 0) + overrides this the instant printing starts. */ +.worker-qr-badges-offscreen { + position: fixed; + left: -10000px; + top: 0; +} +``` + +- [ ] **Step 2: Write the batch dialog** + +```tsx +// src/components/worker-qr-batch-dialog.tsx +"use client"; + +import * as React from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { WorkerQrCard } from "@/components/worker-qr-card"; + +export interface WorkerQrBatchDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + filteredWorkers: { id: string; name: string; employeeNo: string }[]; + selectedWorkers: { id: string; name: string; employeeNo: string }[]; +} + +export function WorkerQrBatchDialog({ + open, + onOpenChange, + filteredWorkers, + selectedWorkers, +}: WorkerQrBatchDialogProps) { + const [mode, setMode] = React.useState<"filtered" | "selected">("filtered"); + + // Default to "selected" whenever the dialog opens with a selection + // already made — the more specific choice wins over "everything visible". + React.useEffect(() => { + if (open) setMode(selectedWorkers.length > 0 ? "selected" : "filtered"); + }, [open, selectedWorkers.length]); + + const workers = mode === "selected" ? selectedWorkers : filteredWorkers; + + function printBadges() { + const el = document.getElementById("worker-badges-print-target"); + if (!el) return; + el.classList.add("print-target"); + document.body.classList.add("printing-answer"); + try { + window.print(); + } finally { + document.body.classList.remove("printing-answer"); + el.classList.remove("print-target"); + } + } + + return ( + + + + Export QR badges + + Prints a multi-card sheet you can cut and laminate. + + + +
+
+ + +
+ +

+ {workers.length} badge{workers.length === 1 ? "" : "s"} will print, ~10 per + A4/Letter sheet. +

+ + +
+ +
+ {workers.map((w) => ( + + ))} +
+
+
+ ); +} +``` + +- [ ] **Step 3: Typecheck now that Tasks 5–7 are all in place** + +Run: `npm run typecheck` +Expected: no errors from any of the files touched in Tasks 5–7. + +- [ ] **Step 4: Commit** + +```bash +git add src/app/globals.css src/components/worker-qr-batch-dialog.tsx +git commit -m "feat(workforce): add batch QR badge export dialog" +``` + +--- + +### Task 8: New worker detail page + +Everything removed from the roster in Task 5 lives here: totals, the 8-week sparkline, full timesheet history, and the single-worker badge print action. + +**Files:** +- Create: `src/app/dashboard/workforce/workers/[employeeId]/page.tsx` +- Create: `src/app/dashboard/workforce/workers/[employeeId]/worker-detail-view.tsx` + +- [ ] **Step 1: Write the loader** + +```tsx +// src/app/dashboard/workforce/workers/[employeeId]/page.tsx +import { notFound } from "next/navigation"; +import { pbAdmin } from "@/lib/pb"; +import { Topbar } from "@/components/layout/sidebar"; +import { buildWeeklyHours, sumHoursInWindow, MS_PER_DAY } from "@/lib/workforce-hours"; +import { WorkerDetailView, type WorkerDetail } from "./worker-detail-view"; + +export const dynamic = "force-dynamic"; + +export default async function WorkerDetailPage({ + params, +}: { + params: Promise<{ employeeId: string }>; +}) { + const { employeeId } = await params; + const pb = await pbAdmin(); + + let employee: { + id: string; + name: string; + employee_no: string; + position: string; + worker_type: "permanent" | "casual"; + rate_per_hour: number; + hours_per_day_standard: number; + site: string; + expand?: { site?: { name?: string; project_code?: string } }; + }; + try { + employee = await pb.collection("employees").getOne(employeeId, { expand: "site" }); + } catch { + notFound(); + } + + const entries = await pb.collection("timesheet_entries").getFullList({ + filter: `employee = "${employeeId}"`, + }); + + const timesheets = entries + .map((t) => ({ + id: t.id as string, + date: (t.date_logged as string) ?? "", + hours: (t.hours_worked as number) || 0, + })) + .sort((a, b) => (a.date < b.date ? 1 : -1)); + + const now = new Date(); + const todayUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); + const last30Start = todayUtc - 30 * MS_PER_DAY; + const monthStart = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1); + + const totalHours = timesheets.reduce((s, t) => s + t.hours, 0); + const rate = employee.rate_per_hour || 0; + const last30DaysHours = sumHoursInWindow(timesheets, last30Start, todayUtc + MS_PER_DAY); + const thisMonthHours = sumHoursInWindow(timesheets, monthStart, todayUtc + MS_PER_DAY); + const weeklyByEmployee = buildWeeklyHours(new Map([[employeeId, timesheets]]), now, 8); + + const detail: WorkerDetail = { + id: employee.id, + name: employee.name, + employeeNo: employee.employee_no, + position: employee.position, + workerType: employee.worker_type, + ratePerHour: rate, + hoursPerDayStandard: employee.hours_per_day_standard || 0, + projectCode: employee.expand?.site?.project_code ?? "", + totalHours, + totalCost: rate * totalHours, + last30DaysHours, + thisMonthHours, + weeklyHours: weeklyByEmployee.get(employeeId) ?? [0, 0, 0, 0, 0, 0, 0, 0], + timesheets, + }; + + return ( + <> + + + + ); +} +``` + +- [ ] **Step 2: Write the view** + +```tsx +// src/app/dashboard/workforce/workers/[employeeId]/worker-detail-view.tsx +"use client"; + +import * as React from "react"; +import { format, parseISO } from "date-fns"; +import { AlertTriangle, Banknote, Briefcase, Calendar, Clock, Printer } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { cn, formatCurrency, formatNumber } from "@/lib/utils"; +import { WorkerQrCard } from "@/components/worker-qr-card"; + +export interface WorkerDetail { + id: string; + name: string; + employeeNo: string; + position: string; + workerType: "permanent" | "casual"; + ratePerHour: number; + hoursPerDayStandard: number; + projectCode: string; + totalHours: number; + totalCost: number; + last30DaysHours: number; + thisMonthHours: number; + weeklyHours: number[]; + timesheets: { id: string; date: string; hours: number }[]; +} + +export function WorkerDetailView({ detail }: { detail: WorkerDetail }) { + const isIdle30 = detail.last30DaysHours === 0; + + function printBadge() { + const el = document.getElementById("worker-badge-print-target"); + if (!el) return; + el.classList.add("print-target"); + document.body.classList.add("printing-answer"); + try { + window.print(); + } finally { + document.body.classList.remove("printing-answer"); + el.classList.remove("print-target"); + } + } + + return ( +
+
+ + {detail.workerType} + + } + /> + + + +
+ +
+ + + + 0h + + ) : ( + formatNumber(detail.last30DaysHours, { maximumFractionDigits: 1 }) + ) + } + /> +
+ +
+
Last 8 weeks
+ +
+ +
+
+ Timesheet history + + {detail.timesheets.length} entries + +
+ {detail.timesheets.length === 0 ? ( +
+ No timesheets logged for this worker yet. +
+ ) : ( + + + + Date + Hours + Cost + + + + {detail.timesheets.map((t) => ( + + + {t.date ? format(parseISO(t.date), "yyyy-MM-dd") : "—"} + + + {formatNumber(t.hours, { maximumFractionDigits: 2 })} + + + {formatCurrency(detail.ratePerHour * t.hours)} + + + ))} + +
+ )} +
+ +
+
+
QR badge
+ +
+
+ +
+
+
+ ); +} + +function StatTile({ + icon: Icon, + label, + value, +}: { + icon: React.ComponentType<{ className?: string }>; + label: string; + value: React.ReactNode; +}) { + return ( +
+
+ {label} +
+
{value}
+
+ ); +} + +function Sparkline({ values }: { values: number[] }) { + const max = Math.max(1, ...values); + return ( +
v.toFixed(1)) + .join(", ")}`} + > + {values.map((v, i) => { + const h = v <= 0 ? 0 : Math.max(8, Math.round((v / max) * 56)); + const isCurrent = i === values.length - 1; + return ( +
+ ); + })} +
+ ); +} +``` + +- [ ] **Step 3: Typecheck** + +Run: `npm run typecheck` +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add "src/app/dashboard/workforce/workers/[employeeId]" +git commit -m "feat(workforce): add worker detail page with history and QR badge" +``` + +--- + +### Task 9: Move the charts to the Workforce landing page + +Per the confirmed decision, `WorkforceHoursChart` and `TopWorkersCostChart` (currently defined inside `employees-view.tsx`, already removed from that file in Task 5) move to a new client component, and the landing page's loader grows the aggregate math needed to feed them plus the two new KPI tiles (Total cost, Idle 30d). + +**Files:** +- Create: `src/app/dashboard/workforce/workforce-charts.tsx` +- Modify: `src/app/dashboard/workforce/page.tsx` + +- [ ] **Step 1: Write the charts component** + +```tsx +// src/app/dashboard/workforce/workforce-charts.tsx +"use client"; + +import { format, parseISO } from "date-fns"; +import { + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { cn, formatCurrency, formatNumber } from "@/lib/utils"; +import type { WeeklyTrendPoint } from "@/lib/workforce-hours"; + +export function WorkforceHoursChart({ weeklyTrend }: { weeklyTrend: WeeklyTrendPoint[] }) { + const totalLast12 = weeklyTrend.reduce((s, p) => s + p.hours, 0); + const avgPerWeek = weeklyTrend.length > 0 ? totalLast12 / weeklyTrend.length : 0; + + return ( + + +
+ Workforce hours +

+ Total hours across all workers, last 12 weeks. +

+
+
+
+ {formatNumber(Math.round(avgPerWeek))} hrs/wk +
+
+ 12-week average +
+
+
+ + {totalLast12 === 0 ? ( +
+ No timesheet data yet. Import a CSV/XLSX to populate this chart. +
+ ) : ( + + + + format(parseISO(v), "MMM d")} + stroke="rgba(127,127,127,0.6)" + fontSize={11} + tickLine={false} + /> + `${v}`} + /> + `Week of ${format(parseISO(v), "yyyy-MM-dd")}`} + formatter={(value: number) => [`${formatNumber(Math.round(value))} hrs`, "Hours"]} + /> + + + + )} +
+
+ ); +} + +export function TopWorkersCostChart({ + workers, +}: { + workers: { id: string; name: string; type: string; cost: number; hours: number }[]; +}) { + const maxCost = Math.max(1, ...workers.map((w) => w.cost)); + return ( + + +
+ Top 5 by cost +

Where the money has gone, all-time.

+
+ cost / total +
+ + {workers.length === 0 ? ( +
+ No cost data yet. +
+ ) : ( +
    + {workers.map((w, i) => { + const pct = (w.cost / maxCost) * 100; + return ( +
  • + + {i + 1} + +
    +
    + {w.name} + + {formatCurrency(w.cost)}{" "} + + · {formatNumber(w.hours, { maximumFractionDigits: 1 })}h + + +
    +
    +
    +
    +
    +
  • + ); + })} +
+ )} +
+
+ ); +} +``` + +- [ ] **Step 2: Replace the landing page** + +```tsx +// src/app/dashboard/workforce/page.tsx +import Link from "next/link"; +import { + AlertTriangle, + ArrowRight, + Banknote, + Clock, + ScrollText, + Users, +} from "lucide-react"; +import { pbAdmin } from "@/lib/pb"; +import { Topbar } from "@/components/layout/sidebar"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { ImportTimesheetsButton } from "@/components/import-timesheets-button"; +import { formatCurrency, formatNumber } from "@/lib/utils"; +import { buildWeeklyTrend, startOfIsoWeekUtc, sumHoursInWindow, MS_PER_DAY } from "@/lib/workforce-hours"; +import { WorkforceHoursChart, TopWorkersCostChart } from "./workforce-charts"; + +export const dynamic = "force-dynamic"; + +async function loadWorkforceSummary() { + const pb = await pbAdmin(); + const now = new Date(); + const todayUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); + const last30Start = todayUtc - 30 * MS_PER_DAY; + const weekStartUtc = startOfIsoWeekUtc(now); + + const [employees, timesheets, sites] = await Promise.all([ + pb.collection("employees").getFullList({ sort: "name" }), + pb.collection("timesheet_entries").getFullList(), + pb.collection("sites").getFullList({ sort: "name" }), + ]); + + const timesheetsByEmployee = new Map(); + const flatTimesheets: { date: string; hours: number }[] = []; + let weekHours = 0; + for (const t of timesheets) { + const hours = t.hours_worked || 0; + const date = t.date_logged as string; + flatTimesheets.push({ date, hours }); + const list = timesheetsByEmployee.get(t.employee) ?? []; + list.push({ date, hours }); + timesheetsByEmployee.set(t.employee, list); + + const d = new Date(date); + if (!Number.isNaN(d.getTime())) { + const dayUtc = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); + if (dayUtc >= weekStartUtc) weekHours += hours; + } + } + + const weeklyTrend = buildWeeklyTrend(flatTimesheets, now, 12); + + let totalHours = 0; + let totalCost = 0; + let idle = 0; + const topByCost: { id: string; name: string; type: string; cost: number; hours: number }[] = []; + for (const e of employees) { + const list = timesheetsByEmployee.get(e.id) ?? []; + const hours = list.reduce((s, t) => s + t.hours, 0); + const cost = (e.rate_per_hour || 0) * hours; + totalHours += hours; + totalCost += cost; + if (sumHoursInWindow(list, last30Start, todayUtc + MS_PER_DAY) === 0) idle += 1; + if (cost > 0) topByCost.push({ id: e.id, name: e.name, type: e.worker_type, cost, hours }); + } + topByCost.sort((a, b) => b.cost - a.cost); + + const permanent = employees.filter((e) => e.worker_type === "permanent").length; + const casual = employees.filter((e) => e.worker_type === "casual").length; + + return { + total: employees.length, + permanent, + casual, + weekHours, + totalHours, + totalCost, + idle, + weeklyTrend, + topByCost: topByCost.slice(0, 5), + sites: sites.map((s) => ({ id: s.id, name: s.name, project_code: s.project_code })), + }; +} + +export default async function WorkforceLanding() { + const summary = await loadWorkforceSummary(); + + return ( + <> + ({ + id: s.id, + name: s.name, + project_code: s.project_code, + }))} + /> + } + /> +
+
+ + + + 0 ? "warning" : undefined} + /> +
+ +
+ + +
+ +
+ + +
+
+ + ); +} + +function KpiTile({ + label, + value, + sub, + icon: Icon, + tone, +}: { + label: string; + value: React.ReactNode; + sub: string; + icon: React.ComponentType<{ className?: string }>; + tone?: "warning"; +}) { + const strip = tone === "warning" ? "from-red-500 to-amber-500" : "from-[#F2B165] to-[#E08A3C]"; + const iconBg = tone === "warning" ? "bg-red-500/15" : "bg-[#F2B165]/15"; + const iconFg = tone === "warning" ? "text-red-600 dark:text-red-400" : "text-[#F2B165]"; + return ( + + + + {label} + + + + + +
{value}
+

{sub}

+
+
+ ); +} + +function SectionCard({ + href, + title, + description, + icon: Icon, +}: { + href: string; + title: string; + description: string; + icon: React.ComponentType<{ className?: string }>; +}) { + return ( + + +
+ {title} +

{description}

+
+ + + +
+ + + +
+ ); +} +``` + +- [ ] **Step 3: Typecheck** + +Run: `npm run typecheck` +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/app/dashboard/workforce/workforce-charts.tsx src/app/dashboard/workforce/page.tsx +git commit -m "feat(workforce): move workforce charts + richer KPIs to the landing page" +``` + +--- + +### Task 10: Timesheets — "Upload status" section + +Adds the section the spec calls for (§3), using data the page already fetches — today `timesheets` is queried with `fields: "id,processing_status"` purely to build a per-entry badge. This task widens that query and gives the data its own first-class table. + +**Files:** +- Modify: `src/app/dashboard/workforce/timesheets/page.tsx` +- Modify: `src/app/dashboard/workforce/timesheets/timesheets-view.tsx` + +- [ ] **Step 1: Widen the documents query and pass an `uploads` prop** + +In `src/app/dashboard/workforce/timesheets/page.tsx`, replace the `pb.collection("timesheets").getFullList(...)` call and everything below it: + +```tsx +// src/app/dashboard/workforce/timesheets/page.tsx +import { pbAdmin } from "@/lib/pb"; +import { Topbar } from "@/components/layout/sidebar"; +import { ImportTimesheetsButton } from "@/components/import-timesheets-button"; +import { TimesheetsView, type TimesheetEntryRow, type UploadRow } from "./timesheets-view"; + +export const dynamic = "force-dynamic"; + +export default async function TimesheetsPage() { + const pb = await pbAdmin(); + + const [entries, employees, sites, documents] = await Promise.all([ + pb.collection("timesheet_entries").getFullList({ + sort: "-date_logged", + expand: "employee,site,timesheet", + }), + pb.collection("employees").getFullList({ sort: "name" }), + pb.collection("sites").getFullList({ sort: "name" }), + pb.collection("timesheets").getFullList({ + sort: "-date_uploaded", + fields: + "id,source_file,date_uploaded,processing_status,rows_created,rows_failed,last_error", + }), + ]); + + const employeeById = new Map(employees.map((e) => [e.id, e])); + const siteById = new Map(sites.map((s) => [s.id, s])); + const docStatusById = new Map(documents.map((d) => [d.id, d.processing_status ?? ""])); + + const rows: TimesheetEntryRow[] = entries.map((e) => { + const emp = (e.expand?.employee ?? + employeeById.get(e.employee)) as { name?: string; employee_no?: string } | undefined; + const site = (e.expand?.site ?? siteById.get(e.site)) as + | { name?: string; project_code?: string } + | undefined; + const timesheetId = e.expand?.timesheet?.id ?? (e.timesheet ?? ""); + return { + id: e.id, + employeeId: e.employee ?? "", + employeeName: emp?.name ?? "(unknown)", + employeeNo: emp?.employee_no ?? "", + siteId: e.site ?? "", + siteName: site?.name ?? "(unknown)", + siteProjectCode: site?.project_code ?? "", + dateLogged: e.date_logged ?? "", + hoursWorked: e.hours_worked ?? 0, + hasDocument: !!timesheetId, + documentStatus: timesheetId ? docStatusById.get(timesheetId) ?? null : null, + }; + }); + + const uploads: UploadRow[] = documents.map((d) => ({ + id: d.id, + sourceFile: (d.source_file as string) ?? "", + dateUploaded: (d.date_uploaded as string) ?? "", + processingStatus: (d.processing_status as string) ?? "", + rowsCreated: (d.rows_created as number) ?? 0, + rowsFailed: (d.rows_failed as number) ?? 0, + lastError: (d.last_error as string) ?? "", + })); + + return ( + <> + ({ + id: s.id, + name: s.name, + project_code: s.project_code, + }))} + /> + } + /> + ({ + id: s.id, + name: s.name, + project_code: s.project_code, + }))} + /> + + ); +} +``` + +- [ ] **Step 2: Add the Upload status section to the view** + +In `src/app/dashboard/workforce/timesheets/timesheets-view.tsx`: + +1. Add the `UploadRow` export and `uploads` prop: + +```tsx +export interface UploadRow { + id: string; + sourceFile: string; + /** ISO date string (YYYY-MM-DD) or empty. */ + dateUploaded: string; + processingStatus: string; + rowsCreated: number; + rowsFailed: number; + lastError: string; +} +``` + +2. Change the component signature to accept it: + +```tsx +export function TimesheetsView({ + rows, + uploads, + sites, +}: { + rows: TimesheetEntryRow[]; + uploads: UploadRow[]; + sites: { id: string; name: string; project_code: string }[]; +}) { +``` + +3. Insert a new section directly after the filter-bar `` (right before the existing `` that renders "Entries"): + +```tsx + + + Upload status +

+ Every uploaded sheet and whether it processed cleanly — separate from + the individual hour entries below. +

+
+ + {uploads.length === 0 ? ( +
+ No timesheets uploaded yet. +
+ ) : ( + + + + File + Uploaded + Status + Rows created + Rows failed + + + + {uploads.map((u) => ( + + + {u.sourceFile || "—"} + + + {u.dateUploaded ? format(parseISO(u.dateUploaded), "yyyy-MM-dd") : "—"} + + + + {u.processingStatus || "pending"} + + + + {formatNumber(u.rowsCreated)} + + + {formatNumber(u.rowsFailed)} + + + ))} + +
+ )} +
+
+ + +``` + +(That last line replaces the existing `` that immediately precedes the "Entries" `CardHeader` — the new section is inserted above it, the existing Entries card is untouched below.) + +- [ ] **Step 3: Typecheck** + +Run: `npm run typecheck` +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/app/dashboard/workforce/timesheets/page.tsx src/app/dashboard/workforce/timesheets/timesheets-view.tsx +git commit -m "feat(timesheets): add upload-status section above the entries table" +``` + +--- + +### Task 11: Full verification pass + +**Files:** none (verification only) + +- [ ] **Step 1: Typecheck the whole project** + +Run: `npm run typecheck` +Expected: 0 errors. + +- [ ] **Step 2: Run the full test suite** + +Run: `npm run test:run` +Expected: all tests pass, including the new `worker-qr.test.ts` and `workforce-hours.test.ts`. + +- [ ] **Step 3: Lint** + +Run: `npm run lint` +Expected: no new errors introduced by this plan's files (pre-existing warnings elsewhere are not this plan's concern). + +- [ ] **Step 4: Manual print-preview check (not automatable)** + +Per spec §6, confirm by hand in a browser: +- Worker detail page → "Print badge" → browser print preview shows one centered card at roughly credit-card size. +- Workers list → select 3+ workers → "Export QR badges" → "Selected" → "Print badges" → preview shows a grid of cards, 2 per row. +- Workers list → clear selection → "Export QR badges" defaults to "All visible" and reflects the current filter (e.g. filter to one project, confirm only that project's workers appear in the print preview). + +- [ ] **Step 5: Commit any fixes found during verification** + +If typecheck/tests/lint surfaced issues, fix them and commit: + +```bash +git add -A +git commit -m "fix(workforce): address verification findings" +``` + +(Skip this step entirely if nothing needed fixing.) + +--- + +## Self-Review + +**Spec coverage:** +- §2 lean roster → Task 5 ✅ +- §2.1 detail page → Task 8 ✅ +- §2.2 button relocation → Task 4 (dropped from Workers), already lived on Timesheets, confirmed untouched in Task 10 ✅ +- §3 Timesheets two sections → Task 10 ✅ +- §4.1 derived QR, no schema change → Task 2 (`worker-qr.ts` has zero PocketBase interaction) ✅ +- §4.2 card layout A → Task 6 (`WorkerQrCard`, centered/QR-forward) ✅ +- §4.3 both single + batch print → Tasks 8 (single) + 7 (batch) ✅ +- §4.4 `qrcode` + browser print, no PDF lib → Task 1 (dependency), Tasks 7/8 (`window.print()`, no jsPDF/html2canvas anywhere) ✅ +- §5 non-scope items → nothing in this plan touches the `employees` schema, adds a `qr_token` field, or builds the timekeeping PWA ✅ +- §6 testing table → QR encoding covered by Task 2's test; upload-status zero-row case covered by Task 10 Step 2's `uploads.length === 0` branch; batch selection-vs-filter logic covered by Task 7's dialog + Task 5's selection state; print CSS explicitly left to Task 11 Step 4's manual check, as the spec itself says vitest can't exercise it ✅ + +**Placeholder scan:** none found — every step has complete, exact code or an exact command with expected output. + +**Type consistency:** `EmployeeRow` (Task 5) intentionally drops the fields `WorkerDetail` (Task 8) keeps — they are different types for different pages, not a naming drift. `WeeklyTrendPoint`, `MS_PER_DAY`, `buildWeeklyHours`, `buildWeeklyTrend`, `sumHoursInWindow`, `startOfIsoWeekUtc` are defined once in `workforce-hours.ts` (Task 3) and imported by name, unchanged, everywhere else they're used (Tasks 8, 9). `buildWorkerQrPayload` (Task 2) is imported by exactly the name and signature used in `WorkerQrCard` (Task 6). + +--- + +## Execution Choice + +Plan complete and saved to `docs/superpowers/plans/2026-09-11-workers-timesheets-qr.md`. Two execution options: + +1. **Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. +2. **Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints. + +Which approach? (Or, per your usual pattern this session, hand it to your MiniMax agent and I'll review the diff afterward — that works too.) diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png index e720d13..3566b76 100644 Binary files a/public/icons/icon-192.png and b/public/icons/icon-192.png differ diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png index e720d13..3566b76 100644 Binary files a/public/icons/icon-512.png and b/public/icons/icon-512.png differ diff --git a/public/icons/maskable-512.png b/public/icons/maskable-512.png index e720d13..3566b76 100644 Binary files a/public/icons/maskable-512.png and b/public/icons/maskable-512.png differ diff --git a/public/logo.png b/public/logo.png index e720d13..3566b76 100644 Binary files a/public/logo.png and b/public/logo.png differ diff --git a/src/app/apple-icon.png b/src/app/apple-icon.png index 9c72f46..3566b76 100644 Binary files a/src/app/apple-icon.png and b/src/app/apple-icon.png differ diff --git a/src/app/apple-icon_old.png b/src/app/apple-icon_old.png new file mode 100644 index 0000000..9c72f46 Binary files /dev/null and b/src/app/apple-icon_old.png differ