# Phase 3b·2 — Reports + All-Users Export — Design - **Created:** 2026-06-24 - **Status:** Approved (brainstorming) — ready for implementation plan - **Tracker:** Plane (workspace `solelog`, project SoleLog) - **Cycle:** Second of three Phase 3b cycles (after manual-sessions 3b·1, before user management) - **Touches:** `packages/shared`, `apps/api`, `apps/admin` ## Goal An admin can open a **Rapporten** screen, pick a period (and optionally narrow by worker / insole type / activity), and see headline production totals plus three breakdowns — **per medewerker**, **per handeling**, **per type** — and export the underlying detail rows (all workers) to CSV. Today's `/api/export` is **self-scoped** to the logged-in user; this cycle adds the cross-user, filterable reporting + export the admin needs to review a week. _Done when:_ an admin can choose a date range (with Deze week / Deze maand / Alles presets) and optional worker/type/activity filters, see correct headline totals and three breakdown tables, and download a CSV of every matching completed session (all workers, with a Worker column) that respects the same filters. ## Scope decisions (confirmed during brainstorming, 2026-06-24) 1. **Both lenses on one screen** — a period summary with breakdowns **per worker AND per activity AND per type** (same underlying query, grouped three ways), plus headline totals. 2. **Filters:** date range (from/to, the spine) + optional worker + optional insole type + optional activity. Default period on open = **this week** (Mon–today); presets Deze week / Deze maand / Alles. 3. **What counts:** only `status='completed'` sessions contribute to totals and export (active = still running, no duration; discarded = cancelled). Mirrors the current export. 4. **Metrics (all four):** gewerkte tijd (worked seconds, excl. paused), aantal zolen (sum of `pair_count`), aantal sessies (count), pauzetijd (paused seconds). Shown both as headline totals and in every breakdown row. 5. **CSV = detail rows, all workers** — every filtered completed session as a row, like today's export plus a leading **Worker** column. (Not the aggregated summary — that's visible on-screen.) 6. **Presentation: tables only** — headline totals card + three breakdown tables, numbers only. No chart library, no CSS bars — keeps the dependency-light ethos and is fastest to ship. ## A. Backend (under the existing admin-gated `/api/admin/*` guard in `routes/admin.ts`) Both new endpoints accept the same query params and share one filtered-query helper so report and export can never drift: - `from` — ISO instant, inclusive lower bound on `start_time`. - `to` — ISO instant, inclusive upper bound on `start_time`. - `user_id?` — restrict to one worker. - `insole_type?` — one of `Kurk | Berk | 3D`. - `activity_id?` — restrict to one handeling. All queries additionally force `status='completed'`. A shared helper `buildSessionFilters({ from, to, user_id, insole_type, activity_id })` returns the Drizzle `where` condition array (completed + range + any provided optional filters), used by both endpoints. ### `GET /api/admin/report` Fetches the filtered rows once (joined to `activities` + `user` for names) and aggregates **in JS** in a single pass — small data, gives names for free, one code path. Returns `ReportResponse`: ``` range: { from, to } // echoes the requested ISO instants totals: { worked_seconds, paused_seconds, pairs, sessions } by_worker: [{ user_id, user_name, worked_seconds, paused_seconds, pairs, sessions }] by_activity: [{ activity_id, activity_name, worked_seconds, paused_seconds, pairs, sessions }] by_type: [{ insole_type, worked_seconds, paused_seconds, pairs, sessions }] ``` - `worked_seconds` sums `duration_seconds` (already excludes paused); `paused_seconds` sums `paused_seconds`; `pairs` sums `pair_count`; `sessions` counts rows. - Headline `totals` equals the sum across any one breakdown (invariant worth a test). - Breakdown arrays are sorted by `worked_seconds` descending. Empty range → all-zero `totals` and empty breakdown arrays. - A row whose `insole_type` is null is bucketed under a `'Onbekend'`-style key in `by_type` (defensive — manual edits allow null type). A row missing an activity/user name falls back to a readable label, never crashes the grouping. ### `GET /api/admin/export` Same filters; returns CSV detail rows (all workers). Columns: **Worker**, ID, Task, Insole Type, No. of Insoles, Date, Total Duration, Paused Duration, Start Time, End Time. `Content-Type: text/csv; charset=utf-8`; `Content-Disposition: attachment; filename="solelog-report__.csv"` (dates as `YYYY-MM-DD`). Ordered by `start_time` ascending. ### DRY refactor (light, in `lib/csv.ts`) Extract the row/header builder currently inline in `sessions.ts`'s `/api/export` into a shared `buildSessionsCsv(rows, { includeWorker })`: - `includeWorker: false` → existing 9-column format, used by the self-scoped worker export (`/api/export`) — output byte-identical to today, so existing tests still pass. - `includeWorker: true` → prepends a `Worker` column, used by `/api/admin/export`. Reuses the existing `quote` + `formatDuration` helpers. One source of truth for the CSV format. ### Validation / errors `from`/`to` required and must parse as dates with `to ≥ from` → else 400. `insole_type` (if given) must be a valid `InsoleType`; `activity_id`/`user_id` (if given) are applied as filters (an unknown id simply yields an empty result, not an error). Admin guard already returns 401 (no session) / 403 (non-admin) for the whole `/api/admin/*` surface. ## B. Shared contracts (`@solelog/shared`) Add zod schemas + inferred types: - `ReportTotals` — `{ worked_seconds, paused_seconds, pairs, sessions }` (all int). - `ReportWorkerRow`, `ReportActivityRow`, `ReportTypeRow` — `ReportTotals` plus the grouping key(s) (`user_id`+`user_name`; `activity_id`+`activity_name`; `insole_type`). - `ReportResponse` — `{ range: { from, to }, totals, by_worker, by_activity, by_type }`. Query params are validated in the route (not a shared schema). The **client** is responsible for sending `from`/`to` as ISO instants spanning whole local days (start-of-from-day … end-of-to-day in the admin's browser tz), so server-side timezone handling is unnecessary. ## C. Admin UI (`apps/admin`) - **`components/Sidebar.tsx`** — move `'Rapporten'` from `soonItems` into `navItems` (`{ to: '/rapporten', label: 'Rapporten' }`); `soonItems` becomes `['Gebruikers']`. - **`App.tsx`** — add `} />`. - **`screens/Reports.tsx`** — the screen: - **Filter bar:** from/to `date` inputs; preset buttons **Deze week** (default on mount) / **Deze maand** / **Alles**; worker `` (Kurk/Berk/3D); activity `