# Plan 3: Core Workshop Pages — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build the 3 missing tenant-facing pages (Clients, Catalog, Work Orders) with domain hooks, modals, and full routing — making the TechXCar app fully operational for workshop staff. **Architecture:** Hooks-per-domain — `hooks/useClients.ts`, `hooks/useCatalog.ts`, `hooks/useWorkOrders.ts` wrap TanStack Query. Pages stay presentational. All modals share one `components/ui/dialog.tsx` Radix UI wrapper. **Tech Stack:** React 19, TypeScript 6, TanStack Query v5, Radix UI Dialog (already installed as `@radix-ui/react-dialog ^1.1.17`), lucide-react icons, Vitest + Testing Library. Forms use plain `useState` — no react-hook-form (consistent with existing codebase). ## Global Constraints - All UI text in Portuguese (pt-PT) — follow existing pages - Dark slate theme: root `bg-slate-950`, panels `bg-slate-900`, borders `border-slate-700`/`border-slate-800`, text `text-white`/`text-slate-400` - Input overrides in dark contexts: add `className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500"` — the base Input is light-themed - All API calls via `apiFetch` from `@/lib/api` — never raw fetch - Query keys must be stable: `['clients']`, `['clients', id]`, `['clients', clientId, 'vehicles']`, `['catalog']`, `['work-orders']`, `['work-orders', id]` - Mutations call `queryClient.invalidateQueries` on success using keys above - Loading state: `

A carregar...

` - Empty state: `

Nenhum registo.

` - Error banner: `

{msg}

` - After each task: run `npm run build` from `frontend/` — must exit 0 - After tasks with tests: run `npm run test:run` from `frontend/` — must exit 0 - Working directory for all npm commands: `frontend/` --- ## File Map **Create:** - `frontend/src/components/ui/dialog.tsx` - `frontend/src/hooks/useClients.ts` - `frontend/src/hooks/useClients.test.ts` - `frontend/src/hooks/useCatalog.ts` - `frontend/src/hooks/useCatalog.test.ts` - `frontend/src/hooks/useWorkOrders.ts` - `frontend/src/hooks/useWorkOrders.test.ts` - `frontend/src/pages/app/clients/ClientsPage.tsx` - `frontend/src/pages/app/clients/ClientDetailPage.tsx` - `frontend/src/pages/app/catalog/CatalogPage.tsx` - `frontend/src/pages/app/work-orders/WorkOrdersPage.tsx` - `frontend/src/pages/app/work-orders/WorkOrderDetailPage.tsx` **Modify:** - `frontend/src/App.tsx` — add 5 new routes --- ### Task 1: Shared Dialog component **Files:** - Create: `frontend/src/components/ui/dialog.tsx` **Interfaces:** - Produces: `Dialog`, `DialogTrigger`, `DialogContent`, `DialogHeader`, `DialogTitle`, `DialogClose` — used by all pages in Tasks 3–9 - [ ] **Step 1: Create dialog.tsx** ```tsx // frontend/src/components/ui/dialog.tsx import * as React from 'react' import * as DialogPrimitive from '@radix-ui/react-dialog' import { X } from 'lucide-react' import { cn } from '@/lib/utils' const Dialog = DialogPrimitive.Root const DialogTrigger = DialogPrimitive.Trigger const DialogPortal = DialogPrimitive.Portal const DialogClose = DialogPrimitive.Close const DialogOverlay = React.forwardRef< React.ComponentRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )) DialogOverlay.displayName = DialogPrimitive.Overlay.displayName const DialogContent = React.forwardRef< React.ComponentRef, React.ComponentPropsWithoutRef >(({ className, children, ...props }, ref) => ( {children} Fechar )) DialogContent.displayName = DialogPrimitive.Content.displayName const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => (
) DialogHeader.displayName = 'DialogHeader' const DialogTitle = React.forwardRef< React.ComponentRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )) DialogTitle.displayName = DialogPrimitive.Title.displayName export { Dialog, DialogTrigger, DialogPortal, DialogOverlay, DialogClose, DialogContent, DialogHeader, DialogTitle, } ``` - [ ] **Step 2: Build check** ```bash cd frontend && npm run build ``` Expected: exit 0, no TypeScript errors. - [ ] **Step 3: Commit** ```bash git add frontend/src/components/ui/dialog.tsx git commit -m "feat: add shadcn Dialog component (Radix UI wrapper)" ``` --- ### Task 2: useClients hook **Files:** - Create: `frontend/src/hooks/useClients.ts` - Create: `frontend/src/hooks/useClients.test.ts` **Interfaces:** - Consumes: `apiFetch` from `@/lib/api`, `queryClient` from `@/lib/queryClient`, types `Client`, `Vehicle` from `@/lib/types` - Produces: - `useClients(): UseQueryResult` - `useClient(id: string): UseQueryResult` - `useCreateClient(): UseMutationResult` - `useUpdateClient(): UseMutationResult` - `useDeleteClient(): UseMutationResult` - `useClientVehicles(clientId: string): UseQueryResult` - `useCreateVehicle(clientId: string): UseMutationResult` - `useUpdateVehicle(clientId: string): UseMutationResult` Where: ```ts type ClientPayload = { name: string; nif: string; phone: string; email: string; address: string; notes: string } type ClientUpdatePayload = ClientPayload & { id: string } type VehiclePayload = { plate: string; brand: string; model: string; year: number; vin: string; mileage: number; notes: string } type VehicleUpdatePayload = VehiclePayload & { id: string } ``` - [ ] **Step 1: Write the failing test** ```ts // frontend/src/hooks/useClients.test.ts import { vi, describe, it, expect, beforeEach } from 'vitest' import { renderHook, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import React from 'react' import { useClients, useClientVehicles } from './useClients' vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })) vi.mock('@/lib/queryClient', () => ({ queryClient: new QueryClient() })) import { apiFetch } from '@/lib/api' const mockFetch = apiFetch as ReturnType function makeWrapper() { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) const Wrapper = ({ children }: { children: React.ReactNode }) => React.createElement(QueryClientProvider, { client: qc }, children) return Wrapper } describe('useClients', () => { beforeEach(() => vi.clearAllMocks()) it('calls GET /clients', async () => { mockFetch.mockResolvedValue([]) const { result } = renderHook(() => useClients(), { wrapper: makeWrapper() }) await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(mockFetch).toHaveBeenCalledWith('/clients') }) }) describe('useClientVehicles', () => { beforeEach(() => vi.clearAllMocks()) it('calls GET /clients/:id/vehicles when clientId provided', async () => { mockFetch.mockResolvedValue([]) const { result } = renderHook(() => useClientVehicles('abc-123'), { wrapper: makeWrapper() }) await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(mockFetch).toHaveBeenCalledWith('/clients/abc-123/vehicles') }) it('does not fetch when clientId is empty', () => { const { result } = renderHook(() => useClientVehicles(''), { wrapper: makeWrapper() }) expect(result.current.fetchStatus).toBe('idle') }) }) ``` - [ ] **Step 2: Run test — verify it fails** ```bash cd frontend && npm run test:run -- useClients ``` Expected: FAIL — `useClients` not found. - [ ] **Step 3: Implement useClients.ts** ```ts // frontend/src/hooks/useClients.ts import { useQuery, useMutation } from '@tanstack/react-query' import { apiFetch } from '@/lib/api' import { queryClient } from '@/lib/queryClient' import type { Client, Vehicle } from '@/lib/types' export type ClientPayload = { name: string; nif: string; phone: string; email: string; address: string; notes: string } export type VehiclePayload = { plate: string; brand: string; model: string; year: number; vin: string; mileage: number; notes: string } export function useClients() { return useQuery({ queryKey: ['clients'], queryFn: () => apiFetch('/clients'), }) } export function useClient(id: string) { return useQuery({ queryKey: ['clients', id], queryFn: () => apiFetch(`/clients/${id}`), enabled: !!id, }) } export function useCreateClient() { return useMutation({ mutationFn: (data: ClientPayload) => apiFetch('/clients', { method: 'POST', body: JSON.stringify(data) }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['clients'] }), }) } export function useUpdateClient() { return useMutation({ mutationFn: ({ id, ...data }: ClientPayload & { id: string }) => apiFetch(`/clients/${id}`, { method: 'PUT', body: JSON.stringify(data) }), onSuccess: (_result, { id }) => { queryClient.invalidateQueries({ queryKey: ['clients'] }) queryClient.invalidateQueries({ queryKey: ['clients', id] }) }, }) } export function useDeleteClient() { return useMutation({ mutationFn: (id: string) => apiFetch(`/clients/${id}`, { method: 'DELETE' }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['clients'] }), }) } export function useClientVehicles(clientId: string) { return useQuery({ queryKey: ['clients', clientId, 'vehicles'], queryFn: () => apiFetch(`/clients/${clientId}/vehicles`), enabled: !!clientId, }) } export function useCreateVehicle(clientId: string) { return useMutation({ mutationFn: (data: VehiclePayload) => apiFetch(`/clients/${clientId}/vehicles`, { method: 'POST', body: JSON.stringify(data) }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['clients', clientId, 'vehicles'] }), }) } export function useUpdateVehicle(clientId: string) { return useMutation({ mutationFn: ({ id, ...data }: VehiclePayload & { id: string }) => apiFetch(`/vehicles/${id}`, { method: 'PUT', body: JSON.stringify(data) }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['clients', clientId, 'vehicles'] }), }) } ``` - [ ] **Step 4: Run tests — verify they pass** ```bash cd frontend && npm run test:run -- useClients ``` Expected: 3 tests PASS. - [ ] **Step 5: Build check** ```bash cd frontend && npm run build ``` Expected: exit 0. - [ ] **Step 6: Commit** ```bash git add frontend/src/hooks/useClients.ts frontend/src/hooks/useClients.test.ts git commit -m "feat: useClients domain hook (clients + vehicles CRUD)" ``` --- ### Task 3: ClientsPage **Files:** - Create: `frontend/src/pages/app/clients/ClientsPage.tsx` **Interfaces:** - Consumes: `useClients`, `useCreateClient`, `useUpdateClient` from `@/hooks/useClients`; `Dialog`, `DialogContent`, `DialogHeader`, `DialogTitle` from `@/components/ui/dialog`; `Button` from `@/components/ui/button`; `Input` from `@/components/ui/input`; `Label` from `@/components/ui/label`; `Link` from `react-router`; `Pencil` from `lucide-react` - Produces: default export `ClientsPage` — rendered at `/app/clients` - [ ] **Step 1: Create ClientsPage.tsx** ```tsx // frontend/src/pages/app/clients/ClientsPage.tsx import { useState } from 'react' import { Link } from 'react-router' import { Pencil } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { useClients, useCreateClient, useUpdateClient, type ClientPayload } from '@/hooks/useClients' import type { Client } from '@/lib/types' const emptyForm: ClientPayload = { name: '', nif: '', phone: '', email: '', address: '', notes: '' } export default function ClientsPage() { const { data: clients = [], isLoading, error } = useClients() const createClient = useCreateClient() const updateClient = useUpdateClient() const [open, setOpen] = useState(false) const [editing, setEditing] = useState(null) const [form, setForm] = useState(emptyForm) function openCreate() { setEditing(null) setForm(emptyForm) setOpen(true) } function openEdit(c: Client) { setEditing(c) setForm({ name: c.name, nif: c.nif, phone: c.phone, email: c.email, address: c.address, notes: c.notes }) setOpen(true) } function handleSubmit(e: React.FormEvent) { e.preventDefault() if (!form.name.trim()) return if (editing) { updateClient.mutate({ id: editing.id, ...form }, { onSuccess: () => setOpen(false) }) } else { createClient.mutate(form, { onSuccess: () => setOpen(false) }) } } const field = (key: keyof ClientPayload) => (e: React.ChangeEvent) => setForm((f) => ({ ...f, [key]: e.target.value })) return (

Clientes

{clients.length} clientes registados

{error && (

{(error as Error).message}

)} {isLoading ? (

A carregar...

) : clients.length === 0 ? (

Nenhum cliente registado.

) : (
{['Nome', 'NIF', 'Telefone', 'Email', 'Criado', ''].map((h) => ( ))} {clients.map((c) => ( ))}
{h}
{c.name} {c.nif || '—'} {c.phone || '—'} {c.email || '—'} {new Intl.DateTimeFormat('pt-PT').format(new Date(c.created_at))}
)} {editing ? 'Editar Cliente' : 'Novo Cliente'}
) } ``` - [ ] **Step 2: Build check** ```bash cd frontend && npm run build ``` Expected: exit 0. - [ ] **Step 3: Commit** ```bash git add frontend/src/pages/app/clients/ClientsPage.tsx git commit -m "feat: ClientsPage — list + create/edit modal" ``` --- ### Task 4: ClientDetailPage **Files:** - Create: `frontend/src/pages/app/clients/ClientDetailPage.tsx` **Interfaces:** - Consumes: `useClient`, `useUpdateClient`, `useClientVehicles`, `useCreateVehicle`, `useUpdateVehicle`, `type ClientPayload`, `type VehiclePayload` from `@/hooks/useClients`; `useParams`, `Link` from `react-router`; `Pencil`, `ArrowLeft` from `lucide-react` - [ ] **Step 1: Create ClientDetailPage.tsx** ```tsx // frontend/src/pages/app/clients/ClientDetailPage.tsx import { useState } from 'react' import { useParams, Link } from 'react-router' import { ArrowLeft, Pencil } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { useClient, useUpdateClient, useClientVehicles, useCreateVehicle, useUpdateVehicle, type ClientPayload, type VehiclePayload, } from '@/hooks/useClients' import type { Vehicle } from '@/lib/types' const emptyVehicle: VehiclePayload = { plate: '', brand: '', model: '', year: 0, vin: '', mileage: 0, notes: '' } export default function ClientDetailPage() { const { id = '' } = useParams() const { data: client, isLoading: loadingClient, error: clientError } = useClient(id) const { data: vehicles = [], isLoading: loadingVehicles } = useClientVehicles(id) const updateClient = useUpdateClient() const createVehicle = useCreateVehicle(id) const updateVehicle = useUpdateVehicle(id) const [editClientOpen, setEditClientOpen] = useState(false) const [clientForm, setClientForm] = useState({ name: '', nif: '', phone: '', email: '', address: '', notes: '' }) const [vehicleOpen, setVehicleOpen] = useState(false) const [editingVehicle, setEditingVehicle] = useState(null) const [vehicleForm, setVehicleForm] = useState(emptyVehicle) function openEditClient() { if (!client) return setClientForm({ name: client.name, nif: client.nif, phone: client.phone, email: client.email, address: client.address, notes: client.notes }) setEditClientOpen(true) } function openAddVehicle() { setEditingVehicle(null) setVehicleForm(emptyVehicle) setVehicleOpen(true) } function openEditVehicle(v: Vehicle) { setEditingVehicle(v) setVehicleForm({ plate: v.plate, brand: v.brand, model: v.model, year: v.year ?? 0, vin: v.vin, mileage: v.mileage ?? 0, notes: v.notes }) setVehicleOpen(true) } function handleClientSubmit(e: React.FormEvent) { e.preventDefault() if (!clientForm.name.trim()) return updateClient.mutate({ id, ...clientForm }, { onSuccess: () => setEditClientOpen(false) }) } function handleVehicleSubmit(e: React.FormEvent) { e.preventDefault() if (!vehicleForm.plate.trim() || !vehicleForm.brand.trim() || !vehicleForm.model.trim()) return if (editingVehicle) { updateVehicle.mutate({ id: editingVehicle.id, ...vehicleForm }, { onSuccess: () => setVehicleOpen(false) }) } else { createVehicle.mutate(vehicleForm, { onSuccess: () => setVehicleOpen(false) }) } } const vf = (key: keyof VehiclePayload) => (e: React.ChangeEvent) => setVehicleForm((f) => ({ ...f, [key]: key === 'year' || key === 'mileage' ? Number(e.target.value) : e.target.value })) const cf = (key: keyof ClientPayload) => (e: React.ChangeEvent) => setClientForm((f) => ({ ...f, [key]: e.target.value })) if (loadingClient) return

A carregar...

if (clientError || !client) return (
Clientes

Cliente não encontrado.

) return (
Clientes {/* Client header */}

{client.name}

{[ ['NIF', client.nif], ['Telefone', client.phone], ['Email', client.email], ['Morada', client.address], ].map(([label, value]) => (
{label}
{value || '—'}
))} {client.notes && (
Notas
{client.notes}
)}
{/* Vehicles section */}

Veículos

{loadingVehicles ? (

A carregar...

) : vehicles.length === 0 ? (

Nenhum veículo associado.

) : (
{['Matrícula', 'Marca', 'Modelo', 'Ano', 'Km', ''].map((h) => ( ))} {vehicles.map((v) => ( ))}
{h}
{v.plate} {v.brand} {v.model} {v.year ?? '—'} {v.mileage != null ? `${v.mileage.toLocaleString('pt-PT')} km` : '—'}
)}
{/* Edit client modal */} Editar Cliente
{/* Add/edit vehicle modal */} {editingVehicle ? 'Editar Veículo' : 'Adicionar Veículo'}
) } ``` - [ ] **Step 2: Build check** ```bash cd frontend && npm run build ``` Expected: exit 0. - [ ] **Step 3: Commit** ```bash git add frontend/src/pages/app/clients/ClientDetailPage.tsx git commit -m "feat: ClientDetailPage — client info + vehicles CRUD" ``` --- ### Task 5: useCatalog hook + CatalogPage **Files:** - Create: `frontend/src/hooks/useCatalog.ts` - Create: `frontend/src/hooks/useCatalog.test.ts` - Create: `frontend/src/pages/app/catalog/CatalogPage.tsx` **Interfaces:** - Produces: - `useCatalog(): UseQueryResult` - `useCreateCatalogItem(): UseMutationResult` - `useUpdateCatalogItem(): UseMutationResult` - `useDeleteCatalogItem(): UseMutationResult` Where: ```ts type CatalogPayload = { code: string; name: string; category: string; unit: 'un' | 'hora' | 'litro' | 'kg'; base_price: number; active: boolean } type CatalogUpdatePayload = CatalogPayload & { id: string } ``` - [ ] **Step 1: Write failing test** ```ts // frontend/src/hooks/useCatalog.test.ts import { vi, describe, it, expect, beforeEach } from 'vitest' import { renderHook, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import React from 'react' import { useCatalog } from './useCatalog' vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })) vi.mock('@/lib/queryClient', () => ({ queryClient: new QueryClient() })) import { apiFetch } from '@/lib/api' const mockFetch = apiFetch as ReturnType function makeWrapper() { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) const Wrapper = ({ children }: { children: React.ReactNode }) => React.createElement(QueryClientProvider, { client: qc }, children) return Wrapper } describe('useCatalog', () => { beforeEach(() => vi.clearAllMocks()) it('calls GET /catalog', async () => { mockFetch.mockResolvedValue([]) const { result } = renderHook(() => useCatalog(), { wrapper: makeWrapper() }) await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(mockFetch).toHaveBeenCalledWith('/catalog') }) }) ``` - [ ] **Step 2: Run test — verify it fails** ```bash cd frontend && npm run test:run -- useCatalog ``` Expected: FAIL — `useCatalog` not found. - [ ] **Step 3: Implement useCatalog.ts** ```ts // frontend/src/hooks/useCatalog.ts import { useQuery, useMutation } from '@tanstack/react-query' import { apiFetch } from '@/lib/api' import { queryClient } from '@/lib/queryClient' import type { CatalogItem } from '@/lib/types' export type CatalogPayload = { code: string name: string category: string unit: 'un' | 'hora' | 'litro' | 'kg' base_price: number active: boolean } export function useCatalog() { return useQuery({ queryKey: ['catalog'], queryFn: () => apiFetch('/catalog'), }) } export function useCreateCatalogItem() { return useMutation({ mutationFn: (data: CatalogPayload) => apiFetch('/catalog', { method: 'POST', body: JSON.stringify(data) }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['catalog'] }), }) } export function useUpdateCatalogItem() { return useMutation({ mutationFn: ({ id, ...data }: CatalogPayload & { id: string }) => apiFetch(`/catalog/${id}`, { method: 'PUT', body: JSON.stringify(data) }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['catalog'] }), }) } export function useDeleteCatalogItem() { return useMutation({ mutationFn: (id: string) => apiFetch(`/catalog/${id}`, { method: 'DELETE' }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['catalog'] }), }) } ``` - [ ] **Step 4: Run tests — verify they pass** ```bash cd frontend && npm run test:run -- useCatalog ``` Expected: 1 test PASS. - [ ] **Step 5: Create CatalogPage.tsx** ```tsx // frontend/src/pages/app/catalog/CatalogPage.tsx import { useState } from 'react' import { Pencil, Trash2 } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { useCatalog, useCreateCatalogItem, useUpdateCatalogItem, useDeleteCatalogItem, type CatalogPayload } from '@/hooks/useCatalog' import type { CatalogItem } from '@/lib/types' const UNITS: CatalogItem['unit'][] = ['un', 'hora', 'litro', 'kg'] const emptyForm: CatalogPayload = { code: '', name: '', category: '', unit: 'un', base_price: 0, active: true } export default function CatalogPage() { const { data: items = [], isLoading, error } = useCatalog() const createItem = useCreateCatalogItem() const updateItem = useUpdateCatalogItem() const deleteItem = useDeleteCatalogItem() const [open, setOpen] = useState(false) const [editing, setEditing] = useState(null) const [form, setForm] = useState(emptyForm) const [confirmDelete, setConfirmDelete] = useState(null) function openCreate() { setEditing(null) setForm(emptyForm) setOpen(true) } function openEdit(item: CatalogItem) { setEditing(item) setForm({ code: item.code, name: item.name, category: item.category, unit: item.unit, base_price: item.base_price, active: item.active }) setOpen(true) } function handleSubmit(e: React.FormEvent) { e.preventDefault() if (!form.code.trim() || !form.name.trim() || !form.category.trim()) return if (editing) { updateItem.mutate({ id: editing.id, ...form }, { onSuccess: () => setOpen(false) }) } else { createItem.mutate(form, { onSuccess: () => setOpen(false) }) } } function handleDelete(id: string) { if (confirmDelete === id) { deleteItem.mutate(id, { onSuccess: () => setConfirmDelete(null) }) } else { setConfirmDelete(id) } } const f = (key: keyof CatalogPayload) => (e: React.ChangeEvent) => setForm((prev) => ({ ...prev, [key]: key === 'base_price' ? Number(e.target.value) : key === 'active' ? (e.target as HTMLInputElement).checked : e.target.value, })) return (

Catálogo

{items.length} itens

{error && (

{(error as Error).message}

)} {isLoading ? (

A carregar...

) : items.length === 0 ? (

Nenhum item no catálogo.

) : (
{['Código', 'Nome', 'Categoria', 'Unidade', 'Preço Base', 'Estado', ''].map((h) => ( ))} {items.map((item) => ( ))}
{h}
{item.code} {item.name} {item.category} {item.unit} {new Intl.NumberFormat('pt-PT', { style: 'currency', currency: 'EUR' }).format(item.base_price)} {item.active ? 'Activo' : 'Inactivo'}
)} {editing ? 'Editar Item' : 'Novo Item'}
) } ``` - [ ] **Step 6: Build check** ```bash cd frontend && npm run build ``` Expected: exit 0. - [ ] **Step 7: Commit** ```bash git add frontend/src/hooks/useCatalog.ts frontend/src/hooks/useCatalog.test.ts frontend/src/pages/app/catalog/CatalogPage.tsx git commit -m "feat: useCatalog hook + CatalogPage — items CRUD with confirm-delete" ``` --- ### Task 6: useWorkOrders hook **Files:** - Create: `frontend/src/hooks/useWorkOrders.ts` - Create: `frontend/src/hooks/useWorkOrders.test.ts` **Interfaces:** - Produces: - `useWorkOrders(status?: string): UseQueryResult` - `useWorkOrder(id: string): UseQueryResult` - `useCreateWorkOrder(): UseMutationResult` - `useTransitionWorkOrder(): UseMutationResult` - `useUpdateWorkOrder(): UseMutationResult` - `useAddWOItem(): UseMutationResult` - `useRemoveWOItem(): UseMutationResult` - `useAddStaffHours(): UseMutationResult` - `useRemoveStaffHours(): UseMutationResult` Where: ```ts type WOPayload = { client_id: string; vehicle_id: string; internal_notes: string; client_notes: string } type WOUpdatePayload = WOPayload & { id: string } type WOItemPayload = { catalog_item_id: string; description: string; qty: number; unit_price: number; discount_pct: number } type StaffHoursPayload = { staff_id: string; hours: number; cost_per_hour: number } ``` - [ ] **Step 1: Write failing test** ```ts // frontend/src/hooks/useWorkOrders.test.ts import { vi, describe, it, expect, beforeEach } from 'vitest' import { renderHook, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import React from 'react' import { useWorkOrders, useWorkOrder } from './useWorkOrders' vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })) vi.mock('@/lib/queryClient', () => ({ queryClient: new QueryClient() })) import { apiFetch } from '@/lib/api' const mockFetch = apiFetch as ReturnType function makeWrapper() { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) const Wrapper = ({ children }: { children: React.ReactNode }) => React.createElement(QueryClientProvider, { client: qc }, children) return Wrapper } describe('useWorkOrders', () => { beforeEach(() => vi.clearAllMocks()) it('calls GET /work-orders without status filter', async () => { mockFetch.mockResolvedValue([]) const { result } = renderHook(() => useWorkOrders(), { wrapper: makeWrapper() }) await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(mockFetch).toHaveBeenCalledWith('/work-orders') }) it('calls GET /work-orders?status=open when status provided', async () => { mockFetch.mockResolvedValue([]) const { result } = renderHook(() => useWorkOrders('open'), { wrapper: makeWrapper() }) await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(mockFetch).toHaveBeenCalledWith('/work-orders?status=open') }) }) describe('useWorkOrder', () => { beforeEach(() => vi.clearAllMocks()) it('calls GET /work-orders/:id', async () => { mockFetch.mockResolvedValue({ id: 'wo-1', items: [], staff_hours: [] }) const { result } = renderHook(() => useWorkOrder('wo-1'), { wrapper: makeWrapper() }) await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(mockFetch).toHaveBeenCalledWith('/work-orders/wo-1') }) it('does not fetch when id is empty', () => { const { result } = renderHook(() => useWorkOrder(''), { wrapper: makeWrapper() }) expect(result.current.fetchStatus).toBe('idle') }) }) ``` - [ ] **Step 2: Run test — verify it fails** ```bash cd frontend && npm run test:run -- useWorkOrders ``` Expected: FAIL — `useWorkOrders` not found. - [ ] **Step 3: Implement useWorkOrders.ts** ```ts // frontend/src/hooks/useWorkOrders.ts import { useQuery, useMutation } from '@tanstack/react-query' import { apiFetch } from '@/lib/api' import { queryClient } from '@/lib/queryClient' import type { WorkOrder, WorkOrderDetail, WOItem, WOStaffHours } from '@/lib/types' export type WOPayload = { client_id: string vehicle_id: string internal_notes: string client_notes: string } export type WOItemPayload = { catalog_item_id: string description: string qty: number unit_price: number discount_pct: number } export type StaffHoursPayload = { staff_id: string hours: number cost_per_hour: number } export function useWorkOrders(status?: string) { const url = status ? `/work-orders?status=${status}` : '/work-orders' return useQuery({ queryKey: status ? ['work-orders', { status }] : ['work-orders'], queryFn: () => apiFetch(url), }) } export function useWorkOrder(id: string) { return useQuery({ queryKey: ['work-orders', id], queryFn: () => apiFetch(`/work-orders/${id}`), enabled: !!id, }) } export function useCreateWorkOrder() { return useMutation({ mutationFn: (data: WOPayload) => apiFetch('/work-orders', { method: 'POST', body: JSON.stringify(data) }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['work-orders'] }), }) } export function useUpdateWorkOrder() { return useMutation({ mutationFn: ({ id, ...data }: WOPayload & { id: string }) => apiFetch(`/work-orders/${id}`, { method: 'PUT', body: JSON.stringify(data) }), onSuccess: (_r, { id }) => { queryClient.invalidateQueries({ queryKey: ['work-orders'] }) queryClient.invalidateQueries({ queryKey: ['work-orders', id] }) }, }) } export function useTransitionWorkOrder() { return useMutation({ mutationFn: ({ id, status }: { id: string; status: string }) => apiFetch(`/work-orders/${id}/transition`, { method: 'POST', body: JSON.stringify({ status }) }), onSuccess: (_r, { id }) => { queryClient.invalidateQueries({ queryKey: ['work-orders'] }) queryClient.invalidateQueries({ queryKey: ['work-orders', id] }) }, }) } export function useAddWOItem() { return useMutation({ mutationFn: ({ woId, ...data }: { woId: string } & WOItemPayload) => apiFetch(`/work-orders/${woId}/items`, { method: 'POST', body: JSON.stringify(data) }), onSuccess: (_r, { woId }) => queryClient.invalidateQueries({ queryKey: ['work-orders', woId] }), }) } export function useRemoveWOItem() { return useMutation({ mutationFn: ({ woId, itemId }: { woId: string; itemId: string }) => apiFetch(`/work-orders/${woId}/items/${itemId}`, { method: 'DELETE' }), onSuccess: (_r, { woId }) => queryClient.invalidateQueries({ queryKey: ['work-orders', woId] }), }) } export function useAddStaffHours() { return useMutation({ mutationFn: ({ woId, ...data }: { woId: string } & StaffHoursPayload) => apiFetch(`/work-orders/${woId}/staff-hours`, { method: 'POST', body: JSON.stringify(data) }), onSuccess: (_r, { woId }) => queryClient.invalidateQueries({ queryKey: ['work-orders', woId] }), }) } export function useRemoveStaffHours() { return useMutation({ mutationFn: ({ woId, shId }: { woId: string; shId: string }) => apiFetch(`/work-orders/${woId}/staff-hours/${shId}`, { method: 'DELETE' }), onSuccess: (_r, { woId }) => queryClient.invalidateQueries({ queryKey: ['work-orders', woId] }), }) } ``` - [ ] **Step 4: Run tests — verify they pass** ```bash cd frontend && npm run test:run -- useWorkOrders ``` Expected: 4 tests PASS. - [ ] **Step 5: Build check** ```bash cd frontend && npm run build ``` Expected: exit 0. - [ ] **Step 6: Commit** ```bash git add frontend/src/hooks/useWorkOrders.ts frontend/src/hooks/useWorkOrders.test.ts git commit -m "feat: useWorkOrders domain hook (list, detail, transitions, items, staff hours)" ``` --- ### Task 7: WorkOrdersPage **Files:** - Create: `frontend/src/pages/app/work-orders/WorkOrdersPage.tsx` **Interfaces:** - Consumes: `useWorkOrders`, `useCreateWorkOrder`, `type WOPayload` from `@/hooks/useWorkOrders`; `useClients`, `useClientVehicles` from `@/hooks/useClients`; `useNavigate` from `react-router` Status badge colours: `open`→slate, `in_progress`→blue, `completed`→green, `invoiced`→purple, `cancelled`→red. - [ ] **Step 1: Create WorkOrdersPage.tsx** ```tsx // frontend/src/pages/app/work-orders/WorkOrdersPage.tsx import { useState } from 'react' import { useNavigate } from 'react-router' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { useWorkOrders, useCreateWorkOrder, type WOPayload } from '@/hooks/useWorkOrders' import { useClients, useClientVehicles } from '@/hooks/useClients' import type { WorkOrder } from '@/lib/types' const STATUS_TABS = [ { value: '', label: 'Todas' }, { value: 'open', label: 'Abertas' }, { value: 'in_progress', label: 'Em Progresso' }, { value: 'completed', label: 'Concluídas' }, { value: 'invoiced', label: 'Faturadas' }, { value: 'cancelled', label: 'Canceladas' }, ] const STATUS_BADGE: Record = { open: 'bg-slate-700 text-slate-300', in_progress: 'bg-blue-900/50 text-blue-300 border border-blue-700', completed: 'bg-green-900/50 text-green-300 border border-green-700', invoiced: 'bg-purple-900/50 text-purple-300 border border-purple-700', cancelled: 'bg-red-900/50 text-red-300 border border-red-700', } const STATUS_LABEL: Record = { open: 'Aberta', in_progress: 'Em Progresso', completed: 'Concluída', invoiced: 'Faturada', cancelled: 'Cancelada', } const emptyForm: WOPayload = { client_id: '', vehicle_id: '', internal_notes: '', client_notes: '' } export default function WorkOrdersPage() { const navigate = useNavigate() const [statusFilter, setStatusFilter] = useState('') const { data: orders = [], isLoading, error } = useWorkOrders(statusFilter || undefined) const createWO = useCreateWorkOrder() const [open, setOpen] = useState(false) const [form, setForm] = useState(emptyForm) const { data: clients = [] } = useClients() const { data: vehicles = [] } = useClientVehicles(form.client_id) function handleSubmit(e: React.FormEvent) { e.preventDefault() createWO.mutate(form, { onSuccess: (wo) => { setOpen(false) setForm(emptyForm) navigate(`/app/work-orders/${wo.id}`) }, }) } return (

Ordens de Trabalho

{orders.length} ordens

{/* Status tabs */}
{STATUS_TABS.map(({ value, label }) => ( ))}
{error && (

{(error as Error).message}

)} {isLoading ? (

A carregar...

) : orders.length === 0 ? (

Nenhuma ordem de trabalho.

) : (
{['Nº OT', 'Estado', 'Notas Internas', 'Data', ''].map((h) => ( ))} {orders.map((wo) => ( navigate(`/app/work-orders/${wo.id}`)} className="border-t border-slate-700 hover:bg-slate-800/50 cursor-pointer" > ))}
{h}
#{wo.number} {STATUS_LABEL[wo.status]} {wo.internal_notes || '—'} {new Intl.DateTimeFormat('pt-PT').format(new Date(wo.created_at))} Ver →
)} Nova Ordem de Trabalho
setForm((f) => ({ ...f, internal_notes: e.target.value }))} className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500" placeholder="Problema reportado, diagnóstico..." />
setForm((f) => ({ ...f, client_notes: e.target.value }))} className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500" placeholder="Mensagem para o cliente..." />
) } ``` - [ ] **Step 2: Build check** ```bash cd frontend && npm run build ``` Expected: exit 0. - [ ] **Step 3: Commit** ```bash git add frontend/src/pages/app/work-orders/WorkOrdersPage.tsx git commit -m "feat: WorkOrdersPage — list with status tabs + create modal" ``` --- ### Task 8: WorkOrderDetailPage **Files:** - Create: `frontend/src/pages/app/work-orders/WorkOrderDetailPage.tsx` **Interfaces:** - Consumes: `useWorkOrder`, `useTransitionWorkOrder`, `useUpdateWorkOrder`, `useAddWOItem`, `useRemoveWOItem`, `useAddStaffHours`, `useRemoveStaffHours`, `type WOItemPayload`, `type StaffHoursPayload` from `@/hooks/useWorkOrders`; `useCatalog` from `@/hooks/useCatalog`; `useParams`, `Link` from `react-router` State machine transitions: - `open` → `in_progress` (button: "Iniciar") - `in_progress` → `completed` (button: "Concluir") - `completed` → `invoiced` (button: "Faturar") - Any state except `invoiced` → `cancelled` (button: "Cancelar OT", destructive style) Stepper steps in order: `open`, `in_progress`, `completed`, `invoiced` - [ ] **Step 1: Create WorkOrderDetailPage.tsx** ```tsx // frontend/src/pages/app/work-orders/WorkOrderDetailPage.tsx import { useState } from 'react' import { useParams, Link } from 'react-router' import { ArrowLeft, Trash2 } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { useWorkOrder, useTransitionWorkOrder, useUpdateWorkOrder, useAddWOItem, useRemoveWOItem, useAddStaffHours, useRemoveStaffHours, type WOItemPayload, type StaffHoursPayload, } from '@/hooks/useWorkOrders' import { useCatalog } from '@/hooks/useCatalog' import type { WorkOrder } from '@/lib/types' const STEPS: WorkOrder['status'][] = ['open', 'in_progress', 'completed', 'invoiced'] const STEP_LABEL: Record = { open: 'Aberta', in_progress: 'Em Progresso', completed: 'Concluída', invoiced: 'Faturada', cancelled: 'Cancelada', } const NEXT_STATUS: Partial> = { open: 'in_progress', in_progress: 'completed', completed: 'invoiced', } const NEXT_LABEL: Partial> = { open: 'Iniciar', in_progress: 'Concluir', completed: 'Faturar', } const STATUS_BADGE: Record = { open: 'bg-slate-700 text-slate-300', in_progress: 'bg-blue-900/50 text-blue-300 border border-blue-700', completed: 'bg-green-900/50 text-green-300 border border-green-700', invoiced: 'bg-purple-900/50 text-purple-300 border border-purple-700', cancelled: 'bg-red-900/50 text-red-300 border border-red-700', } const emptyItem: WOItemPayload = { catalog_item_id: '', description: '', qty: 1, unit_price: 0, discount_pct: 0 } const emptyHours: StaffHoursPayload = { staff_id: '', hours: 0, cost_per_hour: 0 } const fmt = new Intl.NumberFormat('pt-PT', { style: 'currency', currency: 'EUR' }) export default function WorkOrderDetailPage() { const { id = '' } = useParams() const { data: wo, isLoading, error } = useWorkOrder(id) const { data: catalog = [] } = useCatalog() const transition = useTransitionWorkOrder() const updateWO = useUpdateWorkOrder() const addItem = useAddWOItem() const removeItem = useRemoveWOItem() const addHours = useAddStaffHours() const removeHours = useRemoveStaffHours() const [itemOpen, setItemOpen] = useState(false) const [itemForm, setItemForm] = useState(emptyItem) const [hoursOpen, setHoursOpen] = useState(false) const [hoursForm, setHoursForm] = useState(emptyHours) const [editNotesMode, setEditNotesMode] = useState(false) const [notesForm, setNotesForm] = useState({ internal_notes: '', client_notes: '' }) const [cancelConfirm, setCancelConfirm] = useState(false) function handleCatalogSelect(catalogId: string) { const item = catalog.find((c) => c.id === catalogId) if (item) { setItemForm((f) => ({ ...f, catalog_item_id: catalogId, description: item.name, unit_price: item.base_price })) } else { setItemForm((f) => ({ ...f, catalog_item_id: catalogId })) } } function handleAddItem(e: React.FormEvent) { e.preventDefault() if (!itemForm.description.trim() || itemForm.qty <= 0) return addItem.mutate({ woId: id, ...itemForm }, { onSuccess: () => { setItemOpen(false); setItemForm(emptyItem) } }) } function handleAddHours(e: React.FormEvent) { e.preventDefault() if (!hoursForm.staff_id.trim() || hoursForm.hours <= 0) return addHours.mutate({ woId: id, ...hoursForm }, { onSuccess: () => { setHoursOpen(false); setHoursForm(emptyHours) } }) } function openEditNotes() { if (!wo) return setNotesForm({ internal_notes: wo.internal_notes, client_notes: wo.client_notes }) setEditNotesMode(true) } function handleSaveNotes(e: React.FormEvent) { e.preventDefault() if (!wo) return updateWO.mutate( { id, client_id: wo.client_id ?? '', vehicle_id: wo.vehicle_id ?? '', ...notesForm }, { onSuccess: () => setEditNotesMode(false) } ) } if (isLoading) return

A carregar...

if (error || !wo) return (
Ordens de Trabalho

Ordem não encontrada.

) const nextStatus = NEXT_STATUS[wo.status] const canCancel = wo.status !== 'invoiced' && wo.status !== 'cancelled' const isClosed = wo.status === 'invoiced' || wo.status === 'cancelled' const subtotalItems = wo.items.reduce((s, i) => s + i.total, 0) const subtotalHours = wo.staff_hours.reduce((s, h) => s + h.total, 0) return (
Ordens de Trabalho
{/* LEFT: info + transitions */}

OT #{wo.number}

{STEP_LABEL[wo.status]}

{new Intl.DateTimeFormat('pt-PT').format(new Date(wo.created_at))}

{/* Stepper */} {wo.status !== 'cancelled' && (
{STEPS.map((step, i) => { const stepIndex = STEPS.indexOf(wo.status as WorkOrder['status']) const done = STEPS.indexOf(step) <= stepIndex return (
{i < STEPS.length - 1 && null}
) })}
)} {/* Notes */} {editNotesMode ? (
setNotesForm((f) => ({ ...f, internal_notes: e.target.value }))} className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500" />
setNotesForm((f) => ({ ...f, client_notes: e.target.value }))} className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500" />
) : (

Notas Internas

{wo.internal_notes || '—'}

Notas para o Cliente

{wo.client_notes || '—'}

{!isClosed && ( )}
)}
{/* Transition buttons */} {!isClosed && (
{nextStatus && ( )} {canCancel && ( )}
)}
{/* RIGHT: items + staff hours */}
{/* Items */}

Peças / Serviços

{!isClosed && }
{wo.items.length === 0 ? (

Nenhum item adicionado.

) : (
{['Descrição', 'Qty', 'P. Unit.', 'Desc.', 'Total', ''].map((h) => ( ))} {wo.items.map((item) => ( ))}
{h}
{item.description} {item.qty} {fmt.format(item.unit_price)} {item.discount_pct > 0 ? `${item.discount_pct}%` : '—'} {fmt.format(item.total)} {!isClosed && ( )}
Subtotal peças {fmt.format(subtotalItems)}
)}
{/* Staff hours */}

Horas de Técnico

{!isClosed && }
{wo.staff_hours.length === 0 ? (

Nenhuma hora registada.

) : (
{['Técnico', 'Horas', 'Custo/h', 'Total', ''].map((h) => ( ))} {wo.staff_hours.map((sh) => ( ))}
{h}
{sh.staff_id} {sh.hours}h {fmt.format(sh.cost_per_hour)} {fmt.format(sh.total)} {!isClosed && ( )}
Subtotal horas {fmt.format(subtotalHours)}
)}
{/* Grand total */} {(wo.items.length > 0 || wo.staff_hours.length > 0) && (

Total Geral

{fmt.format(subtotalItems + subtotalHours)}

)}
{/* Add item modal */} Adicionar Item
setItemForm((f) => ({ ...f, description: e.target.value }))} required className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500" placeholder="Descrição do serviço ou peça" />
setItemForm((f) => ({ ...f, qty: Number(e.target.value) }))} required className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500" />
setItemForm((f) => ({ ...f, unit_price: Number(e.target.value) }))} className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500" />
setItemForm((f) => ({ ...f, discount_pct: Number(e.target.value) }))} className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500" />
{/* Add staff hours modal */} Adicionar Horas de Técnico
setHoursForm((f) => ({ ...f, staff_id: e.target.value }))} required className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500" placeholder="ID ou nome do técnico" />
setHoursForm((f) => ({ ...f, hours: Number(e.target.value) }))} required className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500" />
setHoursForm((f) => ({ ...f, cost_per_hour: Number(e.target.value) }))} className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500" />
) } ``` - [ ] **Step 2: Build check** ```bash cd frontend && npm run build ``` Expected: exit 0. - [ ] **Step 3: Commit** ```bash git add frontend/src/pages/app/work-orders/WorkOrderDetailPage.tsx git commit -m "feat: WorkOrderDetailPage — detail, state machine, items, staff hours, totals" ``` --- ### Task 9: Wire routes + final verification **Files:** - Modify: `frontend/src/App.tsx` **Interfaces:** - Consumes: all 5 new page components from Tasks 3–8 - [ ] **Step 1: Update App.tsx** Replace the existing `/app` route block with: ```tsx // frontend/src/App.tsx import { BrowserRouter, Routes, Route, Navigate } from 'react-router' import { QueryClientProvider } from '@tanstack/react-query' import { queryClient } from '@/lib/queryClient' import { useAuthStore } from '@/store/authStore' import type { UserRole } from '@/store/authStore' import LoginPage from '@/pages/auth/LoginPage' import AppDashboardPage from '@/pages/app/DashboardPage' import AdminDashboardPage from '@/pages/admin/DashboardPage' import TenantsPage from '@/pages/admin/TenantsPage' import InviteRedeemPage from '@/pages/public/InviteRedeemPage' import ClientsPage from '@/pages/app/clients/ClientsPage' import ClientDetailPage from '@/pages/app/clients/ClientDetailPage' import CatalogPage from '@/pages/app/catalog/CatalogPage' import WorkOrdersPage from '@/pages/app/work-orders/WorkOrdersPage' import WorkOrderDetailPage from '@/pages/app/work-orders/WorkOrderDetailPage' import AppLayout from '@/components/layout/AppLayout' import AdminLayout from '@/components/layout/AdminLayout' function RequireAuth({ children, allowedRoles, }: { children: React.ReactNode allowedRoles: UserRole[] }) { const { isAuthenticated, user } = useAuthStore() if (!isAuthenticated) return if (user && !allowedRoles.includes(user.role)) return return <>{children} } export default function App() { return ( } /> } /> } > } /> } /> } > } /> } /> } /> } /> } /> } /> } /> ) } ``` - [ ] **Step 2: Full build + test run** ```bash cd frontend && npm run build && npm run test:run ``` Expected: build exit 0, all tests PASS (authStore × 4 + useClients × 3 + useCatalog × 1 + useWorkOrders × 4 = 12 tests). - [ ] **Step 3: Rebuild Docker image + smoke test** ```bash cd /path/to/project && docker compose build frontend && docker compose up -d frontend ``` Then verify: ```bash curl -s http://localhost:3000 | grep -q 'TechXCar' && echo "Frontend OK" curl -s -X POST http://localhost:8080/api/v1/auth/login \ -H "Content-Type: application/json" \ -d '{"email":"admin@techxcar.com","password":"TechXCar2026!"}' | grep -q 'access_token' && echo "Auth OK" ``` Expected: both lines print OK. - [ ] **Step 4: Commit** ```bash git add frontend/src/App.tsx git commit -m "feat: wire client, catalog, and work-order routes into App router" ``` --- ## Self-Review **Spec coverage check:** | Spec requirement | Task | |---|---| | `dialog.tsx` shared component | Task 1 | | `useClients` hook (8 operations) | Task 2 | | `ClientsPage` list + create/edit modal | Task 3 | | `ClientDetailPage` + vehicles CRUD | Task 4 | | `useCatalog` hook (4 operations) | Task 5 | | `CatalogPage` with confirm-delete | Task 5 | | `useWorkOrders` hook (8 operations) | Task 6 | | `WorkOrdersPage` + status tabs + create modal | Task 7 | | `WorkOrderDetailPage` + state machine + items + hours | Task 8 | | Route wiring + final verification | Task 9 | **No placeholders, no TBDs.** **Type consistency:** All type names and function signatures are consistent across all tasks. `WOPayload`, `WOItemPayload`, `StaffHoursPayload`, `ClientPayload`, `VehiclePayload`, `CatalogPayload` defined in hooks and re-used in pages.