diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 34fceda..6156417 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -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() { } /> } /> + } /> diff --git a/apps/admin/src/api/admin-sessions.ts b/apps/admin/src/api/admin-sessions.ts index 2dfb022..81adf96 100644 --- a/apps/admin/src/api/admin-sessions.ts +++ b/apps/admin/src/api/admin-sessions.ts @@ -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('/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('/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(`/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(`/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(`/api/admin/sessions/${id}/discard`, { method: 'POST' }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'sessions'] }); + }, + }); +} diff --git a/apps/admin/src/components/Sidebar.tsx b/apps/admin/src/components/Sidebar.tsx index 863c2e2..86b4a52 100644 --- a/apps/admin/src/components/Sidebar.tsx +++ b/apps/admin/src/components/Sidebar.tsx @@ -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(); diff --git a/apps/admin/src/screens/Sessions.test.tsx b/apps/admin/src/screens/Sessions.test.tsx new file mode 100644 index 0000000..6cb0c83 --- /dev/null +++ b/apps/admin/src/screens/Sessions.test.tsx @@ -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 { + 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( + + + , + ); +} + +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(); + }); +}); diff --git a/apps/admin/src/screens/Sessions.tsx b/apps/admin/src/screens/Sessions.tsx new file mode 100644 index 0000000..d249893 --- /dev/null +++ b/apps/admin/src/screens/Sessions.tsx @@ -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 = { + 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('all'); + // Task 4 replaces this stub with the real create/edit form. + const [formOpen, setFormOpen] = useState(false); + + if (isError) { + return ( +
+

Sessies

+

Kon sessies niet laden.

+
+ ); + } + + const sessions = Array.isArray(data) ? data : []; + const visible = filter === 'all' ? sessions : sessions.filter((s) => s.status === filter); + + return ( +
+
+

Sessies

+ +
+ +
+ +
+ + {formOpen && ( +
+

Nieuwe registratie

+ {/* Task 4 fills in the full create/edit form. */} + + +
+ )} + + {isLoading ? ( +

Laden…

+ ) : visible.length === 0 ? ( +

+ {sessions.length === 0 ? 'Nog geen sessies.' : 'Geen sessies in dit filter.'} +

+ ) : ( +
    + {visible.map((session) => ( + stopSession.mutate(session.id)} + onDiscard={() => discardSession.mutate(session.id)} + /> + ))} +
+ )} +
+ ); +} + +function SessionRow({ + session, + onStop, + onDiscard, +}: { + session: WorkSession; + onStop: () => void; + onDiscard: () => void; +}) { + const worked = workedSeconds(session); + const isActive = session.status === 'active'; + + return ( +
  • +
    {session.user_name ?? 'Onbekend'}
    +
    + {session.activity_name ?? 'Onbekende handeling'} + {session.insole_type && {session.insole_type}} +
    +
    + {formatTime(worked)} + {session.paused_seconds > 0 && ( + Pauze {formatTime(session.paused_seconds)} + )} +
    +
    {formatDate(session.start_time)}
    +
    + + {STATUS_LABEL[session.status]} + +
    +
    + + {isActive && ( + <> + + + + )} +
    +
  • + ); +} diff --git a/apps/admin/src/styles.css b/apps/admin/src/styles.css index 8b6e3da..d461bc9 100644 --- a/apps/admin/src/styles.css +++ b/apps/admin/src/styles.css @@ -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; +}