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 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;
|
||||
@@ -43,8 +44,10 @@ export default function Sessions() {
|
||||
const discardSession = useAdminDiscardSession();
|
||||
|
||||
const [filter, setFilter] = useState<StatusFilter>('all');
|
||||
// Task 4 replaces this stub with the real create/edit form.
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
// null = closed; { mode } drives the shared create/edit form.
|
||||
const [form, setForm] = useState<
|
||||
{ mode: 'create' } | { mode: 'edit'; session: WorkSession } | null
|
||||
>(null);
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
@@ -62,7 +65,7 @@ export default function Sessions() {
|
||||
<div className="screen">
|
||||
<div className="sessions-head">
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
@@ -84,20 +87,14 @@ export default function Sessions() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{formOpen && (
|
||||
<section className="card" data-testid="session-form">
|
||||
<h2 className="section-label">Nieuwe registratie</h2>
|
||||
{/* Task 4 fills in the full create/edit form. */}
|
||||
<label className="sessions-filter">
|
||||
Medewerker
|
||||
<select aria-label="Medewerker" disabled>
|
||||
<option>—</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" className="btn-cancel" onClick={() => setFormOpen(false)}>
|
||||
Annuleren
|
||||
</button>
|
||||
</section>
|
||||
{form && (
|
||||
<div data-testid="session-form">
|
||||
<SessionForm
|
||||
mode={form.mode}
|
||||
session={form.mode === 'edit' ? form.session : undefined}
|
||||
onClose={() => setForm(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
@@ -114,6 +111,7 @@ export default function Sessions() {
|
||||
session={session}
|
||||
onStop={() => stopSession.mutate(session.id)}
|
||||
onDiscard={() => discardSession.mutate(session.id)}
|
||||
onEdit={() => setForm({ mode: 'edit', session })}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
@@ -126,10 +124,12 @@ function SessionRow({
|
||||
session,
|
||||
onStop,
|
||||
onDiscard,
|
||||
onEdit,
|
||||
}: {
|
||||
session: WorkSession;
|
||||
onStop: () => void;
|
||||
onDiscard: () => void;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
const worked = workedSeconds(session);
|
||||
const isActive = session.status === 'active';
|
||||
@@ -158,6 +158,7 @@ function SessionRow({
|
||||
type="button"
|
||||
className="icon-btn icon-edit"
|
||||
aria-label={`Bewerk sessie van ${session.user_name ?? 'onbekend'}`}
|
||||
onClick={onEdit}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
|
||||
@@ -577,3 +577,40 @@ body {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user