diff --git a/apps/worker/src/api/activities.ts b/apps/worker/src/api/activities.ts new file mode 100644 index 0000000..bf5d00a --- /dev/null +++ b/apps/worker/src/api/activities.ts @@ -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('/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'] }); + // Deleting an activity cascades to its work sessions on the server. + queryClient.invalidateQueries({ queryKey: ['sessions'] }); + }, + }); +} diff --git a/apps/worker/src/screens/Settings.test.tsx b/apps/worker/src/screens/Settings.test.tsx new file mode 100644 index 0000000..e0ac0e6 --- /dev/null +++ b/apps/worker/src/screens/Settings.test.tsx @@ -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( + + + , + ); +} + +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(); + }); +}); diff --git a/apps/worker/src/screens/Settings.tsx b/apps/worker/src/screens/Settings.tsx index 8d3024d..d4e91e1 100644 --- a/apps/worker/src/screens/Settings.tsx +++ b/apps/worker/src/screens/Settings.tsx @@ -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 = { + 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 ( -
-

Instellingen

+
+ {ALL_TYPES.map((type) => ( + + ))} +
+ ); +} + +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([...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 ( +
+

Instellingen

+

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/worker/src/styles.css b/apps/worker/src/styles.css index 8a962cd..a90c2c9 100644 --- a/apps/worker/src/styles.css +++ b/apps/worker/src/styles.css @@ -150,3 +150,126 @@ body { cursor: pointer; 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; +}