feat(admin): activity management (handelingen CRUD)

This commit is contained in:
Bas van Rossem
2026-06-17 19:11:50 +02:00
parent 67dd0d398f
commit c0d9d21991
4 changed files with 506 additions and 3 deletions

View File

@@ -0,0 +1,49 @@
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'] });
},
});
}

View File

@@ -0,0 +1,106 @@
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 Activities from './Activities';
import { apiFetch } from '../lib/api';
vi.mock('../lib/api', () => ({
apiFetch: vi.fn(),
}));
const mockApiFetch = vi.mocked(apiFetch);
const existing: Activity[] = [
{ id: 1, name: 'Frezen', insole_types: ['Kurk', 'Berk'], created_at: '2026-06-17T00:00:00.000Z' },
{ id: 2, name: 'Lijmen', insole_types: ['3D'], created_at: '2026-06-17T00:00:00.000Z' },
];
function renderActivities() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={queryClient}>
<Activities />
</QueryClientProvider>
);
}
describe('Activities', () => {
beforeEach(() => {
mockApiFetch.mockReset();
// Default: the activities GET returns the existing list.
mockApiFetch.mockResolvedValue(existing);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('renders the title, subtitle, and existing activities', async () => {
renderActivities();
expect(await screen.findByText('Handelingen')).toBeInTheDocument();
expect(screen.getByText('Beheer handelingen per zooltype')).toBeInTheDocument();
expect(await screen.findByText('Frezen')).toBeInTheDocument();
expect(screen.getByText('Lijmen')).toBeInTheDocument();
});
it('adding a handeling POSTs /api/activities with name + insole_types', async () => {
const user = userEvent.setup();
renderActivities();
await screen.findByText('Frezen');
await user.type(screen.getByPlaceholderText('Naam van de stap, bijv. Leerrand'), 'Polijsten');
await user.click(screen.getByRole('button', { name: 'Stap toevoegen' }));
await waitFor(() => {
expect(mockApiFetch).toHaveBeenCalledWith('/api/activities', {
method: 'POST',
body: JSON.stringify({ name: 'Polijsten', insole_types: ['Kurk', 'Berk', '3D'] }),
});
});
});
it('editing a handeling PUTs /api/activities/:id', async () => {
const user = userEvent.setup();
renderActivities();
await screen.findByText('Frezen');
await user.click(screen.getByRole('button', { name: 'Bewerk Frezen' }));
const input = screen.getByDisplayValue('Frezen');
await user.clear(input);
await user.type(input, 'Frezen 2');
await user.click(screen.getByRole('button', { name: 'Opslaan' }));
await waitFor(() => {
expect(mockApiFetch).toHaveBeenCalledWith('/api/activities/1', {
method: 'PUT',
body: JSON.stringify({ name: 'Frezen 2', insole_types: ['Kurk', 'Berk'] }),
});
});
});
it('deleting a handeling (confirmed) DELETEs /api/activities/:id', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true);
const user = userEvent.setup();
renderActivities();
await screen.findByText('Frezen');
await user.click(screen.getByRole('button', { name: 'Verwijder Frezen' }));
await waitFor(() => {
expect(mockApiFetch).toHaveBeenCalledWith('/api/activities/1', { method: 'DELETE' });
});
});
it('does not DELETE when the confirm is cancelled', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(false);
const user = userEvent.setup();
renderActivities();
await screen.findByText('Frezen');
await user.click(screen.getByRole('button', { name: 'Verwijder Frezen' }));
expect(mockApiFetch).not.toHaveBeenCalledWith('/api/activities/1', { method: 'DELETE' });
});
});

View File

@@ -1,4 +1,238 @@
// Placeholder — replaced by the activity-management view in Task 6. import { useState } from 'react';
export default function Activities() { import type { InsoleType } from '@solelog/shared';
return <div className="screen">Handelingen</div>; 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 (
<div className="type-toggles" style={{ display: 'flex', gap: 8 }}>
{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 Activities() {
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">Handelingen</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>
);
} }

View File

@@ -234,6 +234,120 @@ body {
font-size: 15px; font-size: 15px;
} }
/* ---- Activity management (Handelingen) ---- */
.card {
display: flex;
flex-direction: column;
gap: 12px;
border: 1px solid var(--border);
border-radius: 16px;
padding: 16px;
margin-bottom: 24px;
background: #ffffff;
}
.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;
}
.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;
background: #ffffff;
}
.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;
}
/* ---- Live active-work view ---- */ /* ---- Live active-work view ---- */
.live-grid { .live-grid {
display: grid; display: grid;