Compare commits

..

17 Commits

Author SHA1 Message Date
Bas van Rossem
bbf29120ac docs: phase 3b.2 reports/export session log + roadmap note
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m17s
2026-06-24 16:44:17 +02:00
Bas van Rossem
8d75be0462 feat(admin): Rapporten screen with filters, totals, breakdowns + CSV export 2026-06-24 16:40:25 +02:00
Bas van Rossem
8ad2e69ec3 feat(admin): reports API client (useReport + downloadExport) 2026-06-24 16:33:28 +02:00
Bas van Rossem
d33fcb7cce feat(api): admin all-users filtered CSV export 2026-06-24 16:30:17 +02:00
Bas van Rossem
4b213e25a8 feat(api): admin report endpoint + ReportResponse contracts 2026-06-24 16:25:36 +02:00
Bas van Rossem
01fa18f401 refactor(api): extract buildSessionsCsv shared by worker + admin exports 2026-06-24 16:22:06 +02:00
Bas van Rossem
eac1eed71a docs(plan): phase 3b.2 reports + all-users export implementation plan 2026-06-24 16:18:15 +02:00
Bas van Rossem
3eccdef313 docs(spec): phase 3b.2 reports + all-users export design 2026-06-24 15:46:24 +02:00
Bas van Rossem
84677080fc docs: phase 3b.1 manual-sessions session log + roadmap note 2026-06-17 22:54:18 +02:00
Bas van Rossem
7d3daaa760 feat(worker): poll + reconcile stopwatch to server (admin stop/cancel) 2026-06-17 22:50:31 +02:00
Bas van Rossem
f1ec249ee7 feat(admin): stop/cancel an active session from the live view 2026-06-17 22:47:05 +02:00
Bas van Rossem
69b46bee04 feat(admin): manual session create/edit form
Add the shared SessionForm component (create/edit) used by the Sessies screen.
- Create mode: worker picker (useAdminUsers), activity (useActivities), zooltype,
  pair count, start/end datetime-local, pauze, notitie; posts CreateManualSessionInput.
- Edit mode: hides the worker picker, prefills from the row session, adds a status
  select; PUTs AdminUpdateSessionInput (end_time nulled when status=active).
- Builds ISO start_time/end_time from the datetime-local values; live "gewerkt"
  preview = end - start - paused; inline error on a 400.
- Wires the form into Sessions.tsx ("+ Nieuwe registratie" -> create, pencil -> edit).

Products affected: SoleLog admin client
2026-06-17 22:44:06 +02:00
Bas van Rossem
b5366413b6 feat(admin): sessions management screen (list + filter + actions) 2026-06-17 22:38:28 +02:00
Bas van Rossem
53c66ee144 feat(api): admin manual-session create/edit/stop/discard + users roster
Sub
Phase 3b.1 needs admin-side write paths so an admin can hand-create a
session for a worker, correct any session, and stop/cancel a worker's
stuck active session — duration always derived server-side.

Adds under the existing /api/admin/* admin guard:
- GET /api/admin/users — {id,name,email} roster ordered by name (worker picker)
- POST /api/admin/sessions — manual create -> completed, source=manual,
  duration = max(0, round((end-start)/1000) - paused)
- PUT /api/admin/sessions/:id — edit any session (no user reassignment);
  recomputes duration when end_time present, else duration null
- POST /api/admin/sessions/:id/stop — fold open pause, end=now, completed
- POST /api/admin/sessions/:id/discard — status=discarded, end=now

Validates end>=start, pair_count>=1, paused<=span, activity/user exist
(400/404); stop/discard on an already-closed session -> 409. Responses
reuse toWorkSession.

Products affected: SoleLog backend (apps/api)
2026-06-17 22:32:58 +02:00
Bas van Rossem
7d1e816fe2 feat(ci): set database location to local folder 2026-06-17 22:28:55 +02:00
Bas van Rossem
00993c67da feat(shared): manual-session create/update contracts
Add CreateManualSessionInput and AdminUpdateSessionInput zod schemas plus
inferred types. packages/shared has no test runner, so these are exercised
via Task 2's API tests rather than a standalone parse test.
2026-06-17 22:28:02 +02:00
Bas van Rossem
9d21576014 docs: spec + plan for phase 3b.1 manual session entry/edit + admin stop/fix 2026-06-17 22:25:28 +02:00
34 changed files with 5182 additions and 62 deletions

View File

@@ -4,6 +4,8 @@ import Login from './screens/Login';
import Sidebar from './components/Sidebar'; import Sidebar from './components/Sidebar';
import Live from './screens/Live'; import Live from './screens/Live';
import Activities from './screens/Activities'; import Activities from './screens/Activities';
import Sessions from './screens/Sessions';
import Reports from './screens/Reports';
function AuthedShell() { function AuthedShell() {
return ( return (
@@ -14,6 +16,8 @@ function AuthedShell() {
<Routes> <Routes>
<Route path="/" element={<Live />} /> <Route path="/" element={<Live />} />
<Route path="/handelingen" element={<Activities />} /> <Route path="/handelingen" element={<Activities />} />
<Route path="/sessies" element={<Sessions />} />
<Route path="/rapporten" element={<Reports />} />
</Routes> </Routes>
</main> </main>
</div> </div>

View File

@@ -1,5 +1,9 @@
import { useQuery } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { WorkSession } from '@solelog/shared'; import type {
AdminUpdateSessionInput,
CreateManualSessionInput,
WorkSession,
} from '@solelog/shared';
import { apiFetch } from '../lib/api'; import { apiFetch } from '../lib/api';
// Active work sessions across all workers (admin cross-user view). // Active work sessions across all workers (admin cross-user view).
@@ -11,3 +15,69 @@ export function useActiveSessions() {
refetchInterval: 5000, refetchInterval: 5000,
}); });
} }
// Every session (newest first) for the Sessies management screen.
export function useAllSessions() {
return useQuery({
queryKey: ['admin', 'sessions', 'all'],
queryFn: () => apiFetch<WorkSession[]>('/api/admin/sessions'),
});
}
// Roster for the create-form worker picker.
export function useAdminUsers() {
return useQuery({
queryKey: ['admin', 'users'],
queryFn: () => apiFetch<{ id: string; name: string; email: string }[]>('/api/admin/users'),
});
}
export function useCreateManualSession() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateManualSessionInput) =>
apiFetch<WorkSession>('/api/admin/sessions', {
method: 'POST',
body: JSON.stringify(input),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'sessions'] });
},
});
}
export function useUpdateSession() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, input }: { id: number; input: AdminUpdateSessionInput }) =>
apiFetch<WorkSession>(`/api/admin/sessions/${id}`, {
method: 'PUT',
body: JSON.stringify(input),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'sessions'] });
},
});
}
export function useAdminStopSession() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) =>
apiFetch<WorkSession>(`/api/admin/sessions/${id}/stop`, { method: 'POST' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'sessions'] });
},
});
}
export function useAdminDiscardSession() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) =>
apiFetch<WorkSession>(`/api/admin/sessions/${id}/discard`, { method: 'POST' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'sessions'] });
},
});
}

View File

@@ -0,0 +1,67 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { filtersToQuery, downloadExport, type ReportFilters } from './reports';
vi.mock('../lib/auth-storage', () => ({ getToken: () => 'tok-123' }));
const FILTERS: ReportFilters = {
from: '2026-06-15T00:00:00.000Z',
to: '2026-06-21T22:00:00.000Z',
userId: 'u1',
insoleType: 'Kurk',
activityId: 7,
};
describe('filtersToQuery', () => {
it('serializes all params', () => {
const q = new URLSearchParams(filtersToQuery(FILTERS));
expect(q.get('from')).toBe(FILTERS.from);
expect(q.get('to')).toBe(FILTERS.to);
expect(q.get('user_id')).toBe('u1');
expect(q.get('insole_type')).toBe('Kurk');
expect(q.get('activity_id')).toBe('7');
});
it('omits empty optional filters', () => {
const q = new URLSearchParams(filtersToQuery({ from: 'a', to: 'b' }));
expect(q.has('user_id')).toBe(false);
expect(q.has('insole_type')).toBe(false);
expect(q.has('activity_id')).toBe(false);
});
});
describe('downloadExport', () => {
afterEach(() => vi.restoreAllMocks());
it('fetches with the bearer token and triggers a download', async () => {
const blob = new Blob(['csv'], { type: 'text/csv' });
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
blob: () => Promise.resolve(blob),
headers: new Headers({
'content-disposition': 'attachment; filename="solelog-report_2026-06-15_2026-06-21.csv"',
}),
});
vi.stubGlobal('fetch', fetchMock);
const createUrl = vi.fn(() => 'blob:x');
const revokeUrl = vi.fn();
vi.stubGlobal('URL', { ...URL, createObjectURL: createUrl, revokeObjectURL: revokeUrl });
const click = vi.fn();
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(click);
await downloadExport(FILTERS);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toContain('/api/admin/export?');
expect(url).toContain('insole_type=Kurk');
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer tok-123');
expect(createUrl).toHaveBeenCalledWith(blob);
expect(click).toHaveBeenCalledTimes(1);
expect(revokeUrl).toHaveBeenCalledWith('blob:x');
});
it('throws on a non-ok response', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }));
await expect(downloadExport(FILTERS)).rejects.toThrow();
});
});

View File

@@ -0,0 +1,55 @@
import { useQuery } from '@tanstack/react-query';
import type { ReportResponse } from '@solelog/shared';
import { API_URL, apiFetch } from '../lib/api';
import { getToken } from '../lib/auth-storage';
export interface ReportFilters {
from: string; // ISO instant (start of from-day, local tz)
to: string; // ISO instant (end of to-day, local tz)
userId?: string;
insoleType?: string;
activityId?: number;
}
// Build the querystring; omit empty optional filters.
export function filtersToQuery(f: ReportFilters): string {
const params = new URLSearchParams();
params.set('from', f.from);
params.set('to', f.to);
if (f.userId) params.set('user_id', f.userId);
if (f.insoleType) params.set('insole_type', f.insoleType);
if (f.activityId !== undefined) params.set('activity_id', String(f.activityId));
return params.toString();
}
export function useReport(filters: ReportFilters) {
return useQuery({
queryKey: ['admin', 'report', filters],
queryFn: () => apiFetch<ReportResponse>(`/api/admin/report?${filtersToQuery(filters)}`),
});
}
function filenameFromResponse(res: Response): string {
const cd = res.headers.get('content-disposition');
const m = cd?.match(/filename="(.+?)"/);
return m ? m[1] : 'solelog-report.csv';
}
// The export endpoint is bearer-auth'd, so a plain <a href> can't carry the token:
// fetch with the Authorization header, then download the resulting Blob.
export async function downloadExport(filters: ReportFilters): Promise<void> {
const token = getToken();
const res = await fetch(`${API_URL}/api/admin/export?${filtersToQuery(filters)}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error(`Export mislukt: ${res.status}`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filenameFromResponse(res);
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}

View File

@@ -0,0 +1,198 @@
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, WorkSession } from '@solelog/shared';
import SessionForm from './SessionForm';
import { apiFetch } from '../lib/api';
vi.mock('../lib/api', () => ({
apiFetch: vi.fn(),
ApiError: class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
this.name = 'ApiError';
}
},
}));
const mockApiFetch = vi.mocked(apiFetch);
const USERS = [
{ id: 'u1', name: 'Jan', email: 'jan@example.com' },
{ id: 'u2', name: 'Piet', email: 'piet@example.com' },
];
const ACTIVITIES: Activity[] = [
{
id: 10,
name: 'Frezen',
insole_types: ['Kurk', 'Berk', '3D'],
created_at: '2026-06-17T00:00:00.000Z',
sort_order: 0,
},
{
id: 11,
name: 'Lijmen',
insole_types: ['Kurk', 'Berk', '3D'],
created_at: '2026-06-17T00:00:00.000Z',
sort_order: 1,
},
];
function makeSession(over: Partial<WorkSession> = {}): WorkSession {
return {
id: 7,
user_id: 'u2',
activity_id: 11,
activity_name: 'Lijmen',
user_name: 'Piet',
insole_type: 'Berk',
pair_count: 3,
start_time: new Date('2026-06-17T08:00:00Z').toISOString(),
end_time: new Date('2026-06-17T09:00:00Z').toISOString(),
duration_seconds: 3000,
paused_seconds: 600,
paused_at: null,
status: 'completed',
source: 'manual',
notes: 'aantekening',
created_at: new Date('2026-06-17T09:00:00Z').toISOString(),
...over,
};
}
// Route GET roster/activities to fixtures; POST/PUT resolve to a session.
function mockEndpoints() {
mockApiFetch.mockImplementation((path: string) => {
if (path === '/api/admin/users') return Promise.resolve(USERS as never);
if (path === '/api/activities') return Promise.resolve(ACTIVITIES as never);
return Promise.resolve(makeSession() as never);
});
}
function renderForm(props: Parameters<typeof SessionForm>[0]) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={queryClient}>
<SessionForm {...props} />
</QueryClientProvider>,
);
}
describe('SessionForm', () => {
beforeEach(() => {
mockApiFetch.mockReset();
mockEndpoints();
});
afterEach(() => {
vi.clearAllMocks();
});
it('create mode shows the worker picker and posts CreateManualSessionInput', async () => {
const onClose = vi.fn();
renderForm({ mode: 'create', onClose });
// Worker picker populated from the roster.
const worker = await screen.findByLabelText('Medewerker');
expect(worker).toBeInTheDocument();
await screen.findByRole('option', { name: 'Jan' });
await userEvent.selectOptions(worker, 'u1');
await userEvent.selectOptions(screen.getByLabelText('Handeling'), '10');
await userEvent.selectOptions(screen.getByLabelText('Zooltype'), 'Kurk');
// Times: 1h apart.
const start = screen.getByLabelText('Starttijd');
const end = screen.getByLabelText('Eindtijd');
await userEvent.clear(start);
await userEvent.type(start, '2026-06-17T08:00');
await userEvent.clear(end);
await userEvent.type(end, '2026-06-17T09:00');
await userEvent.click(screen.getByRole('button', { name: /Opslaan/ }));
await waitFor(() =>
expect(mockApiFetch).toHaveBeenCalledWith(
'/api/admin/sessions',
expect.objectContaining({ method: 'POST' }),
),
);
const call = mockApiFetch.mock.calls.find((c) => c[0] === '/api/admin/sessions');
expect(call).toBeDefined();
const body = JSON.parse((call![1] as RequestInit).body as string);
expect(body).toMatchObject({
user_id: 'u1',
activity_id: 10,
insole_type: 'Kurk',
});
// ISO times built from the datetime-local values.
expect(new Date(body.end_time).getTime() - new Date(body.start_time).getTime()).toBe(3_600_000);
expect(onClose).toHaveBeenCalled();
});
it('edit mode hides the worker picker, prefills, and PUTs the changes', async () => {
const onClose = vi.fn();
renderForm({ mode: 'edit', session: makeSession(), onClose });
// No worker picker in edit mode.
await screen.findByLabelText('Handeling');
expect(screen.queryByLabelText('Medewerker')).not.toBeInTheDocument();
// Prefilled: activity 11 (Lijmen) — wait for the roster/activities to load.
await screen.findByRole('option', { name: 'Lijmen' });
expect((screen.getByLabelText('Handeling') as HTMLSelectElement).value).toBe('11');
expect((screen.getByLabelText('Notitie') as HTMLTextAreaElement).value).toBe('aantekening');
// Status select is present only in edit mode.
expect(screen.getByLabelText('Status')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: /Opslaan/ }));
await waitFor(() =>
expect(mockApiFetch).toHaveBeenCalledWith(
'/api/admin/sessions/7',
expect.objectContaining({ method: 'PUT' }),
),
);
const call = mockApiFetch.mock.calls.find((c) => c[0] === '/api/admin/sessions/7');
const body = JSON.parse((call![1] as RequestInit).body as string);
expect(body).toMatchObject({
activity_id: 11,
insole_type: 'Berk',
pair_count: 3,
status: 'completed',
notes: 'aantekening',
});
expect(onClose).toHaveBeenCalled();
});
it('shows the gewerkt preview = end - start - paused', async () => {
// 1h span (3600s) minus 600s paused = 3000s = 00:50:00.
renderForm({ mode: 'edit', session: makeSession(), onClose: vi.fn() });
await screen.findByLabelText('Handeling');
expect(await screen.findByText(/00:50:00/)).toBeInTheDocument();
});
it('shows an inline error when the API returns 400', async () => {
const { ApiError } = await import('../lib/api');
mockApiFetch.mockImplementation((path: string) => {
if (path === '/api/admin/users') return Promise.resolve(USERS as never);
if (path === '/api/activities') return Promise.resolve(ACTIVITIES as never);
return Promise.reject(new ApiError(400, 'Invalid input'));
});
renderForm({ mode: 'edit', session: makeSession(), onClose: vi.fn() });
await screen.findByLabelText('Handeling');
await userEvent.click(screen.getByRole('button', { name: /Opslaan/ }));
expect(await screen.findByText(/Controleer de ingevoerde gegevens/)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,279 @@
import { useMemo, useState } from 'react';
import type {
AdminUpdateSessionInput,
CreateManualSessionInput,
InsoleType,
SessionStatus,
WorkSession,
} from '@solelog/shared';
import { useActivities } from '../api/activities';
import { useAdminUsers, useCreateManualSession, useUpdateSession } from '../api/admin-sessions';
import { ApiError } from '../lib/api';
import { formatTime } from '../lib/elapsed';
const ALL_TYPES: InsoleType[] = ['Kurk', 'Berk', '3D'];
const STATUS_OPTIONS: { value: SessionStatus; label: string }[] = [
{ value: 'active', label: 'Actief' },
{ value: 'completed', label: 'Voltooid' },
{ value: 'discarded', label: 'Geannuleerd' },
];
// ISO-8601 → the `YYYY-MM-DDTHH:mm` shape a datetime-local input wants, in local time.
function isoToLocal(iso: string | null): string {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
// A datetime-local value (local wall-clock) → an ISO-8601 instant.
function localToIso(local: string): string | null {
if (!local) return null;
const d = new Date(local);
if (Number.isNaN(d.getTime())) return null;
return d.toISOString();
}
function spanSeconds(startLocal: string, endLocal: string): number | null {
const start = startLocal ? new Date(startLocal) : null;
const end = endLocal ? new Date(endLocal) : null;
if (!start || !end || Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return null;
return Math.round((end.getTime() - start.getTime()) / 1000);
}
type Props = {
mode: 'create' | 'edit';
session?: WorkSession;
onClose: () => void;
};
export default function SessionForm({ mode, session, onClose }: Props) {
const usersQuery = useAdminUsers();
const activitiesQuery = useActivities();
const createSession = useCreateManualSession();
const updateSession = useUpdateSession();
const users = usersQuery.data ?? [];
const activities = activitiesQuery.data ?? [];
const [userId, setUserId] = useState(session?.user_id ?? '');
const [activityId, setActivityId] = useState<number | ''>(session?.activity_id ?? '');
const [insoleType, setInsoleType] = useState<InsoleType | ''>(session?.insole_type ?? '');
const [pairCount, setPairCount] = useState(session?.pair_count ?? 2);
const [startLocal, setStartLocal] = useState(isoToLocal(session?.start_time ?? null));
const [endLocal, setEndLocal] = useState(isoToLocal(session?.end_time ?? null));
const [pausedMinutes, setPausedMinutes] = useState(
session ? Math.round(session.paused_seconds / 60) : 0,
);
const [status, setStatus] = useState<SessionStatus>(session?.status ?? 'completed');
const [notes, setNotes] = useState(session?.notes ?? '');
const [error, setError] = useState<string | null>(null);
const pausedSeconds = Math.max(0, pausedMinutes) * 60;
const workedPreview = useMemo(() => {
const span = spanSeconds(startLocal, endLocal);
if (span === null) return null;
return Math.max(0, span - pausedSeconds);
}, [startLocal, endLocal, pausedSeconds]);
const submitting = createSession.isPending || updateSession.isPending;
function onError(err: unknown) {
if (err instanceof ApiError && err.status === 400) {
setError('Controleer de ingevoerde gegevens (eindtijd na starttijd, aantal ≥ 1).');
} else {
setError('Opslaan mislukt. Probeer het opnieuw.');
}
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
const startIso = localToIso(startLocal);
if (!startIso || activityId === '') {
setError('Vul handeling en starttijd in.');
return;
}
if (mode === 'create') {
const endIso = localToIso(endLocal);
if (!userId || !insoleType || !endIso) {
setError('Vul medewerker, zooltype, start- en eindtijd in.');
return;
}
const input: CreateManualSessionInput = {
user_id: userId,
activity_id: activityId,
insole_type: insoleType,
pair_count: pairCount,
start_time: startIso,
end_time: endIso,
paused_seconds: pausedSeconds,
notes: notes.trim() ? notes.trim() : null,
};
createSession.mutate(input, { onSuccess: () => onClose(), onError });
return;
}
if (!session) return;
const input: AdminUpdateSessionInput = {
activity_id: activityId,
insole_type: insoleType === '' ? null : insoleType,
pair_count: pairCount,
start_time: startIso,
end_time: status === 'active' ? null : localToIso(endLocal),
paused_seconds: pausedSeconds,
notes: notes.trim() ? notes.trim() : null,
status,
};
updateSession.mutate({ id: session.id, input }, { onSuccess: () => onClose(), onError });
}
return (
<form className="card session-form" onSubmit={handleSubmit}>
<h2 className="section-label">
{mode === 'create' ? 'Nieuwe registratie' : 'Sessie bewerken'}
</h2>
{mode === 'create' && (
<label className="session-field">
<span className="sub-label">Medewerker</span>
<select
aria-label="Medewerker"
value={userId}
onChange={(e) => setUserId(e.target.value)}
>
<option value=""> Kies </option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</label>
)}
<label className="session-field">
<span className="sub-label">Handeling</span>
<select
aria-label="Handeling"
value={activityId}
onChange={(e) => setActivityId(e.target.value === '' ? '' : Number(e.target.value))}
>
<option value=""> Kies </option>
{activities.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
</label>
<label className="session-field">
<span className="sub-label">Zooltype</span>
<select
aria-label="Zooltype"
value={insoleType}
onChange={(e) =>
setInsoleType(e.target.value === '' ? '' : (e.target.value as InsoleType))
}
>
<option value=""> Kies </option>
{ALL_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</label>
<label className="session-field">
<span className="sub-label">Aantal paren</span>
<input
type="number"
min={1}
aria-label="Aantal paren"
value={pairCount}
onChange={(e) => setPairCount(Math.max(1, Number(e.target.value) || 1))}
/>
</label>
<label className="session-field">
<span className="sub-label">Starttijd</span>
<input
type="datetime-local"
aria-label="Starttijd"
value={startLocal}
onChange={(e) => setStartLocal(e.target.value)}
/>
</label>
<label className="session-field">
<span className="sub-label">Eindtijd</span>
<input
type="datetime-local"
aria-label="Eindtijd"
value={endLocal}
onChange={(e) => setEndLocal(e.target.value)}
/>
</label>
<label className="session-field">
<span className="sub-label">Pauze (minuten)</span>
<input
type="number"
min={0}
aria-label="Pauze (minuten)"
value={pausedMinutes}
onChange={(e) => setPausedMinutes(Math.max(0, Number(e.target.value) || 0))}
/>
</label>
{mode === 'edit' && (
<label className="session-field">
<span className="sub-label">Status</span>
<select
aria-label="Status"
value={status}
onChange={(e) => setStatus(e.target.value as SessionStatus)}
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
)}
<label className="session-field">
<span className="sub-label">Notitie</span>
<textarea
aria-label="Notitie"
rows={2}
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</label>
<p className="session-preview">
Gewerkt: <strong>{workedPreview === null ? '—' : formatTime(workedPreview)}</strong>
</p>
{error && <p className="login-error">{error}</p>}
<div className="row-actions">
<button type="submit" className="btn-save" disabled={submitting}>
Opslaan
</button>
<button type="button" className="btn-cancel" onClick={onClose} disabled={submitting}>
Annuleren
</button>
</div>
</form>
);
}

View File

@@ -5,10 +5,12 @@ import { useMe } from '../api/me';
const navItems = [ const navItems = [
{ to: '/', label: 'Live' }, { to: '/', label: 'Live' },
{ to: '/handelingen', label: 'Handelingen' }, { to: '/handelingen', label: 'Handelingen' },
{ to: '/sessies', label: 'Sessies' },
{ to: '/rapporten', label: 'Rapporten' },
] as const; ] as const;
// Sections planned for Phase 3b — shown muted/disabled as a hint of what's coming. // Sections planned for the final Phase 3b cycle — shown muted/disabled.
const soonItems = ['Rapporten', 'Gebruikers', 'Handmatig'] as const; const soonItems = ['Gebruikers'] as const;
export default function Sidebar() { export default function Sidebar() {
const { signOut } = useAuth(); const { signOut } = useAuth();

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { dayStartISO, dayEndISO, thisWeek, thisMonth } from './date-range';
describe('date-range helpers', () => {
it('dayStartISO/dayEndISO bound a local day', () => {
const start = new Date(dayStartISO('2026-06-17'));
const end = new Date(dayEndISO('2026-06-17'));
expect(start.getHours()).toBe(0);
expect(start.getMinutes()).toBe(0);
expect(end.getHours()).toBe(23);
expect(end.getMinutes()).toBe(59);
expect(end.getTime()).toBeGreaterThan(start.getTime());
});
it('thisWeek returns Monday..today (from/to are YYYY-MM-DD)', () => {
const ref = new Date('2026-06-17T12:00:00'); // a Wednesday
const { from, to } = thisWeek(ref);
expect(from).toBe('2026-06-15'); // Monday
expect(to).toBe('2026-06-17');
});
it('thisMonth returns the 1st..today', () => {
const ref = new Date('2026-06-17T12:00:00');
const { from, to } = thisMonth(ref);
expect(from).toBe('2026-06-01');
expect(to).toBe('2026-06-17');
});
});

View File

@@ -0,0 +1,40 @@
// Helpers for the report filter bar. Dates are 'YYYY-MM-DD' (what <input type="date"> uses);
// the *ISO instants* sent to the API are derived in the admin's local timezone so a picked
// day means that whole local day.
function pad(n: number): string {
return String(n).padStart(2, '0');
}
export function toDateStr(d: Date): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
// Start of the given local day, as an ISO instant.
export function dayStartISO(dateStr: string): string {
const [y, m, d] = dateStr.split('-').map(Number);
return new Date(y, m - 1, d, 0, 0, 0, 0).toISOString();
}
// End of the given local day, as an ISO instant.
export function dayEndISO(dateStr: string): string {
const [y, m, d] = dateStr.split('-').map(Number);
return new Date(y, m - 1, d, 23, 59, 59, 999).toISOString();
}
export function thisWeek(ref: Date): { from: string; to: string } {
const day = ref.getDay(); // 0=Sun..6=Sat
const diffToMonday = (day + 6) % 7;
const monday = new Date(ref.getFullYear(), ref.getMonth(), ref.getDate() - diffToMonday);
return { from: toDateStr(monday), to: toDateStr(ref) };
}
export function thisMonth(ref: Date): { from: string; to: string } {
const first = new Date(ref.getFullYear(), ref.getMonth(), 1);
return { from: toDateStr(first), to: toDateStr(ref) };
}
// 'Alles' — a wide window from epoch-ish to today.
export function allTime(ref: Date): { from: string; to: string } {
return { from: '2000-01-01', to: toDateStr(ref) };
}

View File

@@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/react'; 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { WorkSession } from '@solelog/shared'; import type { WorkSession } from '@solelog/shared';
@@ -120,6 +121,58 @@ describe('Live', () => {
expect(await screen.findByText('Pauze 00:02:05')).toBeInTheDocument(); expect(await screen.findByText('Pauze 00:02:05')).toBeInTheDocument();
}); });
it('shows Stop and Annuleer buttons on an active card', async () => {
mockApiFetch.mockResolvedValue([makeSession({ id: 7, user_name: 'Jan' })]);
renderLive();
expect(await screen.findByText('Jan')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Stop' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Annuleer' })).toBeInTheDocument();
});
it('clicking Stop calls the stop endpoint for that session', async () => {
const user = userEvent.setup();
mockApiFetch.mockImplementation((path: string) => {
if (path === '/api/admin/sessions/active') {
return Promise.resolve([makeSession({ id: 7, user_name: 'Jan' })]);
}
return Promise.resolve({});
});
renderLive();
await user.click(await screen.findByRole('button', { name: 'Stop' }));
await waitFor(() => {
expect(mockApiFetch).toHaveBeenCalledWith(
'/api/admin/sessions/7/stop',
expect.objectContaining({ method: 'POST' }),
);
});
});
it('clicking Annuleer calls the discard endpoint for that session', async () => {
const user = userEvent.setup();
mockApiFetch.mockImplementation((path: string) => {
if (path === '/api/admin/sessions/active') {
return Promise.resolve([makeSession({ id: 7, user_name: 'Jan' })]);
}
return Promise.resolve({});
});
renderLive();
await user.click(await screen.findByRole('button', { name: 'Annuleer' }));
await waitFor(() => {
expect(mockApiFetch).toHaveBeenCalledWith(
'/api/admin/sessions/7/discard',
expect.objectContaining({ method: 'POST' }),
);
});
});
it('keeps the timer counting (no Gepauzeerd badge) when not paused', async () => { it('keeps the timer counting (no Gepauzeerd badge) when not paused', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
mockApiFetch.mockResolvedValue([ mockApiFetch.mockResolvedValue([

View File

@@ -1,6 +1,10 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { WorkSession } from '@solelog/shared'; import type { WorkSession } from '@solelog/shared';
import { useActiveSessions } from '../api/admin-sessions'; import {
useActiveSessions,
useAdminDiscardSession,
useAdminStopSession,
} from '../api/admin-sessions';
import { formatTime } from '../lib/elapsed'; import { formatTime } from '../lib/elapsed';
export default function Live() { export default function Live() {
@@ -56,6 +60,14 @@ function LiveCard({ session, now }: { session: WorkSession; now: number }) {
0, 0,
Math.floor((base - Date.parse(session.start_time)) / 1000) - session.paused_seconds, Math.floor((base - Date.parse(session.start_time)) / 1000) - session.paused_seconds,
); );
// Stop closes the session (server computes the duration); Annuleer discards it.
// Both hooks invalidate ['admin','sessions'], so the active query refetches and the
// card drops off the Live grid once the session is no longer active.
const stopSession = useAdminStopSession();
const discardSession = useAdminDiscardSession();
const busy = stopSession.isPending || discardSession.isPending;
return ( return (
<article className="live-card"> <article className="live-card">
<div className="live-card-head"> <div className="live-card-head">
@@ -69,6 +81,24 @@ function LiveCard({ session, now }: { session: WorkSession; now: number }) {
{session.paused_seconds > 0 && ( {session.paused_seconds > 0 && (
<span className="live-paused-total">Pauze {formatTime(session.paused_seconds)}</span> <span className="live-paused-total">Pauze {formatTime(session.paused_seconds)}</span>
)} )}
<div className="live-card-actions">
<button
type="button"
className="btn-row-stop"
disabled={busy}
onClick={() => stopSession.mutate(session.id)}
>
Stop
</button>
<button
type="button"
className="btn-row-cancel"
disabled={busy}
onClick={() => discardSession.mutate(session.id)}
>
Annuleer
</button>
</div>
</article> </article>
); );
} }

View File

@@ -0,0 +1,130 @@
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 { ReportResponse } from '@solelog/shared';
import Reports from './Reports';
import { apiFetch } from '../lib/api';
import * as reportsApi from '../api/reports';
vi.mock('../lib/api', () => ({ apiFetch: vi.fn(), API_URL: 'http://test' }));
const mockApiFetch = vi.mocked(apiFetch);
const REPORT: ReportResponse = {
range: { from: '2026-06-15T00:00:00.000Z', to: '2026-06-17T21:59:59.999Z' },
totals: { worked_seconds: 7800, paused_seconds: 600, pairs: 84, sessions: 36 },
by_worker: [
{
user_id: 'u1',
user_name: 'Jan',
worked_seconds: 4500,
paused_seconds: 300,
pairs: 48,
sessions: 21,
},
{
user_id: 'u2',
user_name: 'An',
worked_seconds: 3300,
paused_seconds: 300,
pairs: 36,
sessions: 15,
},
],
by_activity: [
{
activity_id: 1,
activity_name: 'Frezen',
worked_seconds: 4900,
paused_seconds: 200,
pairs: 40,
sessions: 18,
},
{
activity_id: 2,
activity_name: 'Lijmen',
worked_seconds: 2900,
paused_seconds: 400,
pairs: 44,
sessions: 18,
},
],
by_type: [
{ insole_type: 'Kurk', worked_seconds: 5000, paused_seconds: 300, pairs: 50, sessions: 20 },
{ insole_type: 'Berk', worked_seconds: 2800, paused_seconds: 300, pairs: 34, sessions: 16 },
],
};
function mockEndpoints() {
mockApiFetch.mockImplementation((path?: string) => {
if (path?.startsWith('/api/admin/report')) return Promise.resolve(REPORT as never);
if (path === '/api/admin/users')
return Promise.resolve([{ id: 'u1', name: 'Jan', email: 'jan@x' }] as never);
if (path === '/api/activities') return Promise.resolve([] as never);
return Promise.resolve([] as never);
});
}
function renderReports() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={queryClient}>
<Reports />
</QueryClientProvider>,
);
}
describe('Reports', () => {
beforeEach(() => mockApiFetch.mockReset());
afterEach(() => vi.clearAllMocks());
it('renders headline totals and the three breakdown tables', async () => {
mockEndpoints();
renderReports();
// Headline: 7800s = 02:10:00 worked, 84 zolen, 36 sessies.
const headline = await screen.findByText(/02:10:00/);
const totals = headline.closest('.reports-totals') as HTMLElement;
expect(totals).toHaveTextContent(/84/);
expect(totals).toHaveTextContent(/36/);
expect(screen.getByText('Per medewerker')).toBeInTheDocument();
expect(screen.getByText('Per handeling')).toBeInTheDocument();
expect(screen.getByText('Per type')).toBeInTheDocument();
// Names are scoped to table cells: 'Jan' / 'Kurk' also appear as dropdown options.
expect(screen.getByRole('cell', { name: 'Jan' })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: 'Frezen' })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: 'Kurk' })).toBeInTheDocument();
});
it('refetches with new params when a preset is clicked', async () => {
mockEndpoints();
renderReports();
await screen.findByRole('cell', { name: 'Jan' });
const callsBefore = mockApiFetch.mock.calls.filter((c) =>
String(c[0]).startsWith('/api/admin/report'),
).length;
await userEvent.click(screen.getByRole('button', { name: 'Deze maand' }));
await waitFor(() => {
const reportCalls = mockApiFetch.mock.calls.filter((c) =>
String(c[0]).startsWith('/api/admin/report'),
);
expect(reportCalls.length).toBeGreaterThan(callsBefore);
});
});
it('calls downloadExport with the current filters when Exporteer CSV is clicked', async () => {
mockEndpoints();
const spy = vi.spyOn(reportsApi, 'downloadExport').mockResolvedValue();
renderReports();
await screen.findByRole('cell', { name: 'Jan' });
await userEvent.click(screen.getByRole('button', { name: 'Exporteer CSV' }));
expect(spy).toHaveBeenCalledTimes(1);
const arg = spy.mock.calls[0][0];
expect(arg.from).toBeTruthy();
expect(arg.to).toBeTruthy();
});
});

View File

@@ -0,0 +1,211 @@
import { useMemo, useState } from 'react';
import type { InsoleType, ReportTotals } from '@solelog/shared';
import { useReport, type ReportFilters } from '../api/reports';
import * as reportsApi from '../api/reports';
import { useAdminUsers } from '../api/admin-sessions';
import { useActivities } from '../api/activities';
import { formatTime } from '../lib/elapsed';
import { allTime, dayEndISO, dayStartISO, thisMonth, thisWeek, toDateStr } from '../lib/date-range';
type DateRange = { from: string; to: string }; // YYYY-MM-DD
const TYPES: InsoleType[] = ['Kurk', 'Berk', '3D'];
export default function Reports() {
const [range, setRange] = useState<DateRange>(() => thisWeek(new Date()));
const [userId, setUserId] = useState('');
const [insoleType, setInsoleType] = useState('');
const [activityId, setActivityId] = useState('');
const filters: ReportFilters = useMemo(
() => ({
from: dayStartISO(range.from),
to: dayEndISO(range.to),
userId: userId || undefined,
insoleType: insoleType || undefined,
activityId: activityId ? Number(activityId) : undefined,
}),
[range, userId, insoleType, activityId],
);
const reportQuery = useReport(filters);
const usersQuery = useAdminUsers();
const activitiesQuery = useActivities();
const [exportError, setExportError] = useState<string | null>(null);
async function onExport() {
setExportError(null);
try {
await reportsApi.downloadExport(filters);
} catch {
setExportError('Export mislukt. Probeer opnieuw.');
}
}
const t = reportQuery.data?.totals;
return (
<div className="screen">
<div className="reports-head">
<h1 className="screen-title">Rapporten</h1>
<button type="button" className="btn-primary" onClick={onExport}>
Exporteer CSV
</button>
</div>
<div className="reports-filters">
<div className="reports-presets">
<button type="button" onClick={() => setRange(thisWeek(new Date()))}>
Deze week
</button>
<button type="button" onClick={() => setRange(thisMonth(new Date()))}>
Deze maand
</button>
<button type="button" onClick={() => setRange(allTime(new Date()))}>
Alles
</button>
</div>
<label>
Van
<input
type="date"
value={range.from}
max={range.to}
onChange={(e) => setRange((r) => ({ ...r, from: e.target.value }))}
/>
</label>
<label>
Tot
<input
type="date"
value={range.to}
min={range.from}
max={toDateStr(new Date())}
onChange={(e) => setRange((r) => ({ ...r, to: e.target.value }))}
/>
</label>
<label>
Medewerker
<select value={userId} onChange={(e) => setUserId(e.target.value)}>
<option value="">Alle</option>
{(usersQuery.data ?? []).map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</label>
<label>
Type
<select value={insoleType} onChange={(e) => setInsoleType(e.target.value)}>
<option value="">Alle</option>
{TYPES.map((ty) => (
<option key={ty} value={ty}>
{ty}
</option>
))}
</select>
</label>
<label>
Handeling
<select value={activityId} onChange={(e) => setActivityId(e.target.value)}>
<option value="">Alle</option>
{(activitiesQuery.data ?? []).map((a) => (
<option key={a.id} value={String(a.id)}>
{a.name}
</option>
))}
</select>
</label>
</div>
{exportError && <p className="form-error">{exportError}</p>}
{reportQuery.isLoading ? (
<p className="muted">Laden</p>
) : reportQuery.isError ? (
<p className="muted">Kon rapport niet laden.</p>
) : (
<>
<div className="reports-totals">
<strong>{formatTime(t?.worked_seconds ?? 0)}</strong> gewerkt ·{' '}
<strong>{t?.pairs ?? 0}</strong> zolen · <strong>{t?.sessions ?? 0}</strong> sessies ·{' '}
<strong>{formatTime(t?.paused_seconds ?? 0)}</strong> pauze
</div>
<BreakdownTable
title="Per medewerker"
label="Medewerker"
rows={(reportQuery.data?.by_worker ?? []).map((r) => ({
key: r.user_id,
name: r.user_name,
...r,
}))}
/>
<BreakdownTable
title="Per handeling"
label="Handeling"
rows={(reportQuery.data?.by_activity ?? []).map((r) => ({
key: String(r.activity_id),
name: r.activity_name,
...r,
}))}
/>
<BreakdownTable
title="Per type"
label="Type"
rows={(reportQuery.data?.by_type ?? []).map((r) => ({
key: r.insole_type,
name: r.insole_type,
...r,
}))}
/>
</>
)}
</div>
);
}
type BreakdownRow = ReportTotals & { key: string; name: string };
function BreakdownTable({
title,
label,
rows,
}: {
title: string;
label: string;
rows: BreakdownRow[];
}) {
return (
<section className="reports-section">
<h2 className="reports-section-title">{title}</h2>
{rows.length === 0 ? (
<p className="muted">Geen gegevens.</p>
) : (
<table className="reports-table">
<thead>
<tr>
<th>{label}</th>
<th>Gewerkt</th>
<th>Zolen</th>
<th>Sessies</th>
<th>Pauze</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.key}>
<td>{r.name}</td>
<td>{formatTime(r.worked_seconds)}</td>
<td>{r.pairs}</td>
<td>{r.sessions}</td>
<td>{formatTime(r.paused_seconds)}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
);
}

View File

@@ -0,0 +1,195 @@
import { render, screen, waitFor, within } 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 { WorkSession } from '@solelog/shared';
import Sessions from './Sessions';
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('2026-06-17T08:00:00Z').toISOString(),
end_time: new Date('2026-06-17T09:00:00Z').toISOString(),
duration_seconds: 3000,
paused_seconds: 600,
paused_at: null,
status: 'completed',
source: 'manual',
notes: null,
created_at: new Date('2026-06-17T09:00:00Z').toISOString(),
...over,
};
}
const SESSIONS: WorkSession[] = [
makeSession({
id: 1,
user_name: 'Jan',
activity_name: 'Frezen',
status: 'completed',
duration_seconds: 3000,
paused_seconds: 600,
end_time: new Date('2026-06-17T09:00:00Z').toISOString(),
}),
makeSession({
id: 2,
user_name: 'Piet',
activity_name: 'Lijmen',
status: 'active',
duration_seconds: null,
paused_seconds: 0,
end_time: null,
start_time: new Date(Date.now() - 120_000).toISOString(),
}),
makeSession({
id: 3,
user_name: 'Klaas',
activity_name: 'Snijden',
status: 'discarded',
duration_seconds: null,
paused_seconds: 0,
end_time: new Date('2026-06-17T07:00:00Z').toISOString(),
}),
];
// Route any non-sessions call (users roster, activities) to an empty list.
function mockEndpoints(sessions: WorkSession[]) {
mockApiFetch.mockImplementation((path: string) => {
if (path === '/api/admin/sessions') return Promise.resolve(sessions as never);
return Promise.resolve([] as never);
});
}
function renderSessions() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={queryClient}>
<Sessions />
</QueryClientProvider>,
);
}
describe('Sessions', () => {
beforeEach(() => {
mockApiFetch.mockReset();
});
afterEach(() => {
vi.clearAllMocks();
});
it('renders a row per session with worker, activity and worked time', async () => {
mockEndpoints(SESSIONS);
renderSessions();
expect(await screen.findByText('Sessies')).toBeInTheDocument();
expect(await screen.findByText('Jan')).toBeInTheDocument();
expect(screen.getByText('Frezen')).toBeInTheDocument();
expect(screen.getByText('Piet')).toBeInTheDocument();
expect(screen.getByText('Klaas')).toBeInTheDocument();
// worked for the completed manual session = 3000s = 00:50:00
expect(screen.getByText('00:50:00')).toBeInTheDocument();
// pause shown when paused_seconds > 0 (600s = 00:10:00)
expect(screen.getByText(/Pauze 00:10:00/)).toBeInTheDocument();
});
it('narrows the list when the status filter changes', async () => {
mockEndpoints(SESSIONS);
renderSessions();
expect(await screen.findByText('Jan')).toBeInTheDocument();
await userEvent.selectOptions(screen.getByLabelText('Status'), 'active');
expect(screen.getByText('Piet')).toBeInTheDocument();
expect(screen.queryByText('Jan')).not.toBeInTheDocument();
expect(screen.queryByText('Klaas')).not.toBeInTheDocument();
});
it('shows Stop/Annuleer only on active rows and ✎ on all rows', async () => {
mockEndpoints(SESSIONS);
renderSessions();
expect(await screen.findByText('Jan')).toBeInTheDocument();
// ✎ edit on every row.
expect(screen.getAllByRole('button', { name: /Bewerk/ })).toHaveLength(3);
// Stop/Annuleer only on the single active row.
expect(screen.getAllByRole('button', { name: 'Stop' })).toHaveLength(1);
expect(screen.getAllByRole('button', { name: 'Annuleer' })).toHaveLength(1);
});
it('Stop on an active row calls the stop endpoint', async () => {
mockEndpoints(SESSIONS);
renderSessions();
expect(await screen.findByText('Piet')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Stop' }));
await waitFor(() =>
expect(mockApiFetch).toHaveBeenCalledWith(
'/api/admin/sessions/2/stop',
expect.objectContaining({ method: 'POST' }),
),
);
});
it('Annuleer on an active row calls the discard endpoint', async () => {
mockEndpoints(SESSIONS);
renderSessions();
expect(await screen.findByText('Piet')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Annuleer' }));
await waitFor(() =>
expect(mockApiFetch).toHaveBeenCalledWith(
'/api/admin/sessions/2/discard',
expect.objectContaining({ method: 'POST' }),
),
);
});
it('+ Nieuwe registratie opens the create form (worker picker visible)', async () => {
mockEndpoints(SESSIONS);
renderSessions();
expect(await screen.findByText('Jan')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: '+ Nieuwe registratie' }));
// Task 4 fills the form; for now we assert the create panel/region opens.
expect(await screen.findByTestId('session-form')).toBeInTheDocument();
});
it('shows the empty state when there are no sessions', async () => {
mockEndpoints([]);
renderSessions();
expect(await screen.findByText('Nog geen sessies.')).toBeInTheDocument();
});
it('keeps the discarded row hidden when filtering to voltooid', async () => {
mockEndpoints(SESSIONS);
renderSessions();
expect(await screen.findByText('Jan')).toBeInTheDocument();
await userEvent.selectOptions(screen.getByLabelText('Status'), 'completed');
const list = screen.getByRole('list', { name: 'Sessies' });
expect(within(list).getByText('Jan')).toBeInTheDocument();
expect(within(list).queryByText('Piet')).not.toBeInTheDocument();
expect(within(list).queryByText('Klaas')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,178 @@
import { useState } from 'react';
import type { SessionStatus, WorkSession } from '@solelog/shared';
import { useAdminDiscardSession, useAdminStopSession, useAllSessions } from '../api/admin-sessions';
import SessionForm from '../components/SessionForm';
import { formatTime } from '../lib/elapsed';
type StatusFilter = 'all' | SessionStatus;
const STATUS_OPTIONS: { value: StatusFilter; label: string }[] = [
{ value: 'all', label: 'Alle' },
{ value: 'active', label: 'Actief' },
{ value: 'completed', label: 'Voltooid' },
{ value: 'discarded', label: 'Geannuleerd' },
];
const STATUS_LABEL: Record<SessionStatus, string> = {
active: 'Actief',
completed: 'Voltooid',
discarded: 'Geannuleerd',
};
// Worked seconds for a row: completed/discarded use the server-derived duration;
// an active session counts from start paused (frozen at the pause moment when paused).
function workedSeconds(session: WorkSession): number {
if (session.status === 'active') {
const base = session.paused_at ? Date.parse(session.paused_at) : Date.now();
return Math.max(
0,
Math.floor((base - Date.parse(session.start_time)) / 1000) - session.paused_seconds,
);
}
return session.duration_seconds ?? 0;
}
function formatDate(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleDateString('nl-NL', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
export default function Sessions() {
const { data, isLoading, isError } = useAllSessions();
const stopSession = useAdminStopSession();
const discardSession = useAdminDiscardSession();
const [filter, setFilter] = useState<StatusFilter>('all');
// null = closed; { mode } drives the shared create/edit form.
const [form, setForm] = useState<
{ mode: 'create' } | { mode: 'edit'; session: WorkSession } | null
>(null);
if (isError) {
return (
<div className="screen">
<h1 className="screen-title">Sessies</h1>
<p className="muted">Kon sessies niet laden.</p>
</div>
);
}
const sessions = Array.isArray(data) ? data : [];
const visible = filter === 'all' ? sessions : sessions.filter((s) => s.status === filter);
return (
<div className="screen">
<div className="sessions-head">
<h1 className="screen-title">Sessies</h1>
<button type="button" className="btn-primary" onClick={() => setForm({ mode: 'create' })}>
+ Nieuwe registratie
</button>
</div>
<div className="sessions-toolbar">
<label className="sessions-filter">
Status
<select
value={filter}
onChange={(e) => setFilter(e.target.value as StatusFilter)}
aria-label="Status"
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
</div>
{form && (
<div data-testid="session-form">
<SessionForm
mode={form.mode}
session={form.mode === 'edit' ? form.session : undefined}
onClose={() => setForm(null)}
/>
</div>
)}
{isLoading ? (
<p className="muted">Laden</p>
) : visible.length === 0 ? (
<p className="muted">
{sessions.length === 0 ? 'Nog geen sessies.' : 'Geen sessies in dit filter.'}
</p>
) : (
<ul className="sessions-list" aria-label="Sessies">
{visible.map((session) => (
<SessionRow
key={session.id}
session={session}
onStop={() => stopSession.mutate(session.id)}
onDiscard={() => discardSession.mutate(session.id)}
onEdit={() => setForm({ mode: 'edit', session })}
/>
))}
</ul>
)}
</div>
);
}
function SessionRow({
session,
onStop,
onDiscard,
onEdit,
}: {
session: WorkSession;
onStop: () => void;
onDiscard: () => void;
onEdit: () => void;
}) {
const worked = workedSeconds(session);
const isActive = session.status === 'active';
return (
<li className="sessions-row">
<div className="sessions-cell sessions-worker">{session.user_name ?? 'Onbekend'}</div>
<div className="sessions-cell sessions-activity">
{session.activity_name ?? 'Onbekende handeling'}
{session.insole_type && <span className="sessions-type">{session.insole_type}</span>}
</div>
<div className="sessions-cell sessions-worked">
<span className="sessions-time">{formatTime(worked)}</span>
{session.paused_seconds > 0 && (
<span className="sessions-pause">Pauze {formatTime(session.paused_seconds)}</span>
)}
</div>
<div className="sessions-cell sessions-date">{formatDate(session.start_time)}</div>
<div className="sessions-cell sessions-status">
<span className={`sessions-badge sessions-badge-${session.status}`}>
{STATUS_LABEL[session.status]}
</span>
</div>
<div className="sessions-cell sessions-actions">
<button
type="button"
className="icon-btn icon-edit"
aria-label={`Bewerk sessie van ${session.user_name ?? 'onbekend'}`}
onClick={onEdit}
>
</button>
{isActive && (
<>
<button type="button" className="btn-row-stop" onClick={onStop}>
Stop
</button>
<button type="button" className="btn-row-cancel" onClick={onDiscard}>
Annuleer
</button>
</>
)}
</div>
</li>
);
}

View File

@@ -420,3 +420,254 @@ body {
color: var(--text-muted); color: var(--text-muted);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.live-card-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
/* ---- Sessions management (Sessies) ---- */
.sessions-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.sessions-head .screen-title {
margin: 0;
}
.sessions-head .btn-primary {
width: auto;
white-space: nowrap;
}
.sessions-toolbar {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
}
.sessions-filter {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: var(--text-muted);
}
.sessions-filter select {
padding: 8px 12px;
font-size: 15px;
border: 1px solid var(--border);
border-radius: 12px;
background: #ffffff;
color: var(--text);
}
.sessions-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.sessions-row {
display: grid;
grid-template-columns: 1.2fr 1.6fr 1fr 0.8fr 0.8fr auto;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: #ffffff;
border: 1px solid var(--border);
border-radius: 12px;
}
.sessions-worker {
font-weight: 600;
color: var(--text);
}
.sessions-activity {
display: flex;
align-items: center;
gap: 8px;
color: var(--text);
}
.sessions-type {
font-size: 12px;
font-weight: 600;
color: var(--primary);
background: var(--primary-light);
border-radius: 999px;
padding: 2px 10px;
}
.sessions-worked {
display: flex;
flex-direction: column;
gap: 2px;
}
.sessions-time {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.sessions-pause {
font-size: 12px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.sessions-date {
font-size: 14px;
color: var(--text-muted);
}
.sessions-badge {
font-size: 12px;
font-weight: 600;
border-radius: 999px;
padding: 4px 10px;
}
.sessions-badge-active {
color: #166534;
background: #dcfce7;
}
.sessions-badge-completed {
color: var(--text-muted);
background: #f3f4f6;
}
.sessions-badge-discarded {
color: var(--danger);
background: #fee2e2;
}
.sessions-actions {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
}
.btn-row-stop {
padding: 8px 14px;
font-size: 14px;
font-weight: 600;
color: #166534;
background: #dcfce7;
border: 1px solid #86efac;
border-radius: 12px;
cursor: pointer;
}
.btn-row-cancel {
padding: 8px 14px;
font-size: 14px;
font-weight: 600;
color: var(--danger);
background: #fee2e2;
border: 1px solid #fecaca;
border-radius: 12px;
cursor: pointer;
}
/* ---- Session create/edit form ---- */
.session-form {
gap: 14px;
}
.session-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.session-field select,
.session-field input,
.session-field textarea {
padding: 10px 12px;
font-size: 15px;
border: 1px solid var(--border);
border-radius: 12px;
background: #ffffff;
color: var(--text);
font-family: inherit;
}
.session-field select:focus,
.session-field input:focus,
.session-field textarea:focus {
outline: none;
border-color: var(--primary);
}
.session-preview {
margin: 0;
font-size: 15px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.reports-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.reports-filters {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.75rem;
margin: 1rem 0;
}
.reports-filters label {
display: flex;
flex-direction: column;
font-size: 0.8rem;
gap: 0.25rem;
}
.reports-presets {
display: flex;
gap: 0.5rem;
}
.reports-totals {
padding: 0.75rem 1rem;
background: var(--surface, #f4f4f5);
border-radius: 0.5rem;
margin-bottom: 1.5rem;
}
.reports-section {
margin-bottom: 1.5rem;
}
.reports-section-title {
font-size: 1rem;
margin-bottom: 0.5rem;
}
.reports-table {
width: 100%;
border-collapse: collapse;
}
.reports-table th,
.reports-table td {
text-align: left;
padding: 0.4rem 0.6rem;
border-bottom: 1px solid var(--border, #e4e4e7);
}
.reports-table td:not(:first-child),
.reports-table th:not(:first-child) {
text-align: right;
}

View File

@@ -11,3 +11,62 @@ export function formatDuration(totalSeconds: number): string {
const sec = s % 60; const sec = s % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`; return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
} }
// One completed session, flattened for CSV. Times accept Date | number | string.
export interface SessionCsvRow {
id: number;
activityName: string | null;
userName?: string | null;
insoleType: string | null;
pairCount: number;
startTime: Date | number | string;
endTime: Date | number | string | null;
durationSeconds: number | null;
pausedSeconds: number | null;
}
// Build the CSV body shared by the worker self-export and the admin all-users export.
// includeWorker prepends a Worker column; everything else matches the legacy format byte-for-byte.
export function buildSessionsCsv(
rows: SessionCsvRow[],
opts: { includeWorker?: boolean } = {},
): string {
const includeWorker = opts.includeWorker ?? false;
const header = [
...(includeWorker ? ['Worker'] : []),
'ID',
'Task',
'Insole Type',
'No. of Insoles',
'Date',
'Total Duration',
'Paused Duration',
'Start Time',
'End Time',
]
.map(quote)
.join(',');
const dataLines = rows.map((row) => {
const start = new Date(row.startTime);
const end = row.endTime ? new Date(row.endTime) : null;
return [
...(includeWorker ? [row.userName ?? ''] : []),
row.id,
row.activityName ?? '',
row.insoleType ?? 'Kurk',
row.pairCount ?? 2,
start.toLocaleDateString('nl-BE', { day: '2-digit', month: '2-digit', year: 'numeric' }),
formatDuration(row.durationSeconds ?? 0),
formatDuration(row.pausedSeconds ?? 0),
start.toLocaleTimeString('nl-BE', { hour: '2-digit', minute: '2-digit', second: '2-digit' }),
end
? end.toLocaleTimeString('nl-BE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
: '',
]
.map(quote)
.join(',');
});
return [header, ...dataLines].join('\n');
}

View File

@@ -1,9 +1,60 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { desc, eq } from 'drizzle-orm'; import { and, asc, desc, eq, gte, lte, type SQL } from 'drizzle-orm';
import { AdminUpdateSessionInput, CreateManualSessionInput, InsoleType } from '@solelog/shared';
import { db } from '../db/client'; import { db } from '../db/client';
import { activities, user, workSessions } from '../db/schema'; import { activities, user, workSessions } from '../db/schema';
import { getSessionUser, isAdmin } from '../lib/require-user'; import { getSessionUser, isAdmin } from '../lib/require-user';
import { toWorkSession } from '../lib/work-session'; import { toWorkSession } from '../lib/work-session';
import { buildSessionsCsv } from '../lib/csv';
function computeDuration(startMs: number, endMs: number, paused: number): number {
return Math.max(0, Math.round((endMs - startMs) / 1000) - paused);
}
type ReportQuery = {
from: Date;
to: Date;
userId?: string;
insoleType?: string;
activityId?: number;
};
// Parse + validate the shared report/export query params. Returns null on a bad range.
function parseReportQuery(c: {
req: { query: (k: string) => string | undefined };
}): ReportQuery | null {
const fromRaw = c.req.query('from');
const toRaw = c.req.query('to');
if (!fromRaw || !toRaw) return null;
const from = new Date(fromRaw);
const to = new Date(toRaw);
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || to < from) return null;
const insoleType = c.req.query('insole_type') || undefined;
if (insoleType && !InsoleType.safeParse(insoleType).success) return null;
const activityIdRaw = c.req.query('activity_id');
let activityId: number | undefined;
if (activityIdRaw) {
activityId = Number.parseInt(activityIdRaw, 10);
if (Number.isNaN(activityId)) return null;
}
return { from, to, userId: c.req.query('user_id') || undefined, insoleType, activityId };
}
// completed + date-range + optional worker/type/activity — shared by report and export.
function buildSessionFilters(q: ReportQuery): SQL[] {
const conds: SQL[] = [
eq(workSessions.status, 'completed'),
gte(workSessions.startTime, q.from),
lte(workSessions.startTime, q.to),
];
if (q.userId) conds.push(eq(workSessions.userId, q.userId));
if (q.insoleType) conds.push(eq(workSessions.insoleType, q.insoleType));
if (q.activityId !== undefined) conds.push(eq(workSessions.activityId, q.activityId));
return conds;
}
export const adminRoutes = new Hono(); export const adminRoutes = new Hono();
@@ -58,3 +109,291 @@ adminRoutes.get('/api/admin/sessions/active', async (c) => {
), ),
); );
}); });
// Roster for the create-form worker picker. Direct DB read — no better-auth client dependency.
adminRoutes.get('/api/admin/users', async (c) => {
const rows = await db
.select({ id: user.id, name: user.name, email: user.email })
.from(user)
.orderBy(asc(user.name));
return c.json(rows);
});
adminRoutes.get('/api/admin/report', async (c) => {
const q = parseReportQuery(c);
if (!q) return c.json({ error: 'Invalid query' }, 400);
const rows = await db
.select(baseSelect)
.from(workSessions)
.leftJoin(activities, eq(workSessions.activityId, activities.id))
.leftJoin(user, eq(workSessions.userId, user.id))
.where(and(...buildSessionFilters(q)));
const totals = { worked_seconds: 0, paused_seconds: 0, pairs: 0, sessions: 0 };
const workers = new Map<
string,
{
user_id: string;
user_name: string;
worked_seconds: number;
paused_seconds: number;
pairs: number;
sessions: number;
}
>();
const acts = new Map<
number,
{
activity_id: number;
activity_name: string;
worked_seconds: number;
paused_seconds: number;
pairs: number;
sessions: number;
}
>();
const types = new Map<
string,
{
insole_type: string;
worked_seconds: number;
paused_seconds: number;
pairs: number;
sessions: number;
}
>();
for (const r of rows) {
const worked = r.session.durationSeconds ?? 0;
const paused = r.session.pausedSeconds ?? 0;
const pairs = r.session.pairCount ?? 0;
totals.worked_seconds += worked;
totals.paused_seconds += paused;
totals.pairs += pairs;
totals.sessions += 1;
const uid = r.session.userId;
const w = workers.get(uid) ?? {
user_id: uid,
user_name: r.userName ?? 'Onbekend',
worked_seconds: 0,
paused_seconds: 0,
pairs: 0,
sessions: 0,
};
w.worked_seconds += worked;
w.paused_seconds += paused;
w.pairs += pairs;
w.sessions += 1;
workers.set(uid, w);
const aid = r.session.activityId;
const a = acts.get(aid) ?? {
activity_id: aid,
activity_name: r.activityName ?? 'Onbekend',
worked_seconds: 0,
paused_seconds: 0,
pairs: 0,
sessions: 0,
};
a.worked_seconds += worked;
a.paused_seconds += paused;
a.pairs += pairs;
a.sessions += 1;
acts.set(aid, a);
const tkey = r.session.insoleType ?? 'Onbekend';
const t = types.get(tkey) ?? {
insole_type: tkey,
worked_seconds: 0,
paused_seconds: 0,
pairs: 0,
sessions: 0,
};
t.worked_seconds += worked;
t.paused_seconds += paused;
t.pairs += pairs;
t.sessions += 1;
types.set(tkey, t);
}
const byWorked = (x: { worked_seconds: number }, y: { worked_seconds: number }) =>
y.worked_seconds - x.worked_seconds;
return c.json({
range: { from: q.from.toISOString(), to: q.to.toISOString() },
totals,
by_worker: [...workers.values()].sort(byWorked),
by_activity: [...acts.values()].sort(byWorked),
by_type: [...types.values()].sort(byWorked),
});
});
adminRoutes.get('/api/admin/export', async (c) => {
const q = parseReportQuery(c);
if (!q) return c.json({ error: 'Invalid query' }, 400);
const rows = await db
.select(baseSelect)
.from(workSessions)
.leftJoin(activities, eq(workSessions.activityId, activities.id))
.leftJoin(user, eq(workSessions.userId, user.id))
.where(and(...buildSessionFilters(q)))
.orderBy(asc(workSessions.startTime));
const csv = buildSessionsCsv(
rows.map((r) => ({
id: r.session.id,
activityName: r.activityName,
userName: r.userName,
insoleType: r.session.insoleType,
pairCount: r.session.pairCount,
startTime: r.session.startTime,
endTime: r.session.endTime,
durationSeconds: r.session.durationSeconds,
pausedSeconds: r.session.pausedSeconds,
})),
{ includeWorker: true },
);
const fromDate = q.from.toISOString().slice(0, 10);
const toDate = q.to.toISOString().slice(0, 10);
return c.body(csv, 200, {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="solelog-report_${fromDate}_${toDate}.csv"`,
});
});
// Manual create → always completed, source='manual', duration derived server-side.
adminRoutes.post('/api/admin/sessions', async (c) => {
const parsed = CreateManualSessionInput.safeParse(await c.req.json().catch(() => null));
if (!parsed.success) return c.json({ error: 'Invalid input' }, 400);
const d = parsed.data;
const start = new Date(d.start_time);
const end = new Date(d.end_time);
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end < start)
return c.json({ error: 'Invalid input' }, 400);
const span = Math.round((end.getTime() - start.getTime()) / 1000);
if (d.paused_seconds > span) return c.json({ error: 'Invalid input' }, 400);
const [u] = await db.select({ id: user.id }).from(user).where(eq(user.id, d.user_id));
if (!u) return c.json({ error: 'User not found' }, 404);
const [act] = await db.select().from(activities).where(eq(activities.id, d.activity_id));
if (!act) return c.json({ error: 'Activity not found' }, 404);
const [row] = await db
.insert(workSessions)
.values({
userId: d.user_id,
activityId: d.activity_id,
insoleType: d.insole_type,
pairCount: d.pair_count,
startTime: start,
endTime: end,
durationSeconds: computeDuration(start.getTime(), end.getTime(), d.paused_seconds),
pausedSeconds: d.paused_seconds,
pausedAt: null,
status: 'completed',
source: 'manual',
notes: d.notes ?? null,
})
.returning();
return c.json(toWorkSession(row, { activityName: act.name }));
});
// Edit any session (no user reassignment). Duration recomputed when end present, else null.
adminRoutes.put('/api/admin/sessions/:id', async (c) => {
const id = Number.parseInt(c.req.param('id'), 10);
if (Number.isNaN(id)) return c.json({ error: 'Session not found' }, 404);
const parsed = AdminUpdateSessionInput.safeParse(await c.req.json().catch(() => null));
if (!parsed.success) return c.json({ error: 'Invalid input' }, 400);
const d = parsed.data;
const [row] = await db.select().from(workSessions).where(eq(workSessions.id, id));
if (!row) return c.json({ error: 'Session not found' }, 404);
const start = new Date(d.start_time);
if (Number.isNaN(start.getTime())) return c.json({ error: 'Invalid input' }, 400);
let endTime: Date | null = null;
let durationSeconds: number | null = null;
if (d.end_time !== null) {
const end = new Date(d.end_time);
if (Number.isNaN(end.getTime()) || end < start) return c.json({ error: 'Invalid input' }, 400);
const span = Math.round((end.getTime() - start.getTime()) / 1000);
if (d.paused_seconds > span) return c.json({ error: 'Invalid input' }, 400);
endTime = end;
durationSeconds = computeDuration(start.getTime(), end.getTime(), d.paused_seconds);
}
const [act] = await db.select().from(activities).where(eq(activities.id, d.activity_id));
if (!act) return c.json({ error: 'Activity not found' }, 404);
const [updated] = await db
.update(workSessions)
.set({
activityId: d.activity_id,
insoleType: d.insole_type,
pairCount: d.pair_count,
startTime: start,
endTime,
durationSeconds,
pausedSeconds: d.paused_seconds,
pausedAt: null,
status: d.status,
notes: d.notes,
})
.where(eq(workSessions.id, id))
.returning();
return c.json(toWorkSession(updated, { activityName: act.name }));
});
// Quick stop of an active session: fold any open pause, end=now, completed.
adminRoutes.post('/api/admin/sessions/:id/stop', async (c) => {
const id = Number.parseInt(c.req.param('id'), 10);
if (Number.isNaN(id)) return c.json({ error: 'Session not found' }, 404);
const [row] = await db.select().from(workSessions).where(eq(workSessions.id, id));
if (!row) return c.json({ error: 'Session not found' }, 404);
if (row.status !== 'active') return c.json({ error: 'Session already closed' }, 409);
const now = Date.now();
const extraPaused = row.pausedAt
? Math.round((now - new Date(row.pausedAt).getTime()) / 1000)
: 0;
const pausedSeconds = (row.pausedSeconds ?? 0) + extraPaused;
const durationSeconds = computeDuration(new Date(row.startTime).getTime(), now, pausedSeconds);
const [updated] = await db
.update(workSessions)
.set({
endTime: new Date(now),
durationSeconds,
pausedSeconds,
pausedAt: null,
status: 'completed',
})
.where(eq(workSessions.id, id))
.returning();
return c.json(toWorkSession(updated));
});
// Cancel an active session: status='discarded', end=now (no hard delete).
adminRoutes.post('/api/admin/sessions/:id/discard', async (c) => {
const id = Number.parseInt(c.req.param('id'), 10);
if (Number.isNaN(id)) return c.json({ error: 'Session not found' }, 404);
const [row] = await db.select().from(workSessions).where(eq(workSessions.id, id));
if (!row) return c.json({ error: 'Session not found' }, 404);
if (row.status !== 'active') return c.json({ error: 'Session already closed' }, 409);
const [updated] = await db
.update(workSessions)
.set({ status: 'discarded', endTime: new Date() })
.where(eq(workSessions.id, id))
.returning();
return c.json(toWorkSession(updated));
});

View File

@@ -5,7 +5,7 @@ import { db } from '../db/client';
import { activities, workSessions } from '../db/schema'; import { activities, workSessions } from '../db/schema';
import { getSessionUser } from '../lib/require-user'; import { getSessionUser } from '../lib/require-user';
import { toWorkSession } from '../lib/work-session'; import { toWorkSession } from '../lib/work-session';
import { quote, formatDuration } from '../lib/csv'; import { buildSessionsCsv } from '../lib/csv';
export const sessionsRoutes = new Hono(); export const sessionsRoutes = new Hono();
@@ -20,45 +20,18 @@ sessionsRoutes.get('/api/export', async (c) => {
.where(and(eq(workSessions.userId, sessionUser.id), eq(workSessions.status, 'completed'))) .where(and(eq(workSessions.userId, sessionUser.id), eq(workSessions.status, 'completed')))
.orderBy(asc(workSessions.startTime)); .orderBy(asc(workSessions.startTime));
const header = [ const csv = buildSessionsCsv(
'ID', rows.map(({ session, activityName }) => ({
'Task', id: session.id,
'Insole Type', activityName,
'No. of Insoles', insoleType: session.insoleType,
'Date', pairCount: session.pairCount,
'Total Duration', startTime: session.startTime,
'Paused Duration', endTime: session.endTime,
'Start Time', durationSeconds: session.durationSeconds,
'End Time', pausedSeconds: session.pausedSeconds,
] })),
.map(quote) );
.join(',');
const dataLines = rows.map(({ session, activityName }) => {
const start = new Date(session.startTime);
const end = session.endTime ? new Date(session.endTime) : null;
return [
session.id,
activityName ?? '',
session.insoleType ?? 'Kurk',
session.pairCount ?? 2,
start.toLocaleDateString('nl-BE', { day: '2-digit', month: '2-digit', year: 'numeric' }),
formatDuration(session.durationSeconds ?? 0),
formatDuration(session.pausedSeconds ?? 0),
start.toLocaleTimeString('nl-BE', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}),
end
? end.toLocaleTimeString('nl-BE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
: '',
]
.map(quote)
.join(',');
});
const csv = [header, ...dataLines].join('\n');
return c.body(csv, 200, { return c.body(csv, 200, {
'Content-Type': 'text/csv; charset=utf-8', 'Content-Type': 'text/csv; charset=utf-8',

View File

@@ -1,6 +1,15 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { createApp } from '../src/app'; import { createApp } from '../src/app';
import { authToken, bearer, seedActivity } from './helpers'; import { authToken, bearer, createTestUser, seedActivity } from './helpers';
import { db } from '../src/db/client';
import { user } from '../src/db/schema';
import { eq } from 'drizzle-orm';
async function userIdByEmail(email: string): Promise<string> {
const [row] = await db.select({ id: user.id }).from(user).where(eq(user.email, email));
if (!row) throw new Error(`no user for ${email}`);
return row.id;
}
describe('admin session views', () => { describe('admin session views', () => {
it('401s without a token', async () => { it('401s without a token', async () => {
@@ -44,3 +53,400 @@ describe('admin session views', () => {
expect(activeBody.some((s: { id: number }) => s.id === started.id)).toBe(true); expect(activeBody.some((s: { id: number }) => s.id === started.id)).toBe(true);
}); });
}); });
describe('admin users roster', () => {
it('returns id/name/email objects for an admin, ordered by name', async () => {
const app = createApp();
const adminTok = await authToken(app, 'roster-admin@example.com', 'admin');
await createTestUser('roster-w1@example.com');
const res = await app.request('/api/admin/users', { headers: bearer(adminTok) });
expect(res.status).toBe(200);
const body = await res.json();
expect(Array.isArray(body)).toBe(true);
const found = body.find((u: { email: string }) => u.email === 'roster-w1@example.com');
expect(found).toBeTruthy();
expect(found).toHaveProperty('id');
expect(found).toHaveProperty('name');
expect(found).toHaveProperty('email');
// ordered by name ascending
const names = body.map((u: { name: string }) => u.name);
const sorted = [...names].sort();
expect(names).toEqual(sorted);
});
it('403s for a worker', async () => {
const app = createApp();
const workerTok = await authToken(app, 'roster-worker@example.com'); // worker
expect((await app.request('/api/admin/users', { headers: bearer(workerTok) })).status).toBe(
403,
);
});
});
describe('admin manual session create', () => {
it('creates a completed manual session with duration excluding paused', async () => {
const app = createApp();
const adminTok = await authToken(app, 'create-admin@example.com', 'admin');
await createTestUser('create-target@example.com');
const targetId = await userIdByEmail('create-target@example.com');
const activityId = await seedActivity('Snijden');
const start = new Date('2026-06-17T08:00:00.000Z');
const end = new Date('2026-06-17T09:00:00.000Z'); // 1h apart = 3600s
const res = await app.request('/api/admin/sessions', {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({
user_id: targetId,
activity_id: activityId,
insole_type: 'Kurk',
pair_count: 3,
start_time: start.toISOString(),
end_time: end.toISOString(),
paused_seconds: 600,
notes: 'handmatig',
}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.source).toBe('manual');
expect(body.status).toBe('completed');
expect(body.duration_seconds).toBe(3000); // 3600 - 600
expect(body.paused_seconds).toBe(600);
expect(body.paused_at).toBeNull();
expect(body.user_id).toBe(targetId);
expect(body.activity_name).toBe('Snijden');
});
it('400s when end < start', async () => {
const app = createApp();
const adminTok = await authToken(app, 'create-badtime-admin@example.com', 'admin');
await createTestUser('create-badtime-target@example.com');
const targetId = await userIdByEmail('create-badtime-target@example.com');
const activityId = await seedActivity('Frezen');
const res = await app.request('/api/admin/sessions', {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({
user_id: targetId,
activity_id: activityId,
insole_type: 'Kurk',
pair_count: 1,
start_time: '2026-06-17T09:00:00.000Z',
end_time: '2026-06-17T08:00:00.000Z',
}),
});
expect(res.status).toBe(400);
});
it('404s for an unknown user', async () => {
const app = createApp();
const adminTok = await authToken(app, 'create-nouser-admin@example.com', 'admin');
const activityId = await seedActivity('Lijmen');
const res = await app.request('/api/admin/sessions', {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({
user_id: 'does-not-exist',
activity_id: activityId,
insole_type: 'Kurk',
pair_count: 1,
start_time: '2026-06-17T08:00:00.000Z',
end_time: '2026-06-17T09:00:00.000Z',
}),
});
expect(res.status).toBe(404);
});
it('404s for an unknown activity', async () => {
const app = createApp();
const adminTok = await authToken(app, 'create-noact-admin@example.com', 'admin');
await createTestUser('create-noact-target@example.com');
const targetId = await userIdByEmail('create-noact-target@example.com');
const res = await app.request('/api/admin/sessions', {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({
user_id: targetId,
activity_id: 999999,
insole_type: 'Kurk',
pair_count: 1,
start_time: '2026-06-17T08:00:00.000Z',
end_time: '2026-06-17T09:00:00.000Z',
}),
});
expect(res.status).toBe(404);
});
it('403s for a worker', async () => {
const app = createApp();
const workerTok = await authToken(app, 'create-worker@example.com'); // worker
const res = await app.request('/api/admin/sessions', {
method: 'POST',
headers: bearer(workerTok),
body: JSON.stringify({
user_id: 'x',
activity_id: 1,
insole_type: 'Kurk',
pair_count: 1,
start_time: '2026-06-17T08:00:00.000Z',
end_time: '2026-06-17T09:00:00.000Z',
}),
});
expect(res.status).toBe(403);
});
});
describe('admin session edit', () => {
it('recomputes duration on edit', async () => {
const app = createApp();
const adminTok = await authToken(app, 'edit-admin@example.com', 'admin');
await createTestUser('edit-target@example.com');
const targetId = await userIdByEmail('edit-target@example.com');
const activityId = await seedActivity('Polijsten');
const created = await (
await app.request('/api/admin/sessions', {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({
user_id: targetId,
activity_id: activityId,
insole_type: 'Kurk',
pair_count: 2,
start_time: '2026-06-17T08:00:00.000Z',
end_time: '2026-06-17T09:00:00.000Z',
paused_seconds: 0,
}),
})
).json();
expect(created.duration_seconds).toBe(3600);
const res = await app.request(`/api/admin/sessions/${created.id}`, {
method: 'PUT',
headers: bearer(adminTok),
body: JSON.stringify({
activity_id: activityId,
insole_type: 'Berk',
pair_count: 4,
start_time: '2026-06-17T08:00:00.000Z',
end_time: '2026-06-17T10:00:00.000Z', // now 2h
paused_seconds: 300,
notes: 'gecorrigeerd',
status: 'completed',
}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.duration_seconds).toBe(7200 - 300);
expect(body.insole_type).toBe('Berk');
expect(body.pair_count).toBe(4);
expect(body.notes).toBe('gecorrigeerd');
});
it('sets duration null when end_time is null (active)', async () => {
const app = createApp();
const adminTok = await authToken(app, 'edit-active-admin@example.com', 'admin');
await createTestUser('edit-active-target@example.com');
const targetId = await userIdByEmail('edit-active-target@example.com');
const activityId = await seedActivity('Stikken');
const created = await (
await app.request('/api/admin/sessions', {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({
user_id: targetId,
activity_id: activityId,
insole_type: 'Kurk',
pair_count: 2,
start_time: '2026-06-17T08:00:00.000Z',
end_time: '2026-06-17T09:00:00.000Z',
}),
})
).json();
const res = await app.request(`/api/admin/sessions/${created.id}`, {
method: 'PUT',
headers: bearer(adminTok),
body: JSON.stringify({
activity_id: activityId,
insole_type: 'Kurk',
pair_count: 2,
start_time: '2026-06-17T08:00:00.000Z',
end_time: null,
paused_seconds: 0,
notes: null,
status: 'active',
}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.duration_seconds).toBeNull();
expect(body.end_time).toBeNull();
expect(body.status).toBe('active');
});
it('400s when end < start on edit', async () => {
const app = createApp();
const adminTok = await authToken(app, 'edit-badtime-admin@example.com', 'admin');
await createTestUser('edit-badtime-target@example.com');
const targetId = await userIdByEmail('edit-badtime-target@example.com');
const activityId = await seedActivity('Wassen');
const created = await (
await app.request('/api/admin/sessions', {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({
user_id: targetId,
activity_id: activityId,
insole_type: 'Kurk',
pair_count: 2,
start_time: '2026-06-17T08:00:00.000Z',
end_time: '2026-06-17T09:00:00.000Z',
}),
})
).json();
const res = await app.request(`/api/admin/sessions/${created.id}`, {
method: 'PUT',
headers: bearer(adminTok),
body: JSON.stringify({
activity_id: activityId,
insole_type: 'Kurk',
pair_count: 2,
start_time: '2026-06-17T09:00:00.000Z',
end_time: '2026-06-17T08:00:00.000Z',
paused_seconds: 0,
notes: null,
status: 'completed',
}),
});
expect(res.status).toBe(400);
});
it('404s for an unknown session', async () => {
const app = createApp();
const adminTok = await authToken(app, 'edit-404-admin@example.com', 'admin');
const activityId = await seedActivity('Drogen');
const res = await app.request('/api/admin/sessions/999999', {
method: 'PUT',
headers: bearer(adminTok),
body: JSON.stringify({
activity_id: activityId,
insole_type: 'Kurk',
pair_count: 2,
start_time: '2026-06-17T08:00:00.000Z',
end_time: '2026-06-17T09:00:00.000Z',
paused_seconds: 0,
notes: null,
status: 'completed',
}),
});
expect(res.status).toBe(404);
});
});
describe('admin stop/discard another user session', () => {
it("stops another worker's active session and computes duration", async () => {
const app = createApp();
const adminTok = await authToken(app, 'stop-admin@example.com', 'admin');
const workerTok = await authToken(app, 'stop-worker@example.com'); // worker
const activityId = await seedActivity('Frezen');
const started = await (
await app.request('/api/sessions/start', {
method: 'POST',
headers: bearer(workerTok),
body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }),
})
).json();
const res = await app.request(`/api/admin/sessions/${started.id}/stop`, {
method: 'POST',
headers: bearer(adminTok),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.status).toBe('completed');
expect(body.end_time).not.toBeNull();
expect(body.duration_seconds).toBeGreaterThanOrEqual(0);
expect(body.paused_at).toBeNull();
});
it("discards another worker's active session", async () => {
const app = createApp();
const adminTok = await authToken(app, 'discard-admin@example.com', 'admin');
const workerTok = await authToken(app, 'discard-worker@example.com'); // worker
const activityId = await seedActivity('Lijmen');
const started = await (
await app.request('/api/sessions/start', {
method: 'POST',
headers: bearer(workerTok),
body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }),
})
).json();
const res = await app.request(`/api/admin/sessions/${started.id}/discard`, {
method: 'POST',
headers: bearer(adminTok),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.status).toBe('discarded');
expect(body.end_time).not.toBeNull();
});
it('409s when stopping an already-closed session', async () => {
const app = createApp();
const adminTok = await authToken(app, 'stop-closed-admin@example.com', 'admin');
const workerTok = await authToken(app, 'stop-closed-worker@example.com');
const activityId = await seedActivity('Snijden');
const started = await (
await app.request('/api/sessions/start', {
method: 'POST',
headers: bearer(workerTok),
body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }),
})
).json();
await app.request(`/api/admin/sessions/${started.id}/discard`, {
method: 'POST',
headers: bearer(adminTok),
});
const res = await app.request(`/api/admin/sessions/${started.id}/stop`, {
method: 'POST',
headers: bearer(adminTok),
});
expect(res.status).toBe(409);
});
it('403s for a worker hitting stop/discard', async () => {
const app = createApp();
const workerTok = await authToken(app, 'stop-worker-gate@example.com'); // worker
expect(
(
await app.request('/api/admin/sessions/1/stop', {
method: 'POST',
headers: bearer(workerTok),
})
).status,
).toBe(403);
expect(
(
await app.request('/api/admin/sessions/1/discard', {
method: 'POST',
headers: bearer(workerTok),
})
).status,
).toBe(403);
});
});

34
apps/api/test/csv.test.ts Normal file
View File

@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest';
import { buildSessionsCsv, type SessionCsvRow } from '../src/lib/csv';
const row: SessionCsvRow = {
id: 7,
activityName: 'Frezen',
userName: 'Jan',
insoleType: 'Kurk',
pairCount: 2,
startTime: new Date('2026-06-17T08:00:00Z'),
endTime: new Date('2026-06-17T08:01:30Z'),
durationSeconds: 90,
pausedSeconds: 0,
};
describe('buildSessionsCsv', () => {
it('omits the Worker column by default (legacy header)', () => {
const lines = buildSessionsCsv([row]).split('\n');
expect(lines[0]).toBe(
'"ID","Task","Insole Type","No. of Insoles","Date","Total Duration","Paused Duration","Start Time","End Time"',
);
expect(lines[1]).toContain('"Frezen"');
expect(lines[1]).toContain('"00:01:30"');
expect(lines[1]).not.toContain('"Jan"');
});
it('prepends a Worker column when includeWorker is true', () => {
const lines = buildSessionsCsv([row], { includeWorker: true }).split('\n');
expect(lines[0]).toBe(
'"Worker","ID","Task","Insole Type","No. of Insoles","Date","Total Duration","Paused Duration","Start Time","End Time"',
);
expect(lines[1].startsWith('"Jan"')).toBe(true);
});
});

View File

@@ -0,0 +1,190 @@
import { describe, it, expect } from 'vitest';
import type { Hono } from 'hono';
import { createApp } from '../src/app';
import { db } from '../src/db/client';
import { workSessions } from '../src/db/schema';
import { eq } from 'drizzle-orm';
import { authToken, bearer, seedActivity } from './helpers';
const WIDE = `from=${new Date('2000-01-01').toISOString()}&to=${new Date('2100-01-01').toISOString()}`;
async function completed(
app: Hono,
token: string,
activityId: number,
insoleType: string,
durationSeconds: number,
pairCount = 2,
): Promise<number> {
const startRes = await app.request('/api/sessions/start', {
method: 'POST',
headers: bearer(token),
body: JSON.stringify({
activity_id: activityId,
insole_type: insoleType,
pair_count: pairCount,
}),
});
const started = await startRes.json();
await db
.update(workSessions)
.set({ startTime: new Date(Date.now() - durationSeconds * 1000) })
.where(eq(workSessions.id, started.id));
await app.request(`/api/sessions/${started.id}/stop`, { method: 'POST', headers: bearer(token) });
return started.id as number;
}
describe('GET /api/admin/report', () => {
it('401s without a token and 403s for a worker', async () => {
const app = createApp();
expect((await app.request(`/api/admin/report?${WIDE}`)).status).toBe(401);
const workerTok = await authToken(app, 'report-worker@example.com');
const res = await app.request(`/api/admin/report?${WIDE}`, { headers: bearer(workerTok) });
expect(res.status).toBe(403);
});
it('400s on a missing or inverted range', async () => {
const app = createApp();
const adminTok = await authToken(app, 'report-badrange@example.com', 'admin');
expect((await app.request('/api/admin/report', { headers: bearer(adminTok) })).status).toBe(
400,
);
const inverted = `from=${new Date('2026-02-01').toISOString()}&to=${new Date('2026-01-01').toISOString()}`;
expect(
(await app.request(`/api/admin/report?${inverted}`, { headers: bearer(adminTok) })).status,
).toBe(400);
});
it('aggregates totals that equal the sum of each breakdown', async () => {
const app = createApp();
const adminTok = await authToken(app, 'report-agg-admin@example.com', 'admin');
const workerTok = await authToken(app, 'report-agg-worker@example.com'); // 'report-agg-worker'
const frezen = await seedActivity('Frezen');
const lijmen = await seedActivity('Lijmen');
await completed(app, workerTok, frezen, 'Kurk', 100, 2);
await completed(app, workerTok, lijmen, 'Berk', 50, 3);
const res = await app.request(`/api/admin/report?${WIDE}`, { headers: bearer(adminTok) });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.totals.worked_seconds).toBe(150);
expect(body.totals.pairs).toBe(5);
expect(body.totals.sessions).toBe(2);
const sum = (rows: { worked_seconds: number; pairs: number; sessions: number }[]) =>
rows.reduce(
(a, r) => ({
worked_seconds: a.worked_seconds + r.worked_seconds,
pairs: a.pairs + r.pairs,
sessions: a.sessions + r.sessions,
}),
{ worked_seconds: 0, pairs: 0, sessions: 0 },
);
expect(sum(body.by_worker)).toEqual({ worked_seconds: 150, pairs: 5, sessions: 2 });
expect(sum(body.by_activity)).toEqual({ worked_seconds: 150, pairs: 5, sessions: 2 });
expect(sum(body.by_type)).toEqual({ worked_seconds: 150, pairs: 5, sessions: 2 });
expect(body.by_activity).toHaveLength(2);
expect(body.by_type.map((t: { insole_type: string }) => t.insole_type).sort()).toEqual([
'Berk',
'Kurk',
]);
});
it('excludes active/discarded and respects the user and type filters', async () => {
const app = createApp();
const adminTok = await authToken(app, 'report-filter-admin@example.com', 'admin');
const a = await authToken(app, 'report-filter-a@example.com');
const b = await authToken(app, 'report-filter-b@example.com');
const act = await seedActivity('Slijpen');
await completed(app, a, act, 'Kurk', 30, 2); // counts for A/Kurk
await completed(app, b, act, 'Berk', 40, 2); // counts for B/Berk
// A: an active session (no duration) — must be excluded.
await app.request('/api/sessions/start', {
method: 'POST',
headers: bearer(a),
body: JSON.stringify({ activity_id: act, insole_type: 'Kurk', pair_count: 2 }),
});
// Resolve A's user id via the roster.
const roster = await (
await app.request('/api/admin/users', { headers: bearer(adminTok) })
).json();
const userA = roster.find((u: { email: string }) => u.email === 'report-filter-a@example.com');
const res = await app.request(
`/api/admin/report?${WIDE}&user_id=${userA.id}&insole_type=Kurk`,
{ headers: bearer(adminTok) },
);
const body = await res.json();
expect(body.totals.sessions).toBe(1); // only A's completed Kurk session
expect(body.totals.worked_seconds).toBe(30);
});
it('returns zeros and empty arrays for an empty range', async () => {
const app = createApp();
const adminTok = await authToken(app, 'report-empty-admin@example.com', 'admin');
const empty = `from=${new Date('1990-01-01').toISOString()}&to=${new Date('1990-02-01').toISOString()}`;
const res = await app.request(`/api/admin/report?${empty}`, { headers: bearer(adminTok) });
const body = await res.json();
expect(body.totals).toEqual({ worked_seconds: 0, paused_seconds: 0, pairs: 0, sessions: 0 });
expect(body.by_worker).toEqual([]);
expect(body.by_activity).toEqual([]);
expect(body.by_type).toEqual([]);
});
});
describe('GET /api/admin/export', () => {
it('401s without a token and 403s for a worker', async () => {
const app = createApp();
expect((await app.request(`/api/admin/export?${WIDE}`)).status).toBe(401);
const workerTok = await authToken(app, 'export-admin-worker@example.com');
const res = await app.request(`/api/admin/export?${WIDE}`, { headers: bearer(workerTok) });
expect(res.status).toBe(403);
});
it('exports all workers with a leading Worker column, range in the filename', async () => {
const app = createApp();
const adminTok = await authToken(app, 'export-admin@example.com', 'admin');
const a = await authToken(app, 'export-allusers-a@example.com');
const b = await authToken(app, 'export-allusers-b@example.com');
const act = await seedActivity('Frezen');
await completed(app, a, act, 'Kurk', 90, 2);
await completed(app, b, act, 'Berk', 60, 2);
// Scope to this test's own activity so the all-users export isolates from the
// sessions other tests seed into the shared, run-scoped DB.
const res = await app.request(`/api/admin/export?${WIDE}&activity_id=${act}`, {
headers: bearer(adminTok),
});
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toContain('text/csv');
expect(res.headers.get('content-disposition')).toMatch(
/attachment; filename="solelog-report_\d{4}-\d{2}-\d{2}_\d{4}-\d{2}-\d{2}\.csv"/,
);
const lines = (await res.text()).split('\n');
expect(lines[0]).toBe(
'"Worker","ID","Task","Insole Type","No. of Insoles","Date","Total Duration","Paused Duration","Start Time","End Time"',
);
expect(lines).toHaveLength(3); // header + 2 workers' sessions
expect(lines.some((l) => l.includes('export-allusers-a'))).toBe(true);
expect(lines.some((l) => l.includes('export-allusers-b'))).toBe(true);
});
it('respects the type filter', async () => {
const app = createApp();
const adminTok = await authToken(app, 'export-typefilter-admin@example.com', 'admin');
const a = await authToken(app, 'export-typefilter-a@example.com');
const act = await seedActivity('Lijmen');
await completed(app, a, act, 'Kurk', 30, 2);
await completed(app, a, act, 'Berk', 40, 2);
const res = await app.request(`/api/admin/export?${WIDE}&activity_id=${act}&insole_type=Berk`, {
headers: bearer(adminTok),
});
const lines = (await res.text()).split('\n');
expect(lines).toHaveLength(2); // header + only the Berk row
expect(lines[1]).toContain('"Berk"');
});
});

View File

@@ -13,6 +13,8 @@ export function useActiveSessions() {
return useQuery({ return useQuery({
queryKey: ['sessions', 'active'], queryKey: ['sessions', 'active'],
queryFn: () => apiFetch<WorkSession[]>('/api/sessions/active'), queryFn: () => apiFetch<WorkSession[]>('/api/sessions/active'),
// Poll so the stopwatch converges to admin changes (stop/cancel) within ~15s.
refetchInterval: 15000,
}); });
} }

View File

@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { Activity, WorkSession } from '@solelog/shared'; import type { Activity, WorkSession } from '@solelog/shared';
import Stopwatch from './Stopwatch'; import Stopwatch from './Stopwatch';
import { ApiError } from '../lib/api';
import { useActivities } from '../api/activities'; import { useActivities } from '../api/activities';
import { import {
useActiveSessions, useActiveSessions,
@@ -247,4 +248,52 @@ describe('Stopwatch', () => {
expect(await screen.findByText('Gepauzeerd — tik om te hervatten')).toBeInTheDocument(); expect(await screen.findByText('Gepauzeerd — tik om te hervatten')).toBeInTheDocument();
}); });
it('resets and shows a notice when the running session is stopped by the admin', async () => {
// Start with the session present so the screen adopts the running state.
mockedUseActiveSessions.mockReturnValue(
query<ReturnType<typeof useActiveSessions>>([activeSession()]),
);
const { rerender } = renderStopwatch();
// Confirm we are running (the Stop & Opslaan button is shown).
await screen.findByRole('button', { name: 'Stop & Opslaan' });
// The admin stops the session: the active list now no longer contains it.
mockedUseActiveSessions.mockReturnValue(query<ReturnType<typeof useActiveSessions>>([]));
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
rerender(
<QueryClientProvider client={queryClient}>
<Stopwatch />
</QueryClientProvider>,
);
// The stopwatch resets to idle and shows the admin-stopped notice.
expect(
await screen.findByText('Deze sessie is door de beheerder gestopt.'),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Start Stopwatch' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Stop & Opslaan' })).not.toBeInTheDocument();
});
it('resets locally when stop fails with a 409 (already closed)', async () => {
const user = userEvent.setup();
stopMutate.mockImplementation((_id: number, opts?: { onError?: (e: unknown) => void }) => {
opts?.onError?.(new ApiError(409, 'Conflict'));
});
mockedUseActiveSessions.mockReturnValue(
query<ReturnType<typeof useActiveSessions>>([activeSession()]),
);
renderStopwatch();
const stopBtn = await screen.findByRole('button', { name: 'Stop & Opslaan' });
await user.click(stopBtn);
expect(stopMutate).toHaveBeenCalledTimes(1);
// The timer resets to idle despite the 409 (no stuck running state).
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Start Stopwatch' })).toBeInTheDocument(),
);
expect(screen.queryByRole('button', { name: 'Stop & Opslaan' })).not.toBeInTheDocument();
});
}); });

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import type { InsoleType, WorkSession } from '@solelog/shared'; import type { InsoleType, WorkSession } from '@solelog/shared';
import { ApiError } from '../lib/api';
import { useActivities } from '../api/activities'; import { useActivities } from '../api/activities';
import { import {
useActiveSessions, useActiveSessions,
@@ -44,13 +45,26 @@ export default function Stopwatch() {
const [discardPending, setDiscardPending] = useState(false); const [discardPending, setDiscardPending] = useState(false);
const discardTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const discardTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Transient notice when the admin stopped/cancelled this session out from under us.
const [stoppedByAdmin, setStoppedByAdmin] = useState(false);
const isRunning = sessionId !== null; const isRunning = sessionId !== null;
// Recover an active session on load (phone-died / resume-elsewhere path). // Reconcile against server truth on every active-sessions poll:
// - running locally but our session is gone from the active list → admin stopped/cancelled it.
// - idle but the server has an active session → adopt it (phone-died / resume-elsewhere path).
useEffect(() => { useEffect(() => {
if (isRunning) return;
const active = activeSessionsQuery.data; const active = activeSessionsQuery.data;
if (!active || active.length === 0) return; if (!active) return;
if (isRunning) {
const stillActive = active.some((s) => s.id === sessionId);
if (!stillActive) {
resetTimer();
setStoppedByAdmin(true);
}
return;
}
if (active.length === 0) return;
const session: WorkSession = active[0]; const session: WorkSession = active[0];
setSessionId(session.id); setSessionId(session.id);
setStartMs(new Date(session.start_time).getTime()); setStartMs(new Date(session.start_time).getTime());
@@ -107,6 +121,7 @@ export default function Stopwatch() {
function handleStart() { function handleStart() {
if (!canStart || activeActivityId === null) return; if (!canStart || activeActivityId === null) return;
setStoppedByAdmin(false); // clear any prior admin-stopped notice
startSession.mutate( startSession.mutate(
{ activity_id: activeActivityId, insole_type: insoleType, pair_count: pairCount }, { activity_id: activeActivityId, insole_type: insoleType, pair_count: pairCount },
{ {
@@ -155,10 +170,16 @@ export default function Stopwatch() {
} }
} }
// A 409 means the session is already closed server-side (e.g. admin stopped it) —
// reset locally instead of leaving the timer stuck.
function resetIfConflict(error: unknown) {
if (error instanceof ApiError && error.status === 409) resetTimer();
}
function handleStop() { function handleStop() {
if (sessionId === null) return; if (sessionId === null) return;
const id = sessionId; const id = sessionId;
stopSession.mutate(id, { onSuccess: () => resetTimer() }); stopSession.mutate(id, { onSuccess: () => resetTimer(), onError: resetIfConflict });
// Selections (zool/handling/count) persist for the next session. // Selections (zool/handling/count) persist for the next session.
} }
@@ -177,7 +198,7 @@ export default function Stopwatch() {
discardTimerRef.current = null; discardTimerRef.current = null;
} }
const id = sessionId; const id = sessionId;
discardSession.mutate(id, { onSuccess: () => resetTimer() }); discardSession.mutate(id, { onSuccess: () => resetTimer(), onError: resetIfConflict });
} }
const statusPill = !isRunning const statusPill = !isRunning
@@ -192,6 +213,24 @@ export default function Stopwatch() {
<div className="screen"> <div className="screen">
<h1 className="screen-title">Stopwatch</h1> <h1 className="screen-title">Stopwatch</h1>
{stoppedByAdmin && !isRunning && (
<div
role="status"
style={{
marginBottom: 20,
padding: '12px 16px',
borderRadius: 12,
border: '1px solid #FDE68A',
background: '#FEF3C7',
color: '#92400E',
fontSize: 14,
fontWeight: 600,
}}
>
Deze sessie is door de beheerder gestopt.
</div>
)}
{/* Section 1 — Type zool */} {/* Section 1 — Type zool */}
<h2 className="section-label">Type zool</h2> <h2 className="section-label">Type zool</h2>
<div className="segmented" style={{ display: 'flex', gap: 8, marginBottom: 20 }}> <div className="segmented" style={{ display: 'flex', gap: 8, marginBottom: 20 }}>

View File

@@ -20,12 +20,15 @@ services:
api: api:
image: gitea.vrossem.net/bas/solelog:latest image: gitea.vrossem.net/bas/solelog:latest
container_name: solelog-api container_name: solelog-api
env_file: .env # BETTER_AUTH_SECRET, BETTER_AUTH_URL, CORS_ORIGINS env_file: .env # BETTER_AUTH_SECRET, BETTER_AUTH_URL, CORS_ORIGINS
environment: environment:
DATABASE_URL: file:/data/app.db DATABASE_URL: file:/data/app.db
PORT: '3000' PORT: '3000'
volumes: volumes:
- solelog_db:/data # Bind-mount the SQLite data dir onto attached storage on the host.
# Set SOLELOG_DATA_DIR in .env to an absolute path on your mounted disk
# (Docker creates it if missing). Falls back to ./data next to this file.
- ./data:/data
networks: networks:
- solelog_network - solelog_network
restart: unless-stopped restart: unless-stopped
@@ -47,6 +50,3 @@ services:
networks: networks:
solelog_network: solelog_network:
external: true external: true
volumes:
solelog_db:

View File

@@ -180,10 +180,21 @@ Each phase keeps the system working and is its own spec → plan → build cycle
**3a implemented** (`apps/admin`; plan `docs/superpowers/plans/2026-06-17-phase-3a-admin-panel.md`): **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 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 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 management (handelingen CRUD on `/api/activities`).
(all-users filtered CSV — current `/api/export` is self-scoped), user management **3b·1 implemented** (plan `docs/superpowers/plans/2026-06-17-phase-3b1-manual-sessions.md`):
(better-auth `/api/auth/admin/*`), and manual entry/edit + admin stop/fix of a running manual session entry/edit + admin stop/discard of a running session (new admin write
session (needs new backend endpoints). endpoints `POST/PUT /api/admin/sessions[/:id]`, `…/:id/stop`, `…/:id/discard`, plus a
`GET /api/admin/users` roster), an admin **Sessies** screen (list + status filter +
create/edit form + row actions), Stop/Annuleer on the Live cards, and a worker stopwatch
that polls (15 s) and reconciles to server truth (converges within ~15 s when an admin
stops/cancels/edits a session — no stuck state).
**3b·2 implemented** (plan `docs/superpowers/plans/2026-06-24-phase-3b2-reports-export.md`):
the admin **Rapporten** screen (period presets Deze week / Deze maand / Alles +
worker/type/activity filters, headline totals and breakdowns per worker/activity/type) backed by
`GET /api/admin/report` (completed-only, JS aggregation), plus an all-users filtered CSV via
`GET /api/admin/export` — a shared `buildSessionsCsv(rows, {includeWorker})` now serves both the
worker self-export and the admin export. **3b remaining:** user management
(better-auth `/api/auth/admin/*`).
Activity management (add/edit/delete handelingen + their `insole_types`) was removed from the 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 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 backend already exists (`/api/activities` writes are admin-gated; `useActivities`/the legacy

View File

@@ -0,0 +1,98 @@
# Session: 2026-06-17 — Phase 3b·1 (Manual session entry/edit + admin stop/fix + worker convergence)
## Goal
Let an admin take full control of sessions: list/filter every session, manually create a
completed session for a worker, edit any session (server recomputes duration), and
stop/cancel a worker's active session — and make the worker's stopwatch converge to those
admin changes within ~15 s with no stuck state. No DB migration (reuses the existing
`work_sessions` columns, incl. the pause fields from the prior cycle).
Spec: `docs/superpowers/specs/2026-06-17-phase-3b1-manual-sessions-design.md`;
plan: `docs/superpowers/plans/2026-06-17-phase-3b1-manual-sessions.md`.
## Work done
Implemented task-by-task per the plan (TDD throughout), one commit per task:
- **Task 1 — Shared contracts** (`00993c6`). `@solelog/shared` gains
`CreateManualSessionInput` (worker id + activity + insole type + pair count +
`start_time`/`end_time` + `paused_seconds` default 0 + optional `notes`) and
`AdminUpdateSessionInput` (editable fields incl. nullable `end_time`/`insole_type` and a
`status`), with their inferred types. Duration is never accepted from the client.
- **Task 2 — Backend admin session write endpoints + users roster** (`53c66ee`). In
`apps/api/src/routes/admin.ts` (behind the existing `/api/admin/*` admin guard):
`GET /api/admin/users` (id/name/email, ordered by name), `POST /api/admin/sessions`
(validates the worker + activity, rejects `end < start` and `paused > span`, stores
`source='manual'`, `status='completed'`, server-computed `duration_seconds`),
`PUT /api/admin/sessions/:id` (recomputes duration; nullable `end_time` keeps an active
row open), and `POST …/:id/stop` (folds any open pause span, `end=now`, completes) /
`…/:id/discard` (`status='discarded'`, `end=now`). All session responses go through
`toWorkSession`. Workers hitting any of these get 403; stop/discard on an
already-closed session → 409.
- **Task 3 — Admin api hooks + Sessions screen** (`b536641`). `api/admin-sessions.ts`:
`useAllSessions`, `useAdminUsers`, `useCreateManualSession`, `useUpdateSession`,
`useAdminStopSession`, `useAdminDiscardSession` (mutations invalidate
`['admin','sessions']`). New `screens/Sessions.tsx` (title "Sessies", status `<select>`
filter alle/actief/voltooid/geannuleerd, `+ Nieuwe registratie`, a row per session with
worked time + a `Pauze …` line when paused, ✎ on all rows, Stop/Annuleer on active rows).
Sidebar gains a "Sessies" nav item and drops "Handmatig" from the soon group; `App.tsx`
gets the `/sessies` route; table/form/action styles added to `styles.css`.
- **Task 4 — Admin create/edit session form** (`69b46be`). `components/SessionForm.tsx`
(`mode: 'create' | 'edit'`): create mode shows a worker picker (`useAdminUsers`) and posts
`CreateManualSessionInput`; edit mode prefills from the session, hides the worker picker,
and PUTs the changed fields. Activity `<select>`, insole-type toggles, pair-count stepper,
start/end `datetime-local` (built into ISO), paused input, status `<select>` (edit only),
notes, and a derived "gewerkt" line (`end start paused`). Wired into Sessions:
`+ Nieuwe registratie` opens create, ✎ opens edit.
- **Task 5 — Stop/Annuleer on the Live view** (`f1ec249`). The active `LiveCard` gains Stop
(`POST …/:id/stop`) and Annuleer (`…/discard`) buttons via the admin hooks; on success the
active query invalidates and the card drops off.
- **Task 6 — Worker poll + reconcile to server truth** (`7d3daaa`).
`useActiveSessions` gets `refetchInterval: 15000`. The stopwatch's active-session effect
now resets to idle and shows **"Deze sessie is door de beheerder gestopt."** when the
worker's local session id is absent from the server's active list (covers admin stop,
discard, and edit-to-completed alike); a 409 from stop/discard resets locally with no
error surfaced. The existing "adopt an active session when idle" recovery is kept.
- **Task 7 — Docs, lint, verification** (this task). Lint/format clean, full green matrix,
in-process endpoint coverage, and this session log + roadmap note.
## Verification (Task 7)
- `npx oxlint` — clean (exit 0).
- `npx oxfmt --check` over the 16 cycle-changed source files — "All matched files use the
correct format" (earlier tasks formatted as they went; nothing to reformat).
- `yarn workspace @solelog/api typecheck` — pass; `test`**75 passed** (12 files).
- `yarn workspace @solelog/admin typecheck` — pass; `test`**36 passed** (7 files);
`build` — pass (vite, 91 modules).
- `yarn workspace @solelog/worker typecheck` — pass; `test`**30 passed** (8 files);
`build` — pass (vite, 91 modules).
- **Smoke** — the plan's live-server smoke was deliberately driven **in-process** instead of
by starting the API (`createApp()` + `app.request` in `apps/api/test/admin.test.ts`), so
port 3000 was never bound — this avoids the Windows libsql lock trap. The in-process tests
cover every smoke scenario end-to-end: `GET /api/admin/users` (admin 200 / worker 403);
`POST /api/admin/sessions` with `paused_seconds: 600` over a 1 h span →
`source='manual'`, `status='completed'`, `duration_seconds === 3000` (3600 600);
`end < start` → 400; unknown user/activity → 404; `PUT` edit recomputes duration;
`…/:id/stop` on another worker's active session → completed with computed duration;
`…/:id/discard``status='discarded'`; stop after discard → 409; worker token on any
write → 403.
## Outcome
Phase 3b·1 is implemented and green across all three workspaces. An admin can list and
filter every session, manually log a completed session for a worker (duration always
derived server-side and excluding paused time, `source='manual'`), edit any session,
and stop or cancel a worker's running session from either the **Sessies** screen or the
**Live** cards. The worker stopwatch polls every 15 s and reconciles to server truth — when
an admin stops/cancels/edits a worker's active session, the worker's clock resets and shows
"Deze sessie is door de beheerder gestopt." within ~15 s, with no stuck state (a racing 409
also resets cleanly). Roadmap Phase 3 status updated: **3b·1 done; reports/export and user
management remain**.
The two unrelated working-tree edits to `.env.prod.example` / `docker-compose.prod.yml`
(maintainer's deploy work) were left untouched — out of this cycle's scope.
## Next (Phase 3b remainder)
- Reports + all-users filtered CSV export (current `/api/export` is self-scoped).
- User management via better-auth `/api/auth/admin/*`.

View File

@@ -0,0 +1,95 @@
# Session: 2026-06-24 — Phase 3b·2 (Reports + all-users export)
## Goal
Give the admin a **Rapporten** screen: pick a period (Deze week / Deze maand / Alles + manual
from/to) and optionally narrow by worker / insole type / activity, see headline production totals
plus three breakdowns (per medewerker / per handeling / per type), and export the underlying
detail rows (all workers) to CSV. Today's `/api/export` is **self-scoped** to the logged-in user;
this cycle adds the cross-user, filterable reporting + export. Second of three Phase 3b cycles
(after manual-sessions 3b·1; user management remains).
Spec: `docs/superpowers/specs/2026-06-24-phase-3b2-reports-export-design.md`;
plan: `docs/superpowers/plans/2026-06-24-phase-3b2-reports-export.md`.
Tracked as Plane epic **SL-48** with tasks **SL-49…SL-53**.
## Scope decisions (brainstorming)
- **Both lenses on one screen** (totals + breakdowns per worker AND activity AND type).
- **Filters:** date range (spine) + optional worker + type + activity; default **this week**.
- **Completed-only** counts toward totals/export (active/discarded excluded).
- **All four metrics:** gewerkte tijd, aantal zolen, aantal sessies, pauzetijd.
- **CSV = detail rows, all workers** (a leading Worker column), not the aggregated summary.
- **Tables only** — no chart library, no CSS bars (dependency-light).
## Work done
Built via a 5-task TDD **Workflow** (`w7ut902zo`), one commit per task, run sequentially because the
tasks share one working tree:
- **Task 1 — DRY CSV builder** (`01fa18f`). Extracted the inline header/row builder from the
self-scoped `/api/export` into `buildSessionsCsv(rows, { includeWorker })` + a `SessionCsvRow`
interface in `apps/api/src/lib/csv.ts`. `sessions.ts` now maps its query rows into `SessionCsvRow`
and calls the shared builder. `includeWorker:false` keeps the worker export **byte-identical**
(the existing `export.test.ts` is the regression guard); `includeWorker:true` prepends a Worker
column. New `csv.test.ts` covers both header variants.
- **Task 2 — Report contracts + `GET /api/admin/report`** (`4b213e2`). Added `ReportTotals`,
`ReportWorkerRow`, `ReportActivityRow`, `ReportTypeRow`, `ReportResponse` to `@solelog/shared`.
In `admin.ts`: `parseReportQuery` (validates `from`/`to` range, `insole_type`, `activity_id`),
`buildSessionFilters` (completed-only + date range + optional worker/type/activity), and the
report route — fetches the filtered joined rows and aggregates `totals` + `by_worker`/
`by_activity`/`by_type` in JS (sorted by `worked_seconds` desc; empty range → zeros + empty
arrays; null type bucketed under "Onbekend"). `report.test.ts` (5 cases).
- **Task 3 — `GET /api/admin/export`** (`d33fcb7`). All-users filtered CSV reusing
`parseReportQuery` + `buildSessionFilters` (Task 2) and `buildSessionsCsv(includeWorker:true)`
(Task 1); ordered by `start_time` asc; filename `solelog-report_<from>_<to>.csv`. 401/403 inherited
from the `/api/admin/*` guard. Export tests appended to `report.test.ts`.
- **Task 4 — Admin API client** (`8ad2e69`). `apps/admin/src/api/reports.ts`: `ReportFilters`,
`filtersToQuery` (omits empty optionals), `useReport` (React Query), and `downloadExport` — a raw
`fetch` with the bearer token → `Blob` → object-URL download (a plain `<a href>` can't carry the
token). `reports.test.ts` covers serialization + the bearer-fetch download + non-ok throw.
- **Task 5 — Rapporten screen + nav** (`8d75be0`). Pure `lib/date-range.ts` helpers (`thisWeek`,
`thisMonth`, `allTime`, `dayStartISO`/`dayEndISO` — local-day → ISO instants so there's no server
tz ambiguity) with unit tests. `screens/Reports.tsx`: filter bar (presets + date inputs + worker/
type/handeling dropdowns), a headline totals line, and three breakdown tables, plus the
**Exporteer CSV** button wired to `downloadExport`. Sidebar moves **Rapporten** into `navItems`
(soon = just `Gebruikers`); `App.tsx` gets the `/rapporten` route; report styles appended to
`styles.css`.
## Deviations (fix-forward, test-only)
- **Task 3:** the API test DB is reset once per file (`test/setup.ts`), so the WIDE-window export
tests initially picked up sessions seeded by the earlier report tests. Fixed by scoping the two
non-gating export tests to their own freshly-seeded activity via `&activity_id=…` (a real filter
the route already supports). The route code is exactly as planned.
- **Task 5:** the plan's `Reports.test.tsx` had a few assertions that collided with the screen's own
markup (headline `36` vs a table cell; `Jan`/`Kurk` matching dropdown `<option>`s). Tightened three
assertions (scope to `.reports-totals`; target `getByRole('cell', …)`) and loosened the apiFetch
mock signature for a teardown refetch. Screen behavior/markup unchanged.
## Verification (independent of the workflow's self-reports)
- `git log` — five task commits `01fa18f → 8d75be0`; **clean tree**.
- `yarn workspace @solelog/api test`**85 passed** (14 files), incl. `csv`, `report`, and the
unchanged regression `export.test.ts`; `typecheck` clean.
- `yarn workspace @solelog/admin test`**46 passed** (10 files), incl. `reports`, `date-range`,
`Reports`; `typecheck` clean; `build` succeeds (vite, 94 modules).
- `npx oxlint` — clean (exit 0).
## Outcome
Phase 3b·2 is implemented and green across both touched workspaces. An admin opens **Rapporten**,
picks a period + optional filters, and sees correct headline totals (worked / zolen / sessies /
pauze) with three breakdown tables, then exports every matching completed session (all workers, with
a Worker column) to a range-named CSV. One CSV format now serves both the worker self-export and the
admin export. No DB migration (reuses existing `work_sessions` columns). Plane SL-48 + SL-49…SL-53
all marked Done. Roadmap Phase 3 note updated: **3b·2 done; user management is the last 3b cycle.**
The maintainer's deploy WIP (`.env.prod.example` / `docker-compose.prod.yml`) was left untouched.
`origin/main` is **behind** (the pre-compaction push never landed); after this cycle `main` is ahead
**~16** commits — a single `git push origin main` from the maintainer's terminal carries the batch
(and triggers Gitea CI).
## Next (Phase 3b remainder)
- **User management** — create / set role / deactivate via better-auth `/api/auth/admin/*` (the
`Gebruikers` screen). Final 3b cycle.

View File

@@ -0,0 +1,295 @@
# Phase 3b·1 — Manual Session Entry/Edit + Admin Stop/Fix — Implementation Plan
> **For agentic workers:** Implement task-by-task with TDD. Steps use checkbox (`- [ ]`).
> Spec: `docs/superpowers/specs/2026-06-17-phase-3b1-manual-sessions-design.md`.
**Goal:** Admin can list/filter all sessions, manually create a completed session for a worker,
edit any session (server recomputes duration), and stop/cancel a worker's active session — and the
worker's stopwatch converges to admin changes within ~15s (no stuck state).
**Architecture:** New admin-gated write endpoints + a users-roster endpoint in `routes/admin.ts`;
a new admin **Sessies** screen + shared create/edit form; Stop/Annuleer actions on Live; worker
polls + reconciles. **No DB migration** (reuses existing `work_sessions` columns).
**Tech Stack:** Hono + Drizzle + libsql (api), Vite+React+react-query (admin, worker),
`@solelog/shared` zod, vitest. Yarn 4 monorepo.
## Global Constraints
- **TDD**: failing test → see it fail → minimal implementation → green → commit.
- **Commit per task**, conventional-commit message; commit **locally only** (no push/remote/amend);
stage only your task's files.
- **oxlint + oxfmt on changed files only.** `.oxfmtrc.json` now uses `trailingComma: "all"`
(prettier-style) — keep trailing commas on multiline params/args/arrays/objects; `docs/**` and
`**/drizzle/**` are ignored by the formatter. 2-space, single quotes, semicolons, width 100.
- **Dutch UI strings.**
- **No DB migration** this cycle. **Do not start the API server** (tests use in-process
`app.request`); if you must, kill the tree + free port 3000 afterward (Windows lock trap).
- Reuse `toWorkSession` (`apps/api/src/lib/work-session.ts`) for session responses. New admin
routes live in `apps/api/src/routes/admin.ts`, already behind the `/api/admin/*` admin guard.
## File Structure
```
packages/shared/src/index.ts MODIFY CreateManualSessionInput, AdminUpdateSessionInput
apps/api/src/routes/admin.ts MODIFY GET /api/admin/users; POST/PUT/stop/discard sessions
apps/api/test/admin.test.ts MODIFY
apps/admin/src/api/admin-sessions.ts MODIFY add list/users/create/update/stop/discard hooks
apps/admin/src/screens/Sessions.tsx CREATE list + status filter + row actions
apps/admin/src/components/SessionForm.tsx CREATE create/edit form
apps/admin/src/components/Sidebar.tsx MODIFY add 'Sessies' nav; drop 'Handmatig' from soon
apps/admin/src/App.tsx MODIFY add /sessies route
apps/admin/src/screens/Live.tsx MODIFY Stop/Annuleer on LiveCard
apps/admin/src/styles.css MODIFY table/form/action styles
apps/admin/src/screens/Sessions.test.tsx, components/SessionForm.test.tsx, screens/Live.test.tsx TEST
apps/worker/src/api/sessions.ts MODIFY refetchInterval 15000
apps/worker/src/screens/Stopwatch.tsx MODIFY reconcile + 409 handling
apps/worker/src/screens/Stopwatch.test.tsx TEST
```
---
### Task 1: Shared contracts
**Files:** `packages/shared/src/index.ts`; test `packages/shared` (or assert via api tests — add a
tiny parse test if shared has a test setup, else cover via Task 2's api tests).
**Interfaces — Produces:**
- `CreateManualSessionInput = z.object({ user_id: z.string(), activity_id: z.number().int(),
insole_type: InsoleType, pair_count: z.number().int().min(1), start_time: z.string(),
end_time: z.string(), paused_seconds: z.number().int().min(0).default(0),
notes: z.string().nullable().optional() })`.
- `AdminUpdateSessionInput = z.object({ activity_id: z.number().int(), insole_type:
InsoleType.nullable(), pair_count: z.number().int().min(1), start_time: z.string(),
end_time: z.string().nullable(), paused_seconds: z.number().int().min(0),
notes: z.string().nullable(), status: SessionStatus })`.
- [ ] **Step 1:** If `packages/shared` has no test runner, skip a standalone test here and rely on
Task 2's API tests to exercise the schemas (note this in the commit). Otherwise add a parse
test (valid input parses; `pair_count: 0` fails).
- [ ] **Step 2:** Add both schemas + inferred types after `StartSessionInput`/`AdminUser`.
- [ ] **Step 3:** `yarn workspace @solelog/api typecheck` (shared is consumed there) — green.
- [ ] **Step 4: Commit** — `feat(shared): manual-session create/update contracts`.
---
### Task 2: Backend — admin session write endpoints + users roster
**Files:** `apps/api/src/routes/admin.ts`; test `apps/api/test/admin.test.ts`.
**Interfaces — Produces** `GET /api/admin/users`, `POST /api/admin/sessions`,
`PUT /api/admin/sessions/:id`, `POST /api/admin/sessions/:id/stop`,
`POST /api/admin/sessions/:id/discard`.
- [ ] **Step 1: Failing tests** (helpers: `createTestUser`/`bearer`/`seedActivity`; create an admin
with `authToken(app, email, 'admin')` per the existing pattern):
- `GET /api/admin/users` (admin) returns objects with `id/name/email`; 403 for a worker.
- `POST /api/admin/sessions` (admin) with a worker's id + activity + `start_time`/`end_time` 1h
apart + `paused_seconds: 600` → 201/200 with `source==='manual'`, `status==='completed'`,
`duration_seconds === 3000` (3600600).
- `POST` with `end < start` → 400; unknown `user_id`/`activity_id` → 404/400.
- `PUT /api/admin/sessions/:id` edits a session and recomputes duration; `end<start` → 400.
- `POST …/:id/stop` on another user's **active** session → completed with computed duration;
`…/:id/discard` → `status==='discarded'`. Worker token on any of these → 403.
- [ ] **Step 2: Run — fail** (routes 404).
- [ ] **Step 3: Implement** in `admin.ts` (imports: `and, eq, asc, desc` from drizzle-orm,
`CreateManualSessionInput`, `AdminUpdateSessionInput` from `@solelog/shared`, `activities`,
`user`, `workSessions` from schema, `toWorkSession`). All routes sit after the existing
`/api/admin/*` guard (already admin-gated).
```ts
adminRoutes.get('/api/admin/users', async (c) => {
const rows = await db
.select({ id: user.id, name: user.name, email: user.email })
.from(user)
.orderBy(asc(user.name));
return c.json(rows);
});
function computeDuration(startMs: number, endMs: number, paused: number) {
return Math.max(0, Math.round((endMs - startMs) / 1000) - paused);
}
adminRoutes.post('/api/admin/sessions', async (c) => {
const parsed = CreateManualSessionInput.safeParse(await c.req.json().catch(() => null));
if (!parsed.success) return c.json({ error: 'Invalid input' }, 400);
const d = parsed.data;
const start = new Date(d.start_time);
const end = new Date(d.end_time);
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end < start)
return c.json({ error: 'Invalid input' }, 400);
const [u] = await db.select({ id: user.id }).from(user).where(eq(user.id, d.user_id));
if (!u) return c.json({ error: 'User not found' }, 404);
const [act] = await db.select().from(activities).where(eq(activities.id, d.activity_id));
if (!act) return c.json({ error: 'Activity not found' }, 404);
if (d.paused_seconds > Math.round((end.getTime() - start.getTime()) / 1000))
return c.json({ error: 'Invalid input' }, 400);
const [row] = await db
.insert(workSessions)
.values({
userId: d.user_id,
activityId: d.activity_id,
insoleType: d.insole_type,
pairCount: d.pair_count,
startTime: start,
endTime: end,
durationSeconds: computeDuration(start.getTime(), end.getTime(), d.paused_seconds),
pausedSeconds: d.paused_seconds,
pausedAt: null,
status: 'completed',
source: 'manual',
notes: d.notes ?? null,
})
.returning();
return c.json(toWorkSession(row, { activityName: act.name }));
});
```
- **PUT `:id`** — load the row (any user); 404 if missing; validate activity; if `end_time`
present validate `end ≥ start` and `paused ≤ span`, set `durationSeconds` via `computeDuration`,
else `durationSeconds = null` and (if `status==='active'`) keep it open. Set the editable
fields; return `toWorkSession`.
- **stop `:id`** — load active row; if `pausedAt`, fold the open span into `pausedSeconds`;
`end=now`; `durationSeconds=computeDuration(...)`; `status='completed'`, `pausedAt=null`.
- **discard `:id`** — load active row; `status='discarded'`, `end=now`.
- Register `stop`/`discard` (literal subpaths) and the bare `:id` PUT so they don't collide
(Hono matches `/api/admin/sessions/:id/stop` distinctly from `/:id`).
- [ ] **Step 4: Run tests + typecheck — green.**
- [ ] **Step 5: Commit** — `feat(api): admin manual-session create/edit/stop/discard + users roster`.
---
### Task 3: Admin — api hooks + Sessions screen (list + filter + nav/route)
**Files:** `apps/admin/src/api/admin-sessions.ts`, `apps/admin/src/screens/Sessions.tsx` (create),
`apps/admin/src/components/Sidebar.tsx`, `apps/admin/src/App.tsx`, `apps/admin/src/styles.css`;
test `apps/admin/src/screens/Sessions.test.tsx`.
**Interfaces — Produces** `useAllSessions`, `useAdminUsers`, `useCreateManualSession`,
`useUpdateSession`, `useAdminStopSession`, `useAdminDiscardSession`.
- [ ] **Step 1: Failing test** (mock `apiFetch`): `Sessions` renders a row per session (worker +
activity + worked); the status filter narrows the list (e.g. selecting "actief" shows only
active); `Stop`/`Annuleer` appear only on active rows and call the right endpoints; ✎ present on
all rows.
- [ ] **Step 2: Run — fail.**
- [ ] **Step 3: Hooks** in `api/admin-sessions.ts` (keep `useActiveSessions`):
```ts
export function useAllSessions() {
return useQuery({
queryKey: ['admin', 'sessions', 'all'],
queryFn: () => apiFetch<WorkSession[]>('/api/admin/sessions'),
});
}
export function useAdminUsers() {
return useQuery({
queryKey: ['admin', 'users'],
queryFn: () => apiFetch<{ id: string; name: string; email: string }[]>('/api/admin/users'),
});
}
// useCreateManualSession / useUpdateSession / useAdminStopSession / useAdminDiscardSession:
// useMutation hitting POST /api/admin/sessions, PUT /api/admin/sessions/:id,
// POST /api/admin/sessions/:id/stop|/discard; each onSuccess invalidates ['admin','sessions'].
```
- [ ] **Step 4: Sidebar + route** — add `{ to: '/sessies', label: 'Sessies' }` to `navItems` in
`Sidebar.tsx`; remove `'Handmatig'` from `soonItems` (leaves `['Rapporten', 'Gebruikers']`). In
`App.tsx` import `Sessions` and add `<Route path="/sessies" element={<Sessions />} />`.
- [ ] **Step 5: Screen** — `Sessions.tsx`: title "Sessies", a status `<select>` filter
(alle/actief/voltooid/geannuleerd), `+ Nieuwe registratie` button (opens the form from Task 4 —
for now a stub/`onCreate` prop or local state placeholder), a table/list of rows with worked
time (reuse `formatTime` from `lib/elapsed`; show `Pauze …` when `paused_seconds>0`), ✎ edit and
(active only) `Stop`/`Annuleer` buttons wired to the hooks. Loading/error/empty states in Dutch.
- [ ] **Step 6: Styles** — add `.sessions-*`/table/action-button CSS to `styles.css`.
- [ ] **Step 7: Run admin tests + typecheck + build — green.**
- [ ] **Step 8: Commit** — `feat(admin): sessions management screen (list + filter + actions)`.
---
### Task 4: Admin — create/edit session form
**Files:** `apps/admin/src/components/SessionForm.tsx` (create), wire into
`apps/admin/src/screens/Sessions.tsx`, `apps/admin/src/styles.css`; test
`apps/admin/src/components/SessionForm.test.tsx`.
- [ ] **Step 1: Failing tests** (mock `apiFetch`/hooks): in **create** mode the form shows a worker
picker (from `useAdminUsers`) and submitting posts `CreateManualSessionInput` with the entered
values; in **edit** mode it prefills from the session, hides the worker picker, and submitting
PUTs the changed fields. The "gewerkt" preview = `end start paused`.
- [ ] **Step 2: Run — fail.**
- [ ] **Step 3: Implement `SessionForm.tsx`** — props `{ mode: 'create' | 'edit', session?,
onClose }`. Fields: worker `<select>` (create only), activity `<select>` (from `useActivities`),
insole-type toggles, pair-count stepper, start/end `datetime-local`, paused (minutes or H:MM),
status `<select>` (edit only), notes `<textarea>`, and a derived "gewerkt" line. Build the ISO
`start_time`/`end_time` from the datetime-local values. Submit via `useCreateManualSession` /
`useUpdateSession`; close on success. Inline error on 400.
- [ ] **Step 4: Wire into Sessions** — `+ Nieuwe registratie` opens it in create mode; ✎ opens it
in edit mode with the row's session. Modal or inline panel — keep it simple.
- [ ] **Step 5: Run admin tests + typecheck + build — green.**
- [ ] **Step 6: Commit** — `feat(admin): manual session create/edit form`.
---
### Task 5: Admin — Stop/Annuleer on the Live view
**Files:** `apps/admin/src/screens/Live.tsx`, `apps/admin/src/styles.css`; test
`apps/admin/src/screens/Live.test.tsx`.
- [ ] **Step 1: Failing test:** an active LiveCard shows `Stop` and `Annuleer`; clicking `Stop`
calls `POST /api/admin/sessions/:id/stop`, `Annuleer` calls `…/discard`.
- [ ] **Step 2: Run — fail.**
- [ ] **Step 3: Implement** — add the two buttons to `LiveCard`, wired to `useAdminStopSession` /
`useAdminDiscardSession`; on success the active query invalidates and the card drops off.
- [ ] **Step 4: Run admin tests + typecheck + build — green.**
- [ ] **Step 5: Commit** — `feat(admin): stop/cancel an active session from the live view`.
---
### Task 6: Worker — poll + reconcile to server truth
**Files:** `apps/worker/src/api/sessions.ts`, `apps/worker/src/screens/Stopwatch.tsx`; test
`apps/worker/src/screens/Stopwatch.test.tsx`.
- [ ] **Step 1: Failing tests** (mock `apiFetch`): with a session running locally, when the
active-sessions query resolves to a list **without** that session, the stopwatch resets to the
idle/start state and shows **"Deze sessie is door de beheerder gestopt."**; a 409 from the stop
mutation resets locally (no error surfaced).
- [ ] **Step 2: Run — fail.**
- [ ] **Step 3: Poll** — in `api/sessions.ts`, add `refetchInterval: 15000` to `useActiveSessions`.
- [ ] **Step 4: Reconcile** — in `Stopwatch.tsx`, extend the active-session effect: when
`activeSessionsQuery.data` is present and the worker has a local `sessionId` that is **not** in
the returned active list, call `resetTimer()` and set a transient `stoppedByAdmin` notice
(cleared on next start). Keep the existing "adopt an active session when idle" recovery.
- [ ] **Step 5: 409 handling** — give `useStopSession`/`useDiscardSession` (or the `handleStop`/
`handleDiscard` callers) an error path: if the error is `ApiError` with `status === 409`, call
`resetTimer()` (it's already closed) instead of leaving the timer stuck.
- [ ] **Step 6: Run worker tests + typecheck + build — green.**
- [ ] **Step 7: Commit** — `feat(worker): poll + reconcile stopwatch to server (admin stop/cancel)`.
---
### Task 7: Docs, lint, verification
**Files:** `docs/roadmap.md`, `docs/sessions/2026-06-17-phase-3b1-manual-sessions.md` (create).
- [ ] **Step 1: Lint/format** — `npx oxlint` clean; `npx oxfmt` on changed files only.
- [ ] **Step 2: Full green** — `yarn workspace @solelog/api typecheck && test`;
`yarn workspace @solelog/admin typecheck && test && build`;
`yarn workspace @solelog/worker typecheck && test && build`.
- [ ] **Step 3: Live smoke (preferred)** — start API, seed; as admin: `GET /api/admin/users`,
`POST /api/admin/sessions` (manual, confirm duration excludes paused + `source=manual`),
`PUT` edit, `stop`/`discard` on a worker's active session; then **kill the server tree + free
port 3000**.
- [ ] **Step 4: Docs** — session log (goal/work/verification/outcome) + a roadmap note (Phase 3b·1
done; reports/export and user management remain).
- [ ] **Step 5: Commit** — `docs: phase 3b·1 manual-sessions session log + roadmap note`.
## Self-Review notes
- Duration is always derived server-side (`computeDuration`) — never accepted from the client.
- `stop`/`discard` literal subpaths vs the bare `:id` PUT don't collide in Hono.
- Worker reconciliation keys off "my local session id is absent from the server's active list" —
covers admin stop, admin discard, and admin edit-to-completed alike.
- No migration: all fields already exist on `work_sessions` (incl. pause fields from the prior
cycle).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,112 @@
# Phase 3b·1 — Manual Session Entry/Edit + Admin Stop/Fix — Design
- **Created:** 2026-06-17
- **Status:** Approved (brainstorming) — ready for implementation plan
- **Tracker:** Plane (workspace `solelog`, project SoleLog)
- **Cycle:** First of three Phase 3b cycles (then reports/export, then user management)
- **Touches:** `packages/shared`, `apps/api`, `apps/admin`, `apps/worker`
## Goal
An admin can find any work session, **create** a manual one for a worker, **edit/correct** any
session, and **stop or cancel** a worker's stuck active session — the "manual fallback wherever
something fails" from the vision. Because the backend is the source of truth, the **worker
converges** to admin changes (no stuck stopwatch).
_Done when:_ an admin can list/filter all sessions, hand-create a completed session for a worker,
edit any session's fields (server recomputes duration), and stop/cancel a worker's active
session — and within ~15s the worker's stopwatch reflects a stop/cancel done by the admin.
## Scope decisions (confirmed during brainstorming, 2026-06-17)
1. **Slicing:** three sequenced 3b cycles; **this one first** (manual entry/edit + admin
stop/fix), then reports/export, then user management.
2. **Sessions UI:** a full **"Sessies"** admin screen (the all-sessions list deferred from 3a
lands here; the reports cycle reuses it).
3. **Editable fields:** start/end time, activity, insole type, pair count, paused seconds,
notes, status. The worker is chosen **only on create** — no reassignment on edit. Duration is
always **derived** server-side (`end start paused`), never typed.
4. **Worker convergence:** folded into this cycle — the worker polls and reconciles to server
truth (an admin stop/cancel reflects on the phone within ~15s; no stuck state).
## A. Backend (under the existing admin-gated `/api/admin/*` guard in `routes/admin.ts`)
- `GET /api/admin/users``{ id, name, email }[]` from the `user` table (ordered by name), to
populate the create form's worker picker. Direct DB query — no better-auth client dependency.
- `POST /api/admin/sessions` — manual create. Body `CreateManualSessionInput`:
`user_id, activity_id, insole_type, pair_count, start_time, end_time, paused_seconds?, notes?`.
Produces a **completed** session, `source='manual'`, `paused_at=null`,
`duration_seconds = max(0, round((endstart)/1000) paused_seconds)`.
- `PUT /api/admin/sessions/:id` — edit any session. Body `AdminUpdateSessionInput`:
`activity_id, insole_type, pair_count, start_time, end_time(nullable), paused_seconds, notes,
status`. Recomputes `duration_seconds` from times paused when `end_time` is present; when
`status='active'`/`end_time` null, `duration_seconds=null`. No user reassignment.
- `POST /api/admin/sessions/:id/stop` — quick "stop now": fold any open pause, `end=now`,
`status='completed'`, recompute duration.
- `POST /api/admin/sessions/:id/discard``status='discarded'`, `end=now`.
New `@solelog/shared` contracts: `CreateManualSessionInput`, `AdminUpdateSessionInput`. **No DB
migration** — reuses existing `work_sessions` columns (incl. the pause fields). Responses use the
existing `toWorkSession` mapper (so they carry `user_name`/`activity_name` where joined).
### Validation
`end ≥ start`; `pair_count ≥ 1`; `paused_seconds ≥ 0` and `≤ (endstart)`; activity must exist;
user must exist (create); `insole_type` a valid `InsoleType`. Invalid → 400; missing
session/user → 404. No hard delete — cancellation is `status='discarded'` (already excluded from
exports).
## B. Admin UI
- **`components/Sidebar.tsx`** — add `{ to: '/sessies', label: 'Sessies' }` to `navItems`; drop
`'Handmatig'` from the muted `soonItems` (now built → leaves `['Rapporten', 'Gebruikers']`).
- **`App.tsx`** — add `<Route path="/sessies" element={<Sessions />} />`.
- **`screens/Sessions.tsx`** — lists all sessions via `useAllSessions` (`GET /api/admin/sessions`,
newest first), a status filter (alle / actief / voltooid / geannuleerd), and a
`+ Nieuwe registratie` button. Each row: worker · activity · type · worked (+ pauze) · date.
**Active** rows show `[Stop]` `[Annuleer]`; **all** rows show ✎ edit.
- **`components/SessionForm.tsx`** — shared create/edit form: worker picker (create only, from
`useAdminUsers`), activity dropdown, insole-type, pair count, start/end datetime-local, paused,
status (edit only), notes, and a live **"gewerkt"** preview. Submits create or update.
- **`api/admin-sessions.ts`** — add `useAllSessions`, `useAdminUsers`, `useCreateManualSession`,
`useUpdateSession`, `useAdminStopSession`, `useAdminDiscardSession` (all invalidate the
`['admin','sessions']` query family; keep the existing `useActiveSessions`).
- **`screens/Live.tsx`** — add `[Stop]` `[Annuleer]` to each `LiveCard`, wired to the admin
stop/discard hooks (invalidate the active query).
## C. Worker convergence (`apps/worker`)
- **`api/sessions.ts`** — give `useActiveSessions` a `refetchInterval: 15000` (poll), plus the
default refetch-on-window-focus.
- **`screens/Stopwatch.tsx`** — extend the active-session effect to **reconcile**: if a session is
running locally (`sessionId` set) but the latest `activeSessionsQuery.data` no longer contains a
matching **active** session for it, the session was stopped/cancelled elsewhere → reset the
timer and surface a brief notice **"Deze sessie is door de beheerder gestopt."** Also: treat a
**409** from the worker's own stop/discard as already-closed → reset locally instead of erroring.
## Error handling
- Worker reconciliation notice is transient (dismissible / auto-clears on next start).
- Admin form: inline validation errors mirror the API 400s (end before start, count < 1).
- Stop/discard on a non-active session → 409; admin UI refetches and the row reflects truth.
## Testing
- **API** (`admin.test.ts`): create computes duration + `source='manual'`; edit recomputes and
rejects `end<start`; stop/discard act on **another** user's session; all 401/403 gated;
`GET /api/admin/users` returns the roster.
- **Admin:** Sessions list renders + status filter; create form posts the right body; edit
prefills + PUTs; Live `Stop`/`Annuleer` fire the right mutations.
- **Worker:** when the active query returns without a locally-running session, the stopwatch
resets + shows the notice; a 409 on stop resets instead of erroring.
## Out of scope (later 3b cycles)
- Aggregated/on-screen **reporting** + all-users filtered CSV (reports cycle).
- Full **user management** UI — create/role/deactivate via `/api/auth/admin/*` (user-mgmt cycle);
this cycle only *reads* the roster (`GET /api/admin/users`) for the picker.
- Real-time push (SSE) — polling is sufficient at this scale.
## Build approach
spec → `writing-plans` → one **Workflow** (~7 TDD tasks), commit per task, final verify. Tracked
as a Plane epic.

View File

@@ -0,0 +1,173 @@
# Phase 3b·2 — Reports + All-Users Export — Design
- **Created:** 2026-06-24
- **Status:** Approved (brainstorming) — ready for implementation plan
- **Tracker:** Plane (workspace `solelog`, project SoleLog)
- **Cycle:** Second of three Phase 3b cycles (after manual-sessions 3b·1, before user management)
- **Touches:** `packages/shared`, `apps/api`, `apps/admin`
## Goal
An admin can open a **Rapporten** screen, pick a period (and optionally narrow by worker /
insole type / activity), and see headline production totals plus three breakdowns — **per
medewerker**, **per handeling**, **per type** — and export the underlying detail rows (all
workers) to CSV. Today's `/api/export` is **self-scoped** to the logged-in user; this cycle adds
the cross-user, filterable reporting + export the admin needs to review a week.
_Done when:_ an admin can choose a date range (with Deze week / Deze maand / Alles presets) and
optional worker/type/activity filters, see correct headline totals and three breakdown tables, and
download a CSV of every matching completed session (all workers, with a Worker column) that
respects the same filters.
## Scope decisions (confirmed during brainstorming, 2026-06-24)
1. **Both lenses on one screen** — a period summary with breakdowns **per worker AND per
activity AND per type** (same underlying query, grouped three ways), plus headline totals.
2. **Filters:** date range (from/to, the spine) + optional worker + optional insole type +
optional activity. Default period on open = **this week** (Montoday); presets Deze week /
Deze maand / Alles.
3. **What counts:** only `status='completed'` sessions contribute to totals and export (active =
still running, no duration; discarded = cancelled). Mirrors the current export.
4. **Metrics (all four):** gewerkte tijd (worked seconds, excl. paused), aantal zolen (sum of
`pair_count`), aantal sessies (count), pauzetijd (paused seconds). Shown both as headline
totals and in every breakdown row.
5. **CSV = detail rows, all workers** — every filtered completed session as a row, like today's
export plus a leading **Worker** column. (Not the aggregated summary — that's visible
on-screen.)
6. **Presentation: tables only** — headline totals card + three breakdown tables, numbers only.
No chart library, no CSS bars — keeps the dependency-light ethos and is fastest to ship.
## A. Backend (under the existing admin-gated `/api/admin/*` guard in `routes/admin.ts`)
Both new endpoints accept the same query params and share one filtered-query helper so report and
export can never drift:
- `from` — ISO instant, inclusive lower bound on `start_time`.
- `to` — ISO instant, inclusive upper bound on `start_time`.
- `user_id?` — restrict to one worker.
- `insole_type?` — one of `Kurk | Berk | 3D`.
- `activity_id?` — restrict to one handeling.
All queries additionally force `status='completed'`. A shared helper
`buildSessionFilters({ from, to, user_id, insole_type, activity_id })` returns the Drizzle `where`
condition array (completed + range + any provided optional filters), used by both endpoints.
### `GET /api/admin/report`
Fetches the filtered rows once (joined to `activities` + `user` for names) and aggregates **in JS**
in a single pass — small data, gives names for free, one code path. Returns `ReportResponse`:
```
range: { from, to } // echoes the requested ISO instants
totals: { worked_seconds, paused_seconds, pairs, sessions }
by_worker: [{ user_id, user_name, worked_seconds, paused_seconds, pairs, sessions }]
by_activity: [{ activity_id, activity_name, worked_seconds, paused_seconds, pairs, sessions }]
by_type: [{ insole_type, worked_seconds, paused_seconds, pairs, sessions }]
```
- `worked_seconds` sums `duration_seconds` (already excludes paused); `paused_seconds` sums
`paused_seconds`; `pairs` sums `pair_count`; `sessions` counts rows.
- Headline `totals` equals the sum across any one breakdown (invariant worth a test).
- Breakdown arrays are sorted by `worked_seconds` descending. Empty range → all-zero `totals` and
empty breakdown arrays.
- A row whose `insole_type` is null is bucketed under a `'Onbekend'`-style key in `by_type`
(defensive — manual edits allow null type). A row missing an activity/user name falls back to a
readable label, never crashes the grouping.
### `GET /api/admin/export`
Same filters; returns CSV detail rows (all workers). Columns:
**Worker**, ID, Task, Insole Type, No. of Insoles, Date, Total Duration, Paused Duration, Start
Time, End Time. `Content-Type: text/csv; charset=utf-8`; `Content-Disposition: attachment;
filename="solelog-report_<from-date>_<to-date>.csv"` (dates as `YYYY-MM-DD`). Ordered by
`start_time` ascending.
### DRY refactor (light, in `lib/csv.ts`)
Extract the row/header builder currently inline in `sessions.ts`'s `/api/export` into a shared
`buildSessionsCsv(rows, { includeWorker })`:
- `includeWorker: false` → existing 9-column format, used by the self-scoped worker export
(`/api/export`) — output byte-identical to today, so existing tests still pass.
- `includeWorker: true` → prepends a `Worker` column, used by `/api/admin/export`.
Reuses the existing `quote` + `formatDuration` helpers. One source of truth for the CSV format.
### Validation / errors
`from`/`to` required and must parse as dates with `to ≥ from` → else 400. `insole_type` (if given)
must be a valid `InsoleType`; `activity_id`/`user_id` (if given) are applied as filters (an
unknown id simply yields an empty result, not an error). Admin guard already returns 401
(no session) / 403 (non-admin) for the whole `/api/admin/*` surface.
## B. Shared contracts (`@solelog/shared`)
Add zod schemas + inferred types:
- `ReportTotals``{ worked_seconds, paused_seconds, pairs, sessions }` (all int).
- `ReportWorkerRow`, `ReportActivityRow`, `ReportTypeRow``ReportTotals` plus the grouping key(s)
(`user_id`+`user_name`; `activity_id`+`activity_name`; `insole_type`).
- `ReportResponse``{ range: { from, to }, totals, by_worker, by_activity, by_type }`.
Query params are validated in the route (not a shared schema). The **client** is responsible for
sending `from`/`to` as ISO instants spanning whole local days (start-of-from-day …
end-of-to-day in the admin's browser tz), so server-side timezone handling is unnecessary.
## C. Admin UI (`apps/admin`)
- **`components/Sidebar.tsx`** — move `'Rapporten'` from `soonItems` into `navItems`
(`{ to: '/rapporten', label: 'Rapporten' }`); `soonItems` becomes `['Gebruikers']`.
- **`App.tsx`** — add `<Route path="/rapporten" element={<Reports />} />`.
- **`screens/Reports.tsx`** — the screen:
- **Filter bar:** from/to `date` inputs; preset buttons **Deze week** (default on mount) /
**Deze maand** / **Alles**; worker `<select>` (from `useAdminUsers`); insole-type `<select>`
(Kurk/Berk/3D); activity `<select>` (from the admin activities hook). Changing any control
updates the filter state → query refetches.
- **Headline card:** "Totaal: {worked} gewerkt · {pairs} zolen · {sessions} sessies ·
{paused} pauze" for the chosen period.
- **Three tables:** Per medewerker / Per handeling / Per type — each row shows the grouping
label + the four metrics (durations via a shared `formatTime`). Empty state per table.
- **Exporteer CSV** button → `downloadExport(filters)`.
- **`api/reports.ts`**:
- `useReport(filters)``useQuery` keyed `['admin','report', filters]`, calls
`GET /api/admin/report?…` via `apiFetch`.
- `downloadExport(filters)` — because the endpoint is bearer-auth'd, do a raw `fetch` with the
`Authorization` header (token from the same place `apiFetch` reads it), read the `Blob`,
create an object URL, click a transient `<a download>`, then revoke the URL. (A plain
`<a href>` can't attach the bearer token.)
- A small `filtersToQuery(filters)` builds the querystring (omits empty optional filters; maps
the local-day date pickers to ISO instants).
- Reuses `useAdminUsers` (built in 3b·1) and the existing admin activities query hook for the
dropdowns. No new shared UI library.
## Error handling
- Report query error → the screen shows "Kon rapport niet laden." (mirrors other admin screens).
- Export failure (non-2xx) → a brief inline notice near the button; no download is triggered.
- Invalid/missing range is prevented client-side (presets always set a valid range; manual inputs
are clamped so `to ≥ from`), and the API still rejects a bad range with 400 as a backstop.
## Testing
- **API** (`admin.test.ts` / a new `report.test.ts`):
- report totals correct and equal to the sum of each breakdown; completed-only (active/discarded
excluded); date-range boundary (a session exactly at `from`/`to` included; just outside
excluded); worker / type / activity filters each narrow correctly; empty range → zeros +
empty arrays; null `insole_type` bucketed, not crashing.
- export returns all-users rows with a leading Worker column; respects the same filters; filename
carries the range; both endpoints 401 (no session) / 403 (non-admin).
- `buildSessionsCsv`: `includeWorker:false` output identical to the pre-refactor worker export
(regression guard); `includeWorker:true` prepends the Worker column.
- **Admin** (vitest + Testing Library):
- Reports renders headline totals + the three tables from a mocked report payload.
- changing a filter / clicking a preset refetches with the expected query params.
- the Exporteer CSV button calls the download helper with the current filters (fetch mocked).
## Out of scope (later cycle / deliberately excluded)
- The **Gebruikers** screen and any user create / role / deactivate (the third 3b cycle).
- Charts / visualization (tables-only chosen).
- Real-time/auto-refresh of the report (it's on-demand per filter; React Query refetch-on-focus is
enough).
- Per-day time-series breakdown — the three chosen breakdowns (worker/activity/type) cover the
MVP; a by-day table can be added later if wanted.
## Build approach
spec → `writing-plans` → one **Workflow** (~67 TDD tasks, commit per task, final verify),
sequential (dependent tasks share one working tree). Tracked as a Plane epic with one child task
per workflow task.

View File

@@ -86,3 +86,61 @@ export const AdminUser = z.object({
created_at: z.string(), created_at: z.string(),
}); });
export type AdminUser = z.infer<typeof AdminUser>; export type AdminUser = z.infer<typeof AdminUser>;
export const CreateManualSessionInput = z.object({
user_id: z.string(),
activity_id: z.number().int(),
insole_type: InsoleType,
pair_count: z.number().int().min(1),
start_time: z.string(),
end_time: z.string(),
paused_seconds: z.number().int().min(0).default(0),
notes: z.string().nullable().optional(),
});
export type CreateManualSessionInput = z.infer<typeof CreateManualSessionInput>;
export const AdminUpdateSessionInput = z.object({
activity_id: z.number().int(),
insole_type: InsoleType.nullable(),
pair_count: z.number().int().min(1),
start_time: z.string(),
end_time: z.string().nullable(),
paused_seconds: z.number().int().min(0),
notes: z.string().nullable(),
status: SessionStatus,
});
export type AdminUpdateSessionInput = z.infer<typeof AdminUpdateSessionInput>;
export const ReportTotals = z.object({
worked_seconds: z.number().int(),
paused_seconds: z.number().int(),
pairs: z.number().int(),
sessions: z.number().int(),
});
export type ReportTotals = z.infer<typeof ReportTotals>;
export const ReportWorkerRow = ReportTotals.extend({
user_id: z.string(),
user_name: z.string(),
});
export type ReportWorkerRow = z.infer<typeof ReportWorkerRow>;
export const ReportActivityRow = ReportTotals.extend({
activity_id: z.number().int(),
activity_name: z.string(),
});
export type ReportActivityRow = z.infer<typeof ReportActivityRow>;
export const ReportTypeRow = ReportTotals.extend({
insole_type: z.string(),
});
export type ReportTypeRow = z.infer<typeof ReportTypeRow>;
export const ReportResponse = z.object({
range: z.object({ from: z.string(), to: z.string() }),
totals: ReportTotals,
by_worker: z.array(ReportWorkerRow),
by_activity: z.array(ReportActivityRow),
by_type: z.array(ReportTypeRow),
});
export type ReportResponse = z.infer<typeof ReportResponse>;