feat(worker): Geschiedenis screen with session list and CSV export
This commit is contained in:
@@ -2,6 +2,13 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|||||||
import type { StartSessionInput, WorkSession } from '@solelog/shared';
|
import type { StartSessionInput, WorkSession } from '@solelog/shared';
|
||||||
import { apiFetch } from '../lib/api';
|
import { apiFetch } from '../lib/api';
|
||||||
|
|
||||||
|
export function useSessions() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['sessions'],
|
||||||
|
queryFn: () => apiFetch<WorkSession[]>('/api/sessions'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useActiveSessions() {
|
export function useActiveSessions() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['sessions', 'active'],
|
queryKey: ['sessions', 'active'],
|
||||||
|
|||||||
23
apps/worker/src/lib/export.ts
Normal file
23
apps/worker/src/lib/export.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { API_URL } from './api';
|
||||||
|
import { getToken } from './auth-storage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticated CSV download. A plain `<a href>` cannot carry the bearer token,
|
||||||
|
* so we fetch the blob ourselves and trigger a download via an object URL.
|
||||||
|
*/
|
||||||
|
export async function downloadExport(): Promise<void> {
|
||||||
|
const token = getToken();
|
||||||
|
const res = await fetch(`${API_URL}/api/export`, {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Kan de export niet openen');
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'insole-production-report.csv';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
104
apps/worker/src/screens/History.test.tsx
Normal file
104
apps/worker/src/screens/History.test.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { render, screen } 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 { WorkSession } from '@solelog/shared';
|
||||||
|
import History from './History';
|
||||||
|
import { apiFetch } from '../lib/api';
|
||||||
|
import { downloadExport } from '../lib/export';
|
||||||
|
|
||||||
|
// Mock the network layer so no real requests are made.
|
||||||
|
vi.mock('../lib/api', () => ({
|
||||||
|
apiFetch: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the authenticated CSV download helper.
|
||||||
|
vi.mock('../lib/export', () => ({
|
||||||
|
downloadExport: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedApiFetch = vi.mocked(apiFetch);
|
||||||
|
const mockedDownloadExport = vi.mocked(downloadExport);
|
||||||
|
|
||||||
|
function session(overrides: Partial<WorkSession> = {}): WorkSession {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
user_id: 'u1',
|
||||||
|
activity_id: 1,
|
||||||
|
activity_name: 'Frezen',
|
||||||
|
insole_type: 'Kurk',
|
||||||
|
pair_count: 2,
|
||||||
|
start_time: '2026-01-02T08:30:00.000Z',
|
||||||
|
end_time: '2026-01-02T09:31:01.000Z',
|
||||||
|
duration_seconds: 3661,
|
||||||
|
status: 'completed',
|
||||||
|
source: 'app',
|
||||||
|
notes: null,
|
||||||
|
created_at: '2026-01-02T08:30:00.000Z',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHistory() {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<History />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('History', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedApiFetch.mockReset();
|
||||||
|
mockedDownloadExport.mockReset();
|
||||||
|
mockedApiFetch.mockResolvedValue([]);
|
||||||
|
mockedDownloadExport.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the header and export button in Dutch', () => {
|
||||||
|
renderHistory();
|
||||||
|
expect(screen.getByText('Geschiedenis')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Exporteer CSV' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the empty state when there are no sessions', async () => {
|
||||||
|
mockedApiFetch.mockResolvedValue([]);
|
||||||
|
renderHistory();
|
||||||
|
expect(await screen.findByText('Nog geen opgeslagen sessies.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a session card with activity name, type, count and duration', async () => {
|
||||||
|
mockedApiFetch.mockResolvedValue([session({ duration_seconds: 3661 })]);
|
||||||
|
renderHistory();
|
||||||
|
|
||||||
|
expect(await screen.findByText('Frezen')).toBeInTheDocument();
|
||||||
|
const card = screen.getByText('Frezen').closest('.session-card') as HTMLElement;
|
||||||
|
expect(card).not.toBeNull();
|
||||||
|
expect(card.textContent).toContain('Kurk');
|
||||||
|
expect(card.textContent).toContain('2 inlegzolen');
|
||||||
|
// 3661s -> "1h 1m" (hours present).
|
||||||
|
expect(card.textContent).toContain('1h 1m');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the singular noun for a count of 1', async () => {
|
||||||
|
mockedApiFetch.mockResolvedValue([session({ pair_count: 1 })]);
|
||||||
|
renderHistory();
|
||||||
|
|
||||||
|
const card = (await screen.findByText('Frezen')).closest('.session-card') as HTMLElement;
|
||||||
|
expect(card.textContent).toContain('1 inlegzool');
|
||||||
|
expect(card.textContent).not.toContain('1 inlegzolen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('triggers the CSV download on Exporteer CSV', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderHistory();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Exporteer CSV' }));
|
||||||
|
expect(mockedDownloadExport).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,95 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { WorkSession } from '@solelog/shared';
|
||||||
|
import { useSessions } from '../api/sessions';
|
||||||
|
import { downloadExport } from '../lib/export';
|
||||||
|
|
||||||
|
// Legacy History duration format: "1h 5m" / "3m 20s" / "45s".
|
||||||
|
function formatDuration(totalSeconds: number | null): string {
|
||||||
|
const s = totalSeconds ?? 0;
|
||||||
|
const hrs = Math.floor(s / 3600);
|
||||||
|
const mins = Math.floor((s % 3600) / 60);
|
||||||
|
const secs = s % 60;
|
||||||
|
if (hrs > 0) return `${hrs}h ${mins}m`;
|
||||||
|
if (mins > 0) return `${mins}m ${secs}s`;
|
||||||
|
return `${secs}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleDateString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleTimeString(undefined, {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionCard({ session }: { session: WorkSession }) {
|
||||||
|
const noun = session.pair_count === 1 ? 'inlegzool' : 'inlegzolen';
|
||||||
|
return (
|
||||||
|
<li className="session-card">
|
||||||
|
<div className="session-main">
|
||||||
|
<span className="session-name">{session.activity_name ?? 'Onbekende handeling'}</span>
|
||||||
|
<span className="session-datetime">
|
||||||
|
{formatDate(session.start_time)} • {formatTime(session.start_time)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="session-badges">
|
||||||
|
{session.insole_type && <span className="pill pill-grey">{session.insole_type}</span>}
|
||||||
|
<span className="pill pill-blue">
|
||||||
|
{session.pair_count} {noun}
|
||||||
|
</span>
|
||||||
|
<span className="pill pill-grey">{formatDuration(session.duration_seconds)}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function History() {
|
export default function History() {
|
||||||
|
const sessionsQuery = useSessions();
|
||||||
|
const sessions = sessionsQuery.data ?? [];
|
||||||
|
const [exportError, setExportError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleExport() {
|
||||||
|
setExportError(null);
|
||||||
|
try {
|
||||||
|
await downloadExport();
|
||||||
|
} catch {
|
||||||
|
setExportError('Kan de export niet openen');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="screen">
|
<div className="screen">
|
||||||
<h1 className="screen-title">Geschiedenis</h1>
|
<div className="history-header">
|
||||||
|
<h1 className="screen-title">Geschiedenis</h1>
|
||||||
|
<button type="button" className="pill-btn" onClick={handleExport}>
|
||||||
|
Exporteer CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{exportError && (
|
||||||
|
<p className="login-error" style={{ textAlign: 'left' }}>
|
||||||
|
Fout — {exportError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!sessionsQuery.isLoading && sessions.length === 0 ? (
|
||||||
|
<p className="muted" style={{ textAlign: 'center', marginTop: 48 }}>
|
||||||
|
Nog geen opgeslagen sessies.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="session-list">
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<SessionCard key={session.id} session={session} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -273,3 +273,82 @@ body {
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- History (Geschiedenis) ---- */
|
||||||
|
.history-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-btn {
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
background: var(--primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-datetime {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-badges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-grey {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-blue {
|
||||||
|
background: var(--primary-light);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user