docs(admin): phase 3a spec + implementation plan
This commit is contained in:
320
docs/superpowers/plans/2026-06-17-phase-3a-admin-panel.md
Normal file
320
docs/superpowers/plans/2026-06-17-phase-3a-admin-panel.md
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
# Phase 3a — Admin Panel (MVP) Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** Implement task-by-task with TDD. Steps use checkbox (`- [ ]`) syntax.
|
||||||
|
> Spec: `docs/superpowers/specs/2026-06-17-phase-3a-admin-panel-design.md`.
|
||||||
|
|
||||||
|
**Goal:** A new `apps/admin` desktop SPA where an admin logs in, watches who is working
|
||||||
|
live (auto-refreshing, read-only), and manages handelingen — on existing endpoints plus
|
||||||
|
`role` added to `/api/me`.
|
||||||
|
|
||||||
|
**Architecture:** Vite + React 18 + TS client (dev port 5174) mirroring `apps/worker`'s
|
||||||
|
toolchain. Bearer-token auth reused from worker; admin gate reads `role` from `/api/me`.
|
||||||
|
Left-sidebar shell. Live view polls `/api/admin/sessions/active`; activities CRUD via
|
||||||
|
`/api/activities`.
|
||||||
|
|
||||||
|
**Tech Stack:** Vite 7, React 18.3, react-router-dom 6, @tanstack/react-query 5, vitest 3,
|
||||||
|
@testing-library/react 16, TypeScript 5.7, `@solelog/shared` (zod contracts).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- **Reuse, don't reinvent:** `apps/worker` is the canonical template — copy `lib/api.ts`,
|
||||||
|
`lib/auth-storage.ts`, tsconfig/vite/vitest configs, `test/setup.ts`, `main.tsx`
|
||||||
|
structure verbatim, adjusting only names/title/port.
|
||||||
|
- **Dutch UI strings** throughout (worker app is the reference for tone/terms).
|
||||||
|
- **Lint/format:** oxlint + oxfmt — 2-space, single quotes, semicolons, width 100, **es5
|
||||||
|
trailing commas** (no trailing comma in function params or last call args). Run scoped to
|
||||||
|
changed files only; do **not** reformat unrelated files.
|
||||||
|
- **TDD:** write the failing test, see it fail, implement minimally, see it pass, commit.
|
||||||
|
- **Commit per task** with a conventional-commit message.
|
||||||
|
- **No backend change beyond `role` on `/api/me`** (Task 1). Live view is **read-only**.
|
||||||
|
- **Windows libsql lock trap:** any agent that starts the API server must kill the server
|
||||||
|
tree afterward (a lingering `tsx`/node holding `data/app.db` or port 3000 breaks the next
|
||||||
|
run). Prefer not starting the server unless verifying live.
|
||||||
|
- Admin dev port **5174** (worker uses 5173).
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/shared/src/index.ts MODIFY add role to PublicUser
|
||||||
|
apps/api/src/routes/me.ts MODIFY return role
|
||||||
|
apps/api/test/me.test.ts MODIFY assert role present (create if absent)
|
||||||
|
|
||||||
|
apps/admin/ NEW workspace (mirror apps/worker)
|
||||||
|
package.json, index.html, .gitignore, README.md
|
||||||
|
tsconfig.json, tsconfig.app.json, tsconfig.node.json
|
||||||
|
vite.config.ts (port 5174), vitest.config.ts
|
||||||
|
src/
|
||||||
|
main.tsx, vite-env.d.ts, styles.css
|
||||||
|
test/setup.ts
|
||||||
|
lib/api.ts (copied from worker)
|
||||||
|
lib/auth-storage.ts (copied from worker)
|
||||||
|
lib/elapsed.ts (formatTime ported from worker stopwatch)
|
||||||
|
auth/AuthContext.tsx (signIn + admin-role gate)
|
||||||
|
api/me.ts (useMe / fetchMe)
|
||||||
|
api/admin-sessions.ts (useActiveSessions, refetchInterval 5000)
|
||||||
|
api/activities.ts (useActivities + create/update/delete)
|
||||||
|
components/Sidebar.tsx
|
||||||
|
screens/Login.tsx, Live.tsx, Activities.tsx
|
||||||
|
App.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Backend — `role` on `/api/me`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/shared/src/index.ts` (PublicUser)
|
||||||
|
- Modify: `apps/api/src/routes/me.ts`
|
||||||
|
- Test: `apps/api/test/me.test.ts` (extend; or `apps/api/src/routes/me.test.ts` — match
|
||||||
|
where existing route tests live)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `PublicUser` now has `role: Role`; `MeResponse.user.role` is `'worker' | 'admin'`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write/extend the failing test.** Using the test helpers
|
||||||
|
(`apps/api/test/helpers.ts`: `createTestUser`, `authToken`/`bearer`), assert that
|
||||||
|
`GET /api/me` with a worker token returns `user.role === 'worker'`, and with an admin
|
||||||
|
token returns `user.role === 'admin'`. (Create an admin via `auth.api.createUser` with
|
||||||
|
`role: 'admin'` — see `seed.ts` for the cast pattern.)
|
||||||
|
- [ ] **Step 2: Run it, watch it fail** (`role` undefined).
|
||||||
|
`yarn workspace @solelog/api test` (filter to the me test).
|
||||||
|
- [ ] **Step 3: Add `role` to `PublicUser`** in shared:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const PublicUser = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
email: z.string().email(),
|
||||||
|
name: z.string(),
|
||||||
|
role: Role,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Return `role` from the route.** In `apps/api/src/routes/me.ts`, add to the
|
||||||
|
`user` body: `role: ((session.user as { role?: string | null }).role ?? 'worker') as Role`
|
||||||
|
(import `Role`/`MeResponse` type from `@solelog/shared`). Keep the `MeResponse` typing.
|
||||||
|
- [ ] **Step 5: Run tests — pass.** Also run `yarn workspace @solelog/api typecheck`.
|
||||||
|
- [ ] **Step 6: Confirm worker app unaffected** — `yarn workspace @solelog/worker test`
|
||||||
|
still green (it ignores the extra field).
|
||||||
|
- [ ] **Step 7: Commit** — `feat(api): include role in /api/me response`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Scaffold `apps/admin` workspace
|
||||||
|
|
||||||
|
**Files:** all new under `apps/admin/` (see File Structure). Copy from `apps/worker`.
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: an installable `@solelog/admin` workspace that builds and runs an empty app
|
||||||
|
shell; `apiFetch`, `signIn`, `getToken/setToken/clearToken` available.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Copy config + boilerplate from worker**, adjusting only identifiers:
|
||||||
|
- `package.json` → `"name": "@solelog/admin"`, same scripts/deps/devDeps as worker.
|
||||||
|
- `index.html` → `<title>SoleLog Admin</title>`.
|
||||||
|
- `tsconfig.json`, `tsconfig.app.json`, `tsconfig.node.json` → copy verbatim.
|
||||||
|
- `vite.config.ts` → `server: { host: true, port: 5174 }`.
|
||||||
|
- `vitest.config.ts`, `src/test/setup.ts`, `src/vite-env.d.ts` → copy verbatim.
|
||||||
|
- `.gitignore` → copy from worker.
|
||||||
|
- `src/lib/api.ts`, `src/lib/auth-storage.ts` → copy **verbatim** (token key
|
||||||
|
`solelog.token` is shared intentionally — same backend, same browser origin is fine;
|
||||||
|
admin runs on a different port so localStorage is separate anyway).
|
||||||
|
- [ ] **Step 2: Minimal `src/main.tsx`** (copy worker's; renders `<App/>` inside
|
||||||
|
`QueryClientProvider`) and a placeholder `src/App.tsx` returning `<div>SoleLog Admin</div>`
|
||||||
|
and an empty `src/styles.css`.
|
||||||
|
- [ ] **Step 3: Smoke test** `src/App.test.tsx`: renders App, expects "SoleLog Admin" text
|
||||||
|
(wrap in `QueryClientProvider`).
|
||||||
|
- [ ] **Step 4: Install + verify** — from repo root `yarn install`, then
|
||||||
|
`yarn workspace @solelog/admin test` (smoke passes), `yarn workspace @solelog/admin typecheck`,
|
||||||
|
`yarn workspace @solelog/admin build`.
|
||||||
|
- [ ] **Step 5: Commit** — `feat(admin): scaffold Vite+React admin workspace`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Auth context + admin gate + Login screen
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `apps/admin/src/api/me.ts`, `apps/admin/src/auth/AuthContext.tsx`,
|
||||||
|
`apps/admin/src/screens/Login.tsx`
|
||||||
|
- Modify: `apps/admin/src/App.tsx`
|
||||||
|
- Test: `apps/admin/src/auth/AuthContext.test.tsx` (or `Login.test.tsx`)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `signIn` from `lib/api`, `getToken/clearToken` from `lib/auth-storage`,
|
||||||
|
`MeResponse` from `@solelog/shared`.
|
||||||
|
- Produces: `useAuth(): { isAuthed, signIn, signOut }` where `signIn` rejects non-admins;
|
||||||
|
`fetchMe(): Promise<MeResponse>`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests** (mock `lib/api`):
|
||||||
|
- signing in as an admin (`/api/me` → `role: 'admin'`) sets `isAuthed` true;
|
||||||
|
- signing in as a worker (`role: 'worker'`) throws, clears the token, `isAuthed` false.
|
||||||
|
- [ ] **Step 2: Run — fail** (`AuthContext` not implemented).
|
||||||
|
- [ ] **Step 3: Implement `api/me.ts`:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { MeResponse } from '@solelog/shared';
|
||||||
|
import { apiFetch } from '../lib/api';
|
||||||
|
|
||||||
|
export function fetchMe(): Promise<MeResponse> {
|
||||||
|
return apiFetch<MeResponse>('/api/me');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement `auth/AuthContext.tsx`** — mirror worker's, but `signIn` does:
|
||||||
|
call `apiSignIn(email,password)`; then `const me = await fetchMe()`; if
|
||||||
|
`me.user.role !== 'admin'` → `clearToken()` and `throw new Error('not-admin')`; else
|
||||||
|
`setIsAuthed(true)`. `signOut` clears token + sets false. Initial `isAuthed` =
|
||||||
|
`getToken() !== null` (a stale worker token is harmless — the admin endpoints 403 and the
|
||||||
|
next `/api/me`-backed screen can sign out; keep 3a simple).
|
||||||
|
- [ ] **Step 5: Implement `screens/Login.tsx`** — copy worker's Login; change the catch to
|
||||||
|
set `'Geen toegang — alleen beheerders.'` when the error is the not-admin error, else
|
||||||
|
`'Inloggen mislukt'`. (Distinguish by error message/`instanceof`.)
|
||||||
|
- [ ] **Step 6: Wire `App.tsx`** — `AuthProvider` + `Gate` (authed → shell placeholder,
|
||||||
|
else `<Login/>`), following worker's `App.tsx`.
|
||||||
|
- [ ] **Step 7: Run tests — pass.** typecheck.
|
||||||
|
- [ ] **Step 8: Commit** — `feat(admin): bearer auth with admin-only gate + login screen`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Sidebar shell + routing
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `apps/admin/src/components/Sidebar.tsx`
|
||||||
|
- Modify: `apps/admin/src/App.tsx`, `apps/admin/src/styles.css`
|
||||||
|
- Test: `apps/admin/src/App.test.tsx` (replace the Task-2 smoke test)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `useAuth` (signOut), `useMe`/`fetchMe` for the signed-in email.
|
||||||
|
- Produces: an authed shell with `<nav>` containing **Live** and **Handelingen** links, a
|
||||||
|
header with the signed-in email + logout button, and a content `<Routes>` outlet.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test** — with a token set and `apiFetch` mocked (`/api/me`
|
||||||
|
→ admin), the authed app shows nav items "Live" and "Handelingen"; clicking logout clears
|
||||||
|
the token. (Mock `react-router` via `MemoryRouter` or render through `App`.)
|
||||||
|
- [ ] **Step 2: Run — fail.**
|
||||||
|
- [ ] **Step 3: Implement `Sidebar.tsx`** — `<aside>` with brand "SoleLog Admin",
|
||||||
|
`NavLink`s to `/` ("Live") and `/handelingen` ("Handelingen") using an `tab-active`-style
|
||||||
|
active class, a muted disabled group (Rapporten / Gebruikers / Handmatig — "binnenkort"),
|
||||||
|
and a header strip with the signed-in email + a logout `button` (aria-label "Uitloggen").
|
||||||
|
- [ ] **Step 4: Update `App.tsx`** — authed shell = `<BrowserRouter>` with `Sidebar` +
|
||||||
|
`<main>` `<Routes>`: `/` → `Live` (placeholder for now is fine, real in Task 5),
|
||||||
|
`/handelingen` → `Activities` (placeholder, real in Task 6). Use placeholders that the
|
||||||
|
next tasks replace, OR sequence so Task 5/6 add the routes — either way keep tests green.
|
||||||
|
- [ ] **Step 5: Add sidebar/header CSS** to `styles.css` (port worker tokens: `:root`
|
||||||
|
vars, base body; new `.admin-shell` grid `220px 1fr`, `.sidebar`, `.nav-link`,
|
||||||
|
`.nav-link-active`, `.nav-disabled`, `.topbar`, `.btn-logout`).
|
||||||
|
- [ ] **Step 6: Run tests — pass.** typecheck + build.
|
||||||
|
- [ ] **Step 7: Commit** — `feat(admin): sidebar shell + routing`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Live active-work view
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `apps/admin/src/api/admin-sessions.ts`, `apps/admin/src/lib/elapsed.ts`,
|
||||||
|
`apps/admin/src/screens/Live.tsx`
|
||||||
|
- Modify: `apps/admin/src/App.tsx` (route), `apps/admin/src/styles.css`
|
||||||
|
- Test: `apps/admin/src/screens/Live.test.tsx`, `apps/admin/src/lib/elapsed.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `apiFetch`, `WorkSession` from `@solelog/shared`.
|
||||||
|
- Produces: `useActiveSessions()` (react-query, `refetchInterval: 5000`, queryKey
|
||||||
|
`['admin','sessions','active']`); `formatTime(seconds)` HH:MM:SS.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests:**
|
||||||
|
- `elapsed.test.ts`: `formatTime(0)==='00:00:00'`, `formatTime(3661)==='01:01:01'`.
|
||||||
|
- `Live.test.tsx` (mock `apiFetch`): given two active sessions (with `user_name`,
|
||||||
|
`activity_name`, `insole_type`, `pair_count`, `start_time`), renders a card per session
|
||||||
|
showing the worker name + activity + type; header "Actief nu (2)". With `[]`, shows
|
||||||
|
"Niemand is nu aan het werk.".
|
||||||
|
- [ ] **Step 2: Run — fail.**
|
||||||
|
- [ ] **Step 3: Implement `lib/elapsed.ts`** — port `formatTime` from worker
|
||||||
|
`lib/stopwatch.ts` (verbatim).
|
||||||
|
- [ ] **Step 4: Implement `api/admin-sessions.ts`:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import type { WorkSession } from '@solelog/shared';
|
||||||
|
import { apiFetch } from '../lib/api';
|
||||||
|
|
||||||
|
export function useActiveSessions() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['admin', 'sessions', 'active'],
|
||||||
|
queryFn: () => apiFetch<WorkSession[]>('/api/admin/sessions/active'),
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Implement `screens/Live.tsx`** — `useActiveSessions()`; loading "Laden…",
|
||||||
|
error "Kon gegevens niet laden.", empty "Niemand is nu aan het werk.". Header
|
||||||
|
"Actief nu (N)". Per session, a `.live-card` with worker name (`user_name`), activity
|
||||||
|
(`activity_name`), an insole-type pill, pair count, and a ticking elapsed timer: a `now`
|
||||||
|
state updated by `setInterval(…, 1000)` in a `useEffect`; elapsed =
|
||||||
|
`formatTime((now - Date.parse(start_time)) / 1000)`.
|
||||||
|
- [ ] **Step 6: Add route** `/` → `Live` in `App.tsx`; add `.live-card`/`.live-timer`/pill
|
||||||
|
CSS.
|
||||||
|
- [ ] **Step 7: Run tests — pass.** typecheck + build.
|
||||||
|
- [ ] **Step 8: Commit** — `feat(admin): live active-work view (5s refresh)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Activity management (port of legacy Settings)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `apps/admin/src/api/activities.ts`, `apps/admin/src/screens/Activities.tsx`
|
||||||
|
- Modify: `apps/admin/src/App.tsx` (route), `apps/admin/src/styles.css`
|
||||||
|
- Test: `apps/admin/src/screens/Activities.test.tsx`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `apiFetch`, `Activity`/`CreateActivityInput`/`InsoleType` from `@solelog/shared`.
|
||||||
|
- Produces: `useActivities()`, `useCreateActivity()`, `useUpdateActivity()`,
|
||||||
|
`useDeleteActivity()` (queryKey `['activities']`, mutations invalidate it).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests** (mock `apiFetch`): adding a handeling POSTs
|
||||||
|
`/api/activities` with `{name, insole_types}`; editing PUTs `/api/activities/:id`;
|
||||||
|
deleting (confirm stubbed true) DELETEs `/api/activities/:id`. Render shows existing
|
||||||
|
activities from the mocked GET.
|
||||||
|
- [ ] **Step 2: Run — fail.**
|
||||||
|
- [ ] **Step 3: Implement `api/activities.ts`** — recreate the hooks removed from the
|
||||||
|
worker (reference git `decb158:apps/worker/src/api/activities.ts`): `useActivities`
|
||||||
|
(GET `/api/activities`), `useCreateActivity` (POST), `useUpdateActivity` (PUT
|
||||||
|
`/api/activities/${id}` with `{ id, input }`), `useDeleteActivity` (DELETE), all
|
||||||
|
invalidating `['activities']`.
|
||||||
|
- [ ] **Step 4: Implement `screens/Activities.tsx`** — port `decb158:apps/worker/src/screens/Settings.tsx`
|
||||||
|
near-verbatim: `TypeToggles`/type pill helpers, add-form, list with inline edit, delete
|
||||||
|
with `window.confirm`. Title "Handelingen", subtitle "Beheer handelingen per zooltype".
|
||||||
|
Reuse the worker's `.activity-*`/`.btn-*`/`.field-*` class names (port the CSS).
|
||||||
|
- [ ] **Step 5: Add route** `/handelingen` → `Activities`; port the activity-management CSS
|
||||||
|
block from worker `styles.css` into admin `styles.css`.
|
||||||
|
- [ ] **Step 6: Run tests — pass.** typecheck + build.
|
||||||
|
- [ ] **Step 7: Commit** — `feat(admin): activity management (handelingen CRUD)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Docs, lint, and verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/roadmap.md`, `apps/admin/README.md`
|
||||||
|
- Create: `docs/sessions/2026-06-17-phase-3a-admin-panel.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Lint/format** — `npx oxlint` clean; `npx oxfmt` **scoped to changed
|
||||||
|
files** (do not touch unrelated files); fix any es5-trailing-comma issues.
|
||||||
|
- [ ] **Step 2: Full green** — `yarn workspace @solelog/api typecheck` + `test`;
|
||||||
|
`yarn workspace @solelog/admin typecheck` + `test` + `build`;
|
||||||
|
`yarn workspace @solelog/worker test` (regression).
|
||||||
|
- [ ] **Step 3: Live smoke (optional but preferred)** — start the API, seed
|
||||||
|
(`worker@solelog.local` / `admin@solelog.local`), `curl` `/api/me` with an admin bearer
|
||||||
|
to confirm `role`, and `/api/admin/sessions/active`. **Kill the server tree afterward**
|
||||||
|
(Windows lock trap).
|
||||||
|
- [ ] **Step 4: Docs** — update `docs/roadmap.md` (Phase 3 → "3a implemented; 3b
|
||||||
|
remaining"); write the session log; fill `apps/admin/README.md` (dev on :5174, admin-only
|
||||||
|
login, what 3a covers).
|
||||||
|
- [ ] **Step 5: Commit** — `docs(admin): phase 3a session log + roadmap status`.
|
||||||
|
|
||||||
|
## Self-Review notes
|
||||||
|
|
||||||
|
- Type consistency: `formatTime` (Task 5) matches worker's name; `useActiveSessions` here
|
||||||
|
hits `/api/admin/sessions/active` (admin), distinct from worker's same-named hook on
|
||||||
|
`/api/sessions/active` — intentional, different app.
|
||||||
|
- `PublicUser.role` (Task 1) is consumed by the admin gate (Task 3) — defined before use.
|
||||||
|
- Activity hooks (Task 6) mirror the removed worker hooks exactly so the ported Settings
|
||||||
|
component compiles unchanged.
|
||||||
116
docs/superpowers/specs/2026-06-17-phase-3a-admin-panel-design.md
Normal file
116
docs/superpowers/specs/2026-06-17-phase-3a-admin-panel-design.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# Phase 3a — Admin Panel (MVP) Design
|
||||||
|
|
||||||
|
- **Created:** 2026-06-17
|
||||||
|
- **Status:** Approved (brainstorming) — ready for implementation plan
|
||||||
|
- **Phase:** 3a (first of two slices of roadmap Phase 3; see `docs/roadmap.md` §9)
|
||||||
|
- **Tracker:** Plane (workspace `solelog`, project SoleLog)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
A working `apps/admin` desktop web app where an admin logs in, watches who is working
|
||||||
|
**live**, and manages **handelingen** (activities). Everything consumes existing backend
|
||||||
|
endpoints plus one tiny backend touch (`role` on `/api/me`). Reports/export, user
|
||||||
|
management, and manual session entry/edit are explicitly deferred to **Phase 3b**.
|
||||||
|
|
||||||
|
_Done when:_ an admin can sign in to the admin app, see who is working right now (auto-
|
||||||
|
refreshing), and add/edit/delete activities. A worker who signs in with valid credentials
|
||||||
|
is rejected with "Geen toegang".
|
||||||
|
|
||||||
|
## Scope decisions (confirmed during brainstorming, 2026-06-17)
|
||||||
|
|
||||||
|
1. **Slice** → MVP-first, two cycles. 3a = shell + admin login + live view + activity
|
||||||
|
management (existing endpoints). 3b = reports/export + user management + manual
|
||||||
|
entry/edit.
|
||||||
|
2. **Auth** → reuse the worker's bearer-token flow (`POST /api/auth/sign-in/email` →
|
||||||
|
capture `set-auth-token` → localStorage → `Authorization: Bearer`). Gate admin access
|
||||||
|
by reading `role` from `/api/me`; non-admins are signed out with "Geen toegang".
|
||||||
|
3. **Layout** → fixed left sidebar + content area (scales as 3b adds sections).
|
||||||
|
4. **Live refresh** → react-query `refetchInterval: 5000` (5s); elapsed time ticks
|
||||||
|
client-side every second.
|
||||||
|
5. **Live view is read-only in 3a** — no stop/fix button (admin-stopping another worker's
|
||||||
|
session needs a new backend endpoint → 3b).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/admin/ (new Vite + React 18 + TS SPA, dev port 5174)
|
||||||
|
src/
|
||||||
|
lib/ api.ts (apiFetch + signIn), auth-storage.ts ← copied from worker
|
||||||
|
auth/ AuthContext.tsx (signIn + admin-role gate via /api/me)
|
||||||
|
api/ admin-sessions.ts (useActiveSessions), activities.ts (CRUD hooks)
|
||||||
|
screens/ Login.tsx, Live.tsx, Activities.tsx
|
||||||
|
components/ Sidebar.tsx
|
||||||
|
lib/ elapsed.ts (HH:MM:SS formatter, ported from worker stopwatch)
|
||||||
|
App.tsx, main.tsx, styles.css
|
||||||
|
```
|
||||||
|
|
||||||
|
The admin app is a **client only** — it talks to the existing backend over HTTP with a
|
||||||
|
bearer token. No DB access. It mirrors `apps/worker`'s toolchain and conventions exactly
|
||||||
|
so the build can copy proven patterns.
|
||||||
|
|
||||||
|
### Backend change (the only one in 3a)
|
||||||
|
|
||||||
|
- `packages/shared/src/index.ts`: add `role: Role` to `PublicUser` (so `MeResponse.user`
|
||||||
|
carries it).
|
||||||
|
- `apps/api/src/routes/me.ts`: include `role` in the response (read from the session user,
|
||||||
|
default `'worker'`). The worker app ignores the extra field — no worker change needed.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
- **`lib/api.ts` / `lib/auth-storage.ts`** — copied verbatim from worker; bearer token in
|
||||||
|
localStorage, `apiFetch<T>` adds the `Authorization` header.
|
||||||
|
- **`auth/AuthContext.tsx`** — `signIn(email,password)` calls the worker `signIn`, then
|
||||||
|
fetches `/api/me`; if `role !== 'admin'` it clears the token and throws so Login shows
|
||||||
|
"Geen toegang — alleen beheerders." `isAuthed` reflects a present token; an admin check
|
||||||
|
on mount (re-fetch `/api/me`) guards against a stale worker token.
|
||||||
|
- **`screens/Login.tsx`** — Dutch email+password form (no self-signup), error line.
|
||||||
|
- **`components/Sidebar.tsx`** — app name + signed-in email + logout (⏻); nav items **Live**
|
||||||
|
and **Handelingen**; a muted/disabled "binnenkort" group hints 3b (Rapporten /
|
||||||
|
Gebruikers / Handmatig).
|
||||||
|
- **`screens/Live.tsx`** — consumes `GET /api/admin/sessions/active` (`useActiveSessions`,
|
||||||
|
`refetchInterval: 5000`). One card per session: worker name, activity name, insole-type
|
||||||
|
pill, pair count, ticking elapsed timer (from `start_time`). Empty state "Niemand is nu
|
||||||
|
aan het werk."; header "Actief nu (N)".
|
||||||
|
- **`screens/Activities.tsx`** — port of the legacy worker `Settings.tsx` (git `decb158`):
|
||||||
|
add form (name + insole-type toggles), list with inline edit, delete-with-confirm —
|
||||||
|
desktop-styled. Uses `api/activities.ts` hooks against `/api/activities`.
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
1. Admin enters credentials → `signIn` → token stored → `/api/me` confirms `role==='admin'`.
|
||||||
|
2. Live screen polls `/api/admin/sessions/active` every 5s; each card computes elapsed from
|
||||||
|
`start_time` with a 1s client tick.
|
||||||
|
3. Activity CRUD hits `/api/activities` (GET open to any authed user; POST/PUT/DELETE are
|
||||||
|
admin-gated server-side — the admin bearer satisfies them).
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
- Login: 401/no-token → "Inloggen mislukt"; authenticated non-admin → "Geen toegang —
|
||||||
|
alleen beheerders" (token cleared).
|
||||||
|
- Live/Activities: react-query error → inline "Kon gegevens niet laden." with the data
|
||||||
|
hidden; loading → "Laden…".
|
||||||
|
- Delete activity: `window.confirm` warns that the activity's sessions are also deleted
|
||||||
|
(matches backend cascade in `activities.ts`).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Mirror the worker's vitest + testing-library setup, mocking `apiFetch`:
|
||||||
|
|
||||||
|
- **Backend:** `/api/me` includes `role` for a worker and an admin (extend `me.test.ts`).
|
||||||
|
- **Auth/login:** admin signs in; worker credentials are rejected with "Geen toegang".
|
||||||
|
- **Shell:** sidebar renders nav; logout clears the token.
|
||||||
|
- **Live:** renders a card per active session (name/activity/type); empty state when none.
|
||||||
|
- **Activities:** add/edit/delete invoke the correct endpoints (mocked).
|
||||||
|
|
||||||
|
## Out of scope (→ Phase 3b)
|
||||||
|
|
||||||
|
- All-users filtered CSV export (current `/api/export` is self-scoped).
|
||||||
|
- User management via better-auth `/api/auth/admin/*`.
|
||||||
|
- Manual session entry/edit + admin stop/fix of another worker's session (needs new
|
||||||
|
backend endpoints).
|
||||||
|
|
||||||
|
## Build approach
|
||||||
|
|
||||||
|
spec → `writing-plans` → **one Workflow** that executes the plan task-by-task (TDD, commit
|
||||||
|
per task, final lint/typecheck/test/build + live verify), matching the Phase 2 pattern and
|
||||||
|
keeping this session's context clean.
|
||||||
Reference in New Issue
Block a user