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:
198
apps/admin/src/components/SessionForm.test.tsx
Normal file
198
apps/admin/src/components/SessionForm.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
279
apps/admin/src/components/SessionForm.tsx
Normal file
279
apps/admin/src/components/SessionForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user