From c0d9d21991b9809ef38fbbb568b1f8ad96704646 Mon Sep 17 00:00:00 2001 From: Bas van Rossem Date: Wed, 17 Jun 2026 19:11:50 +0200 Subject: [PATCH] feat(admin): activity management (handelingen CRUD) --- apps/admin/src/api/activities.ts | 49 +++++ apps/admin/src/screens/Activities.test.tsx | 106 +++++++++ apps/admin/src/screens/Activities.tsx | 240 ++++++++++++++++++++- apps/admin/src/styles.css | 114 ++++++++++ 4 files changed, 506 insertions(+), 3 deletions(-) create mode 100644 apps/admin/src/api/activities.ts create mode 100644 apps/admin/src/screens/Activities.test.tsx diff --git a/apps/admin/src/api/activities.ts b/apps/admin/src/api/activities.ts new file mode 100644 index 0000000..6c9a4e0 --- /dev/null +++ b/apps/admin/src/api/activities.ts @@ -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('/api/activities'), + }); +} + +export function useCreateActivity() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: CreateActivityInput) => + apiFetch('/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(`/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'] }); + }, + }); +} diff --git a/apps/admin/src/screens/Activities.test.tsx b/apps/admin/src/screens/Activities.test.tsx new file mode 100644 index 0000000..756d1f4 --- /dev/null +++ b/apps/admin/src/screens/Activities.test.tsx @@ -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( + + + + ); +} + +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' }); + }); +}); diff --git a/apps/admin/src/screens/Activities.tsx b/apps/admin/src/screens/Activities.tsx index ac5ecdd..a496e3a 100644 --- a/apps/admin/src/screens/Activities.tsx +++ b/apps/admin/src/screens/Activities.tsx @@ -1,4 +1,238 @@ -// Placeholder — replaced by the activity-management view in Task 6. -export default function Activities() { - return
Handelingen
; +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 = { + 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 ( +
+ {ALL_TYPES.map((type) => ( + + ))} +
+ ); +} + +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([...ALL_TYPES]); + + // Edit state. + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(''); + const [editTypes, setEditTypes] = useState([]); + + 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 ( +
+

Handelingen

+

Beheer handelingen per zooltype

+ + {/* Add new handling card */} +
+

Nieuwe handeling toevoegen

+ setNewName(e.target.value)} + /> +

Van toepassing op

+ setNewTypes((prev) => toggle(prev, type))} + /> + +
+ + {/* Handling list */} +

Huidige stappen ({activities.length})

+ {activitiesQuery.isLoading ? ( +

Laden...

+ ) : activities.length === 0 ? ( +

Nog geen stappen. Voeg er een toe hierboven.

+ ) : ( +
    + {activities.map((activity) => ( +
  • + {editingId === activity.id ? ( + <> + setEditName(e.target.value)} + /> +

    Van toepassing op

    + setEditTypes((prev) => toggle(prev, type))} + /> +
    + + +
    + + ) : ( + <> +
    + {activity.name} +
    + + +
    +
    +
    + {activity.insole_types.map((type) => ( + + {type} + + ))} +
    + + )} +
  • + ))} +
+ )} +
+ ); } diff --git a/apps/admin/src/styles.css b/apps/admin/src/styles.css index e491ef0..f58e117 100644 --- a/apps/admin/src/styles.css +++ b/apps/admin/src/styles.css @@ -234,6 +234,120 @@ body { 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-grid { display: grid;