feat(admin): reports API client (useReport + downloadExport)
This commit is contained in:
67
apps/admin/src/api/reports.test.ts
Normal file
67
apps/admin/src/api/reports.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
55
apps/admin/src/api/reports.ts
Normal file
55
apps/admin/src/api/reports.ts
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user