feat(admin): reports API client (useReport + downloadExport)

This commit is contained in:
Bas van Rossem
2026-06-24 16:33:28 +02:00
parent d33fcb7cce
commit 8ad2e69ec3
2 changed files with 122 additions and 0 deletions

View File

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

View File

@@ -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<ReportResponse>(`/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 <a href> can't carry the token:
// fetch with the Authorization header, then download the resulting Blob.
export async function downloadExport(filters: ReportFilters): Promise<void> {
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);
}