diff --git a/apps/admin/src/components/SessionForm.test.tsx b/apps/admin/src/components/SessionForm.test.tsx new file mode 100644 index 0000000..6fe987c --- /dev/null +++ b/apps/admin/src/components/SessionForm.test.tsx @@ -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 { + 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[0]) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +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(); + }); +}); diff --git a/apps/admin/src/components/SessionForm.tsx b/apps/admin/src/components/SessionForm.tsx new file mode 100644 index 0000000..03792c4 --- /dev/null +++ b/apps/admin/src/components/SessionForm.tsx @@ -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(session?.activity_id ?? ''); + const [insoleType, setInsoleType] = useState(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(session?.status ?? 'completed'); + const [notes, setNotes] = useState(session?.notes ?? ''); + const [error, setError] = useState(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 ( +
+

+ {mode === 'create' ? 'Nieuwe registratie' : 'Sessie bewerken'} +

+ + {mode === 'create' && ( + + )} + + + + + + + + + + + + + + {mode === 'edit' && ( + + )} + +