diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 6156417..359d4d0 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -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() { } /> } /> } /> + } /> diff --git a/apps/admin/src/components/Sidebar.tsx b/apps/admin/src/components/Sidebar.tsx index 86b4a52..4f1bd45 100644 --- a/apps/admin/src/components/Sidebar.tsx +++ b/apps/admin/src/components/Sidebar.tsx @@ -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(); diff --git a/apps/admin/src/lib/date-range.test.ts b/apps/admin/src/lib/date-range.test.ts new file mode 100644 index 0000000..695eb1a --- /dev/null +++ b/apps/admin/src/lib/date-range.test.ts @@ -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'); + }); +}); diff --git a/apps/admin/src/lib/date-range.ts b/apps/admin/src/lib/date-range.ts new file mode 100644 index 0000000..dfd4c8a --- /dev/null +++ b/apps/admin/src/lib/date-range.ts @@ -0,0 +1,40 @@ +// Helpers for the report filter bar. Dates are 'YYYY-MM-DD' (what 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) }; +} diff --git a/apps/admin/src/screens/Reports.test.tsx b/apps/admin/src/screens/Reports.test.tsx new file mode 100644 index 0000000..290bed1 --- /dev/null +++ b/apps/admin/src/screens/Reports.test.tsx @@ -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( + + + , + ); +} + +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(); + }); +}); diff --git a/apps/admin/src/screens/Reports.tsx b/apps/admin/src/screens/Reports.tsx new file mode 100644 index 0000000..4dba152 --- /dev/null +++ b/apps/admin/src/screens/Reports.tsx @@ -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(() => 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(null); + + async function onExport() { + setExportError(null); + try { + await reportsApi.downloadExport(filters); + } catch { + setExportError('Export mislukt. Probeer opnieuw.'); + } + } + + const t = reportQuery.data?.totals; + + return ( +
+
+

Rapporten

+ +
+ +
+
+ + + +
+ + + + + +
+ + {exportError &&

{exportError}

} + + {reportQuery.isLoading ? ( +

Laden…

+ ) : reportQuery.isError ? ( +

Kon rapport niet laden.

+ ) : ( + <> +
+ {formatTime(t?.worked_seconds ?? 0)} gewerkt ·{' '} + {t?.pairs ?? 0} zolen · {t?.sessions ?? 0} sessies ·{' '} + {formatTime(t?.paused_seconds ?? 0)} pauze +
+ + ({ + key: r.user_id, + name: r.user_name, + ...r, + }))} + /> + ({ + key: String(r.activity_id), + name: r.activity_name, + ...r, + }))} + /> + ({ + key: r.insole_type, + name: r.insole_type, + ...r, + }))} + /> + + )} +
+ ); +} + +type BreakdownRow = ReportTotals & { key: string; name: string }; + +function BreakdownTable({ + title, + label, + rows, +}: { + title: string; + label: string; + rows: BreakdownRow[]; +}) { + return ( +
+

{title}

+ {rows.length === 0 ? ( +

Geen gegevens.

+ ) : ( + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + ))} + +
{label}GewerktZolenSessiesPauze
{r.name}{formatTime(r.worked_seconds)}{r.pairs}{r.sessions}{formatTime(r.paused_seconds)}
+ )} +
+ ); +} diff --git a/apps/admin/src/styles.css b/apps/admin/src/styles.css index 228eadf..d525dac 100644 --- a/apps/admin/src/styles.css +++ b/apps/admin/src/styles.css @@ -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; +}