feat(worker): Instellingen screen — activities CRUD per zooltype
This commit is contained in:
51
apps/worker/src/api/activities.ts
Normal file
51
apps/worker/src/api/activities.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type { Activity, CreateActivityInput, UpdateActivityInput } from '@solelog/shared';
|
||||||
|
import { apiFetch } from '../lib/api';
|
||||||
|
|
||||||
|
export function useActivities() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['activities'],
|
||||||
|
queryFn: () => apiFetch<Activity[]>('/api/activities'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateActivity() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: CreateActivityInput) =>
|
||||||
|
apiFetch<Activity>('/api/activities', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['activities'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateActivity() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, input }: { id: number; input: UpdateActivityInput }) =>
|
||||||
|
apiFetch<Activity>(`/api/activities/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['activities'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteActivity() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) =>
|
||||||
|
apiFetch<{ success: true }>(`/api/activities/${id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['activities'] });
|
||||||
|
// Deleting an activity cascades to its work sessions on the server.
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['sessions'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
88
apps/worker/src/screens/Settings.test.tsx
Normal file
88
apps/worker/src/screens/Settings.test.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { render, screen, waitFor } 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 { Activity } from '@solelog/shared';
|
||||||
|
import Settings from './Settings';
|
||||||
|
import { apiFetch } from '../lib/api';
|
||||||
|
|
||||||
|
// Mock the network layer so no real requests are made.
|
||||||
|
vi.mock('../lib/api', () => ({
|
||||||
|
apiFetch: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedApiFetch = vi.mocked(apiFetch);
|
||||||
|
|
||||||
|
function renderSettings() {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Settings />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Settings', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedApiFetch.mockReset();
|
||||||
|
mockedApiFetch.mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the heading and add form in Dutch', () => {
|
||||||
|
renderSettings();
|
||||||
|
expect(screen.getByText('Instellingen')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Beheer handelingen per zooltype')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Nieuwe handeling toevoegen')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByPlaceholderText('Naam van de stap, bijv. Leerrand'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Stap toevoegen' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists activities returned by the API', async () => {
|
||||||
|
const activities: Activity[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Frezen',
|
||||||
|
insole_types: ['Kurk', 'Berk'],
|
||||||
|
created_at: '2026-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockedApiFetch.mockResolvedValue(activities);
|
||||||
|
|
||||||
|
renderSettings();
|
||||||
|
|
||||||
|
expect(await screen.findByText('Frezen')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Huidige stappen (1)')).toBeInTheDocument();
|
||||||
|
// The type badges for the activity render.
|
||||||
|
const card = screen.getByText('Frezen').closest('.activity-card') as HTMLElement;
|
||||||
|
expect(card).not.toBeNull();
|
||||||
|
expect(card.textContent).toContain('Kurk');
|
||||||
|
expect(card.textContent).toContain('Berk');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables the add button until a name is entered', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderSettings();
|
||||||
|
|
||||||
|
const addButton = screen.getByRole('button', { name: 'Stap toevoegen' });
|
||||||
|
expect(addButton).toBeDisabled();
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText('Naam van de stap, bijv. Leerrand');
|
||||||
|
await user.type(input, 'Leerrand');
|
||||||
|
|
||||||
|
await waitFor(() => expect(addButton).toBeEnabled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the empty state when there are no activities', async () => {
|
||||||
|
mockedApiFetch.mockResolvedValue([]);
|
||||||
|
renderSettings();
|
||||||
|
expect(
|
||||||
|
await screen.findByText('Nog geen stappen. Voeg er een toe hierboven.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,240 @@
|
|||||||
export default function Settings() {
|
import { useState } from 'react';
|
||||||
|
import type { InsoleType } from '@solelog/shared';
|
||||||
|
import {
|
||||||
|
useActivities,
|
||||||
|
useCreateActivity,
|
||||||
|
useDeleteActivity,
|
||||||
|
useUpdateActivity,
|
||||||
|
} from '../api/activities';
|
||||||
|
|
||||||
|
const ALL_TYPES: InsoleType[] = ['Kurk', 'Berk', '3D'];
|
||||||
|
|
||||||
|
const TYPE_COLORS: Record<InsoleType, { bg: string; border: string; text: string }> = {
|
||||||
|
Kurk: { bg: '#FEF9C3', border: '#FDE047', text: '#854D0E' },
|
||||||
|
Berk: { bg: '#DCFCE7', border: '#86EFAC', text: '#166534' },
|
||||||
|
'3D': { bg: '#EDE9FE', border: '#C4B5FD', text: '#5B21B6' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function typePillStyle(type: InsoleType, selected: boolean): React.CSSProperties {
|
||||||
|
const c = TYPE_COLORS[type];
|
||||||
|
return {
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: '6px 14px',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: `1px solid ${selected ? c.border : '#E5E7EB'}`,
|
||||||
|
background: selected ? c.bg : '#ffffff',
|
||||||
|
color: selected ? c.text : '#9CA3AF',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeBadgeStyle(type: InsoleType): React.CSSProperties {
|
||||||
|
const c = TYPE_COLORS[type];
|
||||||
|
return {
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: '2px 10px',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
border: `1px solid ${c.border}`,
|
||||||
|
background: c.bg,
|
||||||
|
color: c.text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function TypeToggles({
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
selected: InsoleType[];
|
||||||
|
onToggle: (type: InsoleType) => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="screen">
|
<div className="type-toggles" style={{ display: 'flex', gap: 8 }}>
|
||||||
<h1 className="screen-title">Instellingen</h1>
|
{ALL_TYPES.map((type) => (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={selected.includes(type)}
|
||||||
|
style={typePillStyle(type, selected.includes(type))}
|
||||||
|
onClick={() => onToggle(type)}
|
||||||
|
>
|
||||||
|
{type}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Settings() {
|
||||||
|
const activitiesQuery = useActivities();
|
||||||
|
const createActivity = useCreateActivity();
|
||||||
|
const updateActivity = useUpdateActivity();
|
||||||
|
const deleteActivity = useDeleteActivity();
|
||||||
|
|
||||||
|
// Add form state.
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
const [newTypes, setNewTypes] = useState<InsoleType[]>([...ALL_TYPES]);
|
||||||
|
|
||||||
|
// Edit state.
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
const [editName, setEditName] = useState('');
|
||||||
|
const [editTypes, setEditTypes] = useState<InsoleType[]>([]);
|
||||||
|
|
||||||
|
const activities = activitiesQuery.data ?? [];
|
||||||
|
|
||||||
|
function toggle(list: InsoleType[], type: InsoleType): InsoleType[] {
|
||||||
|
return list.includes(type) ? list.filter((t) => t !== type) : [...list, type];
|
||||||
|
}
|
||||||
|
|
||||||
|
const addEnabled = newName.trim().length > 0 && newTypes.length > 0;
|
||||||
|
|
||||||
|
function handleAdd() {
|
||||||
|
if (!addEnabled) return;
|
||||||
|
createActivity.mutate(
|
||||||
|
{ name: newName.trim(), insole_types: newTypes },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setNewName('');
|
||||||
|
setNewTypes([...ALL_TYPES]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(id: number, name: string, types: InsoleType[]) {
|
||||||
|
setEditingId(id);
|
||||||
|
setEditName(name);
|
||||||
|
setEditTypes(types.length > 0 ? [...types] : [...ALL_TYPES]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
setEditingId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave(id: number) {
|
||||||
|
if (editName.trim().length === 0 || editTypes.length === 0) return;
|
||||||
|
updateActivity.mutate(
|
||||||
|
{ id, input: { name: editName.trim(), insole_types: editTypes } },
|
||||||
|
{ onSuccess: () => setEditingId(null) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(id: number, name: string) {
|
||||||
|
const ok = window.confirm(
|
||||||
|
`"${name}" verwijderen? Alle tijdsregistraties voor deze taak worden ook verwijderd.`,
|
||||||
|
);
|
||||||
|
if (ok) deleteActivity.mutate(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="screen">
|
||||||
|
<h1 className="screen-title">Instellingen</h1>
|
||||||
|
<p className="screen-subtitle">Beheer handelingen per zooltype</p>
|
||||||
|
|
||||||
|
{/* Add new handling card */}
|
||||||
|
<section className="card">
|
||||||
|
<h2 className="section-label">Nieuwe handeling toevoegen</h2>
|
||||||
|
<input
|
||||||
|
className="field-input"
|
||||||
|
placeholder="Naam van de stap, bijv. Leerrand"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="sub-label">Van toepassing op</p>
|
||||||
|
<TypeToggles
|
||||||
|
selected={newTypes}
|
||||||
|
onToggle={(type) => setNewTypes((prev) => toggle(prev, type))}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={!addEnabled || createActivity.isPending}
|
||||||
|
onClick={handleAdd}
|
||||||
|
>
|
||||||
|
Stap toevoegen
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Handling list */}
|
||||||
|
<h2 className="section-label">Huidige stappen ({activities.length})</h2>
|
||||||
|
{activitiesQuery.isLoading ? (
|
||||||
|
<p className="muted">Laden...</p>
|
||||||
|
) : activities.length === 0 ? (
|
||||||
|
<p className="muted">Nog geen stappen. Voeg er een toe hierboven.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="activity-list">
|
||||||
|
{activities.map((activity) => (
|
||||||
|
<li key={activity.id} className="activity-card">
|
||||||
|
{editingId === activity.id ? (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
className="field-input"
|
||||||
|
autoFocus
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="sub-label">Van toepassing op</p>
|
||||||
|
<TypeToggles
|
||||||
|
selected={editTypes}
|
||||||
|
onToggle={(type) => setEditTypes((prev) => toggle(prev, type))}
|
||||||
|
/>
|
||||||
|
<div className="row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-save"
|
||||||
|
disabled={
|
||||||
|
updateActivity.isPending ||
|
||||||
|
editTypes.length === 0 ||
|
||||||
|
editName.trim().length === 0
|
||||||
|
}
|
||||||
|
onClick={() => handleSave(activity.id)}
|
||||||
|
>
|
||||||
|
Opslaan
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn-cancel" onClick={cancelEdit}>
|
||||||
|
Annuleren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="activity-row">
|
||||||
|
<span className="activity-name">{activity.name}</span>
|
||||||
|
<div className="row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-btn icon-edit"
|
||||||
|
aria-label={`Bewerk ${activity.name}`}
|
||||||
|
onClick={() =>
|
||||||
|
startEdit(activity.id, activity.name, activity.insole_types)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
✎
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-btn icon-delete"
|
||||||
|
aria-label={`Verwijder ${activity.name}`}
|
||||||
|
onClick={() => handleDelete(activity.id, activity.name)}
|
||||||
|
>
|
||||||
|
🗑
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="badge-row" style={{ display: 'flex', gap: 6, marginTop: 8 }}>
|
||||||
|
{activity.insole_types.map((type) => (
|
||||||
|
<span key={type} style={typeBadgeStyle(type)}>
|
||||||
|
{type}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,3 +150,126 @@ body {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Settings (Instellingen) ---- */
|
||||||
|
.screen-subtitle {
|
||||||
|
margin: -8px 0 20px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .section-label {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: #ffffff;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-edit {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-delete {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #166534;
|
||||||
|
background: #dcfce7;
|
||||||
|
border: 1px solid #86efac;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: #f3f4f6;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user