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
This commit is contained in:
Bas van Rossem
2026-06-17 22:44:06 +02:00
parent b5366413b6
commit 69b46bee04
4 changed files with 532 additions and 17 deletions

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

@@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import type { SessionStatus, WorkSession } from '@solelog/shared'; import type { SessionStatus, WorkSession } from '@solelog/shared';
import { useAdminDiscardSession, useAdminStopSession, useAllSessions } from '../api/admin-sessions'; import { useAdminDiscardSession, useAdminStopSession, useAllSessions } from '../api/admin-sessions';
import SessionForm from '../components/SessionForm';
import { formatTime } from '../lib/elapsed'; import { formatTime } from '../lib/elapsed';
type StatusFilter = 'all' | SessionStatus; type StatusFilter = 'all' | SessionStatus;
@@ -43,8 +44,10 @@ export default function Sessions() {
const discardSession = useAdminDiscardSession(); const discardSession = useAdminDiscardSession();
const [filter, setFilter] = useState<StatusFilter>('all'); const [filter, setFilter] = useState<StatusFilter>('all');
// Task 4 replaces this stub with the real create/edit form. // null = closed; { mode } drives the shared create/edit form.
const [formOpen, setFormOpen] = useState(false); const [form, setForm] = useState<
{ mode: 'create' } | { mode: 'edit'; session: WorkSession } | null
>(null);
if (isError) { if (isError) {
return ( return (
@@ -62,7 +65,7 @@ export default function Sessions() {
<div className="screen"> <div className="screen">
<div className="sessions-head"> <div className="sessions-head">
<h1 className="screen-title">Sessies</h1> <h1 className="screen-title">Sessies</h1>
<button type="button" className="btn-primary" onClick={() => setFormOpen(true)}> <button type="button" className="btn-primary" onClick={() => setForm({ mode: 'create' })}>
+ Nieuwe registratie + Nieuwe registratie
</button> </button>
</div> </div>
@@ -84,20 +87,14 @@ export default function Sessions() {
</label> </label>
</div> </div>
{formOpen && ( {form && (
<section className="card" data-testid="session-form"> <div data-testid="session-form">
<h2 className="section-label">Nieuwe registratie</h2> <SessionForm
{/* Task 4 fills in the full create/edit form. */} mode={form.mode}
<label className="sessions-filter"> session={form.mode === 'edit' ? form.session : undefined}
Medewerker onClose={() => setForm(null)}
<select aria-label="Medewerker" disabled> />
<option></option> </div>
</select>
</label>
<button type="button" className="btn-cancel" onClick={() => setFormOpen(false)}>
Annuleren
</button>
</section>
)} )}
{isLoading ? ( {isLoading ? (
@@ -114,6 +111,7 @@ export default function Sessions() {
session={session} session={session}
onStop={() => stopSession.mutate(session.id)} onStop={() => stopSession.mutate(session.id)}
onDiscard={() => discardSession.mutate(session.id)} onDiscard={() => discardSession.mutate(session.id)}
onEdit={() => setForm({ mode: 'edit', session })}
/> />
))} ))}
</ul> </ul>
@@ -126,10 +124,12 @@ function SessionRow({
session, session,
onStop, onStop,
onDiscard, onDiscard,
onEdit,
}: { }: {
session: WorkSession; session: WorkSession;
onStop: () => void; onStop: () => void;
onDiscard: () => void; onDiscard: () => void;
onEdit: () => void;
}) { }) {
const worked = workedSeconds(session); const worked = workedSeconds(session);
const isActive = session.status === 'active'; const isActive = session.status === 'active';
@@ -158,6 +158,7 @@ function SessionRow({
type="button" type="button"
className="icon-btn icon-edit" className="icon-btn icon-edit"
aria-label={`Bewerk sessie van ${session.user_name ?? 'onbekend'}`} aria-label={`Bewerk sessie van ${session.user_name ?? 'onbekend'}`}
onClick={onEdit}
> >
</button> </button>

View File

@@ -577,3 +577,40 @@ body {
border-radius: 12px; border-radius: 12px;
cursor: pointer; 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;
}