feat(admin): sessions management screen (list + filter + actions)

This commit is contained in:
Bas van Rossem
2026-06-17 22:38:28 +02:00
parent 53c66ee144
commit b5366413b6
6 changed files with 605 additions and 3 deletions

View File

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

View File

@@ -1,5 +1,9 @@
import { useQuery } from '@tanstack/react-query';
import type { WorkSession } from '@solelog/shared';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type {
AdminUpdateSessionInput,
CreateManualSessionInput,
WorkSession,
} from '@solelog/shared';
import { apiFetch } from '../lib/api';
// Active work sessions across all workers (admin cross-user view).
@@ -11,3 +15,69 @@ export function useActiveSessions() {
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

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

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,177 @@
import { useState } from 'react';
import type { SessionStatus, WorkSession } from '@solelog/shared';
import { useAdminDiscardSession, useAdminStopSession, useAllSessions } from '../api/admin-sessions';
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');
// Task 4 replaces this stub with the real create/edit form.
const [formOpen, setFormOpen] = useState(false);
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={() => setFormOpen(true)}>
+ 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>
{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>
)}
{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)}
/>
))}
</ul>
)}
</div>
);
}
function SessionRow({
session,
onStop,
onDiscard,
}: {
session: WorkSession;
onStop: () => void;
onDiscard: () => 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'}`}
>
</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,160 @@ body {
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
/* ---- 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;
}