diff --git a/apps/admin/src/api/reports.test.ts b/apps/admin/src/api/reports.test.ts new file mode 100644 index 0000000..d7abf8d --- /dev/null +++ b/apps/admin/src/api/reports.test.ts @@ -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).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(); + }); +}); diff --git a/apps/admin/src/api/reports.ts b/apps/admin/src/api/reports.ts new file mode 100644 index 0000000..eeea213 --- /dev/null +++ b/apps/admin/src/api/reports.ts @@ -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(`/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 can't carry the token: +// fetch with the Authorization header, then download the resulting Blob. +export async function downloadExport(filters: ReportFilters): Promise { + 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); +}