Compare commits

..

10 Commits

Author SHA1 Message Date
Bas van Rossem
76ad235c9f docs(admin): phase 3a session log + roadmap status 2026-06-17 19:17:20 +02:00
Bas van Rossem
c0d9d21991 feat(admin): activity management (handelingen CRUD) 2026-06-17 19:11:50 +02:00
Bas van Rossem
67dd0d398f feat(admin): live active-work view (5s refresh) 2026-06-17 19:07:36 +02:00
Bas van Rossem
286e2d29db feat(admin): sidebar shell + routing 2026-06-17 19:03:35 +02:00
Bas van Rossem
77659edf8e feat(admin): bearer auth with admin-only gate + login screen 2026-06-17 18:59:43 +02:00
Bas van Rossem
682a9dce44 feat(admin): scaffold Vite+React admin workspace 2026-06-17 18:56:28 +02:00
Bas van Rossem
02b7522b87 feat(api): include role in /api/me + allow admin origin in CORS
Add `role: Role` to the shared `PublicUser` contract and return it from
`GET /api/me` (defaulting to 'worker' when the session user has no role).
This lets the planned admin app gate access by role.

Also add the admin dev origin `http://localhost:5174` to the default
`WEB_ORIGINS` (env.ts) and to `.env.example`, so the admin SPA on :5174 can
reach the API at :3000 cross-origin (drives both hono/cors and better-auth
trustedOrigins).
2026-06-17 18:53:39 +02:00
Bas van Rossem
bb0a0b2a57 docs(admin): fold admin-origin CORS into phase 3a Task 1 2026-06-17 18:49:16 +02:00
Bas van Rossem
7cdc88e824 docs(admin): phase 3a spec + implementation plan 2026-06-17 18:46:11 +02:00
Bas van Rossem
6fce7a7197 docs(roadmap): fold activity-management UI port into Phase 3 scope 2026-06-17 18:29:40 +02:00
41 changed files with 2221 additions and 12 deletions

2
apps/admin/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
dist
*.tsbuildinfo

98
apps/admin/README.md Normal file
View File

@@ -0,0 +1,98 @@
# @solelog/admin
The SoleLog **Admin panel**: a **Vite + React + TypeScript** single-page app for the
shop-floor administrator. An admin logs in (email + password → bearer token in
`localStorage`), watches who is working **live**, and manages the **handelingen**
(activities). It is a **client only** — it talks to the `@solelog/api` backend over HTTP
with `Authorization: Bearer <token>`; it never touches the database.
> **Admin-only.** Sign-in confirms `role === 'admin'` via `GET /api/me`. A worker who signs
> in with valid credentials is rejected with **"Geen toegang — alleen beheerders."** and the
> token is cleared. The UI is in **Dutch**.
It mirrors `apps/worker`'s toolchain and conventions (same Vite/React/react-query/vitest
versions, the same `lib/api.ts` + `lib/auth-storage.ts`), so it shares proven patterns.
## What Phase 3a covers
- **Login** — Dutch email + password form (no self-signup), with the admin-only gate.
- **Sidebar shell** — left sidebar with the signed-in email + an Uitloggen button, nav to
**Live** and **Handelingen**, and a muted "Binnenkort" group (Rapporten / Gebruikers /
Handmatig) hinting at Phase 3b.
- **Live** (`/`) — who is working right now, from `GET /api/admin/sessions/active`,
auto-refreshing every **5 s** (read-only in 3a). One card per session: worker name,
activity, insole-type pill, pair count, and a client-side ticking elapsed timer. Empty
state: "Niemand is nu aan het werk.".
- **Handelingen** (`/handelingen`) — add / inline-edit / delete activities and their insole
types (`Kurk` / `Berk` / `3D`), against `/api/activities` (writes are admin-gated
server-side; the admin bearer satisfies them).
**Deferred to Phase 3b:** reports + all-users CSV export, user management
(`/api/auth/admin/*`), and manual session entry/edit + admin stop/fix of a running session
(needs new backend endpoints).
## Prerequisites
- Node + Corepack (Yarn 4 is pinned in the repo).
- From the **repo root**, install everything once:
```bash
yarn install
```
## Run it
Two processes: the API on `:3000` and the admin SPA on `:5174`.
### 1. Backend API (`:3000`)
From the repo root:
```bash
yarn workspace @solelog/api db:migrate # apply migrations (creates ./data/app.db on first run)
yarn workspace @solelog/api db:seed # idempotent: seeds reference activities + dev logins
yarn workspace @solelog/api start # Hono server on http://localhost:3000
```
`db:seed` creates two **dev logins** (dev-only — skipped when `NODE_ENV=production`):
**admin** `admin@solelog.local` / `werkplaats-admin` and **worker**
`worker@solelog.local` / `werkplaats123`. Only the admin can use this app.
The API's default `WEB_ORIGINS` already allows `http://localhost:5174` (this app's dev
origin), so cross-origin sign-in and requests work out of the box. Override with
`CORS_ORIGINS` for other origins (e.g. a LAN IP).
### 2. Admin SPA (`:5174`)
From the repo root:
```bash
yarn workspace @solelog/admin dev # Vite dev server on http://localhost:5174
```
Open **http://localhost:5174** and sign in as the admin. The API base URL comes from
`VITE_API_URL` (default `http://localhost:3000`).
## Scripts
```bash
yarn workspace @solelog/admin dev # dev server (:5174, LAN-exposed)
yarn workspace @solelog/admin build # tsc -b && vite build → dist/
yarn workspace @solelog/admin preview # preview the production build
yarn workspace @solelog/admin typecheck # tsc -b
yarn workspace @solelog/admin test # vitest run
```
## Architecture
- **Auth.** `lib/api.ts` + `lib/auth-storage.ts` are copied from the worker: sign-in reads
the bearer token from the `set-auth-token` response header into `localStorage`, and
`apiFetch<T>` attaches `Authorization: Bearer <token>` to every request.
`auth/AuthContext.tsx` adds the admin gate (re-fetch `/api/me`, reject non-admins).
- **Live data.** `api/admin-sessions.ts` `useActiveSessions()` uses react-query with
`refetchInterval: 5000`; each card computes elapsed time from the server `start_time`
with a 1 s client tick (`lib/elapsed.ts` `formatTime`).
- **Activities.** `api/activities.ts` hooks (`useActivities` + create/update/delete)
invalidate the `['activities']` query on every mutation.
- **Shared contracts.** Request/response shapes are zod schemas in `@solelog/shared`,
imported here for a typed client.

15
apps/admin/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#2563EB" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<title>SoleLog Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

34
apps/admin/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "@solelog/admin",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -b",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@solelog/shared": "workspace:*",
"@tanstack/react-query": "^5.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0"
},
"devDependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.4.0",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"jsdom": "^25.0.0",
"typescript": "^5.7.2",
"vite": "^7.0.0",
"vitest": "^3.0.0"
}
}

View File

@@ -0,0 +1,56 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
import { clearToken, getToken, setToken } from './lib/auth-storage';
// Stub the network layer so the authed shell renders without real requests.
// /api/me resolves an admin so the gate stays authed and the header shows the email.
vi.mock('./lib/api', async () => {
const actual = await vi.importActual<typeof import('./lib/api')>('./lib/api');
return {
...actual,
apiFetch: vi.fn().mockResolvedValue({
user: { id: 'u1', email: 'admin@solelog.local', name: 'Admin', role: 'admin' },
}),
};
});
function renderApp() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
}
describe('App', () => {
afterEach(() => {
clearToken();
});
it('shows the login screen when there is no token', () => {
clearToken();
renderApp();
expect(screen.getByText('E-mailadres')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Inloggen' })).toBeInTheDocument();
});
it('shows the sidebar nav when a token is present', () => {
setToken('tok');
renderApp();
const nav = within(screen.getByRole('navigation'));
expect(nav.getByText('Live')).toBeInTheDocument();
expect(nav.getByText('Handelingen')).toBeInTheDocument();
});
it('clears the token when logout is clicked', async () => {
setToken('tok');
renderApp();
expect(getToken()).toBe('tok');
await userEvent.click(screen.getByRole('button', { name: 'Uitloggen' }));
expect(getToken()).toBeNull();
});
});

35
apps/admin/src/App.tsx Normal file
View File

@@ -0,0 +1,35 @@
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { AuthProvider, useAuth } from './auth/AuthContext';
import Login from './screens/Login';
import Sidebar from './components/Sidebar';
import Live from './screens/Live';
import Activities from './screens/Activities';
function AuthedShell() {
return (
<BrowserRouter>
<div className="admin-shell">
<Sidebar />
<main className="admin-main">
<Routes>
<Route path="/" element={<Live />} />
<Route path="/handelingen" element={<Activities />} />
</Routes>
</main>
</div>
</BrowserRouter>
);
}
function Gate() {
const { isAuthed } = useAuth();
return isAuthed ? <AuthedShell /> : <Login />;
}
export default function App() {
return (
<AuthProvider>
<Gate />
</AuthProvider>
);
}

View File

@@ -0,0 +1,49 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { Activity, CreateActivityInput, UpdateActivityInput } from '@solelog/shared';
import { apiFetch } from '../lib/api';
export function useActivities() {
return useQuery({
queryKey: ['activities'],
queryFn: () => apiFetch<Activity[]>('/api/activities'),
});
}
export function useCreateActivity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateActivityInput) =>
apiFetch<Activity>('/api/activities', {
method: 'POST',
body: JSON.stringify(input),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['activities'] });
},
});
}
export function useUpdateActivity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, input }: { id: number; input: UpdateActivityInput }) =>
apiFetch<Activity>(`/api/activities/${id}`, {
method: 'PUT',
body: JSON.stringify(input),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['activities'] });
},
});
}
export function useDeleteActivity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) =>
apiFetch<{ success: true }>(`/api/activities/${id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['activities'] });
},
});
}

View File

@@ -0,0 +1,13 @@
import { useQuery } from '@tanstack/react-query';
import type { WorkSession } from '@solelog/shared';
import { apiFetch } from '../lib/api';
// Active work sessions across all workers (admin cross-user view).
// Polls every 5s so the Live view reflects who is working without a manual refresh.
export function useActiveSessions() {
return useQuery({
queryKey: ['admin', 'sessions', 'active'],
queryFn: () => apiFetch<WorkSession[]>('/api/admin/sessions/active'),
refetchInterval: 5000,
});
}

16
apps/admin/src/api/me.ts Normal file
View File

@@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/react-query';
import type { MeResponse } from '@solelog/shared';
import { apiFetch } from '../lib/api';
// Fetch the signed-in user (id, email, name, role).
export function fetchMe(): Promise<MeResponse> {
return apiFetch<MeResponse>('/api/me');
}
// react-query wrapper around fetchMe; used by authed screens (e.g. the shell header).
export function useMe() {
return useQuery({
queryKey: ['me'],
queryFn: fetchMe,
});
}

View File

@@ -0,0 +1,92 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthProvider, useAuth } from './AuthContext';
import { fetchMe } from '../api/me';
import { signIn as apiSignIn } from '../lib/api';
import { clearToken, getToken, setToken } from '../lib/auth-storage';
// Mock the network layer so no real requests are made.
vi.mock('../lib/api', () => ({
signIn: vi.fn(),
}));
vi.mock('../api/me', () => ({
fetchMe: vi.fn(),
}));
const mockedSignIn = vi.mocked(apiSignIn);
const mockedFetchMe = vi.mocked(fetchMe);
// A tiny harness that exposes the auth context's state + signIn.
function Harness() {
const { isAuthed, signIn } = useAuth();
return (
<div>
<span data-testid="authed">{String(isAuthed)}</span>
<button
type="button"
onClick={() => {
signIn('user@solelog.local', 'pw').catch((err) => {
const el = document.getElementById('err');
if (el) el.textContent = err instanceof Error ? err.message : String(err);
});
}}
>
go
</button>
<span id="err" />
</div>
);
}
function renderHarness() {
return render(
<AuthProvider>
<Harness />
</AuthProvider>
);
}
describe('AuthContext admin gate', () => {
beforeEach(() => {
mockedSignIn.mockReset();
mockedFetchMe.mockReset();
// signIn stores a token (mirror the real lib/api behaviour).
mockedSignIn.mockImplementation(async () => {
setToken('tok');
});
});
afterEach(() => {
clearToken();
vi.clearAllMocks();
});
it('signs in an admin: isAuthed becomes true, token kept', async () => {
mockedFetchMe.mockResolvedValue({
user: { id: 'a1', email: 'admin@solelog.local', name: 'Admin', role: 'admin' },
});
const user = userEvent.setup();
renderHarness();
expect(screen.getByTestId('authed')).toHaveTextContent('false');
await user.click(screen.getByRole('button', { name: 'go' }));
await waitFor(() => expect(screen.getByTestId('authed')).toHaveTextContent('true'));
expect(getToken()).toBe('tok');
});
it('rejects a worker: throws not-admin, clears the token, stays unauthed', async () => {
mockedFetchMe.mockResolvedValue({
user: { id: 'w1', email: 'worker@solelog.local', name: 'Werker', role: 'worker' },
});
const user = userEvent.setup();
renderHarness();
await user.click(screen.getByRole('button', { name: 'go' }));
await waitFor(() => expect(document.getElementById('err')).toHaveTextContent('not-admin'));
expect(getToken()).toBeNull();
expect(screen.getByTestId('authed')).toHaveTextContent('false');
});
});

View File

@@ -0,0 +1,50 @@
import { createContext, useCallback, useContext, useState, type ReactNode } from 'react';
import { clearToken, getToken } from '../lib/auth-storage';
import { signIn as apiSignIn } from '../lib/api';
import { fetchMe } from '../api/me';
// Thrown by signIn when the authenticated user is not an admin. Login distinguishes it
// from a plain auth failure to show a different message.
export class NotAdminError extends Error {
constructor() {
super('not-admin');
this.name = 'NotAdminError';
}
}
interface AuthContextValue {
isAuthed: boolean;
signIn: (email: string, password: string) => Promise<void>;
signOut: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [isAuthed, setIsAuthed] = useState<boolean>(() => getToken() !== null);
const signIn = useCallback(async (email: string, password: string) => {
await apiSignIn(email, password);
const me = await fetchMe();
if (me.user.role !== 'admin') {
clearToken();
throw new NotAdminError();
}
setIsAuthed(true);
}, []);
const signOut = useCallback(() => {
clearToken();
setIsAuthed(false);
}, []);
return (
<AuthContext.Provider value={{ isAuthed, signIn, signOut }}>{children}</AuthContext.Provider>
);
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within an AuthProvider');
return ctx;
}

View File

@@ -0,0 +1,52 @@
import { NavLink } from 'react-router-dom';
import { useAuth } from '../auth/AuthContext';
import { useMe } from '../api/me';
const navItems = [
{ to: '/', label: 'Live' },
{ to: '/handelingen', label: 'Handelingen' },
] as const;
// Sections planned for Phase 3b — shown muted/disabled as a hint of what's coming.
const soonItems = ['Rapporten', 'Gebruikers', 'Handmatig'] as const;
export default function Sidebar() {
const { signOut } = useAuth();
const meQuery = useMe();
const email = meQuery.data?.user.email;
return (
<aside className="sidebar">
<div className="sidebar-brand">SoleLog Admin</div>
<nav className="sidebar-nav">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) => (isActive ? 'nav-link nav-link-active' : 'nav-link')}
>
{item.label}
</NavLink>
))}
<div className="nav-soon">
<span className="nav-soon-label">Binnenkort</span>
{soonItems.map((label) => (
<span key={label} className="nav-disabled" aria-disabled="true">
{label}
</span>
))}
</div>
</nav>
<div className="topbar">
{email && <span className="topbar-email">{email}</span>}
<button type="button" className="btn-logout" aria-label="Uitloggen" onClick={signOut}>
Uitloggen
</button>
</div>
</aside>
);
}

37
apps/admin/src/lib/api.ts Normal file
View File

@@ -0,0 +1,37 @@
import { getToken, setToken } from './auth-storage';
export const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
export class ApiError extends Error {
constructor(
public status: number,
message: string
) {
super(message);
this.name = 'ApiError';
}
}
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
const token = getToken();
const headers = new Headers(init.headers);
if (token) headers.set('Authorization', `Bearer ${token}`);
if (init.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
const res = await fetch(`${API_URL}${path}`, { ...init, headers });
if (!res.ok) throw new ApiError(res.status, `Request failed: ${res.status}`);
const text = await res.text();
return (text ? JSON.parse(text) : undefined) as T;
}
// Sign in: POST /api/auth/sign-in/email, capture the bearer token from the response header.
export async function signIn(email: string, password: string): Promise<void> {
const res = await fetch(`${API_URL}/api/auth/sign-in/email`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new ApiError(res.status, 'Inloggen mislukt');
const token = res.headers.get('set-auth-token');
if (!token) throw new ApiError(500, 'Geen token ontvangen');
setToken(token);
}

View File

@@ -0,0 +1,13 @@
const TOKEN_KEY = 'solelog.token';
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken(): void {
localStorage.removeItem(TOKEN_KEY);
}

View File

@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest';
import { formatTime } from './elapsed';
describe('formatTime', () => {
it('formats seconds as HH:MM:SS', () => {
expect(formatTime(0)).toBe('00:00:00');
expect(formatTime(65)).toBe('00:01:05');
expect(formatTime(3661)).toBe('01:01:01');
});
it('never returns a negative time', () => {
expect(formatTime(-10)).toBe('00:00:00');
});
});

View File

@@ -0,0 +1,11 @@
// Pure timing helper — server-authoritative elapsed.
// Elapsed is computed from the server start_time (wall-clock), not a tick counter,
// so it survives backgrounding. Ported from the worker's lib/stopwatch.ts.
export function formatTime(totalSeconds: number): string {
const s = Math.max(0, Math.floor(totalSeconds));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
}

15
apps/admin/src/main.tsx Normal file
View File

@@ -0,0 +1,15 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
import './styles.css';
const queryClient = new QueryClient();
createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>
);

View File

@@ -0,0 +1,106 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { Activity } from '@solelog/shared';
import Activities from './Activities';
import { apiFetch } from '../lib/api';
vi.mock('../lib/api', () => ({
apiFetch: vi.fn(),
}));
const mockApiFetch = vi.mocked(apiFetch);
const existing: Activity[] = [
{ id: 1, name: 'Frezen', insole_types: ['Kurk', 'Berk'], created_at: '2026-06-17T00:00:00.000Z' },
{ id: 2, name: 'Lijmen', insole_types: ['3D'], created_at: '2026-06-17T00:00:00.000Z' },
];
function renderActivities() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={queryClient}>
<Activities />
</QueryClientProvider>
);
}
describe('Activities', () => {
beforeEach(() => {
mockApiFetch.mockReset();
// Default: the activities GET returns the existing list.
mockApiFetch.mockResolvedValue(existing);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('renders the title, subtitle, and existing activities', async () => {
renderActivities();
expect(await screen.findByText('Handelingen')).toBeInTheDocument();
expect(screen.getByText('Beheer handelingen per zooltype')).toBeInTheDocument();
expect(await screen.findByText('Frezen')).toBeInTheDocument();
expect(screen.getByText('Lijmen')).toBeInTheDocument();
});
it('adding a handeling POSTs /api/activities with name + insole_types', async () => {
const user = userEvent.setup();
renderActivities();
await screen.findByText('Frezen');
await user.type(screen.getByPlaceholderText('Naam van de stap, bijv. Leerrand'), 'Polijsten');
await user.click(screen.getByRole('button', { name: 'Stap toevoegen' }));
await waitFor(() => {
expect(mockApiFetch).toHaveBeenCalledWith('/api/activities', {
method: 'POST',
body: JSON.stringify({ name: 'Polijsten', insole_types: ['Kurk', 'Berk', '3D'] }),
});
});
});
it('editing a handeling PUTs /api/activities/:id', async () => {
const user = userEvent.setup();
renderActivities();
await screen.findByText('Frezen');
await user.click(screen.getByRole('button', { name: 'Bewerk Frezen' }));
const input = screen.getByDisplayValue('Frezen');
await user.clear(input);
await user.type(input, 'Frezen 2');
await user.click(screen.getByRole('button', { name: 'Opslaan' }));
await waitFor(() => {
expect(mockApiFetch).toHaveBeenCalledWith('/api/activities/1', {
method: 'PUT',
body: JSON.stringify({ name: 'Frezen 2', insole_types: ['Kurk', 'Berk'] }),
});
});
});
it('deleting a handeling (confirmed) DELETEs /api/activities/:id', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true);
const user = userEvent.setup();
renderActivities();
await screen.findByText('Frezen');
await user.click(screen.getByRole('button', { name: 'Verwijder Frezen' }));
await waitFor(() => {
expect(mockApiFetch).toHaveBeenCalledWith('/api/activities/1', { method: 'DELETE' });
});
});
it('does not DELETE when the confirm is cancelled', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(false);
const user = userEvent.setup();
renderActivities();
await screen.findByText('Frezen');
await user.click(screen.getByRole('button', { name: 'Verwijder Frezen' }));
expect(mockApiFetch).not.toHaveBeenCalledWith('/api/activities/1', { method: 'DELETE' });
});
});

View File

@@ -0,0 +1,238 @@
import { useState } from 'react';
import type { InsoleType } from '@solelog/shared';
import {
useActivities,
useCreateActivity,
useDeleteActivity,
useUpdateActivity,
} from '../api/activities';
const ALL_TYPES: InsoleType[] = ['Kurk', 'Berk', '3D'];
const TYPE_COLORS: Record<InsoleType, { bg: string; border: string; text: string }> = {
Kurk: { bg: '#FEF9C3', border: '#FDE047', text: '#854D0E' },
Berk: { bg: '#DCFCE7', border: '#86EFAC', text: '#166534' },
'3D': { bg: '#EDE9FE', border: '#C4B5FD', text: '#5B21B6' },
};
function typePillStyle(type: InsoleType, selected: boolean): React.CSSProperties {
const c = TYPE_COLORS[type];
return {
borderRadius: 999,
padding: '6px 14px',
fontSize: 14,
fontWeight: 600,
cursor: 'pointer',
border: `1px solid ${selected ? c.border : '#E5E7EB'}`,
background: selected ? c.bg : '#ffffff',
color: selected ? c.text : '#9CA3AF',
};
}
function typeBadgeStyle(type: InsoleType): React.CSSProperties {
const c = TYPE_COLORS[type];
return {
borderRadius: 999,
padding: '2px 10px',
fontSize: 12,
fontWeight: 600,
border: `1px solid ${c.border}`,
background: c.bg,
color: c.text,
};
}
function TypeToggles({
selected,
onToggle,
}: {
selected: InsoleType[];
onToggle: (type: InsoleType) => void;
}) {
return (
<div className="type-toggles" style={{ display: 'flex', gap: 8 }}>
{ALL_TYPES.map((type) => (
<button
key={type}
type="button"
aria-pressed={selected.includes(type)}
style={typePillStyle(type, selected.includes(type))}
onClick={() => onToggle(type)}
>
{type}
</button>
))}
</div>
);
}
export default function Activities() {
const activitiesQuery = useActivities();
const createActivity = useCreateActivity();
const updateActivity = useUpdateActivity();
const deleteActivity = useDeleteActivity();
// Add form state.
const [newName, setNewName] = useState('');
const [newTypes, setNewTypes] = useState<InsoleType[]>([...ALL_TYPES]);
// Edit state.
const [editingId, setEditingId] = useState<number | null>(null);
const [editName, setEditName] = useState('');
const [editTypes, setEditTypes] = useState<InsoleType[]>([]);
const activities = activitiesQuery.data ?? [];
function toggle(list: InsoleType[], type: InsoleType): InsoleType[] {
return list.includes(type) ? list.filter((t) => t !== type) : [...list, type];
}
const addEnabled = newName.trim().length > 0 && newTypes.length > 0;
function handleAdd() {
if (!addEnabled) return;
createActivity.mutate(
{ name: newName.trim(), insole_types: newTypes },
{
onSuccess: () => {
setNewName('');
setNewTypes([...ALL_TYPES]);
},
}
);
}
function startEdit(id: number, name: string, types: InsoleType[]) {
setEditingId(id);
setEditName(name);
setEditTypes(types.length > 0 ? [...types] : [...ALL_TYPES]);
}
function cancelEdit() {
setEditingId(null);
}
function handleSave(id: number) {
if (editName.trim().length === 0 || editTypes.length === 0) return;
updateActivity.mutate(
{ id, input: { name: editName.trim(), insole_types: editTypes } },
{ onSuccess: () => setEditingId(null) }
);
}
function handleDelete(id: number, name: string) {
const ok = window.confirm(
`"${name}" verwijderen? Alle tijdsregistraties voor deze taak worden ook verwijderd.`
);
if (ok) deleteActivity.mutate(id);
}
return (
<div className="screen">
<h1 className="screen-title">Handelingen</h1>
<p className="screen-subtitle">Beheer handelingen per zooltype</p>
{/* Add new handling card */}
<section className="card">
<h2 className="section-label">Nieuwe handeling toevoegen</h2>
<input
className="field-input"
placeholder="Naam van de stap, bijv. Leerrand"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
<p className="sub-label">Van toepassing op</p>
<TypeToggles
selected={newTypes}
onToggle={(type) => setNewTypes((prev) => toggle(prev, type))}
/>
<button
type="button"
className="btn-primary"
disabled={!addEnabled || createActivity.isPending}
onClick={handleAdd}
>
Stap toevoegen
</button>
</section>
{/* Handling list */}
<h2 className="section-label">Huidige stappen ({activities.length})</h2>
{activitiesQuery.isLoading ? (
<p className="muted">Laden...</p>
) : activities.length === 0 ? (
<p className="muted">Nog geen stappen. Voeg er een toe hierboven.</p>
) : (
<ul className="activity-list">
{activities.map((activity) => (
<li key={activity.id} className="activity-card">
{editingId === activity.id ? (
<>
<input
className="field-input"
autoFocus
value={editName}
onChange={(e) => setEditName(e.target.value)}
/>
<p className="sub-label">Van toepassing op</p>
<TypeToggles
selected={editTypes}
onToggle={(type) => setEditTypes((prev) => toggle(prev, type))}
/>
<div className="row-actions">
<button
type="button"
className="btn-save"
disabled={
updateActivity.isPending ||
editTypes.length === 0 ||
editName.trim().length === 0
}
onClick={() => handleSave(activity.id)}
>
Opslaan
</button>
<button type="button" className="btn-cancel" onClick={cancelEdit}>
Annuleren
</button>
</div>
</>
) : (
<>
<div className="activity-row">
<span className="activity-name">{activity.name}</span>
<div className="row-actions">
<button
type="button"
className="icon-btn icon-edit"
aria-label={`Bewerk ${activity.name}`}
onClick={() => startEdit(activity.id, activity.name, activity.insole_types)}
>
</button>
<button
type="button"
className="icon-btn icon-delete"
aria-label={`Verwijder ${activity.name}`}
onClick={() => handleDelete(activity.id, activity.name)}
>
🗑
</button>
</div>
</div>
<div className="badge-row" style={{ display: 'flex', gap: 6, marginTop: 8 }}>
{activity.insole_types.map((type) => (
<span key={type} style={typeBadgeStyle(type)}>
{type}
</span>
))}
</div>
</>
)}
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,75 @@
import { render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { WorkSession } from '@solelog/shared';
import Live from './Live';
import { apiFetch } from '../lib/api';
vi.mock('../lib/api', () => ({
apiFetch: vi.fn(),
}));
const mockApiFetch = vi.mocked(apiFetch);
function makeSession(over: Partial<WorkSession>): WorkSession {
return {
id: 1,
user_id: 'u1',
activity_id: 10,
activity_name: 'Frezen',
user_name: 'Jan',
insole_type: 'Kurk',
pair_count: 4,
start_time: new Date(Date.now() - 65_000).toISOString(),
end_time: null,
duration_seconds: null,
status: 'active',
source: 'app',
notes: null,
created_at: new Date().toISOString(),
...over,
};
}
function renderLive() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={queryClient}>
<Live />
</QueryClientProvider>
);
}
describe('Live', () => {
beforeEach(() => {
mockApiFetch.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it('renders a card per active session with header count', async () => {
mockApiFetch.mockResolvedValue([
makeSession({ id: 1, user_name: 'Jan', activity_name: 'Frezen', insole_type: 'Kurk' }),
makeSession({ id: 2, user_name: 'Piet', activity_name: 'Lijmen', insole_type: 'Berk' }),
]);
renderLive();
expect(await screen.findByText('Actief nu (2)')).toBeInTheDocument();
expect(screen.getByText('Jan')).toBeInTheDocument();
expect(screen.getByText('Frezen')).toBeInTheDocument();
expect(screen.getByText('Piet')).toBeInTheDocument();
expect(screen.getByText('Lijmen')).toBeInTheDocument();
});
it('shows the empty state when nobody is working', async () => {
mockApiFetch.mockResolvedValue([]);
renderLive();
expect(await screen.findByText('Niemand is nu aan het werk.')).toBeInTheDocument();
expect(screen.getByText('Actief nu (0)')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,64 @@
import { useEffect, useState } from 'react';
import type { WorkSession } from '@solelog/shared';
import { useActiveSessions } from '../api/admin-sessions';
import { formatTime } from '../lib/elapsed';
export default function Live() {
const { data, isLoading, isError } = useActiveSessions();
// Client-side 1s tick so the elapsed timer on each card keeps counting up
// between the 5s server refreshes.
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, []);
if (isLoading) {
return (
<div className="screen">
<p className="muted">Laden</p>
</div>
);
}
if (isError) {
return (
<div className="screen">
<p className="muted">Kon gegevens niet laden.</p>
</div>
);
}
const sessions = Array.isArray(data) ? data : [];
return (
<div className="screen">
<h1 className="screen-title">Actief nu ({sessions.length})</h1>
{sessions.length === 0 ? (
<p className="muted">Niemand is nu aan het werk.</p>
) : (
<div className="live-grid">
{sessions.map((session) => (
<LiveCard key={session.id} session={session} now={now} />
))}
</div>
)}
</div>
);
}
function LiveCard({ session, now }: { session: WorkSession; now: number }) {
const elapsed = formatTime((now - Date.parse(session.start_time)) / 1000);
return (
<article className="live-card">
<div className="live-card-head">
<span className="live-name">{session.user_name ?? 'Onbekend'}</span>
{session.insole_type && <span className="live-pill">{session.insole_type}</span>}
</div>
<div className="live-activity">{session.activity_name ?? 'Onbekende handeling'}</div>
<div className="live-meta">{session.pair_count} zolen</div>
<div className="live-timer">{elapsed}</div>
</article>
);
}

View File

@@ -0,0 +1,57 @@
import { useState, type FormEvent } from 'react';
import { NotAdminError, useAuth } from '../auth/AuthContext';
export default function Login() {
const { signIn } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setBusy(true);
try {
await signIn(email, password);
} catch (err) {
setError(
err instanceof NotAdminError ? 'Geen toegang — alleen beheerders.' : 'Inloggen mislukt'
);
} finally {
setBusy(false);
}
}
return (
<div className="login">
<h1 className="login-title">SoleLog Admin</h1>
<form className="login-form" onSubmit={handleSubmit}>
<label className="field">
<span className="field-label">E-mailadres</span>
<input
className="field-input"
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</label>
<label className="field">
<span className="field-label">Wachtwoord</span>
<input
className="field-input"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</label>
{error && <p className="login-error">{error}</p>}
<button className="btn-primary" type="submit" disabled={busy}>
Inloggen
</button>
</form>
</div>
);
}

406
apps/admin/src/styles.css Normal file
View File

@@ -0,0 +1,406 @@
:root {
--primary: #2563eb;
--primary-light: #eff6ff;
--text: #111827;
--text-muted: #6b7280;
--border: #e5e7eb;
--danger: #dc2626;
--amber: #d97706;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family:
'Inter',
system-ui,
-apple-system,
sans-serif;
color: var(--text);
background: #f9fafb;
}
/* ---- Admin shell layout (desktop) ---- */
.admin-shell {
display: grid;
grid-template-columns: 220px 1fr;
min-height: 100vh;
}
.admin-main {
padding: 32px;
overflow-y: auto;
}
.screen {
max-width: 960px;
margin: 0 auto;
}
.screen-title {
font-size: 28px;
font-weight: 600;
margin: 0 0 8px;
}
.screen-subtitle {
margin: 0 0 24px;
color: var(--text-muted);
font-size: 15px;
}
/* ---- Sidebar ---- */
.sidebar {
display: flex;
flex-direction: column;
gap: 24px;
padding: 24px 16px;
background: #ffffff;
border-right: 1px solid var(--border);
}
.sidebar-brand {
font-size: 18px;
font-weight: 700;
color: var(--primary);
padding: 0 8px;
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 4px;
}
.nav-link {
display: block;
padding: 10px 12px;
font-size: 15px;
font-weight: 500;
text-decoration: none;
color: var(--text-muted);
border-radius: 10px;
}
.nav-link:hover {
background: #f3f4f6;
}
.nav-link-active {
color: var(--primary);
background: var(--primary-light);
}
.nav-soon {
display: flex;
flex-direction: column;
gap: 2px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.nav-soon-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
padding: 0 12px 4px;
}
.nav-disabled {
display: block;
padding: 10px 12px;
font-size: 15px;
font-weight: 500;
color: var(--border);
cursor: not-allowed;
}
/* ---- Topbar (signed-in identity + logout) ---- */
.topbar {
margin-top: auto;
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.topbar-email {
font-size: 13px;
color: var(--text-muted);
padding: 0 8px;
word-break: break-all;
}
.btn-logout {
width: 100%;
padding: 10px;
font-size: 14px;
font-weight: 600;
color: var(--danger);
background: #ffffff;
border: 1px solid var(--danger);
border-radius: 12px;
cursor: pointer;
}
/* ---- Login screen ---- */
.login {
max-width: 420px;
margin: 0 auto;
padding: 48px 24px;
display: flex;
flex-direction: column;
gap: 24px;
min-height: 100vh;
justify-content: center;
}
.login-title {
font-size: 36px;
font-weight: 600;
text-align: center;
color: var(--primary);
margin: 0;
}
.login-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-label {
font-size: 14px;
font-weight: 500;
color: var(--text-muted);
}
.field-input {
width: 100%;
padding: 14px 16px;
font-size: 16px;
border: 1px solid var(--border);
border-radius: 12px;
background: #ffffff;
color: var(--text);
}
.field-input:focus {
outline: none;
border-color: var(--primary);
}
.btn-primary {
width: 100%;
padding: 16px;
font-size: 16px;
font-weight: 600;
color: #ffffff;
background: var(--primary);
border: none;
border-radius: 16px;
cursor: pointer;
}
.btn-primary:disabled {
background: var(--border);
color: var(--text-muted);
cursor: not-allowed;
}
.login-error {
color: var(--danger);
font-size: 14px;
margin: 0;
text-align: center;
}
/* ---- Shared helpers ---- */
.muted {
color: var(--text-muted);
font-size: 15px;
}
/* ---- Activity management (Handelingen) ---- */
.card {
display: flex;
flex-direction: column;
gap: 12px;
border: 1px solid var(--border);
border-radius: 16px;
padding: 16px;
margin-bottom: 24px;
background: #ffffff;
}
.section-label {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
margin: 0 0 12px;
}
.card .section-label {
margin: 0;
}
.sub-label {
font-size: 14px;
font-weight: 500;
color: var(--text-muted);
margin: 0;
}
.activity-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.activity-card {
border: 1px solid var(--border);
border-radius: 16px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
background: #ffffff;
}
.activity-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.activity-name {
font-size: 16px;
font-weight: 600;
}
.row-actions {
display: flex;
gap: 8px;
}
.icon-btn {
width: 40px;
height: 40px;
border-radius: 12px;
border: 1px solid var(--border);
background: #ffffff;
font-size: 16px;
cursor: pointer;
}
.icon-edit {
color: var(--primary);
}
.icon-delete {
color: var(--danger);
}
.btn-save {
flex: 1;
padding: 12px;
font-size: 15px;
font-weight: 600;
color: #166534;
background: #dcfce7;
border: 1px solid #86efac;
border-radius: 12px;
cursor: pointer;
}
.btn-save:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-cancel {
flex: 1;
padding: 12px;
font-size: 15px;
font-weight: 600;
color: var(--text-muted);
background: #f3f4f6;
border: 1px solid var(--border);
border-radius: 12px;
cursor: pointer;
}
/* ---- Live active-work view ---- */
.live-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
}
.live-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 20px;
background: #ffffff;
border: 1px solid var(--border);
border-radius: 16px;
}
.live-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.live-name {
font-size: 17px;
font-weight: 600;
color: var(--text);
}
.live-pill {
font-size: 12px;
font-weight: 600;
color: var(--primary);
background: var(--primary-light);
border-radius: 999px;
padding: 4px 10px;
}
.live-activity {
font-size: 15px;
color: var(--text);
}
.live-meta {
font-size: 13px;
color: var(--text-muted);
}
.live-timer {
margin-top: 4px;
font-size: 28px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--text);
}

View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom';

9
apps/admin/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"composite": true,
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"types": ["vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src"]
}

4
apps/admin/tsconfig.json Normal file
View File

@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"noEmit": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"target": "ES2022",
"skipLibCheck": true,
"types": ["node"]
},
"include": ["vite.config.ts", "vitest.config.ts"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: { host: true, port: 5174 },
});

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: { environment: 'jsdom', globals: true, setupFiles: ['./src/test/setup.ts'] },
});

View File

@@ -3,7 +3,8 @@ BETTER_AUTH_SECRET=change-me-to-a-long-random-string
BETTER_AUTH_URL=http://localhost:3000 BETTER_AUTH_URL=http://localhost:3000
PORT=3000 PORT=3000
# Comma-separated browser origins allowed for CORS + better-auth (the worker SPA). # Comma-separated browser origins allowed for CORS + better-auth (the worker SPA on 5173
# and the admin SPA on 5174).
# Add your phone's LAN origin to test on a device — no code edit needed, e.g.: # Add your phone's LAN origin to test on a device — no code edit needed, e.g.:
# CORS_ORIGINS=http://localhost:5173,http://192.168.1.50:5173 # CORS_ORIGINS=http://localhost:5173,http://192.168.1.50:5173
CORS_ORIGINS=http://localhost:5173 CORS_ORIGINS=http://localhost:5173,http://localhost:5174

View File

@@ -9,6 +9,10 @@ export const env = {
PORT: Number(process.env.PORT ?? 3000), PORT: Number(process.env.PORT ?? 3000),
// Browser origins allowed for CORS + better-auth trustedOrigins. Set CORS_ORIGINS to a // Browser origins allowed for CORS + better-auth trustedOrigins. Set CORS_ORIGINS to a
// comma-separated list (e.g. "http://localhost:5173,http://192.168.1.50:5173") to let a // comma-separated list (e.g. "http://localhost:5173,http://192.168.1.50:5173") to let a
// phone on the LAN reach the API — no code edit needed. Defaults to the local Vite origin. // phone on the LAN reach the API — no code edit needed. Defaults to the local Vite origins
WEB_ORIGINS: webOrigins && webOrigins.length ? webOrigins : ['http://localhost:5173'], // for the worker (5173) and admin (5174) SPAs.
WEB_ORIGINS:
webOrigins && webOrigins.length
? webOrigins
: ['http://localhost:5173', 'http://localhost:5174'],
}; };

View File

@@ -1,5 +1,5 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import type { MeResponse } from '@solelog/shared'; import type { MeResponse, Role } from '@solelog/shared';
import { auth } from '../auth'; import { auth } from '../auth';
export const me = new Hono(); export const me = new Hono();
@@ -14,6 +14,7 @@ me.get('/api/me', async (c) => {
id: session.user.id, id: session.user.id,
email: session.user.email, email: session.user.email,
name: session.user.name, name: session.user.name,
role: ((session.user as { role?: string | null }).role ?? 'worker') as Role,
}, },
}; };
return c.json(body); return c.json(body);

View File

@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
import { createApp } from '../src/app'; import { createApp } from '../src/app';
const ORIGIN = 'http://localhost:5173'; const ORIGIN = 'http://localhost:5173';
const ADMIN_ORIGIN = 'http://localhost:5174';
describe('cors', () => { describe('cors', () => {
it('answers a CORS preflight for the SPA origin', async () => { it('answers a CORS preflight for the SPA origin', async () => {
@@ -18,6 +19,20 @@ describe('cors', () => {
expect(allowMethods).toContain('GET'); expect(allowMethods).toContain('GET');
}); });
it('answers a CORS preflight for the admin SPA origin', async () => {
const app = createApp();
const res = await app.request('/api/activities', {
method: 'OPTIONS',
headers: {
Origin: ADMIN_ORIGIN,
'Access-Control-Request-Method': 'GET',
},
});
expect(res.headers.get('access-control-allow-origin')).toBe(ADMIN_ORIGIN);
const allowMethods = res.headers.get('access-control-allow-methods') ?? '';
expect(allowMethods).toContain('GET');
});
it('exposes set-auth-token to the SPA origin', async () => { it('exposes set-auth-token to the SPA origin', async () => {
const app = createApp(); const app = createApp();
const res = await app.request('/api/activities', { const res = await app.request('/api/activities', {

View File

@@ -19,4 +19,24 @@ describe('GET /api/me', () => {
const body = await res.json(); const body = await res.json();
expect(body.user.email).toBe(email); expect(body.user.email).toBe(email);
}); });
it('returns role "worker" for a worker token', async () => {
const app = createApp();
const token = await authToken(app, 'worker-role@example.com', 'worker');
const res = await app.request('/api/me', { headers: bearer(token) });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.user.role).toBe('worker');
});
it('returns role "admin" for an admin token', async () => {
const app = createApp();
const token = await authToken(app, 'admin-role@example.com', 'admin');
const res = await app.request('/api/me', { headers: bearer(token) });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.user.role).toBe('admin');
});
}); });

View File

@@ -1,7 +1,7 @@
# Insole Production Time Tracker — Rebuild Roadmap & Project Overview # Insole Production Time Tracker — Rebuild Roadmap & Project Overview
- **Created:** 2026-06-17 - **Created:** 2026-06-17
- **Status:** Approved — living project doc; Phases 02 implemented (`docs/plans/phase-2-accounts-roles.md`) - **Status:** Approved — living project doc; Phases 02 implemented + Phase **3a** implemented (`docs/superpowers/plans/2026-06-17-phase-3a-admin-panel.md`)
- **Type:** Greenfield rebuild of an inherited app - **Type:** Greenfield rebuild of an inherited app
- **Tracked in git** under `docs/` (the project's documentation source of truth). - **Tracked in git** under `docs/` (the project's documentation source of truth).
@@ -174,9 +174,23 @@ Each phase keeps the system working and is its own spec → plan → build cycle
an admin account exists; admin manages users via `/api/auth/admin/*` and sees all sessions via an admin account exists; admin manages users via `/api/auth/admin/*` and sees all sessions via
`/api/admin/sessions`. Public sign-up is closed; activity writes are admin-only. `/api/admin/sessions`. Public sign-up is closed; activity writes are admin-only.
_Done when:_ workers see only their own sessions; an admin account exists. _Done when:_ workers see only their own sessions; an admin account exists.
- **Phase 3 — Admin panel.** The React admin app: live active-work view, reports/export, - **Phase 3 — Admin panel.** The React admin app (`apps/admin`, Vite + React, dev port
user management, **manual entry/edit (the fallback)**. _Done when:_ an admin can see 5174): live active-work view, reports/export, user management, **activity management**,
who's working now, manage users, and hand-correct a session. **manual entry/edit (the fallback)**.
**3a implemented** (`apps/admin`; plan `docs/superpowers/plans/2026-06-17-phase-3a-admin-panel.md`):
admin-only login (rejects non-admins via `role` on `/api/me`), the sidebar shell, the live
active-work view (`/api/admin/sessions/active`, 5 s auto-refresh, read-only), and activity
management (handelingen CRUD on `/api/activities`). **3b remaining:** reports/export
(all-users filtered CSV — current `/api/export` is self-scoped), user management
(better-auth `/api/auth/admin/*`), and manual entry/edit + admin stop/fix of a running
session (needs new backend endpoints).
Activity management (add/edit/delete handelingen + their `insole_types`) was removed from the
worker client in the Phase 2 follow-up because it is admin-only; it must be **ported here**. The
backend already exists (`/api/activities` writes are admin-gated; `useActivities`/the legacy
worker `Settings.tsx` at git `decb158`/`1631c16` are the UI reference), so this is a UI-only port.
User management likewise consumes the existing better-auth `/api/auth/admin/*` endpoints, and the
live view + reports consume `/api/admin/sessions[/active]`. _Done when:_ an admin can see who's
working now, manage users **and activities**, and hand-correct a session.
- **Phase 4 — Workbench scanning.** QR at the bench → select workbench/activity, with - **Phase 4 — Workbench scanning.** QR at the bench → select workbench/activity, with
manual selection fallback. _Done when:_ scanning a bench QR pre-fills the session. manual selection fallback. _Done when:_ scanning a bench QR pre-fills the session.
- **Phase 5 — Polish & deploy.** Reporting niceties, dependency slimming, push the - **Phase 5 — Polish & deploy.** Reporting niceties, dependency slimming, push the

View File

@@ -0,0 +1,72 @@
# Session: 2026-06-17 — Phase 3a (Admin panel MVP)
## Goal
Build `apps/admin`: a Vite + React desktop SPA where an admin logs in, watches who is
working **live** (auto-refreshing, read-only), and manages **handelingen** (activities).
Everything rides on existing backend endpoints plus one tiny backend touch (`role` on
`/api/me`). Reports/export, user management, and manual session entry/edit are deferred to
Phase 3b. Spec: `docs/superpowers/specs/2026-06-17-phase-3a-admin-panel-design.md`;
plan: `docs/superpowers/plans/2026-06-17-phase-3a-admin-panel.md`.
## Work done
Implemented Phase 3a task-by-task per the plan (TDD throughout):
- **Task 1 — `role` on `/api/me` + admin-origin CORS** (`02b7522`). Added `role: Role` to
`PublicUser` in `@solelog/shared`; `apps/api/src/routes/me.ts` now returns the session
user's role (default `'worker'`). Added `http://localhost:5174` (admin dev origin) to the
default `WEB_ORIGINS` in `env.ts` + `.env.example` (it drives both `hono/cors` and
better-auth `trustedOrigins`). Extended `me.test.ts` and `cors.test.ts`. Worker client
ignores the extra field — no worker change needed.
- **Task 2 — Scaffold `apps/admin`** (`682a9dc`). New `@solelog/admin` workspace mirroring
`apps/worker`'s toolchain (Vite 7, React 18.3, react-router 6, react-query 5, vitest 3,
TS 5.7). Copied `lib/api.ts` + `lib/auth-storage.ts` verbatim (shared `solelog.token`
key; separate localStorage because the admin runs on a different port). Dev port **5174**.
- **Task 3 — Auth context + admin gate + Login** (`77659ed`). `auth/AuthContext.tsx`:
`signIn` calls the worker `signIn`, fetches `/api/me`, and rejects non-admins
(`clearToken()` + throw) so `Login` shows "Geen toegang — alleen beheerders."; other
failures show "Inloggen mislukt".
- **Task 4 — Sidebar shell + routing** (`286e2d2`). Left-sidebar shell: brand "SoleLog
Admin", `NavLink`s **Live** / **Handelingen**, a muted "Binnenkort" group (Rapporten /
Gebruikers / Handmatig) hinting 3b, and a header strip with the signed-in email + an
Uitloggen button (`aria-label="Uitloggen"`).
- **Task 5 — Live active-work view** (`67dd0d3`). `useActiveSessions()` polls
`/api/admin/sessions/active` with `refetchInterval: 5000`; `lib/elapsed.ts` `formatTime`
ported verbatim from the worker stopwatch. One card per session (worker name, activity,
insole-type pill, pair count, ticking elapsed timer). Header "Actief nu (N)"; empty state
"Niemand is nu aan het werk.".
- **Task 6 — Activity management** (`c0d9d21`). Ported the legacy worker `Settings.tsx`
(git `decb158`) near-verbatim: `api/activities.ts` hooks (`useActivities` +
create/update/delete, all invalidating `['activities']`) and `screens/Activities.tsx`
(add form with insole-type toggles, list with inline edit, delete-with-confirm). Title
"Handelingen".
- **Task 7 — Docs, lint, verification** (this task). Lint/format clean, full green matrix,
live smoke, docs.
## Verification (Task 7)
- `npx oxlint` — clean (exit 0).
- `npx oxfmt --list-different` over the phase-3a files — nothing to change (all formatted).
- `yarn workspace @solelog/api typecheck` — pass; `test`**46 passed** (11 files).
- `yarn workspace @solelog/admin typecheck` — pass; `test`**14 passed** (5 files);
`build` — pass (`vite build`, 89 modules).
- `yarn workspace @solelog/worker test` (regression) — **22 passed** (7 files).
- **Live smoke** (API started, seeded, then server tree killed + port 3000 freed):
- admin sign-in → `GET /api/me``{ ..., "role": "admin" }`.
- `GET /api/admin/sessions/active` with the admin bearer → `[]`, HTTP 200.
- worker sign-in → `GET /api/me``{ ..., "role": "worker" }`.
## Outcome
Phase 3a is implemented and green. An admin can sign in to the admin app, see who is
working right now (5s auto-refresh, read-only), and add/edit/delete handelingen; a worker
who signs in with valid credentials is rejected with "Geen toegang". Roadmap Phase 3 status
updated to "3a implemented; 3b remaining"; `apps/admin/README.md` filled.
## Next (Phase 3b)
- Reports + 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).

View File

@@ -0,0 +1,333 @@
# 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` + allow the admin origin (CORS)
**Files:**
- Modify: `packages/shared/src/index.ts` (PublicUser)
- Modify: `apps/api/src/routes/me.ts`
- Modify: `apps/api/src/env.ts` (default `WEB_ORIGINS`), `apps/api/.env.example` (comment +
default `CORS_ORIGINS`)
- Test: `apps/api/test/me.test.ts` (extend), `apps/api/test/cors.test.ts` (extend)
**Interfaces:**
- Produces: `PublicUser` now has `role: Role`; `MeResponse.user.role` is `'worker' | 'admin'`.
The default CORS/trustedOrigins list includes the admin dev origin `http://localhost:5174`.
**Why CORS:** the admin app runs on dev port 5174 and calls the API on :3000 cross-origin.
`env.WEB_ORIGINS` (used for both `hono/cors` and better-auth `trustedOrigins`) currently
defaults to only `http://localhost:5173`, so without this the admin app's sign-in and every
request are blocked in the browser.
- [ ] **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: CORS — allow the admin origin.** Add `http://localhost:5174` to the
default `WEB_ORIGINS` array in `apps/api/src/env.ts`
(`['http://localhost:5173', 'http://localhost:5174']`) and to the `CORS_ORIGINS=` line +
comment in `apps/api/.env.example`. Extend `apps/api/test/cors.test.ts` with a case
asserting the preflight allows `http://localhost:5174` (parametrize or add a second
origin assertion). Run the cors test — green.
- [ ] **Step 6: Run tests — pass.** Also run `yarn workspace @solelog/api typecheck`.
- [ ] **Step 7: Confirm worker app unaffected**`yarn workspace @solelog/worker test`
still green (it ignores the extra field).
- [ ] **Step 8: Commit**`feat(api): include role in /api/me + allow admin origin in CORS`.
---
### 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.

View File

@@ -0,0 +1,120 @@
# 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 changes (minimal, 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.
- `apps/api/src/env.ts` + `.env.example`: add `http://localhost:5174` (the admin dev
origin) to the default `WEB_ORIGINS` / `CORS_ORIGINS`. Required because `WEB_ORIGINS`
drives both `hono/cors` and better-auth `trustedOrigins`; the admin app at :5174 calls
the API at :3000 cross-origin and would otherwise be blocked.
## 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.

View File

@@ -5,10 +5,14 @@ export const HealthResponse = z.object({
}); });
export type HealthResponse = z.infer<typeof HealthResponse>; export type HealthResponse = z.infer<typeof HealthResponse>;
export const Role = z.enum(['worker', 'admin']);
export type Role = z.infer<typeof Role>;
export const PublicUser = z.object({ export const PublicUser = z.object({
id: z.string(), id: z.string(),
email: z.string().email(), email: z.string().email(),
name: z.string(), name: z.string(),
role: Role,
}); });
export type PublicUser = z.infer<typeof PublicUser>; export type PublicUser = z.infer<typeof PublicUser>;
@@ -20,9 +24,6 @@ export type MeResponse = z.infer<typeof MeResponse>;
export const InsoleType = z.enum(['Kurk', 'Berk', '3D']); export const InsoleType = z.enum(['Kurk', 'Berk', '3D']);
export type InsoleType = z.infer<typeof InsoleType>; export type InsoleType = z.infer<typeof InsoleType>;
export const Role = z.enum(['worker', 'admin']);
export type Role = z.infer<typeof Role>;
export const Activity = z.object({ export const Activity = z.object({
id: z.number().int(), id: z.number().int(),
name: z.string(), name: z.string(),

View File

@@ -1761,6 +1761,29 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@solelog/admin@workspace:apps/admin":
version: 0.0.0-use.local
resolution: "@solelog/admin@workspace:apps/admin"
dependencies:
"@solelog/shared": "workspace:*"
"@tanstack/react-query": "npm:^5.0.0"
"@testing-library/dom": "npm:^10.4.1"
"@testing-library/jest-dom": "npm:^6.4.0"
"@testing-library/react": "npm:^16.0.0"
"@testing-library/user-event": "npm:^14.5.0"
"@types/react": "npm:^18.3.0"
"@types/react-dom": "npm:^18.3.0"
"@vitejs/plugin-react": "npm:^4.3.0"
jsdom: "npm:^25.0.0"
react: "npm:^18.3.1"
react-dom: "npm:^18.3.1"
react-router-dom: "npm:^6.26.0"
typescript: "npm:^5.7.2"
vite: "npm:^7.0.0"
vitest: "npm:^3.0.0"
languageName: unknown
linkType: soft
"@solelog/api@workspace:apps/api": "@solelog/api@workspace:apps/api":
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "@solelog/api@workspace:apps/api" resolution: "@solelog/api@workspace:apps/api"