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

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