Compare commits
17 Commits
70ac27ec8e
...
bbf29120ac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbf29120ac | ||
|
|
8d75be0462 | ||
|
|
8ad2e69ec3 | ||
|
|
d33fcb7cce | ||
|
|
4b213e25a8 | ||
|
|
01fa18f401 | ||
|
|
eac1eed71a | ||
|
|
3eccdef313 | ||
|
|
84677080fc | ||
|
|
7d3daaa760 | ||
|
|
f1ec249ee7 | ||
|
|
69b46bee04 | ||
|
|
b5366413b6 | ||
|
|
53c66ee144 | ||
|
|
7d1e816fe2 | ||
|
|
00993c67da | ||
|
|
9d21576014 |
@@ -4,6 +4,8 @@ import Login from './screens/Login';
|
|||||||
import Sidebar from './components/Sidebar';
|
import Sidebar from './components/Sidebar';
|
||||||
import Live from './screens/Live';
|
import Live from './screens/Live';
|
||||||
import Activities from './screens/Activities';
|
import Activities from './screens/Activities';
|
||||||
|
import Sessions from './screens/Sessions';
|
||||||
|
import Reports from './screens/Reports';
|
||||||
|
|
||||||
function AuthedShell() {
|
function AuthedShell() {
|
||||||
return (
|
return (
|
||||||
@@ -14,6 +16,8 @@ function AuthedShell() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Live />} />
|
<Route path="/" element={<Live />} />
|
||||||
<Route path="/handelingen" element={<Activities />} />
|
<Route path="/handelingen" element={<Activities />} />
|
||||||
|
<Route path="/sessies" element={<Sessions />} />
|
||||||
|
<Route path="/rapporten" element={<Reports />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import type { WorkSession } from '@solelog/shared';
|
import type {
|
||||||
|
AdminUpdateSessionInput,
|
||||||
|
CreateManualSessionInput,
|
||||||
|
WorkSession,
|
||||||
|
} from '@solelog/shared';
|
||||||
import { apiFetch } from '../lib/api';
|
import { apiFetch } from '../lib/api';
|
||||||
|
|
||||||
// Active work sessions across all workers (admin cross-user view).
|
// Active work sessions across all workers (admin cross-user view).
|
||||||
@@ -11,3 +15,69 @@ export function useActiveSessions() {
|
|||||||
refetchInterval: 5000,
|
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'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
67
apps/admin/src/api/reports.test.ts
Normal file
67
apps/admin/src/api/reports.test.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { filtersToQuery, downloadExport, type ReportFilters } from './reports';
|
||||||
|
|
||||||
|
vi.mock('../lib/auth-storage', () => ({ getToken: () => 'tok-123' }));
|
||||||
|
|
||||||
|
const FILTERS: ReportFilters = {
|
||||||
|
from: '2026-06-15T00:00:00.000Z',
|
||||||
|
to: '2026-06-21T22:00:00.000Z',
|
||||||
|
userId: 'u1',
|
||||||
|
insoleType: 'Kurk',
|
||||||
|
activityId: 7,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('filtersToQuery', () => {
|
||||||
|
it('serializes all params', () => {
|
||||||
|
const q = new URLSearchParams(filtersToQuery(FILTERS));
|
||||||
|
expect(q.get('from')).toBe(FILTERS.from);
|
||||||
|
expect(q.get('to')).toBe(FILTERS.to);
|
||||||
|
expect(q.get('user_id')).toBe('u1');
|
||||||
|
expect(q.get('insole_type')).toBe('Kurk');
|
||||||
|
expect(q.get('activity_id')).toBe('7');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits empty optional filters', () => {
|
||||||
|
const q = new URLSearchParams(filtersToQuery({ from: 'a', to: 'b' }));
|
||||||
|
expect(q.has('user_id')).toBe(false);
|
||||||
|
expect(q.has('insole_type')).toBe(false);
|
||||||
|
expect(q.has('activity_id')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('downloadExport', () => {
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
it('fetches with the bearer token and triggers a download', async () => {
|
||||||
|
const blob = new Blob(['csv'], { type: 'text/csv' });
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
blob: () => Promise.resolve(blob),
|
||||||
|
headers: new Headers({
|
||||||
|
'content-disposition': 'attachment; filename="solelog-report_2026-06-15_2026-06-21.csv"',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
const createUrl = vi.fn(() => 'blob:x');
|
||||||
|
const revokeUrl = vi.fn();
|
||||||
|
vi.stubGlobal('URL', { ...URL, createObjectURL: createUrl, revokeObjectURL: revokeUrl });
|
||||||
|
const click = vi.fn();
|
||||||
|
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(click);
|
||||||
|
|
||||||
|
await downloadExport(FILTERS);
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [url, init] = fetchMock.mock.calls[0];
|
||||||
|
expect(url).toContain('/api/admin/export?');
|
||||||
|
expect(url).toContain('insole_type=Kurk');
|
||||||
|
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer tok-123');
|
||||||
|
expect(createUrl).toHaveBeenCalledWith(blob);
|
||||||
|
expect(click).toHaveBeenCalledTimes(1);
|
||||||
|
expect(revokeUrl).toHaveBeenCalledWith('blob:x');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on a non-ok response', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }));
|
||||||
|
await expect(downloadExport(FILTERS)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
55
apps/admin/src/api/reports.ts
Normal file
55
apps/admin/src/api/reports.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import type { ReportResponse } from '@solelog/shared';
|
||||||
|
import { API_URL, apiFetch } from '../lib/api';
|
||||||
|
import { getToken } from '../lib/auth-storage';
|
||||||
|
|
||||||
|
export interface ReportFilters {
|
||||||
|
from: string; // ISO instant (start of from-day, local tz)
|
||||||
|
to: string; // ISO instant (end of to-day, local tz)
|
||||||
|
userId?: string;
|
||||||
|
insoleType?: string;
|
||||||
|
activityId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the querystring; omit empty optional filters.
|
||||||
|
export function filtersToQuery(f: ReportFilters): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('from', f.from);
|
||||||
|
params.set('to', f.to);
|
||||||
|
if (f.userId) params.set('user_id', f.userId);
|
||||||
|
if (f.insoleType) params.set('insole_type', f.insoleType);
|
||||||
|
if (f.activityId !== undefined) params.set('activity_id', String(f.activityId));
|
||||||
|
return params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReport(filters: ReportFilters) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['admin', 'report', filters],
|
||||||
|
queryFn: () => apiFetch<ReportResponse>(`/api/admin/report?${filtersToQuery(filters)}`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function filenameFromResponse(res: Response): string {
|
||||||
|
const cd = res.headers.get('content-disposition');
|
||||||
|
const m = cd?.match(/filename="(.+?)"/);
|
||||||
|
return m ? m[1] : 'solelog-report.csv';
|
||||||
|
}
|
||||||
|
|
||||||
|
// The export endpoint is bearer-auth'd, so a plain <a href> can't carry the token:
|
||||||
|
// fetch with the Authorization header, then download the resulting Blob.
|
||||||
|
export async function downloadExport(filters: ReportFilters): Promise<void> {
|
||||||
|
const token = getToken();
|
||||||
|
const res = await fetch(`${API_URL}/api/admin/export?${filtersToQuery(filters)}`, {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Export mislukt: ${res.status}`);
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filenameFromResponse(res);
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,10 +5,12 @@ import { useMe } from '../api/me';
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/', label: 'Live' },
|
{ to: '/', label: 'Live' },
|
||||||
{ to: '/handelingen', label: 'Handelingen' },
|
{ to: '/handelingen', label: 'Handelingen' },
|
||||||
|
{ to: '/sessies', label: 'Sessies' },
|
||||||
|
{ to: '/rapporten', label: 'Rapporten' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// Sections planned for Phase 3b — shown muted/disabled as a hint of what's coming.
|
// Sections planned for the final Phase 3b cycle — shown muted/disabled.
|
||||||
const soonItems = ['Rapporten', 'Gebruikers', 'Handmatig'] as const;
|
const soonItems = ['Gebruikers'] as const;
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
const { signOut } = useAuth();
|
const { signOut } = useAuth();
|
||||||
|
|||||||
28
apps/admin/src/lib/date-range.test.ts
Normal file
28
apps/admin/src/lib/date-range.test.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { dayStartISO, dayEndISO, thisWeek, thisMonth } from './date-range';
|
||||||
|
|
||||||
|
describe('date-range helpers', () => {
|
||||||
|
it('dayStartISO/dayEndISO bound a local day', () => {
|
||||||
|
const start = new Date(dayStartISO('2026-06-17'));
|
||||||
|
const end = new Date(dayEndISO('2026-06-17'));
|
||||||
|
expect(start.getHours()).toBe(0);
|
||||||
|
expect(start.getMinutes()).toBe(0);
|
||||||
|
expect(end.getHours()).toBe(23);
|
||||||
|
expect(end.getMinutes()).toBe(59);
|
||||||
|
expect(end.getTime()).toBeGreaterThan(start.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('thisWeek returns Monday..today (from/to are YYYY-MM-DD)', () => {
|
||||||
|
const ref = new Date('2026-06-17T12:00:00'); // a Wednesday
|
||||||
|
const { from, to } = thisWeek(ref);
|
||||||
|
expect(from).toBe('2026-06-15'); // Monday
|
||||||
|
expect(to).toBe('2026-06-17');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('thisMonth returns the 1st..today', () => {
|
||||||
|
const ref = new Date('2026-06-17T12:00:00');
|
||||||
|
const { from, to } = thisMonth(ref);
|
||||||
|
expect(from).toBe('2026-06-01');
|
||||||
|
expect(to).toBe('2026-06-17');
|
||||||
|
});
|
||||||
|
});
|
||||||
40
apps/admin/src/lib/date-range.ts
Normal file
40
apps/admin/src/lib/date-range.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// Helpers for the report filter bar. Dates are 'YYYY-MM-DD' (what <input type="date"> uses);
|
||||||
|
// the *ISO instants* sent to the API are derived in the admin's local timezone so a picked
|
||||||
|
// day means that whole local day.
|
||||||
|
|
||||||
|
function pad(n: number): string {
|
||||||
|
return String(n).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toDateStr(d: Date): string {
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start of the given local day, as an ISO instant.
|
||||||
|
export function dayStartISO(dateStr: string): string {
|
||||||
|
const [y, m, d] = dateStr.split('-').map(Number);
|
||||||
|
return new Date(y, m - 1, d, 0, 0, 0, 0).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// End of the given local day, as an ISO instant.
|
||||||
|
export function dayEndISO(dateStr: string): string {
|
||||||
|
const [y, m, d] = dateStr.split('-').map(Number);
|
||||||
|
return new Date(y, m - 1, d, 23, 59, 59, 999).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function thisWeek(ref: Date): { from: string; to: string } {
|
||||||
|
const day = ref.getDay(); // 0=Sun..6=Sat
|
||||||
|
const diffToMonday = (day + 6) % 7;
|
||||||
|
const monday = new Date(ref.getFullYear(), ref.getMonth(), ref.getDate() - diffToMonday);
|
||||||
|
return { from: toDateStr(monday), to: toDateStr(ref) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function thisMonth(ref: Date): { from: string; to: string } {
|
||||||
|
const first = new Date(ref.getFullYear(), ref.getMonth(), 1);
|
||||||
|
return { from: toDateStr(first), to: toDateStr(ref) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'Alles' — a wide window from epoch-ish to today.
|
||||||
|
export function allTime(ref: Date): { from: string; to: string } {
|
||||||
|
return { from: '2000-01-01', to: toDateStr(ref) };
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import type { WorkSession } from '@solelog/shared';
|
import type { WorkSession } from '@solelog/shared';
|
||||||
@@ -120,6 +121,58 @@ describe('Live', () => {
|
|||||||
expect(await screen.findByText('Pauze 00:02:05')).toBeInTheDocument();
|
expect(await screen.findByText('Pauze 00:02:05')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows Stop and Annuleer buttons on an active card', async () => {
|
||||||
|
mockApiFetch.mockResolvedValue([makeSession({ id: 7, user_name: 'Jan' })]);
|
||||||
|
|
||||||
|
renderLive();
|
||||||
|
|
||||||
|
expect(await screen.findByText('Jan')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Stop' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Annuleer' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking Stop calls the stop endpoint for that session', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
mockApiFetch.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/admin/sessions/active') {
|
||||||
|
return Promise.resolve([makeSession({ id: 7, user_name: 'Jan' })]);
|
||||||
|
}
|
||||||
|
return Promise.resolve({});
|
||||||
|
});
|
||||||
|
|
||||||
|
renderLive();
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Stop' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockApiFetch).toHaveBeenCalledWith(
|
||||||
|
'/api/admin/sessions/7/stop',
|
||||||
|
expect.objectContaining({ method: 'POST' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking Annuleer calls the discard endpoint for that session', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
mockApiFetch.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/admin/sessions/active') {
|
||||||
|
return Promise.resolve([makeSession({ id: 7, user_name: 'Jan' })]);
|
||||||
|
}
|
||||||
|
return Promise.resolve({});
|
||||||
|
});
|
||||||
|
|
||||||
|
renderLive();
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Annuleer' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockApiFetch).toHaveBeenCalledWith(
|
||||||
|
'/api/admin/sessions/7/discard',
|
||||||
|
expect.objectContaining({ method: 'POST' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps the timer counting (no Gepauzeerd badge) when not paused', async () => {
|
it('keeps the timer counting (no Gepauzeerd badge) when not paused', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
mockApiFetch.mockResolvedValue([
|
mockApiFetch.mockResolvedValue([
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import type { WorkSession } from '@solelog/shared';
|
import type { WorkSession } from '@solelog/shared';
|
||||||
import { useActiveSessions } from '../api/admin-sessions';
|
import {
|
||||||
|
useActiveSessions,
|
||||||
|
useAdminDiscardSession,
|
||||||
|
useAdminStopSession,
|
||||||
|
} from '../api/admin-sessions';
|
||||||
import { formatTime } from '../lib/elapsed';
|
import { formatTime } from '../lib/elapsed';
|
||||||
|
|
||||||
export default function Live() {
|
export default function Live() {
|
||||||
@@ -56,6 +60,14 @@ function LiveCard({ session, now }: { session: WorkSession; now: number }) {
|
|||||||
0,
|
0,
|
||||||
Math.floor((base - Date.parse(session.start_time)) / 1000) - session.paused_seconds,
|
Math.floor((base - Date.parse(session.start_time)) / 1000) - session.paused_seconds,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Stop closes the session (server computes the duration); Annuleer discards it.
|
||||||
|
// Both hooks invalidate ['admin','sessions'], so the active query refetches and the
|
||||||
|
// card drops off the Live grid once the session is no longer active.
|
||||||
|
const stopSession = useAdminStopSession();
|
||||||
|
const discardSession = useAdminDiscardSession();
|
||||||
|
const busy = stopSession.isPending || discardSession.isPending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="live-card">
|
<article className="live-card">
|
||||||
<div className="live-card-head">
|
<div className="live-card-head">
|
||||||
@@ -69,6 +81,24 @@ function LiveCard({ session, now }: { session: WorkSession; now: number }) {
|
|||||||
{session.paused_seconds > 0 && (
|
{session.paused_seconds > 0 && (
|
||||||
<span className="live-paused-total">Pauze {formatTime(session.paused_seconds)}</span>
|
<span className="live-paused-total">Pauze {formatTime(session.paused_seconds)}</span>
|
||||||
)}
|
)}
|
||||||
|
<div className="live-card-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-row-stop"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => stopSession.mutate(session.id)}
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-row-cancel"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => discardSession.mutate(session.id)}
|
||||||
|
>
|
||||||
|
Annuleer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
130
apps/admin/src/screens/Reports.test.tsx
Normal file
130
apps/admin/src/screens/Reports.test.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
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 { ReportResponse } from '@solelog/shared';
|
||||||
|
import Reports from './Reports';
|
||||||
|
import { apiFetch } from '../lib/api';
|
||||||
|
import * as reportsApi from '../api/reports';
|
||||||
|
|
||||||
|
vi.mock('../lib/api', () => ({ apiFetch: vi.fn(), API_URL: 'http://test' }));
|
||||||
|
const mockApiFetch = vi.mocked(apiFetch);
|
||||||
|
|
||||||
|
const REPORT: ReportResponse = {
|
||||||
|
range: { from: '2026-06-15T00:00:00.000Z', to: '2026-06-17T21:59:59.999Z' },
|
||||||
|
totals: { worked_seconds: 7800, paused_seconds: 600, pairs: 84, sessions: 36 },
|
||||||
|
by_worker: [
|
||||||
|
{
|
||||||
|
user_id: 'u1',
|
||||||
|
user_name: 'Jan',
|
||||||
|
worked_seconds: 4500,
|
||||||
|
paused_seconds: 300,
|
||||||
|
pairs: 48,
|
||||||
|
sessions: 21,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
user_id: 'u2',
|
||||||
|
user_name: 'An',
|
||||||
|
worked_seconds: 3300,
|
||||||
|
paused_seconds: 300,
|
||||||
|
pairs: 36,
|
||||||
|
sessions: 15,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
by_activity: [
|
||||||
|
{
|
||||||
|
activity_id: 1,
|
||||||
|
activity_name: 'Frezen',
|
||||||
|
worked_seconds: 4900,
|
||||||
|
paused_seconds: 200,
|
||||||
|
pairs: 40,
|
||||||
|
sessions: 18,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
activity_id: 2,
|
||||||
|
activity_name: 'Lijmen',
|
||||||
|
worked_seconds: 2900,
|
||||||
|
paused_seconds: 400,
|
||||||
|
pairs: 44,
|
||||||
|
sessions: 18,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
by_type: [
|
||||||
|
{ insole_type: 'Kurk', worked_seconds: 5000, paused_seconds: 300, pairs: 50, sessions: 20 },
|
||||||
|
{ insole_type: 'Berk', worked_seconds: 2800, paused_seconds: 300, pairs: 34, sessions: 16 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function mockEndpoints() {
|
||||||
|
mockApiFetch.mockImplementation((path?: string) => {
|
||||||
|
if (path?.startsWith('/api/admin/report')) return Promise.resolve(REPORT as never);
|
||||||
|
if (path === '/api/admin/users')
|
||||||
|
return Promise.resolve([{ id: 'u1', name: 'Jan', email: 'jan@x' }] as never);
|
||||||
|
if (path === '/api/activities') return Promise.resolve([] as never);
|
||||||
|
return Promise.resolve([] as never);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderReports() {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Reports />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Reports', () => {
|
||||||
|
beforeEach(() => mockApiFetch.mockReset());
|
||||||
|
afterEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it('renders headline totals and the three breakdown tables', async () => {
|
||||||
|
mockEndpoints();
|
||||||
|
renderReports();
|
||||||
|
|
||||||
|
// Headline: 7800s = 02:10:00 worked, 84 zolen, 36 sessies.
|
||||||
|
const headline = await screen.findByText(/02:10:00/);
|
||||||
|
const totals = headline.closest('.reports-totals') as HTMLElement;
|
||||||
|
expect(totals).toHaveTextContent(/84/);
|
||||||
|
expect(totals).toHaveTextContent(/36/);
|
||||||
|
|
||||||
|
expect(screen.getByText('Per medewerker')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Per handeling')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Per type')).toBeInTheDocument();
|
||||||
|
// Names are scoped to table cells: 'Jan' / 'Kurk' also appear as dropdown options.
|
||||||
|
expect(screen.getByRole('cell', { name: 'Jan' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('cell', { name: 'Frezen' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('cell', { name: 'Kurk' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refetches with new params when a preset is clicked', async () => {
|
||||||
|
mockEndpoints();
|
||||||
|
renderReports();
|
||||||
|
await screen.findByRole('cell', { name: 'Jan' });
|
||||||
|
|
||||||
|
const callsBefore = mockApiFetch.mock.calls.filter((c) =>
|
||||||
|
String(c[0]).startsWith('/api/admin/report'),
|
||||||
|
).length;
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Deze maand' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const reportCalls = mockApiFetch.mock.calls.filter((c) =>
|
||||||
|
String(c[0]).startsWith('/api/admin/report'),
|
||||||
|
);
|
||||||
|
expect(reportCalls.length).toBeGreaterThan(callsBefore);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls downloadExport with the current filters when Exporteer CSV is clicked', async () => {
|
||||||
|
mockEndpoints();
|
||||||
|
const spy = vi.spyOn(reportsApi, 'downloadExport').mockResolvedValue();
|
||||||
|
renderReports();
|
||||||
|
await screen.findByRole('cell', { name: 'Jan' });
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Exporteer CSV' }));
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
const arg = spy.mock.calls[0][0];
|
||||||
|
expect(arg.from).toBeTruthy();
|
||||||
|
expect(arg.to).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
211
apps/admin/src/screens/Reports.tsx
Normal file
211
apps/admin/src/screens/Reports.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import type { InsoleType, ReportTotals } from '@solelog/shared';
|
||||||
|
import { useReport, type ReportFilters } from '../api/reports';
|
||||||
|
import * as reportsApi from '../api/reports';
|
||||||
|
import { useAdminUsers } from '../api/admin-sessions';
|
||||||
|
import { useActivities } from '../api/activities';
|
||||||
|
import { formatTime } from '../lib/elapsed';
|
||||||
|
import { allTime, dayEndISO, dayStartISO, thisMonth, thisWeek, toDateStr } from '../lib/date-range';
|
||||||
|
|
||||||
|
type DateRange = { from: string; to: string }; // YYYY-MM-DD
|
||||||
|
|
||||||
|
const TYPES: InsoleType[] = ['Kurk', 'Berk', '3D'];
|
||||||
|
|
||||||
|
export default function Reports() {
|
||||||
|
const [range, setRange] = useState<DateRange>(() => thisWeek(new Date()));
|
||||||
|
const [userId, setUserId] = useState('');
|
||||||
|
const [insoleType, setInsoleType] = useState('');
|
||||||
|
const [activityId, setActivityId] = useState('');
|
||||||
|
|
||||||
|
const filters: ReportFilters = useMemo(
|
||||||
|
() => ({
|
||||||
|
from: dayStartISO(range.from),
|
||||||
|
to: dayEndISO(range.to),
|
||||||
|
userId: userId || undefined,
|
||||||
|
insoleType: insoleType || undefined,
|
||||||
|
activityId: activityId ? Number(activityId) : undefined,
|
||||||
|
}),
|
||||||
|
[range, userId, insoleType, activityId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const reportQuery = useReport(filters);
|
||||||
|
const usersQuery = useAdminUsers();
|
||||||
|
const activitiesQuery = useActivities();
|
||||||
|
const [exportError, setExportError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function onExport() {
|
||||||
|
setExportError(null);
|
||||||
|
try {
|
||||||
|
await reportsApi.downloadExport(filters);
|
||||||
|
} catch {
|
||||||
|
setExportError('Export mislukt. Probeer opnieuw.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = reportQuery.data?.totals;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="screen">
|
||||||
|
<div className="reports-head">
|
||||||
|
<h1 className="screen-title">Rapporten</h1>
|
||||||
|
<button type="button" className="btn-primary" onClick={onExport}>
|
||||||
|
Exporteer CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="reports-filters">
|
||||||
|
<div className="reports-presets">
|
||||||
|
<button type="button" onClick={() => setRange(thisWeek(new Date()))}>
|
||||||
|
Deze week
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setRange(thisMonth(new Date()))}>
|
||||||
|
Deze maand
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setRange(allTime(new Date()))}>
|
||||||
|
Alles
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
Van
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={range.from}
|
||||||
|
max={range.to}
|
||||||
|
onChange={(e) => setRange((r) => ({ ...r, from: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Tot
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={range.to}
|
||||||
|
min={range.from}
|
||||||
|
max={toDateStr(new Date())}
|
||||||
|
onChange={(e) => setRange((r) => ({ ...r, to: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Medewerker
|
||||||
|
<select value={userId} onChange={(e) => setUserId(e.target.value)}>
|
||||||
|
<option value="">Alle</option>
|
||||||
|
{(usersQuery.data ?? []).map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Type
|
||||||
|
<select value={insoleType} onChange={(e) => setInsoleType(e.target.value)}>
|
||||||
|
<option value="">Alle</option>
|
||||||
|
{TYPES.map((ty) => (
|
||||||
|
<option key={ty} value={ty}>
|
||||||
|
{ty}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Handeling
|
||||||
|
<select value={activityId} onChange={(e) => setActivityId(e.target.value)}>
|
||||||
|
<option value="">Alle</option>
|
||||||
|
{(activitiesQuery.data ?? []).map((a) => (
|
||||||
|
<option key={a.id} value={String(a.id)}>
|
||||||
|
{a.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{exportError && <p className="form-error">{exportError}</p>}
|
||||||
|
|
||||||
|
{reportQuery.isLoading ? (
|
||||||
|
<p className="muted">Laden…</p>
|
||||||
|
) : reportQuery.isError ? (
|
||||||
|
<p className="muted">Kon rapport niet laden.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="reports-totals">
|
||||||
|
<strong>{formatTime(t?.worked_seconds ?? 0)}</strong> gewerkt ·{' '}
|
||||||
|
<strong>{t?.pairs ?? 0}</strong> zolen · <strong>{t?.sessions ?? 0}</strong> sessies ·{' '}
|
||||||
|
<strong>{formatTime(t?.paused_seconds ?? 0)}</strong> pauze
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BreakdownTable
|
||||||
|
title="Per medewerker"
|
||||||
|
label="Medewerker"
|
||||||
|
rows={(reportQuery.data?.by_worker ?? []).map((r) => ({
|
||||||
|
key: r.user_id,
|
||||||
|
name: r.user_name,
|
||||||
|
...r,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<BreakdownTable
|
||||||
|
title="Per handeling"
|
||||||
|
label="Handeling"
|
||||||
|
rows={(reportQuery.data?.by_activity ?? []).map((r) => ({
|
||||||
|
key: String(r.activity_id),
|
||||||
|
name: r.activity_name,
|
||||||
|
...r,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<BreakdownTable
|
||||||
|
title="Per type"
|
||||||
|
label="Type"
|
||||||
|
rows={(reportQuery.data?.by_type ?? []).map((r) => ({
|
||||||
|
key: r.insole_type,
|
||||||
|
name: r.insole_type,
|
||||||
|
...r,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type BreakdownRow = ReportTotals & { key: string; name: string };
|
||||||
|
|
||||||
|
function BreakdownTable({
|
||||||
|
title,
|
||||||
|
label,
|
||||||
|
rows,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
label: string;
|
||||||
|
rows: BreakdownRow[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="reports-section">
|
||||||
|
<h2 className="reports-section-title">{title}</h2>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<p className="muted">Geen gegevens.</p>
|
||||||
|
) : (
|
||||||
|
<table className="reports-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{label}</th>
|
||||||
|
<th>Gewerkt</th>
|
||||||
|
<th>Zolen</th>
|
||||||
|
<th>Sessies</th>
|
||||||
|
<th>Pauze</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr key={r.key}>
|
||||||
|
<td>{r.name}</td>
|
||||||
|
<td>{formatTime(r.worked_seconds)}</td>
|
||||||
|
<td>{r.pairs}</td>
|
||||||
|
<td>{r.sessions}</td>
|
||||||
|
<td>{formatTime(r.paused_seconds)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
195
apps/admin/src/screens/Sessions.test.tsx
Normal file
195
apps/admin/src/screens/Sessions.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
178
apps/admin/src/screens/Sessions.tsx
Normal file
178
apps/admin/src/screens/Sessions.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
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');
|
||||||
|
// null = closed; { mode } drives the shared create/edit form.
|
||||||
|
const [form, setForm] = useState<
|
||||||
|
{ mode: 'create' } | { mode: 'edit'; session: WorkSession } | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
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={() => setForm({ mode: 'create' })}>
|
||||||
|
+ 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>
|
||||||
|
|
||||||
|
{form && (
|
||||||
|
<div data-testid="session-form">
|
||||||
|
<SessionForm
|
||||||
|
mode={form.mode}
|
||||||
|
session={form.mode === 'edit' ? form.session : undefined}
|
||||||
|
onClose={() => setForm(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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)}
|
||||||
|
onEdit={() => setForm({ mode: 'edit', session })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionRow({
|
||||||
|
session,
|
||||||
|
onStop,
|
||||||
|
onDiscard,
|
||||||
|
onEdit,
|
||||||
|
}: {
|
||||||
|
session: WorkSession;
|
||||||
|
onStop: () => void;
|
||||||
|
onDiscard: () => void;
|
||||||
|
onEdit: () => 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'}`}
|
||||||
|
onClick={onEdit}
|
||||||
|
>
|
||||||
|
✎
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -420,3 +420,254 @@ body {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.live-card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reports-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.reports-filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
.reports-filters label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.reports-presets {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.reports-totals {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--surface, #f4f4f5);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.reports-section {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.reports-section-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.reports-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.reports-table th,
|
||||||
|
.reports-table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border-bottom: 1px solid var(--border, #e4e4e7);
|
||||||
|
}
|
||||||
|
.reports-table td:not(:first-child),
|
||||||
|
.reports-table th:not(:first-child) {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,3 +11,62 @@ export function formatDuration(totalSeconds: number): string {
|
|||||||
const sec = s % 60;
|
const sec = s % 60;
|
||||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One completed session, flattened for CSV. Times accept Date | number | string.
|
||||||
|
export interface SessionCsvRow {
|
||||||
|
id: number;
|
||||||
|
activityName: string | null;
|
||||||
|
userName?: string | null;
|
||||||
|
insoleType: string | null;
|
||||||
|
pairCount: number;
|
||||||
|
startTime: Date | number | string;
|
||||||
|
endTime: Date | number | string | null;
|
||||||
|
durationSeconds: number | null;
|
||||||
|
pausedSeconds: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the CSV body shared by the worker self-export and the admin all-users export.
|
||||||
|
// includeWorker prepends a Worker column; everything else matches the legacy format byte-for-byte.
|
||||||
|
export function buildSessionsCsv(
|
||||||
|
rows: SessionCsvRow[],
|
||||||
|
opts: { includeWorker?: boolean } = {},
|
||||||
|
): string {
|
||||||
|
const includeWorker = opts.includeWorker ?? false;
|
||||||
|
const header = [
|
||||||
|
...(includeWorker ? ['Worker'] : []),
|
||||||
|
'ID',
|
||||||
|
'Task',
|
||||||
|
'Insole Type',
|
||||||
|
'No. of Insoles',
|
||||||
|
'Date',
|
||||||
|
'Total Duration',
|
||||||
|
'Paused Duration',
|
||||||
|
'Start Time',
|
||||||
|
'End Time',
|
||||||
|
]
|
||||||
|
.map(quote)
|
||||||
|
.join(',');
|
||||||
|
|
||||||
|
const dataLines = rows.map((row) => {
|
||||||
|
const start = new Date(row.startTime);
|
||||||
|
const end = row.endTime ? new Date(row.endTime) : null;
|
||||||
|
return [
|
||||||
|
...(includeWorker ? [row.userName ?? ''] : []),
|
||||||
|
row.id,
|
||||||
|
row.activityName ?? '',
|
||||||
|
row.insoleType ?? 'Kurk',
|
||||||
|
row.pairCount ?? 2,
|
||||||
|
start.toLocaleDateString('nl-BE', { day: '2-digit', month: '2-digit', year: 'numeric' }),
|
||||||
|
formatDuration(row.durationSeconds ?? 0),
|
||||||
|
formatDuration(row.pausedSeconds ?? 0),
|
||||||
|
start.toLocaleTimeString('nl-BE', { hour: '2-digit', minute: '2-digit', second: '2-digit' }),
|
||||||
|
end
|
||||||
|
? end.toLocaleTimeString('nl-BE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||||
|
: '',
|
||||||
|
]
|
||||||
|
.map(quote)
|
||||||
|
.join(',');
|
||||||
|
});
|
||||||
|
|
||||||
|
return [header, ...dataLines].join('\n');
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,60 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { desc, eq } from 'drizzle-orm';
|
import { and, asc, desc, eq, gte, lte, type SQL } from 'drizzle-orm';
|
||||||
|
import { AdminUpdateSessionInput, CreateManualSessionInput, InsoleType } from '@solelog/shared';
|
||||||
import { db } from '../db/client';
|
import { db } from '../db/client';
|
||||||
import { activities, user, workSessions } from '../db/schema';
|
import { activities, user, workSessions } from '../db/schema';
|
||||||
import { getSessionUser, isAdmin } from '../lib/require-user';
|
import { getSessionUser, isAdmin } from '../lib/require-user';
|
||||||
import { toWorkSession } from '../lib/work-session';
|
import { toWorkSession } from '../lib/work-session';
|
||||||
|
import { buildSessionsCsv } from '../lib/csv';
|
||||||
|
|
||||||
|
function computeDuration(startMs: number, endMs: number, paused: number): number {
|
||||||
|
return Math.max(0, Math.round((endMs - startMs) / 1000) - paused);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReportQuery = {
|
||||||
|
from: Date;
|
||||||
|
to: Date;
|
||||||
|
userId?: string;
|
||||||
|
insoleType?: string;
|
||||||
|
activityId?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse + validate the shared report/export query params. Returns null on a bad range.
|
||||||
|
function parseReportQuery(c: {
|
||||||
|
req: { query: (k: string) => string | undefined };
|
||||||
|
}): ReportQuery | null {
|
||||||
|
const fromRaw = c.req.query('from');
|
||||||
|
const toRaw = c.req.query('to');
|
||||||
|
if (!fromRaw || !toRaw) return null;
|
||||||
|
const from = new Date(fromRaw);
|
||||||
|
const to = new Date(toRaw);
|
||||||
|
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || to < from) return null;
|
||||||
|
|
||||||
|
const insoleType = c.req.query('insole_type') || undefined;
|
||||||
|
if (insoleType && !InsoleType.safeParse(insoleType).success) return null;
|
||||||
|
|
||||||
|
const activityIdRaw = c.req.query('activity_id');
|
||||||
|
let activityId: number | undefined;
|
||||||
|
if (activityIdRaw) {
|
||||||
|
activityId = Number.parseInt(activityIdRaw, 10);
|
||||||
|
if (Number.isNaN(activityId)) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { from, to, userId: c.req.query('user_id') || undefined, insoleType, activityId };
|
||||||
|
}
|
||||||
|
|
||||||
|
// completed + date-range + optional worker/type/activity — shared by report and export.
|
||||||
|
function buildSessionFilters(q: ReportQuery): SQL[] {
|
||||||
|
const conds: SQL[] = [
|
||||||
|
eq(workSessions.status, 'completed'),
|
||||||
|
gte(workSessions.startTime, q.from),
|
||||||
|
lte(workSessions.startTime, q.to),
|
||||||
|
];
|
||||||
|
if (q.userId) conds.push(eq(workSessions.userId, q.userId));
|
||||||
|
if (q.insoleType) conds.push(eq(workSessions.insoleType, q.insoleType));
|
||||||
|
if (q.activityId !== undefined) conds.push(eq(workSessions.activityId, q.activityId));
|
||||||
|
return conds;
|
||||||
|
}
|
||||||
|
|
||||||
export const adminRoutes = new Hono();
|
export const adminRoutes = new Hono();
|
||||||
|
|
||||||
@@ -58,3 +109,291 @@ adminRoutes.get('/api/admin/sessions/active', async (c) => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Roster for the create-form worker picker. Direct DB read — no better-auth client dependency.
|
||||||
|
adminRoutes.get('/api/admin/users', async (c) => {
|
||||||
|
const rows = await db
|
||||||
|
.select({ id: user.id, name: user.name, email: user.email })
|
||||||
|
.from(user)
|
||||||
|
.orderBy(asc(user.name));
|
||||||
|
return c.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRoutes.get('/api/admin/report', async (c) => {
|
||||||
|
const q = parseReportQuery(c);
|
||||||
|
if (!q) return c.json({ error: 'Invalid query' }, 400);
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select(baseSelect)
|
||||||
|
.from(workSessions)
|
||||||
|
.leftJoin(activities, eq(workSessions.activityId, activities.id))
|
||||||
|
.leftJoin(user, eq(workSessions.userId, user.id))
|
||||||
|
.where(and(...buildSessionFilters(q)));
|
||||||
|
|
||||||
|
const totals = { worked_seconds: 0, paused_seconds: 0, pairs: 0, sessions: 0 };
|
||||||
|
const workers = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
user_id: string;
|
||||||
|
user_name: string;
|
||||||
|
worked_seconds: number;
|
||||||
|
paused_seconds: number;
|
||||||
|
pairs: number;
|
||||||
|
sessions: number;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
const acts = new Map<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
activity_id: number;
|
||||||
|
activity_name: string;
|
||||||
|
worked_seconds: number;
|
||||||
|
paused_seconds: number;
|
||||||
|
pairs: number;
|
||||||
|
sessions: number;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
const types = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
insole_type: string;
|
||||||
|
worked_seconds: number;
|
||||||
|
paused_seconds: number;
|
||||||
|
pairs: number;
|
||||||
|
sessions: number;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const r of rows) {
|
||||||
|
const worked = r.session.durationSeconds ?? 0;
|
||||||
|
const paused = r.session.pausedSeconds ?? 0;
|
||||||
|
const pairs = r.session.pairCount ?? 0;
|
||||||
|
|
||||||
|
totals.worked_seconds += worked;
|
||||||
|
totals.paused_seconds += paused;
|
||||||
|
totals.pairs += pairs;
|
||||||
|
totals.sessions += 1;
|
||||||
|
|
||||||
|
const uid = r.session.userId;
|
||||||
|
const w = workers.get(uid) ?? {
|
||||||
|
user_id: uid,
|
||||||
|
user_name: r.userName ?? 'Onbekend',
|
||||||
|
worked_seconds: 0,
|
||||||
|
paused_seconds: 0,
|
||||||
|
pairs: 0,
|
||||||
|
sessions: 0,
|
||||||
|
};
|
||||||
|
w.worked_seconds += worked;
|
||||||
|
w.paused_seconds += paused;
|
||||||
|
w.pairs += pairs;
|
||||||
|
w.sessions += 1;
|
||||||
|
workers.set(uid, w);
|
||||||
|
|
||||||
|
const aid = r.session.activityId;
|
||||||
|
const a = acts.get(aid) ?? {
|
||||||
|
activity_id: aid,
|
||||||
|
activity_name: r.activityName ?? 'Onbekend',
|
||||||
|
worked_seconds: 0,
|
||||||
|
paused_seconds: 0,
|
||||||
|
pairs: 0,
|
||||||
|
sessions: 0,
|
||||||
|
};
|
||||||
|
a.worked_seconds += worked;
|
||||||
|
a.paused_seconds += paused;
|
||||||
|
a.pairs += pairs;
|
||||||
|
a.sessions += 1;
|
||||||
|
acts.set(aid, a);
|
||||||
|
|
||||||
|
const tkey = r.session.insoleType ?? 'Onbekend';
|
||||||
|
const t = types.get(tkey) ?? {
|
||||||
|
insole_type: tkey,
|
||||||
|
worked_seconds: 0,
|
||||||
|
paused_seconds: 0,
|
||||||
|
pairs: 0,
|
||||||
|
sessions: 0,
|
||||||
|
};
|
||||||
|
t.worked_seconds += worked;
|
||||||
|
t.paused_seconds += paused;
|
||||||
|
t.pairs += pairs;
|
||||||
|
t.sessions += 1;
|
||||||
|
types.set(tkey, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
const byWorked = (x: { worked_seconds: number }, y: { worked_seconds: number }) =>
|
||||||
|
y.worked_seconds - x.worked_seconds;
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
range: { from: q.from.toISOString(), to: q.to.toISOString() },
|
||||||
|
totals,
|
||||||
|
by_worker: [...workers.values()].sort(byWorked),
|
||||||
|
by_activity: [...acts.values()].sort(byWorked),
|
||||||
|
by_type: [...types.values()].sort(byWorked),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRoutes.get('/api/admin/export', async (c) => {
|
||||||
|
const q = parseReportQuery(c);
|
||||||
|
if (!q) return c.json({ error: 'Invalid query' }, 400);
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select(baseSelect)
|
||||||
|
.from(workSessions)
|
||||||
|
.leftJoin(activities, eq(workSessions.activityId, activities.id))
|
||||||
|
.leftJoin(user, eq(workSessions.userId, user.id))
|
||||||
|
.where(and(...buildSessionFilters(q)))
|
||||||
|
.orderBy(asc(workSessions.startTime));
|
||||||
|
|
||||||
|
const csv = buildSessionsCsv(
|
||||||
|
rows.map((r) => ({
|
||||||
|
id: r.session.id,
|
||||||
|
activityName: r.activityName,
|
||||||
|
userName: r.userName,
|
||||||
|
insoleType: r.session.insoleType,
|
||||||
|
pairCount: r.session.pairCount,
|
||||||
|
startTime: r.session.startTime,
|
||||||
|
endTime: r.session.endTime,
|
||||||
|
durationSeconds: r.session.durationSeconds,
|
||||||
|
pausedSeconds: r.session.pausedSeconds,
|
||||||
|
})),
|
||||||
|
{ includeWorker: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const fromDate = q.from.toISOString().slice(0, 10);
|
||||||
|
const toDate = q.to.toISOString().slice(0, 10);
|
||||||
|
return c.body(csv, 200, {
|
||||||
|
'Content-Type': 'text/csv; charset=utf-8',
|
||||||
|
'Content-Disposition': `attachment; filename="solelog-report_${fromDate}_${toDate}.csv"`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Manual create → always completed, source='manual', duration derived server-side.
|
||||||
|
adminRoutes.post('/api/admin/sessions', async (c) => {
|
||||||
|
const parsed = CreateManualSessionInput.safeParse(await c.req.json().catch(() => null));
|
||||||
|
if (!parsed.success) return c.json({ error: 'Invalid input' }, 400);
|
||||||
|
const d = parsed.data;
|
||||||
|
const start = new Date(d.start_time);
|
||||||
|
const end = new Date(d.end_time);
|
||||||
|
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end < start)
|
||||||
|
return c.json({ error: 'Invalid input' }, 400);
|
||||||
|
const span = Math.round((end.getTime() - start.getTime()) / 1000);
|
||||||
|
if (d.paused_seconds > span) return c.json({ error: 'Invalid input' }, 400);
|
||||||
|
|
||||||
|
const [u] = await db.select({ id: user.id }).from(user).where(eq(user.id, d.user_id));
|
||||||
|
if (!u) return c.json({ error: 'User not found' }, 404);
|
||||||
|
const [act] = await db.select().from(activities).where(eq(activities.id, d.activity_id));
|
||||||
|
if (!act) return c.json({ error: 'Activity not found' }, 404);
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.insert(workSessions)
|
||||||
|
.values({
|
||||||
|
userId: d.user_id,
|
||||||
|
activityId: d.activity_id,
|
||||||
|
insoleType: d.insole_type,
|
||||||
|
pairCount: d.pair_count,
|
||||||
|
startTime: start,
|
||||||
|
endTime: end,
|
||||||
|
durationSeconds: computeDuration(start.getTime(), end.getTime(), d.paused_seconds),
|
||||||
|
pausedSeconds: d.paused_seconds,
|
||||||
|
pausedAt: null,
|
||||||
|
status: 'completed',
|
||||||
|
source: 'manual',
|
||||||
|
notes: d.notes ?? null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return c.json(toWorkSession(row, { activityName: act.name }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edit any session (no user reassignment). Duration recomputed when end present, else null.
|
||||||
|
adminRoutes.put('/api/admin/sessions/:id', async (c) => {
|
||||||
|
const id = Number.parseInt(c.req.param('id'), 10);
|
||||||
|
if (Number.isNaN(id)) return c.json({ error: 'Session not found' }, 404);
|
||||||
|
|
||||||
|
const parsed = AdminUpdateSessionInput.safeParse(await c.req.json().catch(() => null));
|
||||||
|
if (!parsed.success) return c.json({ error: 'Invalid input' }, 400);
|
||||||
|
const d = parsed.data;
|
||||||
|
|
||||||
|
const [row] = await db.select().from(workSessions).where(eq(workSessions.id, id));
|
||||||
|
if (!row) return c.json({ error: 'Session not found' }, 404);
|
||||||
|
|
||||||
|
const start = new Date(d.start_time);
|
||||||
|
if (Number.isNaN(start.getTime())) return c.json({ error: 'Invalid input' }, 400);
|
||||||
|
|
||||||
|
let endTime: Date | null = null;
|
||||||
|
let durationSeconds: number | null = null;
|
||||||
|
if (d.end_time !== null) {
|
||||||
|
const end = new Date(d.end_time);
|
||||||
|
if (Number.isNaN(end.getTime()) || end < start) return c.json({ error: 'Invalid input' }, 400);
|
||||||
|
const span = Math.round((end.getTime() - start.getTime()) / 1000);
|
||||||
|
if (d.paused_seconds > span) return c.json({ error: 'Invalid input' }, 400);
|
||||||
|
endTime = end;
|
||||||
|
durationSeconds = computeDuration(start.getTime(), end.getTime(), d.paused_seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [act] = await db.select().from(activities).where(eq(activities.id, d.activity_id));
|
||||||
|
if (!act) return c.json({ error: 'Activity not found' }, 404);
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(workSessions)
|
||||||
|
.set({
|
||||||
|
activityId: d.activity_id,
|
||||||
|
insoleType: d.insole_type,
|
||||||
|
pairCount: d.pair_count,
|
||||||
|
startTime: start,
|
||||||
|
endTime,
|
||||||
|
durationSeconds,
|
||||||
|
pausedSeconds: d.paused_seconds,
|
||||||
|
pausedAt: null,
|
||||||
|
status: d.status,
|
||||||
|
notes: d.notes,
|
||||||
|
})
|
||||||
|
.where(eq(workSessions.id, id))
|
||||||
|
.returning();
|
||||||
|
return c.json(toWorkSession(updated, { activityName: act.name }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Quick stop of an active session: fold any open pause, end=now, completed.
|
||||||
|
adminRoutes.post('/api/admin/sessions/:id/stop', async (c) => {
|
||||||
|
const id = Number.parseInt(c.req.param('id'), 10);
|
||||||
|
if (Number.isNaN(id)) return c.json({ error: 'Session not found' }, 404);
|
||||||
|
|
||||||
|
const [row] = await db.select().from(workSessions).where(eq(workSessions.id, id));
|
||||||
|
if (!row) return c.json({ error: 'Session not found' }, 404);
|
||||||
|
if (row.status !== 'active') return c.json({ error: 'Session already closed' }, 409);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const extraPaused = row.pausedAt
|
||||||
|
? Math.round((now - new Date(row.pausedAt).getTime()) / 1000)
|
||||||
|
: 0;
|
||||||
|
const pausedSeconds = (row.pausedSeconds ?? 0) + extraPaused;
|
||||||
|
const durationSeconds = computeDuration(new Date(row.startTime).getTime(), now, pausedSeconds);
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(workSessions)
|
||||||
|
.set({
|
||||||
|
endTime: new Date(now),
|
||||||
|
durationSeconds,
|
||||||
|
pausedSeconds,
|
||||||
|
pausedAt: null,
|
||||||
|
status: 'completed',
|
||||||
|
})
|
||||||
|
.where(eq(workSessions.id, id))
|
||||||
|
.returning();
|
||||||
|
return c.json(toWorkSession(updated));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancel an active session: status='discarded', end=now (no hard delete).
|
||||||
|
adminRoutes.post('/api/admin/sessions/:id/discard', async (c) => {
|
||||||
|
const id = Number.parseInt(c.req.param('id'), 10);
|
||||||
|
if (Number.isNaN(id)) return c.json({ error: 'Session not found' }, 404);
|
||||||
|
|
||||||
|
const [row] = await db.select().from(workSessions).where(eq(workSessions.id, id));
|
||||||
|
if (!row) return c.json({ error: 'Session not found' }, 404);
|
||||||
|
if (row.status !== 'active') return c.json({ error: 'Session already closed' }, 409);
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(workSessions)
|
||||||
|
.set({ status: 'discarded', endTime: new Date() })
|
||||||
|
.where(eq(workSessions.id, id))
|
||||||
|
.returning();
|
||||||
|
return c.json(toWorkSession(updated));
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { db } from '../db/client';
|
|||||||
import { activities, workSessions } from '../db/schema';
|
import { activities, workSessions } from '../db/schema';
|
||||||
import { getSessionUser } from '../lib/require-user';
|
import { getSessionUser } from '../lib/require-user';
|
||||||
import { toWorkSession } from '../lib/work-session';
|
import { toWorkSession } from '../lib/work-session';
|
||||||
import { quote, formatDuration } from '../lib/csv';
|
import { buildSessionsCsv } from '../lib/csv';
|
||||||
|
|
||||||
export const sessionsRoutes = new Hono();
|
export const sessionsRoutes = new Hono();
|
||||||
|
|
||||||
@@ -20,45 +20,18 @@ sessionsRoutes.get('/api/export', async (c) => {
|
|||||||
.where(and(eq(workSessions.userId, sessionUser.id), eq(workSessions.status, 'completed')))
|
.where(and(eq(workSessions.userId, sessionUser.id), eq(workSessions.status, 'completed')))
|
||||||
.orderBy(asc(workSessions.startTime));
|
.orderBy(asc(workSessions.startTime));
|
||||||
|
|
||||||
const header = [
|
const csv = buildSessionsCsv(
|
||||||
'ID',
|
rows.map(({ session, activityName }) => ({
|
||||||
'Task',
|
id: session.id,
|
||||||
'Insole Type',
|
activityName,
|
||||||
'No. of Insoles',
|
insoleType: session.insoleType,
|
||||||
'Date',
|
pairCount: session.pairCount,
|
||||||
'Total Duration',
|
startTime: session.startTime,
|
||||||
'Paused Duration',
|
endTime: session.endTime,
|
||||||
'Start Time',
|
durationSeconds: session.durationSeconds,
|
||||||
'End Time',
|
pausedSeconds: session.pausedSeconds,
|
||||||
]
|
})),
|
||||||
.map(quote)
|
);
|
||||||
.join(',');
|
|
||||||
|
|
||||||
const dataLines = rows.map(({ session, activityName }) => {
|
|
||||||
const start = new Date(session.startTime);
|
|
||||||
const end = session.endTime ? new Date(session.endTime) : null;
|
|
||||||
return [
|
|
||||||
session.id,
|
|
||||||
activityName ?? '',
|
|
||||||
session.insoleType ?? 'Kurk',
|
|
||||||
session.pairCount ?? 2,
|
|
||||||
start.toLocaleDateString('nl-BE', { day: '2-digit', month: '2-digit', year: 'numeric' }),
|
|
||||||
formatDuration(session.durationSeconds ?? 0),
|
|
||||||
formatDuration(session.pausedSeconds ?? 0),
|
|
||||||
start.toLocaleTimeString('nl-BE', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit',
|
|
||||||
}),
|
|
||||||
end
|
|
||||||
? end.toLocaleTimeString('nl-BE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
||||||
: '',
|
|
||||||
]
|
|
||||||
.map(quote)
|
|
||||||
.join(',');
|
|
||||||
});
|
|
||||||
|
|
||||||
const csv = [header, ...dataLines].join('\n');
|
|
||||||
|
|
||||||
return c.body(csv, 200, {
|
return c.body(csv, 200, {
|
||||||
'Content-Type': 'text/csv; charset=utf-8',
|
'Content-Type': 'text/csv; charset=utf-8',
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { createApp } from '../src/app';
|
import { createApp } from '../src/app';
|
||||||
import { authToken, bearer, seedActivity } from './helpers';
|
import { authToken, bearer, createTestUser, seedActivity } from './helpers';
|
||||||
|
import { db } from '../src/db/client';
|
||||||
|
import { user } from '../src/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
async function userIdByEmail(email: string): Promise<string> {
|
||||||
|
const [row] = await db.select({ id: user.id }).from(user).where(eq(user.email, email));
|
||||||
|
if (!row) throw new Error(`no user for ${email}`);
|
||||||
|
return row.id;
|
||||||
|
}
|
||||||
|
|
||||||
describe('admin session views', () => {
|
describe('admin session views', () => {
|
||||||
it('401s without a token', async () => {
|
it('401s without a token', async () => {
|
||||||
@@ -44,3 +53,400 @@ describe('admin session views', () => {
|
|||||||
expect(activeBody.some((s: { id: number }) => s.id === started.id)).toBe(true);
|
expect(activeBody.some((s: { id: number }) => s.id === started.id)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('admin users roster', () => {
|
||||||
|
it('returns id/name/email objects for an admin, ordered by name', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'roster-admin@example.com', 'admin');
|
||||||
|
await createTestUser('roster-w1@example.com');
|
||||||
|
|
||||||
|
const res = await app.request('/api/admin/users', { headers: bearer(adminTok) });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(Array.isArray(body)).toBe(true);
|
||||||
|
const found = body.find((u: { email: string }) => u.email === 'roster-w1@example.com');
|
||||||
|
expect(found).toBeTruthy();
|
||||||
|
expect(found).toHaveProperty('id');
|
||||||
|
expect(found).toHaveProperty('name');
|
||||||
|
expect(found).toHaveProperty('email');
|
||||||
|
// ordered by name ascending
|
||||||
|
const names = body.map((u: { name: string }) => u.name);
|
||||||
|
const sorted = [...names].sort();
|
||||||
|
expect(names).toEqual(sorted);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('403s for a worker', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const workerTok = await authToken(app, 'roster-worker@example.com'); // worker
|
||||||
|
expect((await app.request('/api/admin/users', { headers: bearer(workerTok) })).status).toBe(
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('admin manual session create', () => {
|
||||||
|
it('creates a completed manual session with duration excluding paused', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'create-admin@example.com', 'admin');
|
||||||
|
await createTestUser('create-target@example.com');
|
||||||
|
const targetId = await userIdByEmail('create-target@example.com');
|
||||||
|
const activityId = await seedActivity('Snijden');
|
||||||
|
|
||||||
|
const start = new Date('2026-06-17T08:00:00.000Z');
|
||||||
|
const end = new Date('2026-06-17T09:00:00.000Z'); // 1h apart = 3600s
|
||||||
|
const res = await app.request('/api/admin/sessions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
body: JSON.stringify({
|
||||||
|
user_id: targetId,
|
||||||
|
activity_id: activityId,
|
||||||
|
insole_type: 'Kurk',
|
||||||
|
pair_count: 3,
|
||||||
|
start_time: start.toISOString(),
|
||||||
|
end_time: end.toISOString(),
|
||||||
|
paused_seconds: 600,
|
||||||
|
notes: 'handmatig',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.source).toBe('manual');
|
||||||
|
expect(body.status).toBe('completed');
|
||||||
|
expect(body.duration_seconds).toBe(3000); // 3600 - 600
|
||||||
|
expect(body.paused_seconds).toBe(600);
|
||||||
|
expect(body.paused_at).toBeNull();
|
||||||
|
expect(body.user_id).toBe(targetId);
|
||||||
|
expect(body.activity_name).toBe('Snijden');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('400s when end < start', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'create-badtime-admin@example.com', 'admin');
|
||||||
|
await createTestUser('create-badtime-target@example.com');
|
||||||
|
const targetId = await userIdByEmail('create-badtime-target@example.com');
|
||||||
|
const activityId = await seedActivity('Frezen');
|
||||||
|
|
||||||
|
const res = await app.request('/api/admin/sessions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
body: JSON.stringify({
|
||||||
|
user_id: targetId,
|
||||||
|
activity_id: activityId,
|
||||||
|
insole_type: 'Kurk',
|
||||||
|
pair_count: 1,
|
||||||
|
start_time: '2026-06-17T09:00:00.000Z',
|
||||||
|
end_time: '2026-06-17T08:00:00.000Z',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404s for an unknown user', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'create-nouser-admin@example.com', 'admin');
|
||||||
|
const activityId = await seedActivity('Lijmen');
|
||||||
|
|
||||||
|
const res = await app.request('/api/admin/sessions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
body: JSON.stringify({
|
||||||
|
user_id: 'does-not-exist',
|
||||||
|
activity_id: activityId,
|
||||||
|
insole_type: 'Kurk',
|
||||||
|
pair_count: 1,
|
||||||
|
start_time: '2026-06-17T08:00:00.000Z',
|
||||||
|
end_time: '2026-06-17T09:00:00.000Z',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404s for an unknown activity', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'create-noact-admin@example.com', 'admin');
|
||||||
|
await createTestUser('create-noact-target@example.com');
|
||||||
|
const targetId = await userIdByEmail('create-noact-target@example.com');
|
||||||
|
|
||||||
|
const res = await app.request('/api/admin/sessions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
body: JSON.stringify({
|
||||||
|
user_id: targetId,
|
||||||
|
activity_id: 999999,
|
||||||
|
insole_type: 'Kurk',
|
||||||
|
pair_count: 1,
|
||||||
|
start_time: '2026-06-17T08:00:00.000Z',
|
||||||
|
end_time: '2026-06-17T09:00:00.000Z',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('403s for a worker', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const workerTok = await authToken(app, 'create-worker@example.com'); // worker
|
||||||
|
const res = await app.request('/api/admin/sessions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(workerTok),
|
||||||
|
body: JSON.stringify({
|
||||||
|
user_id: 'x',
|
||||||
|
activity_id: 1,
|
||||||
|
insole_type: 'Kurk',
|
||||||
|
pair_count: 1,
|
||||||
|
start_time: '2026-06-17T08:00:00.000Z',
|
||||||
|
end_time: '2026-06-17T09:00:00.000Z',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('admin session edit', () => {
|
||||||
|
it('recomputes duration on edit', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'edit-admin@example.com', 'admin');
|
||||||
|
await createTestUser('edit-target@example.com');
|
||||||
|
const targetId = await userIdByEmail('edit-target@example.com');
|
||||||
|
const activityId = await seedActivity('Polijsten');
|
||||||
|
|
||||||
|
const created = await (
|
||||||
|
await app.request('/api/admin/sessions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
body: JSON.stringify({
|
||||||
|
user_id: targetId,
|
||||||
|
activity_id: activityId,
|
||||||
|
insole_type: 'Kurk',
|
||||||
|
pair_count: 2,
|
||||||
|
start_time: '2026-06-17T08:00:00.000Z',
|
||||||
|
end_time: '2026-06-17T09:00:00.000Z',
|
||||||
|
paused_seconds: 0,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
).json();
|
||||||
|
expect(created.duration_seconds).toBe(3600);
|
||||||
|
|
||||||
|
const res = await app.request(`/api/admin/sessions/${created.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
body: JSON.stringify({
|
||||||
|
activity_id: activityId,
|
||||||
|
insole_type: 'Berk',
|
||||||
|
pair_count: 4,
|
||||||
|
start_time: '2026-06-17T08:00:00.000Z',
|
||||||
|
end_time: '2026-06-17T10:00:00.000Z', // now 2h
|
||||||
|
paused_seconds: 300,
|
||||||
|
notes: 'gecorrigeerd',
|
||||||
|
status: 'completed',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.duration_seconds).toBe(7200 - 300);
|
||||||
|
expect(body.insole_type).toBe('Berk');
|
||||||
|
expect(body.pair_count).toBe(4);
|
||||||
|
expect(body.notes).toBe('gecorrigeerd');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets duration null when end_time is null (active)', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'edit-active-admin@example.com', 'admin');
|
||||||
|
await createTestUser('edit-active-target@example.com');
|
||||||
|
const targetId = await userIdByEmail('edit-active-target@example.com');
|
||||||
|
const activityId = await seedActivity('Stikken');
|
||||||
|
|
||||||
|
const created = await (
|
||||||
|
await app.request('/api/admin/sessions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
body: JSON.stringify({
|
||||||
|
user_id: targetId,
|
||||||
|
activity_id: activityId,
|
||||||
|
insole_type: 'Kurk',
|
||||||
|
pair_count: 2,
|
||||||
|
start_time: '2026-06-17T08:00:00.000Z',
|
||||||
|
end_time: '2026-06-17T09:00:00.000Z',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
).json();
|
||||||
|
|
||||||
|
const res = await app.request(`/api/admin/sessions/${created.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
body: JSON.stringify({
|
||||||
|
activity_id: activityId,
|
||||||
|
insole_type: 'Kurk',
|
||||||
|
pair_count: 2,
|
||||||
|
start_time: '2026-06-17T08:00:00.000Z',
|
||||||
|
end_time: null,
|
||||||
|
paused_seconds: 0,
|
||||||
|
notes: null,
|
||||||
|
status: 'active',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.duration_seconds).toBeNull();
|
||||||
|
expect(body.end_time).toBeNull();
|
||||||
|
expect(body.status).toBe('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('400s when end < start on edit', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'edit-badtime-admin@example.com', 'admin');
|
||||||
|
await createTestUser('edit-badtime-target@example.com');
|
||||||
|
const targetId = await userIdByEmail('edit-badtime-target@example.com');
|
||||||
|
const activityId = await seedActivity('Wassen');
|
||||||
|
|
||||||
|
const created = await (
|
||||||
|
await app.request('/api/admin/sessions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
body: JSON.stringify({
|
||||||
|
user_id: targetId,
|
||||||
|
activity_id: activityId,
|
||||||
|
insole_type: 'Kurk',
|
||||||
|
pair_count: 2,
|
||||||
|
start_time: '2026-06-17T08:00:00.000Z',
|
||||||
|
end_time: '2026-06-17T09:00:00.000Z',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
).json();
|
||||||
|
|
||||||
|
const res = await app.request(`/api/admin/sessions/${created.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
body: JSON.stringify({
|
||||||
|
activity_id: activityId,
|
||||||
|
insole_type: 'Kurk',
|
||||||
|
pair_count: 2,
|
||||||
|
start_time: '2026-06-17T09:00:00.000Z',
|
||||||
|
end_time: '2026-06-17T08:00:00.000Z',
|
||||||
|
paused_seconds: 0,
|
||||||
|
notes: null,
|
||||||
|
status: 'completed',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404s for an unknown session', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'edit-404-admin@example.com', 'admin');
|
||||||
|
const activityId = await seedActivity('Drogen');
|
||||||
|
|
||||||
|
const res = await app.request('/api/admin/sessions/999999', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
body: JSON.stringify({
|
||||||
|
activity_id: activityId,
|
||||||
|
insole_type: 'Kurk',
|
||||||
|
pair_count: 2,
|
||||||
|
start_time: '2026-06-17T08:00:00.000Z',
|
||||||
|
end_time: '2026-06-17T09:00:00.000Z',
|
||||||
|
paused_seconds: 0,
|
||||||
|
notes: null,
|
||||||
|
status: 'completed',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('admin stop/discard another user session', () => {
|
||||||
|
it("stops another worker's active session and computes duration", async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'stop-admin@example.com', 'admin');
|
||||||
|
const workerTok = await authToken(app, 'stop-worker@example.com'); // worker
|
||||||
|
const activityId = await seedActivity('Frezen');
|
||||||
|
|
||||||
|
const started = await (
|
||||||
|
await app.request('/api/sessions/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(workerTok),
|
||||||
|
body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }),
|
||||||
|
})
|
||||||
|
).json();
|
||||||
|
|
||||||
|
const res = await app.request(`/api/admin/sessions/${started.id}/stop`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.status).toBe('completed');
|
||||||
|
expect(body.end_time).not.toBeNull();
|
||||||
|
expect(body.duration_seconds).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(body.paused_at).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discards another worker's active session", async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'discard-admin@example.com', 'admin');
|
||||||
|
const workerTok = await authToken(app, 'discard-worker@example.com'); // worker
|
||||||
|
const activityId = await seedActivity('Lijmen');
|
||||||
|
|
||||||
|
const started = await (
|
||||||
|
await app.request('/api/sessions/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(workerTok),
|
||||||
|
body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }),
|
||||||
|
})
|
||||||
|
).json();
|
||||||
|
|
||||||
|
const res = await app.request(`/api/admin/sessions/${started.id}/discard`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.status).toBe('discarded');
|
||||||
|
expect(body.end_time).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('409s when stopping an already-closed session', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'stop-closed-admin@example.com', 'admin');
|
||||||
|
const workerTok = await authToken(app, 'stop-closed-worker@example.com');
|
||||||
|
const activityId = await seedActivity('Snijden');
|
||||||
|
|
||||||
|
const started = await (
|
||||||
|
await app.request('/api/sessions/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(workerTok),
|
||||||
|
body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }),
|
||||||
|
})
|
||||||
|
).json();
|
||||||
|
await app.request(`/api/admin/sessions/${started.id}/discard`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request(`/api/admin/sessions/${started.id}/stop`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('403s for a worker hitting stop/discard', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const workerTok = await authToken(app, 'stop-worker-gate@example.com'); // worker
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.request('/api/admin/sessions/1/stop', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(workerTok),
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe(403);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.request('/api/admin/sessions/1/discard', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(workerTok),
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
34
apps/api/test/csv.test.ts
Normal file
34
apps/api/test/csv.test.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { buildSessionsCsv, type SessionCsvRow } from '../src/lib/csv';
|
||||||
|
|
||||||
|
const row: SessionCsvRow = {
|
||||||
|
id: 7,
|
||||||
|
activityName: 'Frezen',
|
||||||
|
userName: 'Jan',
|
||||||
|
insoleType: 'Kurk',
|
||||||
|
pairCount: 2,
|
||||||
|
startTime: new Date('2026-06-17T08:00:00Z'),
|
||||||
|
endTime: new Date('2026-06-17T08:01:30Z'),
|
||||||
|
durationSeconds: 90,
|
||||||
|
pausedSeconds: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('buildSessionsCsv', () => {
|
||||||
|
it('omits the Worker column by default (legacy header)', () => {
|
||||||
|
const lines = buildSessionsCsv([row]).split('\n');
|
||||||
|
expect(lines[0]).toBe(
|
||||||
|
'"ID","Task","Insole Type","No. of Insoles","Date","Total Duration","Paused Duration","Start Time","End Time"',
|
||||||
|
);
|
||||||
|
expect(lines[1]).toContain('"Frezen"');
|
||||||
|
expect(lines[1]).toContain('"00:01:30"');
|
||||||
|
expect(lines[1]).not.toContain('"Jan"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prepends a Worker column when includeWorker is true', () => {
|
||||||
|
const lines = buildSessionsCsv([row], { includeWorker: true }).split('\n');
|
||||||
|
expect(lines[0]).toBe(
|
||||||
|
'"Worker","ID","Task","Insole Type","No. of Insoles","Date","Total Duration","Paused Duration","Start Time","End Time"',
|
||||||
|
);
|
||||||
|
expect(lines[1].startsWith('"Jan"')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
190
apps/api/test/report.test.ts
Normal file
190
apps/api/test/report.test.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import type { Hono } from 'hono';
|
||||||
|
import { createApp } from '../src/app';
|
||||||
|
import { db } from '../src/db/client';
|
||||||
|
import { workSessions } from '../src/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { authToken, bearer, seedActivity } from './helpers';
|
||||||
|
|
||||||
|
const WIDE = `from=${new Date('2000-01-01').toISOString()}&to=${new Date('2100-01-01').toISOString()}`;
|
||||||
|
|
||||||
|
async function completed(
|
||||||
|
app: Hono,
|
||||||
|
token: string,
|
||||||
|
activityId: number,
|
||||||
|
insoleType: string,
|
||||||
|
durationSeconds: number,
|
||||||
|
pairCount = 2,
|
||||||
|
): Promise<number> {
|
||||||
|
const startRes = await app.request('/api/sessions/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
body: JSON.stringify({
|
||||||
|
activity_id: activityId,
|
||||||
|
insole_type: insoleType,
|
||||||
|
pair_count: pairCount,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const started = await startRes.json();
|
||||||
|
await db
|
||||||
|
.update(workSessions)
|
||||||
|
.set({ startTime: new Date(Date.now() - durationSeconds * 1000) })
|
||||||
|
.where(eq(workSessions.id, started.id));
|
||||||
|
await app.request(`/api/sessions/${started.id}/stop`, { method: 'POST', headers: bearer(token) });
|
||||||
|
return started.id as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GET /api/admin/report', () => {
|
||||||
|
it('401s without a token and 403s for a worker', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
expect((await app.request(`/api/admin/report?${WIDE}`)).status).toBe(401);
|
||||||
|
const workerTok = await authToken(app, 'report-worker@example.com');
|
||||||
|
const res = await app.request(`/api/admin/report?${WIDE}`, { headers: bearer(workerTok) });
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('400s on a missing or inverted range', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'report-badrange@example.com', 'admin');
|
||||||
|
expect((await app.request('/api/admin/report', { headers: bearer(adminTok) })).status).toBe(
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
const inverted = `from=${new Date('2026-02-01').toISOString()}&to=${new Date('2026-01-01').toISOString()}`;
|
||||||
|
expect(
|
||||||
|
(await app.request(`/api/admin/report?${inverted}`, { headers: bearer(adminTok) })).status,
|
||||||
|
).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aggregates totals that equal the sum of each breakdown', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'report-agg-admin@example.com', 'admin');
|
||||||
|
const workerTok = await authToken(app, 'report-agg-worker@example.com'); // 'report-agg-worker'
|
||||||
|
const frezen = await seedActivity('Frezen');
|
||||||
|
const lijmen = await seedActivity('Lijmen');
|
||||||
|
await completed(app, workerTok, frezen, 'Kurk', 100, 2);
|
||||||
|
await completed(app, workerTok, lijmen, 'Berk', 50, 3);
|
||||||
|
|
||||||
|
const res = await app.request(`/api/admin/report?${WIDE}`, { headers: bearer(adminTok) });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(body.totals.worked_seconds).toBe(150);
|
||||||
|
expect(body.totals.pairs).toBe(5);
|
||||||
|
expect(body.totals.sessions).toBe(2);
|
||||||
|
|
||||||
|
const sum = (rows: { worked_seconds: number; pairs: number; sessions: number }[]) =>
|
||||||
|
rows.reduce(
|
||||||
|
(a, r) => ({
|
||||||
|
worked_seconds: a.worked_seconds + r.worked_seconds,
|
||||||
|
pairs: a.pairs + r.pairs,
|
||||||
|
sessions: a.sessions + r.sessions,
|
||||||
|
}),
|
||||||
|
{ worked_seconds: 0, pairs: 0, sessions: 0 },
|
||||||
|
);
|
||||||
|
expect(sum(body.by_worker)).toEqual({ worked_seconds: 150, pairs: 5, sessions: 2 });
|
||||||
|
expect(sum(body.by_activity)).toEqual({ worked_seconds: 150, pairs: 5, sessions: 2 });
|
||||||
|
expect(sum(body.by_type)).toEqual({ worked_seconds: 150, pairs: 5, sessions: 2 });
|
||||||
|
expect(body.by_activity).toHaveLength(2);
|
||||||
|
expect(body.by_type.map((t: { insole_type: string }) => t.insole_type).sort()).toEqual([
|
||||||
|
'Berk',
|
||||||
|
'Kurk',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes active/discarded and respects the user and type filters', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'report-filter-admin@example.com', 'admin');
|
||||||
|
const a = await authToken(app, 'report-filter-a@example.com');
|
||||||
|
const b = await authToken(app, 'report-filter-b@example.com');
|
||||||
|
const act = await seedActivity('Slijpen');
|
||||||
|
await completed(app, a, act, 'Kurk', 30, 2); // counts for A/Kurk
|
||||||
|
await completed(app, b, act, 'Berk', 40, 2); // counts for B/Berk
|
||||||
|
// A: an active session (no duration) — must be excluded.
|
||||||
|
await app.request('/api/sessions/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(a),
|
||||||
|
body: JSON.stringify({ activity_id: act, insole_type: 'Kurk', pair_count: 2 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolve A's user id via the roster.
|
||||||
|
const roster = await (
|
||||||
|
await app.request('/api/admin/users', { headers: bearer(adminTok) })
|
||||||
|
).json();
|
||||||
|
const userA = roster.find((u: { email: string }) => u.email === 'report-filter-a@example.com');
|
||||||
|
|
||||||
|
const res = await app.request(
|
||||||
|
`/api/admin/report?${WIDE}&user_id=${userA.id}&insole_type=Kurk`,
|
||||||
|
{ headers: bearer(adminTok) },
|
||||||
|
);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.totals.sessions).toBe(1); // only A's completed Kurk session
|
||||||
|
expect(body.totals.worked_seconds).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns zeros and empty arrays for an empty range', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'report-empty-admin@example.com', 'admin');
|
||||||
|
const empty = `from=${new Date('1990-01-01').toISOString()}&to=${new Date('1990-02-01').toISOString()}`;
|
||||||
|
const res = await app.request(`/api/admin/report?${empty}`, { headers: bearer(adminTok) });
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.totals).toEqual({ worked_seconds: 0, paused_seconds: 0, pairs: 0, sessions: 0 });
|
||||||
|
expect(body.by_worker).toEqual([]);
|
||||||
|
expect(body.by_activity).toEqual([]);
|
||||||
|
expect(body.by_type).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/admin/export', () => {
|
||||||
|
it('401s without a token and 403s for a worker', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
expect((await app.request(`/api/admin/export?${WIDE}`)).status).toBe(401);
|
||||||
|
const workerTok = await authToken(app, 'export-admin-worker@example.com');
|
||||||
|
const res = await app.request(`/api/admin/export?${WIDE}`, { headers: bearer(workerTok) });
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports all workers with a leading Worker column, range in the filename', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'export-admin@example.com', 'admin');
|
||||||
|
const a = await authToken(app, 'export-allusers-a@example.com');
|
||||||
|
const b = await authToken(app, 'export-allusers-b@example.com');
|
||||||
|
const act = await seedActivity('Frezen');
|
||||||
|
await completed(app, a, act, 'Kurk', 90, 2);
|
||||||
|
await completed(app, b, act, 'Berk', 60, 2);
|
||||||
|
|
||||||
|
// Scope to this test's own activity so the all-users export isolates from the
|
||||||
|
// sessions other tests seed into the shared, run-scoped DB.
|
||||||
|
const res = await app.request(`/api/admin/export?${WIDE}&activity_id=${act}`, {
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get('content-type')).toContain('text/csv');
|
||||||
|
expect(res.headers.get('content-disposition')).toMatch(
|
||||||
|
/attachment; filename="solelog-report_\d{4}-\d{2}-\d{2}_\d{4}-\d{2}-\d{2}\.csv"/,
|
||||||
|
);
|
||||||
|
|
||||||
|
const lines = (await res.text()).split('\n');
|
||||||
|
expect(lines[0]).toBe(
|
||||||
|
'"Worker","ID","Task","Insole Type","No. of Insoles","Date","Total Duration","Paused Duration","Start Time","End Time"',
|
||||||
|
);
|
||||||
|
expect(lines).toHaveLength(3); // header + 2 workers' sessions
|
||||||
|
expect(lines.some((l) => l.includes('export-allusers-a'))).toBe(true);
|
||||||
|
expect(lines.some((l) => l.includes('export-allusers-b'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects the type filter', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'export-typefilter-admin@example.com', 'admin');
|
||||||
|
const a = await authToken(app, 'export-typefilter-a@example.com');
|
||||||
|
const act = await seedActivity('Lijmen');
|
||||||
|
await completed(app, a, act, 'Kurk', 30, 2);
|
||||||
|
await completed(app, a, act, 'Berk', 40, 2);
|
||||||
|
|
||||||
|
const res = await app.request(`/api/admin/export?${WIDE}&activity_id=${act}&insole_type=Berk`, {
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
});
|
||||||
|
const lines = (await res.text()).split('\n');
|
||||||
|
expect(lines).toHaveLength(2); // header + only the Berk row
|
||||||
|
expect(lines[1]).toContain('"Berk"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,8 @@ export function useActiveSessions() {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['sessions', 'active'],
|
queryKey: ['sessions', 'active'],
|
||||||
queryFn: () => apiFetch<WorkSession[]>('/api/sessions/active'),
|
queryFn: () => apiFetch<WorkSession[]>('/api/sessions/active'),
|
||||||
|
// Poll so the stopwatch converges to admin changes (stop/cancel) within ~15s.
|
||||||
|
refetchInterval: 15000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import type { Activity, WorkSession } from '@solelog/shared';
|
import type { Activity, WorkSession } from '@solelog/shared';
|
||||||
import Stopwatch from './Stopwatch';
|
import Stopwatch from './Stopwatch';
|
||||||
|
import { ApiError } from '../lib/api';
|
||||||
import { useActivities } from '../api/activities';
|
import { useActivities } from '../api/activities';
|
||||||
import {
|
import {
|
||||||
useActiveSessions,
|
useActiveSessions,
|
||||||
@@ -247,4 +248,52 @@ describe('Stopwatch', () => {
|
|||||||
|
|
||||||
expect(await screen.findByText('Gepauzeerd — tik om te hervatten')).toBeInTheDocument();
|
expect(await screen.findByText('Gepauzeerd — tik om te hervatten')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resets and shows a notice when the running session is stopped by the admin', async () => {
|
||||||
|
// Start with the session present so the screen adopts the running state.
|
||||||
|
mockedUseActiveSessions.mockReturnValue(
|
||||||
|
query<ReturnType<typeof useActiveSessions>>([activeSession()]),
|
||||||
|
);
|
||||||
|
const { rerender } = renderStopwatch();
|
||||||
|
|
||||||
|
// Confirm we are running (the Stop & Opslaan button is shown).
|
||||||
|
await screen.findByRole('button', { name: 'Stop & Opslaan' });
|
||||||
|
|
||||||
|
// The admin stops the session: the active list now no longer contains it.
|
||||||
|
mockedUseActiveSessions.mockReturnValue(query<ReturnType<typeof useActiveSessions>>([]));
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
rerender(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Stopwatch />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The stopwatch resets to idle and shows the admin-stopped notice.
|
||||||
|
expect(
|
||||||
|
await screen.findByText('Deze sessie is door de beheerder gestopt.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Start Stopwatch' })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: 'Stop & Opslaan' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets locally when stop fails with a 409 (already closed)', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
stopMutate.mockImplementation((_id: number, opts?: { onError?: (e: unknown) => void }) => {
|
||||||
|
opts?.onError?.(new ApiError(409, 'Conflict'));
|
||||||
|
});
|
||||||
|
mockedUseActiveSessions.mockReturnValue(
|
||||||
|
query<ReturnType<typeof useActiveSessions>>([activeSession()]),
|
||||||
|
);
|
||||||
|
renderStopwatch();
|
||||||
|
|
||||||
|
const stopBtn = await screen.findByRole('button', { name: 'Stop & Opslaan' });
|
||||||
|
await user.click(stopBtn);
|
||||||
|
|
||||||
|
expect(stopMutate).toHaveBeenCalledTimes(1);
|
||||||
|
// The timer resets to idle despite the 409 (no stuck running state).
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole('button', { name: 'Start Stopwatch' })).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole('button', { name: 'Stop & Opslaan' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import type { InsoleType, WorkSession } from '@solelog/shared';
|
import type { InsoleType, WorkSession } from '@solelog/shared';
|
||||||
|
import { ApiError } from '../lib/api';
|
||||||
import { useActivities } from '../api/activities';
|
import { useActivities } from '../api/activities';
|
||||||
import {
|
import {
|
||||||
useActiveSessions,
|
useActiveSessions,
|
||||||
@@ -44,13 +45,26 @@ export default function Stopwatch() {
|
|||||||
const [discardPending, setDiscardPending] = useState(false);
|
const [discardPending, setDiscardPending] = useState(false);
|
||||||
const discardTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const discardTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Transient notice when the admin stopped/cancelled this session out from under us.
|
||||||
|
const [stoppedByAdmin, setStoppedByAdmin] = useState(false);
|
||||||
|
|
||||||
const isRunning = sessionId !== null;
|
const isRunning = sessionId !== null;
|
||||||
|
|
||||||
// Recover an active session on load (phone-died / resume-elsewhere path).
|
// Reconcile against server truth on every active-sessions poll:
|
||||||
|
// - running locally but our session is gone from the active list → admin stopped/cancelled it.
|
||||||
|
// - idle but the server has an active session → adopt it (phone-died / resume-elsewhere path).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isRunning) return;
|
|
||||||
const active = activeSessionsQuery.data;
|
const active = activeSessionsQuery.data;
|
||||||
if (!active || active.length === 0) return;
|
if (!active) return;
|
||||||
|
if (isRunning) {
|
||||||
|
const stillActive = active.some((s) => s.id === sessionId);
|
||||||
|
if (!stillActive) {
|
||||||
|
resetTimer();
|
||||||
|
setStoppedByAdmin(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (active.length === 0) return;
|
||||||
const session: WorkSession = active[0];
|
const session: WorkSession = active[0];
|
||||||
setSessionId(session.id);
|
setSessionId(session.id);
|
||||||
setStartMs(new Date(session.start_time).getTime());
|
setStartMs(new Date(session.start_time).getTime());
|
||||||
@@ -107,6 +121,7 @@ export default function Stopwatch() {
|
|||||||
|
|
||||||
function handleStart() {
|
function handleStart() {
|
||||||
if (!canStart || activeActivityId === null) return;
|
if (!canStart || activeActivityId === null) return;
|
||||||
|
setStoppedByAdmin(false); // clear any prior admin-stopped notice
|
||||||
startSession.mutate(
|
startSession.mutate(
|
||||||
{ activity_id: activeActivityId, insole_type: insoleType, pair_count: pairCount },
|
{ activity_id: activeActivityId, insole_type: insoleType, pair_count: pairCount },
|
||||||
{
|
{
|
||||||
@@ -155,10 +170,16 @@ export default function Stopwatch() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A 409 means the session is already closed server-side (e.g. admin stopped it) —
|
||||||
|
// reset locally instead of leaving the timer stuck.
|
||||||
|
function resetIfConflict(error: unknown) {
|
||||||
|
if (error instanceof ApiError && error.status === 409) resetTimer();
|
||||||
|
}
|
||||||
|
|
||||||
function handleStop() {
|
function handleStop() {
|
||||||
if (sessionId === null) return;
|
if (sessionId === null) return;
|
||||||
const id = sessionId;
|
const id = sessionId;
|
||||||
stopSession.mutate(id, { onSuccess: () => resetTimer() });
|
stopSession.mutate(id, { onSuccess: () => resetTimer(), onError: resetIfConflict });
|
||||||
// Selections (zool/handling/count) persist for the next session.
|
// Selections (zool/handling/count) persist for the next session.
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +198,7 @@ export default function Stopwatch() {
|
|||||||
discardTimerRef.current = null;
|
discardTimerRef.current = null;
|
||||||
}
|
}
|
||||||
const id = sessionId;
|
const id = sessionId;
|
||||||
discardSession.mutate(id, { onSuccess: () => resetTimer() });
|
discardSession.mutate(id, { onSuccess: () => resetTimer(), onError: resetIfConflict });
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusPill = !isRunning
|
const statusPill = !isRunning
|
||||||
@@ -192,6 +213,24 @@ export default function Stopwatch() {
|
|||||||
<div className="screen">
|
<div className="screen">
|
||||||
<h1 className="screen-title">Stopwatch</h1>
|
<h1 className="screen-title">Stopwatch</h1>
|
||||||
|
|
||||||
|
{stoppedByAdmin && !isRunning && (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
style={{
|
||||||
|
marginBottom: 20,
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderRadius: 12,
|
||||||
|
border: '1px solid #FDE68A',
|
||||||
|
background: '#FEF3C7',
|
||||||
|
color: '#92400E',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Deze sessie is door de beheerder gestopt.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Section 1 — Type zool */}
|
{/* Section 1 — Type zool */}
|
||||||
<h2 className="section-label">Type zool</h2>
|
<h2 className="section-label">Type zool</h2>
|
||||||
<div className="segmented" style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
|
<div className="segmented" style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
|
||||||
|
|||||||
@@ -20,12 +20,15 @@ services:
|
|||||||
api:
|
api:
|
||||||
image: gitea.vrossem.net/bas/solelog:latest
|
image: gitea.vrossem.net/bas/solelog:latest
|
||||||
container_name: solelog-api
|
container_name: solelog-api
|
||||||
env_file: .env # BETTER_AUTH_SECRET, BETTER_AUTH_URL, CORS_ORIGINS
|
env_file: .env # BETTER_AUTH_SECRET, BETTER_AUTH_URL, CORS_ORIGINS
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: file:/data/app.db
|
DATABASE_URL: file:/data/app.db
|
||||||
PORT: '3000'
|
PORT: '3000'
|
||||||
volumes:
|
volumes:
|
||||||
- solelog_db:/data
|
# Bind-mount the SQLite data dir onto attached storage on the host.
|
||||||
|
# Set SOLELOG_DATA_DIR in .env to an absolute path on your mounted disk
|
||||||
|
# (Docker creates it if missing). Falls back to ./data next to this file.
|
||||||
|
- ./data:/data
|
||||||
networks:
|
networks:
|
||||||
- solelog_network
|
- solelog_network
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -47,6 +50,3 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
solelog_network:
|
solelog_network:
|
||||||
external: true
|
external: true
|
||||||
|
|
||||||
volumes:
|
|
||||||
solelog_db:
|
|
||||||
|
|||||||
@@ -180,10 +180,21 @@ Each phase keeps the system working and is its own spec → plan → build cycle
|
|||||||
**3a implemented** (`apps/admin`; plan `docs/superpowers/plans/2026-06-17-phase-3a-admin-panel.md`):
|
**3a implemented** (`apps/admin`; plan `docs/superpowers/plans/2026-06-17-phase-3a-admin-panel.md`):
|
||||||
admin-only login (rejects non-admins via `role` on `/api/me`), the sidebar shell, the live
|
admin-only login (rejects non-admins via `role` on `/api/me`), the sidebar shell, the live
|
||||||
active-work view (`/api/admin/sessions/active`, 5 s auto-refresh, read-only), and activity
|
active-work view (`/api/admin/sessions/active`, 5 s auto-refresh, read-only), and activity
|
||||||
management (handelingen CRUD on `/api/activities`). **3b remaining:** reports/export
|
management (handelingen CRUD on `/api/activities`).
|
||||||
(all-users filtered CSV — current `/api/export` is self-scoped), user management
|
**3b·1 implemented** (plan `docs/superpowers/plans/2026-06-17-phase-3b1-manual-sessions.md`):
|
||||||
(better-auth `/api/auth/admin/*`), and manual entry/edit + admin stop/fix of a running
|
manual session entry/edit + admin stop/discard of a running session (new admin write
|
||||||
session (needs new backend endpoints).
|
endpoints `POST/PUT /api/admin/sessions[/:id]`, `…/:id/stop`, `…/:id/discard`, plus a
|
||||||
|
`GET /api/admin/users` roster), an admin **Sessies** screen (list + status filter +
|
||||||
|
create/edit form + row actions), Stop/Annuleer on the Live cards, and a worker stopwatch
|
||||||
|
that polls (15 s) and reconciles to server truth (converges within ~15 s when an admin
|
||||||
|
stops/cancels/edits a session — no stuck state).
|
||||||
|
**3b·2 implemented** (plan `docs/superpowers/plans/2026-06-24-phase-3b2-reports-export.md`):
|
||||||
|
the admin **Rapporten** screen (period presets Deze week / Deze maand / Alles +
|
||||||
|
worker/type/activity filters, headline totals and breakdowns per worker/activity/type) backed by
|
||||||
|
`GET /api/admin/report` (completed-only, JS aggregation), plus an all-users filtered CSV via
|
||||||
|
`GET /api/admin/export` — a shared `buildSessionsCsv(rows, {includeWorker})` now serves both the
|
||||||
|
worker self-export and the admin export. **3b remaining:** user management
|
||||||
|
(better-auth `/api/auth/admin/*`).
|
||||||
Activity management (add/edit/delete handelingen + their `insole_types`) was removed from the
|
Activity management (add/edit/delete handelingen + their `insole_types`) was removed from the
|
||||||
worker client in the Phase 2 follow-up because it is admin-only; it must be **ported here**. The
|
worker client in the Phase 2 follow-up because it is admin-only; it must be **ported here**. The
|
||||||
backend already exists (`/api/activities` writes are admin-gated; `useActivities`/the legacy
|
backend already exists (`/api/activities` writes are admin-gated; `useActivities`/the legacy
|
||||||
|
|||||||
98
docs/sessions/2026-06-17-phase-3b1-manual-sessions.md
Normal file
98
docs/sessions/2026-06-17-phase-3b1-manual-sessions.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# Session: 2026-06-17 — Phase 3b·1 (Manual session entry/edit + admin stop/fix + worker convergence)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Let an admin take full control of sessions: list/filter every session, manually create a
|
||||||
|
completed session for a worker, edit any session (server recomputes duration), and
|
||||||
|
stop/cancel a worker's active session — and make the worker's stopwatch converge to those
|
||||||
|
admin changes within ~15 s with no stuck state. No DB migration (reuses the existing
|
||||||
|
`work_sessions` columns, incl. the pause fields from the prior cycle).
|
||||||
|
Spec: `docs/superpowers/specs/2026-06-17-phase-3b1-manual-sessions-design.md`;
|
||||||
|
plan: `docs/superpowers/plans/2026-06-17-phase-3b1-manual-sessions.md`.
|
||||||
|
|
||||||
|
## Work done
|
||||||
|
|
||||||
|
Implemented task-by-task per the plan (TDD throughout), one commit per task:
|
||||||
|
|
||||||
|
- **Task 1 — Shared contracts** (`00993c6`). `@solelog/shared` gains
|
||||||
|
`CreateManualSessionInput` (worker id + activity + insole type + pair count +
|
||||||
|
`start_time`/`end_time` + `paused_seconds` default 0 + optional `notes`) and
|
||||||
|
`AdminUpdateSessionInput` (editable fields incl. nullable `end_time`/`insole_type` and a
|
||||||
|
`status`), with their inferred types. Duration is never accepted from the client.
|
||||||
|
- **Task 2 — Backend admin session write endpoints + users roster** (`53c66ee`). In
|
||||||
|
`apps/api/src/routes/admin.ts` (behind the existing `/api/admin/*` admin guard):
|
||||||
|
`GET /api/admin/users` (id/name/email, ordered by name), `POST /api/admin/sessions`
|
||||||
|
(validates the worker + activity, rejects `end < start` and `paused > span`, stores
|
||||||
|
`source='manual'`, `status='completed'`, server-computed `duration_seconds`),
|
||||||
|
`PUT /api/admin/sessions/:id` (recomputes duration; nullable `end_time` keeps an active
|
||||||
|
row open), and `POST …/:id/stop` (folds any open pause span, `end=now`, completes) /
|
||||||
|
`…/:id/discard` (`status='discarded'`, `end=now`). All session responses go through
|
||||||
|
`toWorkSession`. Workers hitting any of these get 403; stop/discard on an
|
||||||
|
already-closed session → 409.
|
||||||
|
- **Task 3 — Admin api hooks + Sessions screen** (`b536641`). `api/admin-sessions.ts`:
|
||||||
|
`useAllSessions`, `useAdminUsers`, `useCreateManualSession`, `useUpdateSession`,
|
||||||
|
`useAdminStopSession`, `useAdminDiscardSession` (mutations invalidate
|
||||||
|
`['admin','sessions']`). New `screens/Sessions.tsx` (title "Sessies", status `<select>`
|
||||||
|
filter alle/actief/voltooid/geannuleerd, `+ Nieuwe registratie`, a row per session with
|
||||||
|
worked time + a `Pauze …` line when paused, ✎ on all rows, Stop/Annuleer on active rows).
|
||||||
|
Sidebar gains a "Sessies" nav item and drops "Handmatig" from the soon group; `App.tsx`
|
||||||
|
gets the `/sessies` route; table/form/action styles added to `styles.css`.
|
||||||
|
- **Task 4 — Admin create/edit session form** (`69b46be`). `components/SessionForm.tsx`
|
||||||
|
(`mode: 'create' | 'edit'`): create mode shows a worker picker (`useAdminUsers`) and posts
|
||||||
|
`CreateManualSessionInput`; edit mode prefills from the session, hides the worker picker,
|
||||||
|
and PUTs the changed fields. Activity `<select>`, insole-type toggles, pair-count stepper,
|
||||||
|
start/end `datetime-local` (built into ISO), paused input, status `<select>` (edit only),
|
||||||
|
notes, and a derived "gewerkt" line (`end − start − paused`). Wired into Sessions:
|
||||||
|
`+ Nieuwe registratie` opens create, ✎ opens edit.
|
||||||
|
- **Task 5 — Stop/Annuleer on the Live view** (`f1ec249`). The active `LiveCard` gains Stop
|
||||||
|
(`POST …/:id/stop`) and Annuleer (`…/discard`) buttons via the admin hooks; on success the
|
||||||
|
active query invalidates and the card drops off.
|
||||||
|
- **Task 6 — Worker poll + reconcile to server truth** (`7d3daaa`).
|
||||||
|
`useActiveSessions` gets `refetchInterval: 15000`. The stopwatch's active-session effect
|
||||||
|
now resets to idle and shows **"Deze sessie is door de beheerder gestopt."** when the
|
||||||
|
worker's local session id is absent from the server's active list (covers admin stop,
|
||||||
|
discard, and edit-to-completed alike); a 409 from stop/discard resets locally with no
|
||||||
|
error surfaced. The existing "adopt an active session when idle" recovery is kept.
|
||||||
|
- **Task 7 — Docs, lint, verification** (this task). Lint/format clean, full green matrix,
|
||||||
|
in-process endpoint coverage, and this session log + roadmap note.
|
||||||
|
|
||||||
|
## Verification (Task 7)
|
||||||
|
|
||||||
|
- `npx oxlint` — clean (exit 0).
|
||||||
|
- `npx oxfmt --check` over the 16 cycle-changed source files — "All matched files use the
|
||||||
|
correct format" (earlier tasks formatted as they went; nothing to reformat).
|
||||||
|
- `yarn workspace @solelog/api typecheck` — pass; `test` — **75 passed** (12 files).
|
||||||
|
- `yarn workspace @solelog/admin typecheck` — pass; `test` — **36 passed** (7 files);
|
||||||
|
`build` — pass (vite, 91 modules).
|
||||||
|
- `yarn workspace @solelog/worker typecheck` — pass; `test` — **30 passed** (8 files);
|
||||||
|
`build` — pass (vite, 91 modules).
|
||||||
|
- **Smoke** — the plan's live-server smoke was deliberately driven **in-process** instead of
|
||||||
|
by starting the API (`createApp()` + `app.request` in `apps/api/test/admin.test.ts`), so
|
||||||
|
port 3000 was never bound — this avoids the Windows libsql lock trap. The in-process tests
|
||||||
|
cover every smoke scenario end-to-end: `GET /api/admin/users` (admin 200 / worker 403);
|
||||||
|
`POST /api/admin/sessions` with `paused_seconds: 600` over a 1 h span →
|
||||||
|
`source='manual'`, `status='completed'`, `duration_seconds === 3000` (3600 − 600);
|
||||||
|
`end < start` → 400; unknown user/activity → 404; `PUT` edit recomputes duration;
|
||||||
|
`…/:id/stop` on another worker's active session → completed with computed duration;
|
||||||
|
`…/:id/discard` → `status='discarded'`; stop after discard → 409; worker token on any
|
||||||
|
write → 403.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
Phase 3b·1 is implemented and green across all three workspaces. An admin can list and
|
||||||
|
filter every session, manually log a completed session for a worker (duration always
|
||||||
|
derived server-side and excluding paused time, `source='manual'`), edit any session,
|
||||||
|
and stop or cancel a worker's running session from either the **Sessies** screen or the
|
||||||
|
**Live** cards. The worker stopwatch polls every 15 s and reconciles to server truth — when
|
||||||
|
an admin stops/cancels/edits a worker's active session, the worker's clock resets and shows
|
||||||
|
"Deze sessie is door de beheerder gestopt." within ~15 s, with no stuck state (a racing 409
|
||||||
|
also resets cleanly). Roadmap Phase 3 status updated: **3b·1 done; reports/export and user
|
||||||
|
management remain**.
|
||||||
|
|
||||||
|
The two unrelated working-tree edits to `.env.prod.example` / `docker-compose.prod.yml`
|
||||||
|
(maintainer's deploy work) were left untouched — out of this cycle's scope.
|
||||||
|
|
||||||
|
## Next (Phase 3b remainder)
|
||||||
|
|
||||||
|
- Reports + all-users filtered CSV export (current `/api/export` is self-scoped).
|
||||||
|
- User management via better-auth `/api/auth/admin/*`.
|
||||||
95
docs/sessions/2026-06-24-phase-3b2-reports-export.md
Normal file
95
docs/sessions/2026-06-24-phase-3b2-reports-export.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# Session: 2026-06-24 — Phase 3b·2 (Reports + all-users export)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Give the admin a **Rapporten** screen: pick a period (Deze week / Deze maand / Alles + manual
|
||||||
|
from/to) and optionally narrow by worker / insole type / activity, see headline production totals
|
||||||
|
plus three breakdowns (per medewerker / per handeling / per type), and export the underlying
|
||||||
|
detail rows (all workers) to CSV. Today's `/api/export` is **self-scoped** to the logged-in user;
|
||||||
|
this cycle adds the cross-user, filterable reporting + export. Second of three Phase 3b cycles
|
||||||
|
(after manual-sessions 3b·1; user management remains).
|
||||||
|
Spec: `docs/superpowers/specs/2026-06-24-phase-3b2-reports-export-design.md`;
|
||||||
|
plan: `docs/superpowers/plans/2026-06-24-phase-3b2-reports-export.md`.
|
||||||
|
Tracked as Plane epic **SL-48** with tasks **SL-49…SL-53**.
|
||||||
|
|
||||||
|
## Scope decisions (brainstorming)
|
||||||
|
|
||||||
|
- **Both lenses on one screen** (totals + breakdowns per worker AND activity AND type).
|
||||||
|
- **Filters:** date range (spine) + optional worker + type + activity; default **this week**.
|
||||||
|
- **Completed-only** counts toward totals/export (active/discarded excluded).
|
||||||
|
- **All four metrics:** gewerkte tijd, aantal zolen, aantal sessies, pauzetijd.
|
||||||
|
- **CSV = detail rows, all workers** (a leading Worker column), not the aggregated summary.
|
||||||
|
- **Tables only** — no chart library, no CSS bars (dependency-light).
|
||||||
|
|
||||||
|
## Work done
|
||||||
|
|
||||||
|
Built via a 5-task TDD **Workflow** (`w7ut902zo`), one commit per task, run sequentially because the
|
||||||
|
tasks share one working tree:
|
||||||
|
|
||||||
|
- **Task 1 — DRY CSV builder** (`01fa18f`). Extracted the inline header/row builder from the
|
||||||
|
self-scoped `/api/export` into `buildSessionsCsv(rows, { includeWorker })` + a `SessionCsvRow`
|
||||||
|
interface in `apps/api/src/lib/csv.ts`. `sessions.ts` now maps its query rows into `SessionCsvRow`
|
||||||
|
and calls the shared builder. `includeWorker:false` keeps the worker export **byte-identical**
|
||||||
|
(the existing `export.test.ts` is the regression guard); `includeWorker:true` prepends a Worker
|
||||||
|
column. New `csv.test.ts` covers both header variants.
|
||||||
|
- **Task 2 — Report contracts + `GET /api/admin/report`** (`4b213e2`). Added `ReportTotals`,
|
||||||
|
`ReportWorkerRow`, `ReportActivityRow`, `ReportTypeRow`, `ReportResponse` to `@solelog/shared`.
|
||||||
|
In `admin.ts`: `parseReportQuery` (validates `from`/`to` range, `insole_type`, `activity_id`),
|
||||||
|
`buildSessionFilters` (completed-only + date range + optional worker/type/activity), and the
|
||||||
|
report route — fetches the filtered joined rows and aggregates `totals` + `by_worker`/
|
||||||
|
`by_activity`/`by_type` in JS (sorted by `worked_seconds` desc; empty range → zeros + empty
|
||||||
|
arrays; null type bucketed under "Onbekend"). `report.test.ts` (5 cases).
|
||||||
|
- **Task 3 — `GET /api/admin/export`** (`d33fcb7`). All-users filtered CSV reusing
|
||||||
|
`parseReportQuery` + `buildSessionFilters` (Task 2) and `buildSessionsCsv(includeWorker:true)`
|
||||||
|
(Task 1); ordered by `start_time` asc; filename `solelog-report_<from>_<to>.csv`. 401/403 inherited
|
||||||
|
from the `/api/admin/*` guard. Export tests appended to `report.test.ts`.
|
||||||
|
- **Task 4 — Admin API client** (`8ad2e69`). `apps/admin/src/api/reports.ts`: `ReportFilters`,
|
||||||
|
`filtersToQuery` (omits empty optionals), `useReport` (React Query), and `downloadExport` — a raw
|
||||||
|
`fetch` with the bearer token → `Blob` → object-URL download (a plain `<a href>` can't carry the
|
||||||
|
token). `reports.test.ts` covers serialization + the bearer-fetch download + non-ok throw.
|
||||||
|
- **Task 5 — Rapporten screen + nav** (`8d75be0`). Pure `lib/date-range.ts` helpers (`thisWeek`,
|
||||||
|
`thisMonth`, `allTime`, `dayStartISO`/`dayEndISO` — local-day → ISO instants so there's no server
|
||||||
|
tz ambiguity) with unit tests. `screens/Reports.tsx`: filter bar (presets + date inputs + worker/
|
||||||
|
type/handeling dropdowns), a headline totals line, and three breakdown tables, plus the
|
||||||
|
**Exporteer CSV** button wired to `downloadExport`. Sidebar moves **Rapporten** into `navItems`
|
||||||
|
(soon = just `Gebruikers`); `App.tsx` gets the `/rapporten` route; report styles appended to
|
||||||
|
`styles.css`.
|
||||||
|
|
||||||
|
## Deviations (fix-forward, test-only)
|
||||||
|
|
||||||
|
- **Task 3:** the API test DB is reset once per file (`test/setup.ts`), so the WIDE-window export
|
||||||
|
tests initially picked up sessions seeded by the earlier report tests. Fixed by scoping the two
|
||||||
|
non-gating export tests to their own freshly-seeded activity via `&activity_id=…` (a real filter
|
||||||
|
the route already supports). The route code is exactly as planned.
|
||||||
|
- **Task 5:** the plan's `Reports.test.tsx` had a few assertions that collided with the screen's own
|
||||||
|
markup (headline `36` vs a table cell; `Jan`/`Kurk` matching dropdown `<option>`s). Tightened three
|
||||||
|
assertions (scope to `.reports-totals`; target `getByRole('cell', …)`) and loosened the apiFetch
|
||||||
|
mock signature for a teardown refetch. Screen behavior/markup unchanged.
|
||||||
|
|
||||||
|
## Verification (independent of the workflow's self-reports)
|
||||||
|
|
||||||
|
- `git log` — five task commits `01fa18f → 8d75be0`; **clean tree**.
|
||||||
|
- `yarn workspace @solelog/api test` — **85 passed** (14 files), incl. `csv`, `report`, and the
|
||||||
|
unchanged regression `export.test.ts`; `typecheck` clean.
|
||||||
|
- `yarn workspace @solelog/admin test` — **46 passed** (10 files), incl. `reports`, `date-range`,
|
||||||
|
`Reports`; `typecheck` clean; `build` succeeds (vite, 94 modules).
|
||||||
|
- `npx oxlint` — clean (exit 0).
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
Phase 3b·2 is implemented and green across both touched workspaces. An admin opens **Rapporten**,
|
||||||
|
picks a period + optional filters, and sees correct headline totals (worked / zolen / sessies /
|
||||||
|
pauze) with three breakdown tables, then exports every matching completed session (all workers, with
|
||||||
|
a Worker column) to a range-named CSV. One CSV format now serves both the worker self-export and the
|
||||||
|
admin export. No DB migration (reuses existing `work_sessions` columns). Plane SL-48 + SL-49…SL-53
|
||||||
|
all marked Done. Roadmap Phase 3 note updated: **3b·2 done; user management is the last 3b cycle.**
|
||||||
|
|
||||||
|
The maintainer's deploy WIP (`.env.prod.example` / `docker-compose.prod.yml`) was left untouched.
|
||||||
|
`origin/main` is **behind** (the pre-compaction push never landed); after this cycle `main` is ahead
|
||||||
|
**~16** commits — a single `git push origin main` from the maintainer's terminal carries the batch
|
||||||
|
(and triggers Gitea CI).
|
||||||
|
|
||||||
|
## Next (Phase 3b remainder)
|
||||||
|
|
||||||
|
- **User management** — create / set role / deactivate via better-auth `/api/auth/admin/*` (the
|
||||||
|
`Gebruikers` screen). Final 3b cycle.
|
||||||
295
docs/superpowers/plans/2026-06-17-phase-3b1-manual-sessions.md
Normal file
295
docs/superpowers/plans/2026-06-17-phase-3b1-manual-sessions.md
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
# Phase 3b·1 — Manual Session Entry/Edit + Admin Stop/Fix — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** Implement task-by-task with TDD. Steps use checkbox (`- [ ]`).
|
||||||
|
> Spec: `docs/superpowers/specs/2026-06-17-phase-3b1-manual-sessions-design.md`.
|
||||||
|
|
||||||
|
**Goal:** Admin can list/filter all sessions, manually create a completed session for a worker,
|
||||||
|
edit any session (server recomputes duration), and stop/cancel a worker's active session — and the
|
||||||
|
worker's stopwatch converges to admin changes within ~15s (no stuck state).
|
||||||
|
|
||||||
|
**Architecture:** New admin-gated write endpoints + a users-roster endpoint in `routes/admin.ts`;
|
||||||
|
a new admin **Sessies** screen + shared create/edit form; Stop/Annuleer actions on Live; worker
|
||||||
|
polls + reconciles. **No DB migration** (reuses existing `work_sessions` columns).
|
||||||
|
|
||||||
|
**Tech Stack:** Hono + Drizzle + libsql (api), Vite+React+react-query (admin, worker),
|
||||||
|
`@solelog/shared` zod, vitest. Yarn 4 monorepo.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- **TDD**: failing test → see it fail → minimal implementation → green → commit.
|
||||||
|
- **Commit per task**, conventional-commit message; commit **locally only** (no push/remote/amend);
|
||||||
|
stage only your task's files.
|
||||||
|
- **oxlint + oxfmt on changed files only.** `.oxfmtrc.json` now uses `trailingComma: "all"`
|
||||||
|
(prettier-style) — keep trailing commas on multiline params/args/arrays/objects; `docs/**` and
|
||||||
|
`**/drizzle/**` are ignored by the formatter. 2-space, single quotes, semicolons, width 100.
|
||||||
|
- **Dutch UI strings.**
|
||||||
|
- **No DB migration** this cycle. **Do not start the API server** (tests use in-process
|
||||||
|
`app.request`); if you must, kill the tree + free port 3000 afterward (Windows lock trap).
|
||||||
|
- Reuse `toWorkSession` (`apps/api/src/lib/work-session.ts`) for session responses. New admin
|
||||||
|
routes live in `apps/api/src/routes/admin.ts`, already behind the `/api/admin/*` admin guard.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/shared/src/index.ts MODIFY CreateManualSessionInput, AdminUpdateSessionInput
|
||||||
|
apps/api/src/routes/admin.ts MODIFY GET /api/admin/users; POST/PUT/stop/discard sessions
|
||||||
|
apps/api/test/admin.test.ts MODIFY
|
||||||
|
apps/admin/src/api/admin-sessions.ts MODIFY add list/users/create/update/stop/discard hooks
|
||||||
|
apps/admin/src/screens/Sessions.tsx CREATE list + status filter + row actions
|
||||||
|
apps/admin/src/components/SessionForm.tsx CREATE create/edit form
|
||||||
|
apps/admin/src/components/Sidebar.tsx MODIFY add 'Sessies' nav; drop 'Handmatig' from soon
|
||||||
|
apps/admin/src/App.tsx MODIFY add /sessies route
|
||||||
|
apps/admin/src/screens/Live.tsx MODIFY Stop/Annuleer on LiveCard
|
||||||
|
apps/admin/src/styles.css MODIFY table/form/action styles
|
||||||
|
apps/admin/src/screens/Sessions.test.tsx, components/SessionForm.test.tsx, screens/Live.test.tsx TEST
|
||||||
|
apps/worker/src/api/sessions.ts MODIFY refetchInterval 15000
|
||||||
|
apps/worker/src/screens/Stopwatch.tsx MODIFY reconcile + 409 handling
|
||||||
|
apps/worker/src/screens/Stopwatch.test.tsx TEST
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Shared contracts
|
||||||
|
|
||||||
|
**Files:** `packages/shared/src/index.ts`; test `packages/shared` (or assert via api tests — add a
|
||||||
|
tiny parse test if shared has a test setup, else cover via Task 2's api tests).
|
||||||
|
|
||||||
|
**Interfaces — Produces:**
|
||||||
|
- `CreateManualSessionInput = z.object({ user_id: z.string(), activity_id: z.number().int(),
|
||||||
|
insole_type: InsoleType, pair_count: z.number().int().min(1), start_time: z.string(),
|
||||||
|
end_time: z.string(), paused_seconds: z.number().int().min(0).default(0),
|
||||||
|
notes: z.string().nullable().optional() })`.
|
||||||
|
- `AdminUpdateSessionInput = z.object({ activity_id: z.number().int(), insole_type:
|
||||||
|
InsoleType.nullable(), pair_count: z.number().int().min(1), start_time: z.string(),
|
||||||
|
end_time: z.string().nullable(), paused_seconds: z.number().int().min(0),
|
||||||
|
notes: z.string().nullable(), status: SessionStatus })`.
|
||||||
|
|
||||||
|
- [ ] **Step 1:** If `packages/shared` has no test runner, skip a standalone test here and rely on
|
||||||
|
Task 2's API tests to exercise the schemas (note this in the commit). Otherwise add a parse
|
||||||
|
test (valid input parses; `pair_count: 0` fails).
|
||||||
|
- [ ] **Step 2:** Add both schemas + inferred types after `StartSessionInput`/`AdminUser`.
|
||||||
|
- [ ] **Step 3:** `yarn workspace @solelog/api typecheck` (shared is consumed there) — green.
|
||||||
|
- [ ] **Step 4: Commit** — `feat(shared): manual-session create/update contracts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Backend — admin session write endpoints + users roster
|
||||||
|
|
||||||
|
**Files:** `apps/api/src/routes/admin.ts`; test `apps/api/test/admin.test.ts`.
|
||||||
|
|
||||||
|
**Interfaces — Produces** `GET /api/admin/users`, `POST /api/admin/sessions`,
|
||||||
|
`PUT /api/admin/sessions/:id`, `POST /api/admin/sessions/:id/stop`,
|
||||||
|
`POST /api/admin/sessions/:id/discard`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing tests** (helpers: `createTestUser`/`bearer`/`seedActivity`; create an admin
|
||||||
|
with `authToken(app, email, 'admin')` per the existing pattern):
|
||||||
|
- `GET /api/admin/users` (admin) returns objects with `id/name/email`; 403 for a worker.
|
||||||
|
- `POST /api/admin/sessions` (admin) with a worker's id + activity + `start_time`/`end_time` 1h
|
||||||
|
apart + `paused_seconds: 600` → 201/200 with `source==='manual'`, `status==='completed'`,
|
||||||
|
`duration_seconds === 3000` (3600−600).
|
||||||
|
- `POST` with `end < start` → 400; unknown `user_id`/`activity_id` → 404/400.
|
||||||
|
- `PUT /api/admin/sessions/:id` edits a session and recomputes duration; `end<start` → 400.
|
||||||
|
- `POST …/:id/stop` on another user's **active** session → completed with computed duration;
|
||||||
|
`…/:id/discard` → `status==='discarded'`. Worker token on any of these → 403.
|
||||||
|
- [ ] **Step 2: Run — fail** (routes 404).
|
||||||
|
- [ ] **Step 3: Implement** in `admin.ts` (imports: `and, eq, asc, desc` from drizzle-orm,
|
||||||
|
`CreateManualSessionInput`, `AdminUpdateSessionInput` from `@solelog/shared`, `activities`,
|
||||||
|
`user`, `workSessions` from schema, `toWorkSession`). All routes sit after the existing
|
||||||
|
`/api/admin/*` guard (already admin-gated).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
adminRoutes.get('/api/admin/users', async (c) => {
|
||||||
|
const rows = await db
|
||||||
|
.select({ id: user.id, name: user.name, email: user.email })
|
||||||
|
.from(user)
|
||||||
|
.orderBy(asc(user.name));
|
||||||
|
return c.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
function computeDuration(startMs: number, endMs: number, paused: number) {
|
||||||
|
return Math.max(0, Math.round((endMs - startMs) / 1000) - paused);
|
||||||
|
}
|
||||||
|
|
||||||
|
adminRoutes.post('/api/admin/sessions', async (c) => {
|
||||||
|
const parsed = CreateManualSessionInput.safeParse(await c.req.json().catch(() => null));
|
||||||
|
if (!parsed.success) return c.json({ error: 'Invalid input' }, 400);
|
||||||
|
const d = parsed.data;
|
||||||
|
const start = new Date(d.start_time);
|
||||||
|
const end = new Date(d.end_time);
|
||||||
|
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end < start)
|
||||||
|
return c.json({ error: 'Invalid input' }, 400);
|
||||||
|
const [u] = await db.select({ id: user.id }).from(user).where(eq(user.id, d.user_id));
|
||||||
|
if (!u) return c.json({ error: 'User not found' }, 404);
|
||||||
|
const [act] = await db.select().from(activities).where(eq(activities.id, d.activity_id));
|
||||||
|
if (!act) return c.json({ error: 'Activity not found' }, 404);
|
||||||
|
if (d.paused_seconds > Math.round((end.getTime() - start.getTime()) / 1000))
|
||||||
|
return c.json({ error: 'Invalid input' }, 400);
|
||||||
|
const [row] = await db
|
||||||
|
.insert(workSessions)
|
||||||
|
.values({
|
||||||
|
userId: d.user_id,
|
||||||
|
activityId: d.activity_id,
|
||||||
|
insoleType: d.insole_type,
|
||||||
|
pairCount: d.pair_count,
|
||||||
|
startTime: start,
|
||||||
|
endTime: end,
|
||||||
|
durationSeconds: computeDuration(start.getTime(), end.getTime(), d.paused_seconds),
|
||||||
|
pausedSeconds: d.paused_seconds,
|
||||||
|
pausedAt: null,
|
||||||
|
status: 'completed',
|
||||||
|
source: 'manual',
|
||||||
|
notes: d.notes ?? null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return c.json(toWorkSession(row, { activityName: act.name }));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- **PUT `:id`** — load the row (any user); 404 if missing; validate activity; if `end_time`
|
||||||
|
present validate `end ≥ start` and `paused ≤ span`, set `durationSeconds` via `computeDuration`,
|
||||||
|
else `durationSeconds = null` and (if `status==='active'`) keep it open. Set the editable
|
||||||
|
fields; return `toWorkSession`.
|
||||||
|
- **stop `:id`** — load active row; if `pausedAt`, fold the open span into `pausedSeconds`;
|
||||||
|
`end=now`; `durationSeconds=computeDuration(...)`; `status='completed'`, `pausedAt=null`.
|
||||||
|
- **discard `:id`** — load active row; `status='discarded'`, `end=now`.
|
||||||
|
- Register `stop`/`discard` (literal subpaths) and the bare `:id` PUT so they don't collide
|
||||||
|
(Hono matches `/api/admin/sessions/:id/stop` distinctly from `/:id`).
|
||||||
|
- [ ] **Step 4: Run tests + typecheck — green.**
|
||||||
|
- [ ] **Step 5: Commit** — `feat(api): admin manual-session create/edit/stop/discard + users roster`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Admin — api hooks + Sessions screen (list + filter + nav/route)
|
||||||
|
|
||||||
|
**Files:** `apps/admin/src/api/admin-sessions.ts`, `apps/admin/src/screens/Sessions.tsx` (create),
|
||||||
|
`apps/admin/src/components/Sidebar.tsx`, `apps/admin/src/App.tsx`, `apps/admin/src/styles.css`;
|
||||||
|
test `apps/admin/src/screens/Sessions.test.tsx`.
|
||||||
|
|
||||||
|
**Interfaces — Produces** `useAllSessions`, `useAdminUsers`, `useCreateManualSession`,
|
||||||
|
`useUpdateSession`, `useAdminStopSession`, `useAdminDiscardSession`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing test** (mock `apiFetch`): `Sessions` renders a row per session (worker +
|
||||||
|
activity + worked); the status filter narrows the list (e.g. selecting "actief" shows only
|
||||||
|
active); `Stop`/`Annuleer` appear only on active rows and call the right endpoints; ✎ present on
|
||||||
|
all rows.
|
||||||
|
- [ ] **Step 2: Run — fail.**
|
||||||
|
- [ ] **Step 3: Hooks** in `api/admin-sessions.ts` (keep `useActiveSessions`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function useAllSessions() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['admin', 'sessions', 'all'],
|
||||||
|
queryFn: () => apiFetch<WorkSession[]>('/api/admin/sessions'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function useAdminUsers() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['admin', 'users'],
|
||||||
|
queryFn: () => apiFetch<{ id: string; name: string; email: string }[]>('/api/admin/users'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// useCreateManualSession / useUpdateSession / useAdminStopSession / useAdminDiscardSession:
|
||||||
|
// useMutation hitting POST /api/admin/sessions, PUT /api/admin/sessions/:id,
|
||||||
|
// POST /api/admin/sessions/:id/stop|/discard; each onSuccess invalidates ['admin','sessions'].
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Sidebar + route** — add `{ to: '/sessies', label: 'Sessies' }` to `navItems` in
|
||||||
|
`Sidebar.tsx`; remove `'Handmatig'` from `soonItems` (leaves `['Rapporten', 'Gebruikers']`). In
|
||||||
|
`App.tsx` import `Sessions` and add `<Route path="/sessies" element={<Sessions />} />`.
|
||||||
|
- [ ] **Step 5: Screen** — `Sessions.tsx`: title "Sessies", a status `<select>` filter
|
||||||
|
(alle/actief/voltooid/geannuleerd), `+ Nieuwe registratie` button (opens the form from Task 4 —
|
||||||
|
for now a stub/`onCreate` prop or local state placeholder), a table/list of rows with worked
|
||||||
|
time (reuse `formatTime` from `lib/elapsed`; show `Pauze …` when `paused_seconds>0`), ✎ edit and
|
||||||
|
(active only) `Stop`/`Annuleer` buttons wired to the hooks. Loading/error/empty states in Dutch.
|
||||||
|
- [ ] **Step 6: Styles** — add `.sessions-*`/table/action-button CSS to `styles.css`.
|
||||||
|
- [ ] **Step 7: Run admin tests + typecheck + build — green.**
|
||||||
|
- [ ] **Step 8: Commit** — `feat(admin): sessions management screen (list + filter + actions)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Admin — create/edit session form
|
||||||
|
|
||||||
|
**Files:** `apps/admin/src/components/SessionForm.tsx` (create), wire into
|
||||||
|
`apps/admin/src/screens/Sessions.tsx`, `apps/admin/src/styles.css`; test
|
||||||
|
`apps/admin/src/components/SessionForm.test.tsx`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing tests** (mock `apiFetch`/hooks): in **create** mode the form shows a worker
|
||||||
|
picker (from `useAdminUsers`) and submitting posts `CreateManualSessionInput` with the entered
|
||||||
|
values; in **edit** mode it prefills from the session, hides the worker picker, and submitting
|
||||||
|
PUTs the changed fields. The "gewerkt" preview = `end − start − paused`.
|
||||||
|
- [ ] **Step 2: Run — fail.**
|
||||||
|
- [ ] **Step 3: Implement `SessionForm.tsx`** — props `{ mode: 'create' | 'edit', session?,
|
||||||
|
onClose }`. Fields: worker `<select>` (create only), activity `<select>` (from `useActivities`),
|
||||||
|
insole-type toggles, pair-count stepper, start/end `datetime-local`, paused (minutes or H:MM),
|
||||||
|
status `<select>` (edit only), notes `<textarea>`, and a derived "gewerkt" line. Build the ISO
|
||||||
|
`start_time`/`end_time` from the datetime-local values. Submit via `useCreateManualSession` /
|
||||||
|
`useUpdateSession`; close on success. Inline error on 400.
|
||||||
|
- [ ] **Step 4: Wire into Sessions** — `+ Nieuwe registratie` opens it in create mode; ✎ opens it
|
||||||
|
in edit mode with the row's session. Modal or inline panel — keep it simple.
|
||||||
|
- [ ] **Step 5: Run admin tests + typecheck + build — green.**
|
||||||
|
- [ ] **Step 6: Commit** — `feat(admin): manual session create/edit form`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Admin — Stop/Annuleer on the Live view
|
||||||
|
|
||||||
|
**Files:** `apps/admin/src/screens/Live.tsx`, `apps/admin/src/styles.css`; test
|
||||||
|
`apps/admin/src/screens/Live.test.tsx`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing test:** an active LiveCard shows `Stop` and `Annuleer`; clicking `Stop`
|
||||||
|
calls `POST /api/admin/sessions/:id/stop`, `Annuleer` calls `…/discard`.
|
||||||
|
- [ ] **Step 2: Run — fail.**
|
||||||
|
- [ ] **Step 3: Implement** — add the two buttons to `LiveCard`, wired to `useAdminStopSession` /
|
||||||
|
`useAdminDiscardSession`; on success the active query invalidates and the card drops off.
|
||||||
|
- [ ] **Step 4: Run admin tests + typecheck + build — green.**
|
||||||
|
- [ ] **Step 5: Commit** — `feat(admin): stop/cancel an active session from the live view`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Worker — poll + reconcile to server truth
|
||||||
|
|
||||||
|
**Files:** `apps/worker/src/api/sessions.ts`, `apps/worker/src/screens/Stopwatch.tsx`; test
|
||||||
|
`apps/worker/src/screens/Stopwatch.test.tsx`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing tests** (mock `apiFetch`): with a session running locally, when the
|
||||||
|
active-sessions query resolves to a list **without** that session, the stopwatch resets to the
|
||||||
|
idle/start state and shows **"Deze sessie is door de beheerder gestopt."**; a 409 from the stop
|
||||||
|
mutation resets locally (no error surfaced).
|
||||||
|
- [ ] **Step 2: Run — fail.**
|
||||||
|
- [ ] **Step 3: Poll** — in `api/sessions.ts`, add `refetchInterval: 15000` to `useActiveSessions`.
|
||||||
|
- [ ] **Step 4: Reconcile** — in `Stopwatch.tsx`, extend the active-session effect: when
|
||||||
|
`activeSessionsQuery.data` is present and the worker has a local `sessionId` that is **not** in
|
||||||
|
the returned active list, call `resetTimer()` and set a transient `stoppedByAdmin` notice
|
||||||
|
(cleared on next start). Keep the existing "adopt an active session when idle" recovery.
|
||||||
|
- [ ] **Step 5: 409 handling** — give `useStopSession`/`useDiscardSession` (or the `handleStop`/
|
||||||
|
`handleDiscard` callers) an error path: if the error is `ApiError` with `status === 409`, call
|
||||||
|
`resetTimer()` (it's already closed) instead of leaving the timer stuck.
|
||||||
|
- [ ] **Step 6: Run worker tests + typecheck + build — green.**
|
||||||
|
- [ ] **Step 7: Commit** — `feat(worker): poll + reconcile stopwatch to server (admin stop/cancel)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Docs, lint, verification
|
||||||
|
|
||||||
|
**Files:** `docs/roadmap.md`, `docs/sessions/2026-06-17-phase-3b1-manual-sessions.md` (create).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Lint/format** — `npx oxlint` clean; `npx oxfmt` on changed files only.
|
||||||
|
- [ ] **Step 2: Full green** — `yarn workspace @solelog/api typecheck && test`;
|
||||||
|
`yarn workspace @solelog/admin typecheck && test && build`;
|
||||||
|
`yarn workspace @solelog/worker typecheck && test && build`.
|
||||||
|
- [ ] **Step 3: Live smoke (preferred)** — start API, seed; as admin: `GET /api/admin/users`,
|
||||||
|
`POST /api/admin/sessions` (manual, confirm duration excludes paused + `source=manual`),
|
||||||
|
`PUT` edit, `stop`/`discard` on a worker's active session; then **kill the server tree + free
|
||||||
|
port 3000**.
|
||||||
|
- [ ] **Step 4: Docs** — session log (goal/work/verification/outcome) + a roadmap note (Phase 3b·1
|
||||||
|
done; reports/export and user management remain).
|
||||||
|
- [ ] **Step 5: Commit** — `docs: phase 3b·1 manual-sessions session log + roadmap note`.
|
||||||
|
|
||||||
|
## Self-Review notes
|
||||||
|
|
||||||
|
- Duration is always derived server-side (`computeDuration`) — never accepted from the client.
|
||||||
|
- `stop`/`discard` literal subpaths vs the bare `:id` PUT don't collide in Hono.
|
||||||
|
- Worker reconciliation keys off "my local session id is absent from the server's active list" —
|
||||||
|
covers admin stop, admin discard, and admin edit-to-completed alike.
|
||||||
|
- No migration: all fields already exist on `work_sessions` (incl. pause fields from the prior
|
||||||
|
cycle).
|
||||||
1396
docs/superpowers/plans/2026-06-24-phase-3b2-reports-export.md
Normal file
1396
docs/superpowers/plans/2026-06-24-phase-3b2-reports-export.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,112 @@
|
|||||||
|
# Phase 3b·1 — Manual Session Entry/Edit + Admin Stop/Fix — Design
|
||||||
|
|
||||||
|
- **Created:** 2026-06-17
|
||||||
|
- **Status:** Approved (brainstorming) — ready for implementation plan
|
||||||
|
- **Tracker:** Plane (workspace `solelog`, project SoleLog)
|
||||||
|
- **Cycle:** First of three Phase 3b cycles (then reports/export, then user management)
|
||||||
|
- **Touches:** `packages/shared`, `apps/api`, `apps/admin`, `apps/worker`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
An admin can find any work session, **create** a manual one for a worker, **edit/correct** any
|
||||||
|
session, and **stop or cancel** a worker's stuck active session — the "manual fallback wherever
|
||||||
|
something fails" from the vision. Because the backend is the source of truth, the **worker
|
||||||
|
converges** to admin changes (no stuck stopwatch).
|
||||||
|
|
||||||
|
_Done when:_ an admin can list/filter all sessions, hand-create a completed session for a worker,
|
||||||
|
edit any session's fields (server recomputes duration), and stop/cancel a worker's active
|
||||||
|
session — and within ~15s the worker's stopwatch reflects a stop/cancel done by the admin.
|
||||||
|
|
||||||
|
## Scope decisions (confirmed during brainstorming, 2026-06-17)
|
||||||
|
|
||||||
|
1. **Slicing:** three sequenced 3b cycles; **this one first** (manual entry/edit + admin
|
||||||
|
stop/fix), then reports/export, then user management.
|
||||||
|
2. **Sessions UI:** a full **"Sessies"** admin screen (the all-sessions list deferred from 3a
|
||||||
|
lands here; the reports cycle reuses it).
|
||||||
|
3. **Editable fields:** start/end time, activity, insole type, pair count, paused seconds,
|
||||||
|
notes, status. The worker is chosen **only on create** — no reassignment on edit. Duration is
|
||||||
|
always **derived** server-side (`end − start − paused`), never typed.
|
||||||
|
4. **Worker convergence:** folded into this cycle — the worker polls and reconciles to server
|
||||||
|
truth (an admin stop/cancel reflects on the phone within ~15s; no stuck state).
|
||||||
|
|
||||||
|
## A. Backend (under the existing admin-gated `/api/admin/*` guard in `routes/admin.ts`)
|
||||||
|
|
||||||
|
- `GET /api/admin/users` — `{ id, name, email }[]` from the `user` table (ordered by name), to
|
||||||
|
populate the create form's worker picker. Direct DB query — no better-auth client dependency.
|
||||||
|
- `POST /api/admin/sessions` — manual create. Body `CreateManualSessionInput`:
|
||||||
|
`user_id, activity_id, insole_type, pair_count, start_time, end_time, paused_seconds?, notes?`.
|
||||||
|
Produces a **completed** session, `source='manual'`, `paused_at=null`,
|
||||||
|
`duration_seconds = max(0, round((end−start)/1000) − paused_seconds)`.
|
||||||
|
- `PUT /api/admin/sessions/:id` — edit any session. Body `AdminUpdateSessionInput`:
|
||||||
|
`activity_id, insole_type, pair_count, start_time, end_time(nullable), paused_seconds, notes,
|
||||||
|
status`. Recomputes `duration_seconds` from times − paused when `end_time` is present; when
|
||||||
|
`status='active'`/`end_time` null, `duration_seconds=null`. No user reassignment.
|
||||||
|
- `POST /api/admin/sessions/:id/stop` — quick "stop now": fold any open pause, `end=now`,
|
||||||
|
`status='completed'`, recompute duration.
|
||||||
|
- `POST /api/admin/sessions/:id/discard` — `status='discarded'`, `end=now`.
|
||||||
|
|
||||||
|
New `@solelog/shared` contracts: `CreateManualSessionInput`, `AdminUpdateSessionInput`. **No DB
|
||||||
|
migration** — reuses existing `work_sessions` columns (incl. the pause fields). Responses use the
|
||||||
|
existing `toWorkSession` mapper (so they carry `user_name`/`activity_name` where joined).
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
`end ≥ start`; `pair_count ≥ 1`; `paused_seconds ≥ 0` and `≤ (end−start)`; activity must exist;
|
||||||
|
user must exist (create); `insole_type` a valid `InsoleType`. Invalid → 400; missing
|
||||||
|
session/user → 404. No hard delete — cancellation is `status='discarded'` (already excluded from
|
||||||
|
exports).
|
||||||
|
|
||||||
|
## B. Admin UI
|
||||||
|
|
||||||
|
- **`components/Sidebar.tsx`** — add `{ to: '/sessies', label: 'Sessies' }` to `navItems`; drop
|
||||||
|
`'Handmatig'` from the muted `soonItems` (now built → leaves `['Rapporten', 'Gebruikers']`).
|
||||||
|
- **`App.tsx`** — add `<Route path="/sessies" element={<Sessions />} />`.
|
||||||
|
- **`screens/Sessions.tsx`** — lists all sessions via `useAllSessions` (`GET /api/admin/sessions`,
|
||||||
|
newest first), a status filter (alle / actief / voltooid / geannuleerd), and a
|
||||||
|
`+ Nieuwe registratie` button. Each row: worker · activity · type · worked (+ pauze) · date.
|
||||||
|
**Active** rows show `[Stop]` `[Annuleer]`; **all** rows show ✎ edit.
|
||||||
|
- **`components/SessionForm.tsx`** — shared create/edit form: worker picker (create only, from
|
||||||
|
`useAdminUsers`), activity dropdown, insole-type, pair count, start/end datetime-local, paused,
|
||||||
|
status (edit only), notes, and a live **"gewerkt"** preview. Submits create or update.
|
||||||
|
- **`api/admin-sessions.ts`** — add `useAllSessions`, `useAdminUsers`, `useCreateManualSession`,
|
||||||
|
`useUpdateSession`, `useAdminStopSession`, `useAdminDiscardSession` (all invalidate the
|
||||||
|
`['admin','sessions']` query family; keep the existing `useActiveSessions`).
|
||||||
|
- **`screens/Live.tsx`** — add `[Stop]` `[Annuleer]` to each `LiveCard`, wired to the admin
|
||||||
|
stop/discard hooks (invalidate the active query).
|
||||||
|
|
||||||
|
## C. Worker convergence (`apps/worker`)
|
||||||
|
|
||||||
|
- **`api/sessions.ts`** — give `useActiveSessions` a `refetchInterval: 15000` (poll), plus the
|
||||||
|
default refetch-on-window-focus.
|
||||||
|
- **`screens/Stopwatch.tsx`** — extend the active-session effect to **reconcile**: if a session is
|
||||||
|
running locally (`sessionId` set) but the latest `activeSessionsQuery.data` no longer contains a
|
||||||
|
matching **active** session for it, the session was stopped/cancelled elsewhere → reset the
|
||||||
|
timer and surface a brief notice **"Deze sessie is door de beheerder gestopt."** Also: treat a
|
||||||
|
**409** from the worker's own stop/discard as already-closed → reset locally instead of erroring.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
- Worker reconciliation notice is transient (dismissible / auto-clears on next start).
|
||||||
|
- Admin form: inline validation errors mirror the API 400s (end before start, count < 1).
|
||||||
|
- Stop/discard on a non-active session → 409; admin UI refetches and the row reflects truth.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **API** (`admin.test.ts`): create computes duration + `source='manual'`; edit recomputes and
|
||||||
|
rejects `end<start`; stop/discard act on **another** user's session; all 401/403 gated;
|
||||||
|
`GET /api/admin/users` returns the roster.
|
||||||
|
- **Admin:** Sessions list renders + status filter; create form posts the right body; edit
|
||||||
|
prefills + PUTs; Live `Stop`/`Annuleer` fire the right mutations.
|
||||||
|
- **Worker:** when the active query returns without a locally-running session, the stopwatch
|
||||||
|
resets + shows the notice; a 409 on stop resets instead of erroring.
|
||||||
|
|
||||||
|
## Out of scope (later 3b cycles)
|
||||||
|
|
||||||
|
- Aggregated/on-screen **reporting** + all-users filtered CSV (reports cycle).
|
||||||
|
- Full **user management** UI — create/role/deactivate via `/api/auth/admin/*` (user-mgmt cycle);
|
||||||
|
this cycle only *reads* the roster (`GET /api/admin/users`) for the picker.
|
||||||
|
- Real-time push (SSE) — polling is sufficient at this scale.
|
||||||
|
|
||||||
|
## Build approach
|
||||||
|
|
||||||
|
spec → `writing-plans` → one **Workflow** (~7 TDD tasks), commit per task, final verify. Tracked
|
||||||
|
as a Plane epic.
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# Phase 3b·2 — Reports + All-Users Export — Design
|
||||||
|
|
||||||
|
- **Created:** 2026-06-24
|
||||||
|
- **Status:** Approved (brainstorming) — ready for implementation plan
|
||||||
|
- **Tracker:** Plane (workspace `solelog`, project SoleLog)
|
||||||
|
- **Cycle:** Second of three Phase 3b cycles (after manual-sessions 3b·1, before user management)
|
||||||
|
- **Touches:** `packages/shared`, `apps/api`, `apps/admin`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
An admin can open a **Rapporten** screen, pick a period (and optionally narrow by worker /
|
||||||
|
insole type / activity), and see headline production totals plus three breakdowns — **per
|
||||||
|
medewerker**, **per handeling**, **per type** — and export the underlying detail rows (all
|
||||||
|
workers) to CSV. Today's `/api/export` is **self-scoped** to the logged-in user; this cycle adds
|
||||||
|
the cross-user, filterable reporting + export the admin needs to review a week.
|
||||||
|
|
||||||
|
_Done when:_ an admin can choose a date range (with Deze week / Deze maand / Alles presets) and
|
||||||
|
optional worker/type/activity filters, see correct headline totals and three breakdown tables, and
|
||||||
|
download a CSV of every matching completed session (all workers, with a Worker column) that
|
||||||
|
respects the same filters.
|
||||||
|
|
||||||
|
## Scope decisions (confirmed during brainstorming, 2026-06-24)
|
||||||
|
|
||||||
|
1. **Both lenses on one screen** — a period summary with breakdowns **per worker AND per
|
||||||
|
activity AND per type** (same underlying query, grouped three ways), plus headline totals.
|
||||||
|
2. **Filters:** date range (from/to, the spine) + optional worker + optional insole type +
|
||||||
|
optional activity. Default period on open = **this week** (Mon–today); presets Deze week /
|
||||||
|
Deze maand / Alles.
|
||||||
|
3. **What counts:** only `status='completed'` sessions contribute to totals and export (active =
|
||||||
|
still running, no duration; discarded = cancelled). Mirrors the current export.
|
||||||
|
4. **Metrics (all four):** gewerkte tijd (worked seconds, excl. paused), aantal zolen (sum of
|
||||||
|
`pair_count`), aantal sessies (count), pauzetijd (paused seconds). Shown both as headline
|
||||||
|
totals and in every breakdown row.
|
||||||
|
5. **CSV = detail rows, all workers** — every filtered completed session as a row, like today's
|
||||||
|
export plus a leading **Worker** column. (Not the aggregated summary — that's visible
|
||||||
|
on-screen.)
|
||||||
|
6. **Presentation: tables only** — headline totals card + three breakdown tables, numbers only.
|
||||||
|
No chart library, no CSS bars — keeps the dependency-light ethos and is fastest to ship.
|
||||||
|
|
||||||
|
## A. Backend (under the existing admin-gated `/api/admin/*` guard in `routes/admin.ts`)
|
||||||
|
|
||||||
|
Both new endpoints accept the same query params and share one filtered-query helper so report and
|
||||||
|
export can never drift:
|
||||||
|
|
||||||
|
- `from` — ISO instant, inclusive lower bound on `start_time`.
|
||||||
|
- `to` — ISO instant, inclusive upper bound on `start_time`.
|
||||||
|
- `user_id?` — restrict to one worker.
|
||||||
|
- `insole_type?` — one of `Kurk | Berk | 3D`.
|
||||||
|
- `activity_id?` — restrict to one handeling.
|
||||||
|
|
||||||
|
All queries additionally force `status='completed'`. A shared helper
|
||||||
|
`buildSessionFilters({ from, to, user_id, insole_type, activity_id })` returns the Drizzle `where`
|
||||||
|
condition array (completed + range + any provided optional filters), used by both endpoints.
|
||||||
|
|
||||||
|
### `GET /api/admin/report`
|
||||||
|
Fetches the filtered rows once (joined to `activities` + `user` for names) and aggregates **in JS**
|
||||||
|
in a single pass — small data, gives names for free, one code path. Returns `ReportResponse`:
|
||||||
|
|
||||||
|
```
|
||||||
|
range: { from, to } // echoes the requested ISO instants
|
||||||
|
totals: { worked_seconds, paused_seconds, pairs, sessions }
|
||||||
|
by_worker: [{ user_id, user_name, worked_seconds, paused_seconds, pairs, sessions }]
|
||||||
|
by_activity: [{ activity_id, activity_name, worked_seconds, paused_seconds, pairs, sessions }]
|
||||||
|
by_type: [{ insole_type, worked_seconds, paused_seconds, pairs, sessions }]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `worked_seconds` sums `duration_seconds` (already excludes paused); `paused_seconds` sums
|
||||||
|
`paused_seconds`; `pairs` sums `pair_count`; `sessions` counts rows.
|
||||||
|
- Headline `totals` equals the sum across any one breakdown (invariant worth a test).
|
||||||
|
- Breakdown arrays are sorted by `worked_seconds` descending. Empty range → all-zero `totals` and
|
||||||
|
empty breakdown arrays.
|
||||||
|
- A row whose `insole_type` is null is bucketed under a `'Onbekend'`-style key in `by_type`
|
||||||
|
(defensive — manual edits allow null type). A row missing an activity/user name falls back to a
|
||||||
|
readable label, never crashes the grouping.
|
||||||
|
|
||||||
|
### `GET /api/admin/export`
|
||||||
|
Same filters; returns CSV detail rows (all workers). Columns:
|
||||||
|
**Worker**, ID, Task, Insole Type, No. of Insoles, Date, Total Duration, Paused Duration, Start
|
||||||
|
Time, End Time. `Content-Type: text/csv; charset=utf-8`; `Content-Disposition: attachment;
|
||||||
|
filename="solelog-report_<from-date>_<to-date>.csv"` (dates as `YYYY-MM-DD`). Ordered by
|
||||||
|
`start_time` ascending.
|
||||||
|
|
||||||
|
### DRY refactor (light, in `lib/csv.ts`)
|
||||||
|
Extract the row/header builder currently inline in `sessions.ts`'s `/api/export` into a shared
|
||||||
|
`buildSessionsCsv(rows, { includeWorker })`:
|
||||||
|
- `includeWorker: false` → existing 9-column format, used by the self-scoped worker export
|
||||||
|
(`/api/export`) — output byte-identical to today, so existing tests still pass.
|
||||||
|
- `includeWorker: true` → prepends a `Worker` column, used by `/api/admin/export`.
|
||||||
|
|
||||||
|
Reuses the existing `quote` + `formatDuration` helpers. One source of truth for the CSV format.
|
||||||
|
|
||||||
|
### Validation / errors
|
||||||
|
`from`/`to` required and must parse as dates with `to ≥ from` → else 400. `insole_type` (if given)
|
||||||
|
must be a valid `InsoleType`; `activity_id`/`user_id` (if given) are applied as filters (an
|
||||||
|
unknown id simply yields an empty result, not an error). Admin guard already returns 401
|
||||||
|
(no session) / 403 (non-admin) for the whole `/api/admin/*` surface.
|
||||||
|
|
||||||
|
## B. Shared contracts (`@solelog/shared`)
|
||||||
|
|
||||||
|
Add zod schemas + inferred types:
|
||||||
|
- `ReportTotals` — `{ worked_seconds, paused_seconds, pairs, sessions }` (all int).
|
||||||
|
- `ReportWorkerRow`, `ReportActivityRow`, `ReportTypeRow` — `ReportTotals` plus the grouping key(s)
|
||||||
|
(`user_id`+`user_name`; `activity_id`+`activity_name`; `insole_type`).
|
||||||
|
- `ReportResponse` — `{ range: { from, to }, totals, by_worker, by_activity, by_type }`.
|
||||||
|
|
||||||
|
Query params are validated in the route (not a shared schema). The **client** is responsible for
|
||||||
|
sending `from`/`to` as ISO instants spanning whole local days (start-of-from-day …
|
||||||
|
end-of-to-day in the admin's browser tz), so server-side timezone handling is unnecessary.
|
||||||
|
|
||||||
|
## C. Admin UI (`apps/admin`)
|
||||||
|
|
||||||
|
- **`components/Sidebar.tsx`** — move `'Rapporten'` from `soonItems` into `navItems`
|
||||||
|
(`{ to: '/rapporten', label: 'Rapporten' }`); `soonItems` becomes `['Gebruikers']`.
|
||||||
|
- **`App.tsx`** — add `<Route path="/rapporten" element={<Reports />} />`.
|
||||||
|
- **`screens/Reports.tsx`** — the screen:
|
||||||
|
- **Filter bar:** from/to `date` inputs; preset buttons **Deze week** (default on mount) /
|
||||||
|
**Deze maand** / **Alles**; worker `<select>` (from `useAdminUsers`); insole-type `<select>`
|
||||||
|
(Kurk/Berk/3D); activity `<select>` (from the admin activities hook). Changing any control
|
||||||
|
updates the filter state → query refetches.
|
||||||
|
- **Headline card:** "Totaal: {worked} gewerkt · {pairs} zolen · {sessions} sessies ·
|
||||||
|
{paused} pauze" for the chosen period.
|
||||||
|
- **Three tables:** Per medewerker / Per handeling / Per type — each row shows the grouping
|
||||||
|
label + the four metrics (durations via a shared `formatTime`). Empty state per table.
|
||||||
|
- **Exporteer CSV** button → `downloadExport(filters)`.
|
||||||
|
- **`api/reports.ts`**:
|
||||||
|
- `useReport(filters)` — `useQuery` keyed `['admin','report', filters]`, calls
|
||||||
|
`GET /api/admin/report?…` via `apiFetch`.
|
||||||
|
- `downloadExport(filters)` — because the endpoint is bearer-auth'd, do a raw `fetch` with the
|
||||||
|
`Authorization` header (token from the same place `apiFetch` reads it), read the `Blob`,
|
||||||
|
create an object URL, click a transient `<a download>`, then revoke the URL. (A plain
|
||||||
|
`<a href>` can't attach the bearer token.)
|
||||||
|
- A small `filtersToQuery(filters)` builds the querystring (omits empty optional filters; maps
|
||||||
|
the local-day date pickers to ISO instants).
|
||||||
|
- Reuses `useAdminUsers` (built in 3b·1) and the existing admin activities query hook for the
|
||||||
|
dropdowns. No new shared UI library.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
- Report query error → the screen shows "Kon rapport niet laden." (mirrors other admin screens).
|
||||||
|
- Export failure (non-2xx) → a brief inline notice near the button; no download is triggered.
|
||||||
|
- Invalid/missing range is prevented client-side (presets always set a valid range; manual inputs
|
||||||
|
are clamped so `to ≥ from`), and the API still rejects a bad range with 400 as a backstop.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **API** (`admin.test.ts` / a new `report.test.ts`):
|
||||||
|
- report totals correct and equal to the sum of each breakdown; completed-only (active/discarded
|
||||||
|
excluded); date-range boundary (a session exactly at `from`/`to` included; just outside
|
||||||
|
excluded); worker / type / activity filters each narrow correctly; empty range → zeros +
|
||||||
|
empty arrays; null `insole_type` bucketed, not crashing.
|
||||||
|
- export returns all-users rows with a leading Worker column; respects the same filters; filename
|
||||||
|
carries the range; both endpoints 401 (no session) / 403 (non-admin).
|
||||||
|
- `buildSessionsCsv`: `includeWorker:false` output identical to the pre-refactor worker export
|
||||||
|
(regression guard); `includeWorker:true` prepends the Worker column.
|
||||||
|
- **Admin** (vitest + Testing Library):
|
||||||
|
- Reports renders headline totals + the three tables from a mocked report payload.
|
||||||
|
- changing a filter / clicking a preset refetches with the expected query params.
|
||||||
|
- the Exporteer CSV button calls the download helper with the current filters (fetch mocked).
|
||||||
|
|
||||||
|
## Out of scope (later cycle / deliberately excluded)
|
||||||
|
|
||||||
|
- The **Gebruikers** screen and any user create / role / deactivate (the third 3b cycle).
|
||||||
|
- Charts / visualization (tables-only chosen).
|
||||||
|
- Real-time/auto-refresh of the report (it's on-demand per filter; React Query refetch-on-focus is
|
||||||
|
enough).
|
||||||
|
- Per-day time-series breakdown — the three chosen breakdowns (worker/activity/type) cover the
|
||||||
|
MVP; a by-day table can be added later if wanted.
|
||||||
|
|
||||||
|
## Build approach
|
||||||
|
|
||||||
|
spec → `writing-plans` → one **Workflow** (~6–7 TDD tasks, commit per task, final verify),
|
||||||
|
sequential (dependent tasks share one working tree). Tracked as a Plane epic with one child task
|
||||||
|
per workflow task.
|
||||||
@@ -86,3 +86,61 @@ export const AdminUser = z.object({
|
|||||||
created_at: z.string(),
|
created_at: z.string(),
|
||||||
});
|
});
|
||||||
export type AdminUser = z.infer<typeof AdminUser>;
|
export type AdminUser = z.infer<typeof AdminUser>;
|
||||||
|
|
||||||
|
export const CreateManualSessionInput = z.object({
|
||||||
|
user_id: z.string(),
|
||||||
|
activity_id: z.number().int(),
|
||||||
|
insole_type: InsoleType,
|
||||||
|
pair_count: z.number().int().min(1),
|
||||||
|
start_time: z.string(),
|
||||||
|
end_time: z.string(),
|
||||||
|
paused_seconds: z.number().int().min(0).default(0),
|
||||||
|
notes: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
export type CreateManualSessionInput = z.infer<typeof CreateManualSessionInput>;
|
||||||
|
|
||||||
|
export const AdminUpdateSessionInput = z.object({
|
||||||
|
activity_id: z.number().int(),
|
||||||
|
insole_type: InsoleType.nullable(),
|
||||||
|
pair_count: z.number().int().min(1),
|
||||||
|
start_time: z.string(),
|
||||||
|
end_time: z.string().nullable(),
|
||||||
|
paused_seconds: z.number().int().min(0),
|
||||||
|
notes: z.string().nullable(),
|
||||||
|
status: SessionStatus,
|
||||||
|
});
|
||||||
|
export type AdminUpdateSessionInput = z.infer<typeof AdminUpdateSessionInput>;
|
||||||
|
|
||||||
|
export const ReportTotals = z.object({
|
||||||
|
worked_seconds: z.number().int(),
|
||||||
|
paused_seconds: z.number().int(),
|
||||||
|
pairs: z.number().int(),
|
||||||
|
sessions: z.number().int(),
|
||||||
|
});
|
||||||
|
export type ReportTotals = z.infer<typeof ReportTotals>;
|
||||||
|
|
||||||
|
export const ReportWorkerRow = ReportTotals.extend({
|
||||||
|
user_id: z.string(),
|
||||||
|
user_name: z.string(),
|
||||||
|
});
|
||||||
|
export type ReportWorkerRow = z.infer<typeof ReportWorkerRow>;
|
||||||
|
|
||||||
|
export const ReportActivityRow = ReportTotals.extend({
|
||||||
|
activity_id: z.number().int(),
|
||||||
|
activity_name: z.string(),
|
||||||
|
});
|
||||||
|
export type ReportActivityRow = z.infer<typeof ReportActivityRow>;
|
||||||
|
|
||||||
|
export const ReportTypeRow = ReportTotals.extend({
|
||||||
|
insole_type: z.string(),
|
||||||
|
});
|
||||||
|
export type ReportTypeRow = z.infer<typeof ReportTypeRow>;
|
||||||
|
|
||||||
|
export const ReportResponse = z.object({
|
||||||
|
range: z.object({ from: z.string(), to: z.string() }),
|
||||||
|
totals: ReportTotals,
|
||||||
|
by_worker: z.array(ReportWorkerRow),
|
||||||
|
by_activity: z.array(ReportActivityRow),
|
||||||
|
by_type: z.array(ReportTypeRow),
|
||||||
|
});
|
||||||
|
export type ReportResponse = z.infer<typeof ReportResponse>;
|
||||||
|
|||||||
Reference in New Issue
Block a user