16 KiB
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/workeris the canonical template — copylib/api.ts,lib/auth-storage.ts, tsconfig/vite/vitest configs,test/setup.ts,main.tsxstructure 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
roleon/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 holdingdata/app.dbor 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; orapps/api/src/routes/me.test.ts— match where existing route tests live)
Interfaces:
-
Produces:
PublicUsernow hasrole: Role;MeResponse.user.roleis'worker' | 'admin'. -
Step 1: Write/extend the failing test. Using the test helpers (
apps/api/test/helpers.ts:createTestUser,authToken/bearer), assert thatGET /api/mewith a worker token returnsuser.role === 'worker', and with an admin token returnsuser.role === 'admin'. (Create an admin viaauth.api.createUserwithrole: 'admin'— seeseed.tsfor the cast pattern.) -
Step 2: Run it, watch it fail (
roleundefined).yarn workspace @solelog/api test(filter to the me test). -
Step 3: Add
roletoPublicUserin shared:
export const PublicUser = z.object({
id: z.string(),
email: z.string().email(),
name: z.string(),
role: Role,
});
- Step 4: Return
rolefrom the route. Inapps/api/src/routes/me.ts, add to theuserbody:role: ((session.user as { role?: string | null }).role ?? 'worker') as Role(importRole/MeResponsetype from@solelog/shared). Keep theMeResponsetyping. - Step 5: Run tests — pass. Also run
yarn workspace @solelog/api typecheck. - Step 6: Confirm worker app unaffected —
yarn workspace @solelog/worker teststill 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/adminworkspace that builds and runs an empty app shell;apiFetch,signIn,getToken/setToken/clearTokenavailable. -
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 keysolelog.tokenis 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/>insideQueryClientProvider) and a placeholdersrc/App.tsxreturning<div>SoleLog Admin</div>and an emptysrc/styles.css. -
Step 3: Smoke test
src/App.test.tsx: renders App, expects "SoleLog Admin" text (wrap inQueryClientProvider). -
Step 4: Install + verify — from repo root
yarn install, thenyarn 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(orLogin.test.tsx)
Interfaces:
-
Consumes:
signInfromlib/api,getToken/clearTokenfromlib/auth-storage,MeResponsefrom@solelog/shared. -
Produces:
useAuth(): { isAuthed, signIn, signOut }wheresignInrejects non-admins;fetchMe(): Promise<MeResponse>. -
Step 1: Write failing tests (mock
lib/api):- signing in as an admin (
/api/me→role: 'admin') setsisAuthedtrue; - signing in as a worker (
role: 'worker') throws, clears the token,isAuthedfalse.
- signing in as an admin (
-
Step 2: Run — fail (
AuthContextnot implemented). -
Step 3: Implement
api/me.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, butsignIndoes: callapiSignIn(email,password); thenconst me = await fetchMe(); ifme.user.role !== 'admin'→clearToken()andthrow new Error('not-admin'); elsesetIsAuthed(true).signOutclears token + sets false. InitialisAuthed=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'sApp.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/fetchMefor 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
apiFetchmocked (/api/me→ admin), the authed app shows nav items "Live" and "Handelingen"; clicking logout clears the token. (Mockreact-routerviaMemoryRouteror render throughApp.) -
Step 2: Run — fail.
-
Step 3: Implement
Sidebar.tsx—<aside>with brand "SoleLog Admin",NavLinks to/("Live") and/handelingen("Handelingen") using antab-active-style active class, a muted disabled group (Rapporten / Gebruikers / Handmatig — "binnenkort"), and a header strip with the signed-in email + a logoutbutton(aria-label "Uitloggen"). -
Step 4: Update
App.tsx— authed shell =<BrowserRouter>withSidebar+<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::rootvars, base body; new.admin-shellgrid220px 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,WorkSessionfrom@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(mockapiFetch): given two active sessions (withuser_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— portformatTimefrom workerlib/stopwatch.ts(verbatim). -
Step 4: Implement
api/admin-sessions.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-cardwith worker name (user_name), activity (activity_name), an insole-type pill, pair count, and a ticking elapsed timer: anowstate updated bysetInterval(…, 1000)in auseEffect; elapsed =formatTime((now - Date.parse(start_time)) / 1000). - Step 6: Add route
/→LiveinApp.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/InsoleTypefrom@solelog/shared. -
Produces:
useActivities(),useCreateActivity(),useUpdateActivity(),useDeleteActivity()(queryKey['activities'], mutations invalidate it). -
Step 1: Write failing tests (mock
apiFetch): adding a handeling POSTs/api/activitieswith{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 gitdecb158: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— portdecb158:apps/worker/src/screens/Settings.tsxnear-verbatim:TypeToggles/type pill helpers, add-form, list with inline edit, delete withwindow.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 workerstyles.cssinto adminstyles.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 oxlintclean;npx oxfmtscoped 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/mewith an admin bearer to confirmrole, 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; fillapps/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;useActiveSessionshere 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.