feat(admin): Rapporten screen with filters, totals, breakdowns + CSV export

This commit is contained in:
Bas van Rossem
2026-06-24 16:40:25 +02:00
parent 8ad2e69ec3
commit 8d75be0462
7 changed files with 465 additions and 2 deletions

View File

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

View File

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

View 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');
});
});

View 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) };
}

View 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();
});
});

View 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>
);
}

View File

@@ -620,3 +620,54 @@ body {
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;
}