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