feat(admin): Rapporten screen with filters, totals, breakdowns + CSV export
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
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) };
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user