Files
techxcar/docs/superpowers/plans/2026-06-22-plan3-core-pages.md
Luciano Milani 5de37bb512 Inicial
2026-07-02 12:47:55 +01:00

88 KiB
Raw Permalink Blame History

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: <p className="text-slate-400">A carregar...</p>
  • Empty state: <p className="text-slate-500 text-sm">Nenhum registo.</p>
  • Error banner: <div className="mb-4 p-3 bg-red-900/20 border border-red-800 rounded-lg"><p className="text-red-400 text-sm">{msg}</p></div>
  • 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 39

  • Step 1: Create dialog.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<typeof DialogPrimitive.Overlay>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
  <DialogPrimitive.Overlay
    ref={ref}
    className={cn(
      'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
      className
    )}
    {...props}
  />
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName

const DialogContent = React.forwardRef<
  React.ComponentRef<typeof DialogPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
  <DialogPortal>
    <DialogOverlay />
    <DialogPrimitive.Content
      ref={ref}
      className={cn(
        'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 bg-slate-900 border border-slate-700 p-6 shadow-lg rounded-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
        className
      )}
      {...props}
    >
      {children}
      <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:pointer-events-none">
        <X className="h-4 w-4 text-slate-400" />
        <span className="sr-only">Fechar</span>
      </DialogPrimitive.Close>
    </DialogPrimitive.Content>
  </DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName

const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
  <div className={cn('flex flex-col space-y-1.5', className)} {...props} />
)
DialogHeader.displayName = 'DialogHeader'

const DialogTitle = React.forwardRef<
  React.ComponentRef<typeof DialogPrimitive.Title>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
  <DialogPrimitive.Title
    ref={ref}
    className={cn('text-lg font-semibold text-white', className)}
    {...props}
  />
))
DialogTitle.displayName = DialogPrimitive.Title.displayName

export {
  Dialog,
  DialogTrigger,
  DialogPortal,
  DialogOverlay,
  DialogClose,
  DialogContent,
  DialogHeader,
  DialogTitle,
}
  • Step 2: Build check
cd frontend && npm run build

Expected: exit 0, no TypeScript errors.

  • Step 3: Commit
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<Client[]>
    • useClient(id: string): UseQueryResult<Client>
    • useCreateClient(): UseMutationResult<Client, Error, ClientPayload>
    • useUpdateClient(): UseMutationResult<Client, Error, ClientUpdatePayload>
    • useDeleteClient(): UseMutationResult<unknown, Error, string>
    • useClientVehicles(clientId: string): UseQueryResult<Vehicle[]>
    • useCreateVehicle(clientId: string): UseMutationResult<Vehicle, Error, VehiclePayload>
    • useUpdateVehicle(clientId: string): UseMutationResult<Vehicle, Error, VehicleUpdatePayload>

Where:

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
// 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<typeof vi.fn>

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
cd frontend && npm run test:run -- useClients

Expected: FAIL — useClients not found.

  • Step 3: Implement useClients.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<Client[]>({
    queryKey: ['clients'],
    queryFn: () => apiFetch<Client[]>('/clients'),
  })
}

export function useClient(id: string) {
  return useQuery<Client>({
    queryKey: ['clients', id],
    queryFn: () => apiFetch<Client>(`/clients/${id}`),
    enabled: !!id,
  })
}

export function useCreateClient() {
  return useMutation({
    mutationFn: (data: ClientPayload) =>
      apiFetch<Client>('/clients', { method: 'POST', body: JSON.stringify(data) }),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['clients'] }),
  })
}

export function useUpdateClient() {
  return useMutation({
    mutationFn: ({ id, ...data }: ClientPayload & { id: string }) =>
      apiFetch<Client>(`/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<Vehicle[]>({
    queryKey: ['clients', clientId, 'vehicles'],
    queryFn: () => apiFetch<Vehicle[]>(`/clients/${clientId}/vehicles`),
    enabled: !!clientId,
  })
}

export function useCreateVehicle(clientId: string) {
  return useMutation({
    mutationFn: (data: VehiclePayload) =>
      apiFetch<Vehicle>(`/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<Vehicle>(`/vehicles/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
    onSuccess: () =>
      queryClient.invalidateQueries({ queryKey: ['clients', clientId, 'vehicles'] }),
  })
}
  • Step 4: Run tests — verify they pass
cd frontend && npm run test:run -- useClients

Expected: 3 tests PASS.

  • Step 5: Build check
cd frontend && npm run build

Expected: exit 0.

  • Step 6: Commit
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

// 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<Client | null>(null)
  const [form, setForm] = useState<ClientPayload>(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<HTMLInputElement>) =>
    setForm((f) => ({ ...f, [key]: e.target.value }))

  return (
    <div>
      <div className="flex items-center justify-between mb-6">
        <div>
          <h1 className="text-2xl font-bold text-white">Clientes</h1>
          <p className="text-slate-400 text-sm mt-0.5">{clients.length} clientes registados</p>
        </div>
        <Button onClick={openCreate}>Novo Cliente</Button>
      </div>

      {error && (
        <div className="mb-4 p-3 bg-red-900/20 border border-red-800 rounded-lg">
          <p className="text-red-400 text-sm">{(error as Error).message}</p>
        </div>
      )}

      {isLoading ? (
        <p className="text-slate-400">A carregar...</p>
      ) : clients.length === 0 ? (
        <p className="text-slate-500 text-sm">Nenhum cliente registado.</p>
      ) : (
        <div className="rounded-lg border border-slate-700 overflow-hidden">
          <table className="w-full text-sm">
            <thead className="bg-slate-800">
              <tr>
                {['Nome', 'NIF', 'Telefone', 'Email', 'Criado', ''].map((h) => (
                  <th key={h} className="text-left px-4 py-3 text-slate-400 font-medium">{h}</th>
                ))}
              </tr>
            </thead>
            <tbody>
              {clients.map((c) => (
                <tr key={c.id} className="border-t border-slate-700 hover:bg-slate-800/50">
                  <td className="px-4 py-3">
                    <Link to={`/app/clients/${c.id}`} className="text-white font-medium hover:text-blue-400 transition-colors">
                      {c.name}
                    </Link>
                  </td>
                  <td className="px-4 py-3 text-slate-400 font-mono text-xs">{c.nif || '—'}</td>
                  <td className="px-4 py-3 text-slate-400">{c.phone || '—'}</td>
                  <td className="px-4 py-3 text-slate-400">{c.email || '—'}</td>
                  <td className="px-4 py-3 text-slate-400">
                    {new Intl.DateTimeFormat('pt-PT').format(new Date(c.created_at))}
                  </td>
                  <td className="px-4 py-3 text-right">
                    <button onClick={() => openEdit(c)} className="text-slate-500 hover:text-white transition-colors">
                      <Pencil className="h-4 w-4" />
                    </button>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}

      <Dialog open={open} onOpenChange={setOpen}>
        <DialogContent>
          <DialogHeader>
            <DialogTitle>{editing ? 'Editar Cliente' : 'Novo Cliente'}</DialogTitle>
          </DialogHeader>
          <form onSubmit={handleSubmit} className="space-y-4 mt-2">
            <div className="space-y-1.5">
              <Label htmlFor="cl-name">Nome *</Label>
              <Input id="cl-name" value={form.name} onChange={field('name')} required
                className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500"
                placeholder="Nome do cliente" />
            </div>
            <div className="grid grid-cols-2 gap-3">
              <div className="space-y-1.5">
                <Label htmlFor="cl-nif">NIF</Label>
                <Input id="cl-nif" value={form.nif} onChange={field('nif')}
                  className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500"
                  placeholder="123456789" />
              </div>
              <div className="space-y-1.5">
                <Label htmlFor="cl-phone">Telefone</Label>
                <Input id="cl-phone" value={form.phone} onChange={field('phone')}
                  className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500"
                  placeholder="+351 912 345 678" />
              </div>
            </div>
            <div className="space-y-1.5">
              <Label htmlFor="cl-email">Email</Label>
              <Input id="cl-email" type="email" value={form.email} onChange={field('email')}
                className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500"
                placeholder="cliente@exemplo.pt" />
            </div>
            <div className="space-y-1.5">
              <Label htmlFor="cl-addr">Morada</Label>
              <Input id="cl-addr" value={form.address} onChange={field('address')}
                className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500"
                placeholder="Rua, nº, localidade" />
            </div>
            <div className="space-y-1.5">
              <Label htmlFor="cl-notes">Notas</Label>
              <Input id="cl-notes" value={form.notes} onChange={field('notes')}
                className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500"
                placeholder="Observações" />
            </div>
            <div className="flex justify-end gap-2 pt-2">
              <Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancelar</Button>
              <Button type="submit" disabled={createClient.isPending || updateClient.isPending}>
                {editing ? 'Guardar' : 'Criar'}
              </Button>
            </div>
          </form>
        </DialogContent>
      </Dialog>
    </div>
  )
}
  • Step 2: Build check
cd frontend && npm run build

Expected: exit 0.

  • Step 3: Commit
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

// 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<ClientPayload>({ name: '', nif: '', phone: '', email: '', address: '', notes: '' })

  const [vehicleOpen, setVehicleOpen] = useState(false)
  const [editingVehicle, setEditingVehicle] = useState<Vehicle | null>(null)
  const [vehicleForm, setVehicleForm] = useState<VehiclePayload>(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<HTMLInputElement>) =>
    setVehicleForm((f) => ({ ...f, [key]: key === 'year' || key === 'mileage' ? Number(e.target.value) : e.target.value }))

  const cf = (key: keyof ClientPayload) => (e: React.ChangeEvent<HTMLInputElement>) =>
    setClientForm((f) => ({ ...f, [key]: e.target.value }))

  if (loadingClient) return <p className="text-slate-400">A carregar...</p>
  if (clientError || !client) return (
    <div>
      <Link to="/app/clients" className="flex items-center gap-1 text-slate-400 hover:text-white text-sm mb-4 transition-colors">
        <ArrowLeft className="h-4 w-4" /> Clientes
      </Link>
      <p className="text-red-400 text-sm">Cliente não encontrado.</p>
    </div>
  )

  return (
    <div>
      <Link to="/app/clients" className="flex items-center gap-1 text-slate-400 hover:text-white text-sm mb-6 w-fit transition-colors">
        <ArrowLeft className="h-4 w-4" /> Clientes
      </Link>

      {/* Client header */}
      <div className="bg-slate-900 border border-slate-700 rounded-lg p-6 mb-6">
        <div className="flex items-start justify-between mb-4">
          <h1 className="text-2xl font-bold text-white">{client.name}</h1>
          <button onClick={openEditClient} className="text-slate-500 hover:text-white transition-colors">
            <Pencil className="h-4 w-4" />
          </button>
        </div>
        <dl className="grid grid-cols-2 gap-x-8 gap-y-3 text-sm">
          {[
            ['NIF', client.nif],
            ['Telefone', client.phone],
            ['Email', client.email],
            ['Morada', client.address],
          ].map(([label, value]) => (
            <div key={label}>
              <dt className="text-slate-400">{label}</dt>
              <dd className="text-white mt-0.5">{value || '—'}</dd>
            </div>
          ))}
          {client.notes && (
            <div className="col-span-2">
              <dt className="text-slate-400">Notas</dt>
              <dd className="text-white mt-0.5">{client.notes}</dd>
            </div>
          )}
        </dl>
      </div>

      {/* Vehicles section */}
      <div>
        <div className="flex items-center justify-between mb-4">
          <h2 className="text-lg font-semibold text-white">Veículos</h2>
          <Button size="sm" onClick={openAddVehicle}>Adicionar Veículo</Button>
        </div>
        {loadingVehicles ? (
          <p className="text-slate-400">A carregar...</p>
        ) : vehicles.length === 0 ? (
          <p className="text-slate-500 text-sm">Nenhum veículo associado.</p>
        ) : (
          <div className="rounded-lg border border-slate-700 overflow-hidden">
            <table className="w-full text-sm">
              <thead className="bg-slate-800">
                <tr>
                  {['Matrícula', 'Marca', 'Modelo', 'Ano', 'Km', ''].map((h) => (
                    <th key={h} className="text-left px-4 py-3 text-slate-400 font-medium">{h}</th>
                  ))}
                </tr>
              </thead>
              <tbody>
                {vehicles.map((v) => (
                  <tr key={v.id} className="border-t border-slate-700 hover:bg-slate-800/50">
                    <td className="px-4 py-3 text-white font-mono font-medium">{v.plate}</td>
                    <td className="px-4 py-3 text-slate-300">{v.brand}</td>
                    <td className="px-4 py-3 text-slate-300">{v.model}</td>
                    <td className="px-4 py-3 text-slate-400">{v.year ?? '—'}</td>
                    <td className="px-4 py-3 text-slate-400">{v.mileage != null ? `${v.mileage.toLocaleString('pt-PT')} km` : '—'}</td>
                    <td className="px-4 py-3 text-right">
                      <button onClick={() => openEditVehicle(v)} className="text-slate-500 hover:text-white transition-colors">
                        <Pencil className="h-4 w-4" />
                      </button>
                    </td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        )}
      </div>

      {/* Edit client modal */}
      <Dialog open={editClientOpen} onOpenChange={setEditClientOpen}>
        <DialogContent>
          <DialogHeader><DialogTitle>Editar Cliente</DialogTitle></DialogHeader>
          <form onSubmit={handleClientSubmit} className="space-y-4 mt-2">
            <div className="space-y-1.5">
              <Label htmlFor="dc-name">Nome *</Label>
              <Input id="dc-name" value={clientForm.name} onChange={cf('name')} required
                className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500" />
            </div>
            <div className="grid grid-cols-2 gap-3">
              <div className="space-y-1.5">
                <Label htmlFor="dc-nif">NIF</Label>
                <Input id="dc-nif" value={clientForm.nif} onChange={cf('nif')}
                  className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500" />
              </div>
              <div className="space-y-1.5">
                <Label htmlFor="dc-phone">Telefone</Label>
                <Input id="dc-phone" value={clientForm.phone} onChange={cf('phone')}
                  className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500" />
              </div>
            </div>
            <div className="space-y-1.5">
              <Label htmlFor="dc-email">Email</Label>
              <Input id="dc-email" type="email" value={clientForm.email} onChange={cf('email')}
                className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500" />
            </div>
            <div className="space-y-1.5">
              <Label htmlFor="dc-addr">Morada</Label>
              <Input id="dc-addr" value={clientForm.address} onChange={cf('address')}
                className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500" />
            </div>
            <div className="space-y-1.5">
              <Label htmlFor="dc-notes">Notas</Label>
              <Input id="dc-notes" value={clientForm.notes} onChange={cf('notes')}
                className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500" />
            </div>
            <div className="flex justify-end gap-2 pt-2">
              <Button type="button" variant="outline" onClick={() => setEditClientOpen(false)}>Cancelar</Button>
              <Button type="submit" disabled={updateClient.isPending}>Guardar</Button>
            </div>
          </form>
        </DialogContent>
      </Dialog>

      {/* Add/edit vehicle modal */}
      <Dialog open={vehicleOpen} onOpenChange={setVehicleOpen}>
        <DialogContent>
          <DialogHeader>
            <DialogTitle>{editingVehicle ? 'Editar Veículo' : 'Adicionar Veículo'}</DialogTitle>
          </DialogHeader>
          <form onSubmit={handleVehicleSubmit} className="space-y-4 mt-2">
            <div className="grid grid-cols-2 gap-3">
              <div className="space-y-1.5">
                <Label htmlFor="v-plate">Matrícula *</Label>
                <Input id="v-plate" value={vehicleForm.plate} onChange={vf('plate')} required
                  className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500"
                  placeholder="AA-00-BB" />
              </div>
              <div className="space-y-1.5">
                <Label htmlFor="v-year">Ano</Label>
                <Input id="v-year" type="number" min={1900} max={2099} value={vehicleForm.year || ''} onChange={vf('year')}
                  className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500"
                  placeholder="2020" />
              </div>
            </div>
            <div className="grid grid-cols-2 gap-3">
              <div className="space-y-1.5">
                <Label htmlFor="v-brand">Marca *</Label>
                <Input id="v-brand" value={vehicleForm.brand} onChange={vf('brand')} required
                  className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500"
                  placeholder="Volkswagen" />
              </div>
              <div className="space-y-1.5">
                <Label htmlFor="v-model">Modelo *</Label>
                <Input id="v-model" value={vehicleForm.model} onChange={vf('model')} required
                  className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500"
                  placeholder="Golf" />
              </div>
            </div>
            <div className="grid grid-cols-2 gap-3">
              <div className="space-y-1.5">
                <Label htmlFor="v-vin">VIN</Label>
                <Input id="v-vin" value={vehicleForm.vin} onChange={vf('vin')}
                  className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500 font-mono text-xs" />
              </div>
              <div className="space-y-1.5">
                <Label htmlFor="v-km">Quilómetros</Label>
                <Input id="v-km" type="number" min={0} value={vehicleForm.mileage || ''} onChange={vf('mileage')}
                  className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500"
                  placeholder="85000" />
              </div>
            </div>
            <div className="space-y-1.5">
              <Label htmlFor="v-notes">Notas</Label>
              <Input id="v-notes" value={vehicleForm.notes} onChange={vf('notes')}
                className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500" />
            </div>
            <div className="flex justify-end gap-2 pt-2">
              <Button type="button" variant="outline" onClick={() => setVehicleOpen(false)}>Cancelar</Button>
              <Button type="submit" disabled={createVehicle.isPending || updateVehicle.isPending}>
                {editingVehicle ? 'Guardar' : 'Adicionar'}
              </Button>
            </div>
          </form>
        </DialogContent>
      </Dialog>
    </div>
  )
}
  • Step 2: Build check
cd frontend && npm run build

Expected: exit 0.

  • Step 3: Commit
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<CatalogItem[]>
    • useCreateCatalogItem(): UseMutationResult<CatalogItem, Error, CatalogPayload>
    • useUpdateCatalogItem(): UseMutationResult<CatalogItem, Error, CatalogUpdatePayload>
    • useDeleteCatalogItem(): UseMutationResult<unknown, Error, string>

Where:

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
// 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<typeof vi.fn>

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
cd frontend && npm run test:run -- useCatalog

Expected: FAIL — useCatalog not found.

  • Step 3: Implement useCatalog.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<CatalogItem[]>({
    queryKey: ['catalog'],
    queryFn: () => apiFetch<CatalogItem[]>('/catalog'),
  })
}

export function useCreateCatalogItem() {
  return useMutation({
    mutationFn: (data: CatalogPayload) =>
      apiFetch<CatalogItem>('/catalog', { method: 'POST', body: JSON.stringify(data) }),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['catalog'] }),
  })
}

export function useUpdateCatalogItem() {
  return useMutation({
    mutationFn: ({ id, ...data }: CatalogPayload & { id: string }) =>
      apiFetch<CatalogItem>(`/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
cd frontend && npm run test:run -- useCatalog

Expected: 1 test PASS.

  • Step 5: Create CatalogPage.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<CatalogItem | null>(null)
  const [form, setForm] = useState<CatalogPayload>(emptyForm)
  const [confirmDelete, setConfirmDelete] = useState<string | null>(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<HTMLInputElement | HTMLSelectElement>) =>
    setForm((prev) => ({
      ...prev,
      [key]: key === 'base_price' ? Number(e.target.value)
           : key === 'active' ? (e.target as HTMLInputElement).checked
           : e.target.value,
    }))

  return (
    <div>
      <div className="flex items-center justify-between mb-6">
        <div>
          <h1 className="text-2xl font-bold text-white">Catálogo</h1>
          <p className="text-slate-400 text-sm mt-0.5">{items.length} itens</p>
        </div>
        <Button onClick={openCreate}>Novo Item</Button>
      </div>

      {error && (
        <div className="mb-4 p-3 bg-red-900/20 border border-red-800 rounded-lg">
          <p className="text-red-400 text-sm">{(error as Error).message}</p>
        </div>
      )}

      {isLoading ? (
        <p className="text-slate-400">A carregar...</p>
      ) : items.length === 0 ? (
        <p className="text-slate-500 text-sm">Nenhum item no catálogo.</p>
      ) : (
        <div className="rounded-lg border border-slate-700 overflow-hidden">
          <table className="w-full text-sm">
            <thead className="bg-slate-800">
              <tr>
                {['Código', 'Nome', 'Categoria', 'Unidade', 'Preço Base', 'Estado', ''].map((h) => (
                  <th key={h} className="text-left px-4 py-3 text-slate-400 font-medium">{h}</th>
                ))}
              </tr>
            </thead>
            <tbody>
              {items.map((item) => (
                <tr key={item.id} className="border-t border-slate-700 hover:bg-slate-800/50">
                  <td className="px-4 py-3 text-slate-400 font-mono text-xs">{item.code}</td>
                  <td className="px-4 py-3 text-white font-medium">{item.name}</td>
                  <td className="px-4 py-3 text-slate-400">{item.category}</td>
                  <td className="px-4 py-3 text-slate-400">{item.unit}</td>
                  <td className="px-4 py-3 text-white">
                    {new Intl.NumberFormat('pt-PT', { style: 'currency', currency: 'EUR' }).format(item.base_price)}
                  </td>
                  <td className="px-4 py-3">
                    <Badge variant={item.active ? 'default' : 'secondary'}>
                      {item.active ? 'Activo' : 'Inactivo'}
                    </Badge>
                  </td>
                  <td className="px-4 py-3">
                    <div className="flex items-center justify-end gap-2">
                      <button onClick={() => openEdit(item)} className="text-slate-500 hover:text-white transition-colors">
                        <Pencil className="h-4 w-4" />
                      </button>
                      <button
                        onClick={() => handleDelete(item.id)}
                        className={`transition-colors text-xs ${confirmDelete === item.id ? 'text-red-400 hover:text-red-300' : 'text-slate-500 hover:text-red-400'}`}
                      >
                        {confirmDelete === item.id ? 'Confirmar?' : <Trash2 className="h-4 w-4" />}
                      </button>
                    </div>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}

      <Dialog open={open} onOpenChange={setOpen}>
        <DialogContent>
          <DialogHeader>
            <DialogTitle>{editing ? 'Editar Item' : 'Novo Item'}</DialogTitle>
          </DialogHeader>
          <form onSubmit={handleSubmit} className="space-y-4 mt-2">
            <div className="grid grid-cols-2 gap-3">
              <div className="space-y-1.5">
                <Label htmlFor="cat-code">Código *</Label>
                <Input id="cat-code" value={form.code} onChange={f('code')} required
                  className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500"
                  placeholder="SVC-001" />
              </div>
              <div className="space-y-1.5">
                <Label htmlFor="cat-cat">Categoria *</Label>
                <Input id="cat-cat" value={form.category} onChange={f('category')} required
                  className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500"
                  placeholder="Mão de Obra" />
              </div>
            </div>
            <div className="space-y-1.5">
              <Label htmlFor="cat-name">Nome *</Label>
              <Input id="cat-name" value={form.name} onChange={f('name')} required
                className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500"
                placeholder="Troca de óleo" />
            </div>
            <div className="grid grid-cols-2 gap-3">
              <div className="space-y-1.5">
                <Label htmlFor="cat-unit">Unidade *</Label>
                <select
                  id="cat-unit"
                  value={form.unit}
                  onChange={f('unit')}
                  className="flex h-9 w-full rounded-md border border-slate-600 bg-slate-800 px-3 py-1 text-sm text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
                >
                  {UNITS.map((u) => <option key={u} value={u}>{u}</option>)}
                </select>
              </div>
              <div className="space-y-1.5">
                <Label htmlFor="cat-price">Preço Base ()</Label>
                <Input id="cat-price" type="number" min={0} step={0.01} value={form.base_price || ''} onChange={f('base_price')}
                  className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500"
                  placeholder="0.00" />
              </div>
            </div>
            <div className="flex items-center gap-2">
              <input
                id="cat-active"
                type="checkbox"
                checked={form.active}
                onChange={f('active')}
                className="h-4 w-4 rounded border-slate-600 bg-slate-800"
              />
              <Label htmlFor="cat-active" className="text-slate-300 cursor-pointer">Activo</Label>
            </div>
            <div className="flex justify-end gap-2 pt-2">
              <Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancelar</Button>
              <Button type="submit" disabled={createItem.isPending || updateItem.isPending}>
                {editing ? 'Guardar' : 'Criar'}
              </Button>
            </div>
          </form>
        </DialogContent>
      </Dialog>
    </div>
  )
}
  • Step 6: Build check
cd frontend && npm run build

Expected: exit 0.

  • Step 7: Commit
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<WorkOrder[]>
    • useWorkOrder(id: string): UseQueryResult<WorkOrderDetail>
    • useCreateWorkOrder(): UseMutationResult<WorkOrder, Error, WOPayload>
    • useTransitionWorkOrder(): UseMutationResult<WorkOrder, Error, { id: string; status: string }>
    • useUpdateWorkOrder(): UseMutationResult<WorkOrder, Error, WOUpdatePayload>
    • useAddWOItem(): UseMutationResult<WOItem, Error, { woId: string } & WOItemPayload>
    • useRemoveWOItem(): UseMutationResult<unknown, Error, { woId: string; itemId: string }>
    • useAddStaffHours(): UseMutationResult<WOStaffHours, Error, { woId: string } & StaffHoursPayload>
    • useRemoveStaffHours(): UseMutationResult<unknown, Error, { woId: string; shId: string }>

Where:

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
// 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<typeof vi.fn>

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
cd frontend && npm run test:run -- useWorkOrders

Expected: FAIL — useWorkOrders not found.

  • Step 3: Implement useWorkOrders.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<WorkOrder[]>({
    queryKey: status ? ['work-orders', { status }] : ['work-orders'],
    queryFn: () => apiFetch<WorkOrder[]>(url),
  })
}

export function useWorkOrder(id: string) {
  return useQuery<WorkOrderDetail>({
    queryKey: ['work-orders', id],
    queryFn: () => apiFetch<WorkOrderDetail>(`/work-orders/${id}`),
    enabled: !!id,
  })
}

export function useCreateWorkOrder() {
  return useMutation({
    mutationFn: (data: WOPayload) =>
      apiFetch<WorkOrder>('/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<WorkOrder>(`/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<WorkOrder>(`/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<WOItem>(`/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<WOStaffHours>(`/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
cd frontend && npm run test:run -- useWorkOrders

Expected: 4 tests PASS.

  • Step 5: Build check
cd frontend && npm run build

Expected: exit 0.

  • Step 6: Commit
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
// 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<WorkOrder['status'], string> = {
  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<WorkOrder['status'], string> = {
  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<WOPayload>(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 (
    <div>
      <div className="flex items-center justify-between mb-6">
        <div>
          <h1 className="text-2xl font-bold text-white">Ordens de Trabalho</h1>
          <p className="text-slate-400 text-sm mt-0.5">{orders.length} ordens</p>
        </div>
        <Button onClick={() => { setForm(emptyForm); setOpen(true) }}>Nova OT</Button>
      </div>

      {/* Status tabs */}
      <div className="flex gap-1 mb-4 flex-wrap">
        {STATUS_TABS.map(({ value, label }) => (
          <button
            key={value}
            onClick={() => setStatusFilter(value)}
            className={`px-3 py-1.5 rounded-md text-sm transition-colors ${
              statusFilter === value
                ? 'bg-slate-700 text-white'
                : 'text-slate-400 hover:text-white hover:bg-slate-800'
            }`}
          >
            {label}
          </button>
        ))}
      </div>

      {error && (
        <div className="mb-4 p-3 bg-red-900/20 border border-red-800 rounded-lg">
          <p className="text-red-400 text-sm">{(error as Error).message}</p>
        </div>
      )}

      {isLoading ? (
        <p className="text-slate-400">A carregar...</p>
      ) : orders.length === 0 ? (
        <p className="text-slate-500 text-sm">Nenhuma ordem de trabalho.</p>
      ) : (
        <div className="rounded-lg border border-slate-700 overflow-hidden">
          <table className="w-full text-sm">
            <thead className="bg-slate-800">
              <tr>
                {['Nº OT', 'Estado', 'Notas Internas', 'Data', ''].map((h) => (
                  <th key={h} className="text-left px-4 py-3 text-slate-400 font-medium">{h}</th>
                ))}
              </tr>
            </thead>
            <tbody>
              {orders.map((wo) => (
                <tr
                  key={wo.id}
                  onClick={() => navigate(`/app/work-orders/${wo.id}`)}
                  className="border-t border-slate-700 hover:bg-slate-800/50 cursor-pointer"
                >
                  <td className="px-4 py-3 text-white font-mono font-medium">#{wo.number}</td>
                  <td className="px-4 py-3">
                    <span className={`px-2 py-0.5 rounded text-xs font-medium ${STATUS_BADGE[wo.status]}`}>
                      {STATUS_LABEL[wo.status]}
                    </span>
                  </td>
                  <td className="px-4 py-3 text-slate-400 max-w-xs truncate">{wo.internal_notes || '—'}</td>
                  <td className="px-4 py-3 text-slate-400">
                    {new Intl.DateTimeFormat('pt-PT').format(new Date(wo.created_at))}
                  </td>
                  <td className="px-4 py-3 text-right text-slate-500 text-xs">Ver </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}

      <Dialog open={open} onOpenChange={setOpen}>
        <DialogContent>
          <DialogHeader><DialogTitle>Nova Ordem de Trabalho</DialogTitle></DialogHeader>
          <form onSubmit={handleSubmit} className="space-y-4 mt-2">
            <div className="space-y-1.5">
              <Label htmlFor="wo-client">Cliente</Label>
              <select
                id="wo-client"
                value={form.client_id}
                onChange={(e) => setForm((f) => ({ ...f, client_id: e.target.value, vehicle_id: '' }))}
                className="flex h-9 w-full rounded-md border border-slate-600 bg-slate-800 px-3 py-1 text-sm text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
              >
                <option value=""> Seleccionar cliente </option>
                {clients.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
              </select>
            </div>
            <div className="space-y-1.5">
              <Label htmlFor="wo-vehicle">Veículo</Label>
              <select
                id="wo-vehicle"
                value={form.vehicle_id}
                onChange={(e) => setForm((f) => ({ ...f, vehicle_id: e.target.value }))}
                disabled={!form.client_id}
                className="flex h-9 w-full rounded-md border border-slate-600 bg-slate-800 px-3 py-1 text-sm text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:opacity-50"
              >
                <option value=""> Seleccionar veículo </option>
                {vehicles.map((v) => <option key={v.id} value={v.id}>{v.plate}  {v.brand} {v.model}</option>)}
              </select>
            </div>
            <div className="space-y-1.5">
              <Label htmlFor="wo-inotes">Notas Internas</Label>
              <Input id="wo-inotes" value={form.internal_notes}
                onChange={(e) => 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..." />
            </div>
            <div className="space-y-1.5">
              <Label htmlFor="wo-cnotes">Notas para o Cliente</Label>
              <Input id="wo-cnotes" value={form.client_notes}
                onChange={(e) => 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..." />
            </div>
            <div className="flex justify-end gap-2 pt-2">
              <Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancelar</Button>
              <Button type="submit" disabled={createWO.isPending}>Criar OT</Button>
            </div>
          </form>
        </DialogContent>
      </Dialog>
    </div>
  )
}
  • Step 2: Build check
cd frontend && npm run build

Expected: exit 0.

  • Step 3: Commit
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:

  • openin_progress (button: "Iniciar")
  • in_progresscompleted (button: "Concluir")
  • completedinvoiced (button: "Faturar")
  • Any state except invoicedcancelled (button: "Cancelar OT", destructive style)

Stepper steps in order: open, in_progress, completed, invoiced

  • Step 1: Create WorkOrderDetailPage.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<WorkOrder['status'], string> = {
  open: 'Aberta',
  in_progress: 'Em Progresso',
  completed: 'Concluída',
  invoiced: 'Faturada',
  cancelled: 'Cancelada',
}
const NEXT_STATUS: Partial<Record<WorkOrder['status'], WorkOrder['status']>> = {
  open: 'in_progress',
  in_progress: 'completed',
  completed: 'invoiced',
}
const NEXT_LABEL: Partial<Record<WorkOrder['status'], string>> = {
  open: 'Iniciar',
  in_progress: 'Concluir',
  completed: 'Faturar',
}
const STATUS_BADGE: Record<WorkOrder['status'], string> = {
  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<WOItemPayload>(emptyItem)

  const [hoursOpen, setHoursOpen] = useState(false)
  const [hoursForm, setHoursForm] = useState<StaffHoursPayload>(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 <p className="text-slate-400">A carregar...</p>
  if (error || !wo) return (
    <div>
      <Link to="/app/work-orders" className="flex items-center gap-1 text-slate-400 hover:text-white text-sm mb-4 transition-colors">
        <ArrowLeft className="h-4 w-4" /> Ordens de Trabalho
      </Link>
      <p className="text-red-400 text-sm">Ordem não encontrada.</p>
    </div>
  )

  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 (
    <div>
      <Link to="/app/work-orders" className="flex items-center gap-1 text-slate-400 hover:text-white text-sm mb-6 w-fit transition-colors">
        <ArrowLeft className="h-4 w-4" /> Ordens de Trabalho
      </Link>

      <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
        {/* LEFT: info + transitions */}
        <div className="space-y-4">
          <div className="bg-slate-900 border border-slate-700 rounded-lg p-6">
            <div className="flex items-start justify-between mb-4">
              <div>
                <h1 className="text-2xl font-bold text-white">OT #{wo.number}</h1>
                <span className={`mt-1 inline-block px-2 py-0.5 rounded text-xs font-medium ${STATUS_BADGE[wo.status]}`}>
                  {STEP_LABEL[wo.status]}
                </span>
              </div>
              <p className="text-slate-500 text-xs">
                {new Intl.DateTimeFormat('pt-PT').format(new Date(wo.created_at))}
              </p>
            </div>

            {/* Stepper */}
            {wo.status !== 'cancelled' && (
              <div className="flex items-center gap-1 mb-6">
                {STEPS.map((step, i) => {
                  const stepIndex = STEPS.indexOf(wo.status as WorkOrder['status'])
                  const done = STEPS.indexOf(step) <= stepIndex
                  return (
                    <div key={step} className="flex items-center gap-1 flex-1">
                      <div className={`h-2 flex-1 rounded-full ${done ? 'bg-blue-500' : 'bg-slate-700'}`} />
                      {i < STEPS.length - 1 && null}
                    </div>
                  )
                })}
              </div>
            )}

            {/* Notes */}
            {editNotesMode ? (
              <form onSubmit={handleSaveNotes} className="space-y-3">
                <div className="space-y-1.5">
                  <Label>Notas Internas</Label>
                  <Input
                    value={notesForm.internal_notes}
                    onChange={(e) => setNotesForm((f) => ({ ...f, internal_notes: e.target.value }))}
                    className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500"
                  />
                </div>
                <div className="space-y-1.5">
                  <Label>Notas para o Cliente</Label>
                  <Input
                    value={notesForm.client_notes}
                    onChange={(e) => setNotesForm((f) => ({ ...f, client_notes: e.target.value }))}
                    className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500"
                  />
                </div>
                <div className="flex gap-2">
                  <Button type="submit" size="sm" disabled={updateWO.isPending}>Guardar</Button>
                  <Button type="button" size="sm" variant="outline" onClick={() => setEditNotesMode(false)}>Cancelar</Button>
                </div>
              </form>
            ) : (
              <div className="space-y-3">
                <div>
                  <p className="text-slate-400 text-xs">Notas Internas</p>
                  <p className="text-white text-sm mt-0.5">{wo.internal_notes || '—'}</p>
                </div>
                <div>
                  <p className="text-slate-400 text-xs">Notas para o Cliente</p>
                  <p className="text-white text-sm mt-0.5">{wo.client_notes || '—'}</p>
                </div>
                {!isClosed && (
                  <button onClick={openEditNotes} className="text-xs text-slate-500 hover:text-white transition-colors">
                    Editar notas
                  </button>
                )}
              </div>
            )}
          </div>

          {/* Transition buttons */}
          {!isClosed && (
            <div className="flex gap-2">
              {nextStatus && (
                <Button
                  onClick={() => transition.mutate({ id, status: nextStatus })}
                  disabled={transition.isPending}
                  className="flex-1"
                >
                  {NEXT_LABEL[wo.status]}
                </Button>
              )}
              {canCancel && (
                <Button
                  variant="destructive"
                  onClick={() => {
                    if (cancelConfirm) {
                      transition.mutate({ id, status: 'cancelled' })
                      setCancelConfirm(false)
                    } else {
                      setCancelConfirm(true)
                    }
                  }}
                  disabled={transition.isPending}
                >
                  {cancelConfirm ? 'Confirmar cancelamento?' : 'Cancelar OT'}
                </Button>
              )}
            </div>
          )}
        </div>

        {/* RIGHT: items + staff hours */}
        <div className="space-y-6">
          {/* Items */}
          <div>
            <div className="flex items-center justify-between mb-3">
              <h2 className="text-lg font-semibold text-white">Peças / Serviços</h2>
              {!isClosed && <Button size="sm" onClick={() => { setItemForm(emptyItem); setItemOpen(true) }}>Adicionar Item</Button>}
            </div>
            {wo.items.length === 0 ? (
              <p className="text-slate-500 text-sm">Nenhum item adicionado.</p>
            ) : (
              <div className="rounded-lg border border-slate-700 overflow-hidden">
                <table className="w-full text-sm">
                  <thead className="bg-slate-800">
                    <tr>
                      {['Descrição', 'Qty', 'P. Unit.', 'Desc.', 'Total', ''].map((h) => (
                        <th key={h} className="text-left px-3 py-2 text-slate-400 font-medium text-xs">{h}</th>
                      ))}
                    </tr>
                  </thead>
                  <tbody>
                    {wo.items.map((item) => (
                      <tr key={item.id} className="border-t border-slate-700">
                        <td className="px-3 py-2 text-white text-xs">{item.description}</td>
                        <td className="px-3 py-2 text-slate-400 text-xs">{item.qty}</td>
                        <td className="px-3 py-2 text-slate-400 text-xs">{fmt.format(item.unit_price)}</td>
                        <td className="px-3 py-2 text-slate-400 text-xs">{item.discount_pct > 0 ? `${item.discount_pct}%` : '—'}</td>
                        <td className="px-3 py-2 text-white text-xs font-medium">{fmt.format(item.total)}</td>
                        <td className="px-3 py-2 text-right">
                          {!isClosed && (
                            <button
                              onClick={() => removeItem.mutate({ woId: id, itemId: item.id })}
                              className="text-slate-500 hover:text-red-400 transition-colors"
                            >
                              <Trash2 className="h-3.5 w-3.5" />
                            </button>
                          )}
                        </td>
                      </tr>
                    ))}
                    <tr className="border-t border-slate-600 bg-slate-800/50">
                      <td colSpan={4} className="px-3 py-2 text-right text-slate-400 text-xs font-medium">Subtotal peças</td>
                      <td colSpan={2} className="px-3 py-2 text-white text-xs font-semibold">{fmt.format(subtotalItems)}</td>
                    </tr>
                  </tbody>
                </table>
              </div>
            )}
          </div>

          {/* Staff hours */}
          <div>
            <div className="flex items-center justify-between mb-3">
              <h2 className="text-lg font-semibold text-white">Horas de Técnico</h2>
              {!isClosed && <Button size="sm" onClick={() => { setHoursForm(emptyHours); setHoursOpen(true) }}>Adicionar Horas</Button>}
            </div>
            {wo.staff_hours.length === 0 ? (
              <p className="text-slate-500 text-sm">Nenhuma hora registada.</p>
            ) : (
              <div className="rounded-lg border border-slate-700 overflow-hidden">
                <table className="w-full text-sm">
                  <thead className="bg-slate-800">
                    <tr>
                      {['Técnico', 'Horas', 'Custo/h', 'Total', ''].map((h) => (
                        <th key={h} className="text-left px-3 py-2 text-slate-400 font-medium text-xs">{h}</th>
                      ))}
                    </tr>
                  </thead>
                  <tbody>
                    {wo.staff_hours.map((sh) => (
                      <tr key={sh.id} className="border-t border-slate-700">
                        <td className="px-3 py-2 text-white text-xs font-mono">{sh.staff_id}</td>
                        <td className="px-3 py-2 text-slate-400 text-xs">{sh.hours}h</td>
                        <td className="px-3 py-2 text-slate-400 text-xs">{fmt.format(sh.cost_per_hour)}</td>
                        <td className="px-3 py-2 text-white text-xs font-medium">{fmt.format(sh.total)}</td>
                        <td className="px-3 py-2 text-right">
                          {!isClosed && (
                            <button
                              onClick={() => removeHours.mutate({ woId: id, shId: sh.id })}
                              className="text-slate-500 hover:text-red-400 transition-colors"
                            >
                              <Trash2 className="h-3.5 w-3.5" />
                            </button>
                          )}
                        </td>
                      </tr>
                    ))}
                    <tr className="border-t border-slate-600 bg-slate-800/50">
                      <td colSpan={3} className="px-3 py-2 text-right text-slate-400 text-xs font-medium">Subtotal horas</td>
                      <td colSpan={2} className="px-3 py-2 text-white text-xs font-semibold">{fmt.format(subtotalHours)}</td>
                    </tr>
                  </tbody>
                </table>
              </div>
            )}
          </div>

          {/* Grand total */}
          {(wo.items.length > 0 || wo.staff_hours.length > 0) && (
            <div className="flex justify-end">
              <div className="bg-slate-800 border border-slate-700 rounded-lg px-6 py-3">
                <p className="text-slate-400 text-xs mb-0.5">Total Geral</p>
                <p className="text-white text-xl font-bold">{fmt.format(subtotalItems + subtotalHours)}</p>
              </div>
            </div>
          )}
        </div>
      </div>

      {/* Add item modal */}
      <Dialog open={itemOpen} onOpenChange={setItemOpen}>
        <DialogContent>
          <DialogHeader><DialogTitle>Adicionar Item</DialogTitle></DialogHeader>
          <form onSubmit={handleAddItem} className="space-y-4 mt-2">
            <div className="space-y-1.5">
              <Label>Item do Catálogo</Label>
              <select
                value={itemForm.catalog_item_id}
                onChange={(e) => handleCatalogSelect(e.target.value)}
                className="flex h-9 w-full rounded-md border border-slate-600 bg-slate-800 px-3 py-1 text-sm text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
              >
                <option value=""> Seleccionar do catálogo </option>
                {catalog.filter((c) => c.active).map((c) => (
                  <option key={c.id} value={c.id}>{c.code}  {c.name}</option>
                ))}
              </select>
            </div>
            <div className="space-y-1.5">
              <Label htmlFor="item-desc">Descrição *</Label>
              <Input id="item-desc" value={itemForm.description}
                onChange={(e) => 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" />
            </div>
            <div className="grid grid-cols-3 gap-3">
              <div className="space-y-1.5">
                <Label htmlFor="item-qty">Qty *</Label>
                <Input id="item-qty" type="number" min={0.01} step={0.01} value={itemForm.qty || ''}
                  onChange={(e) => setItemForm((f) => ({ ...f, qty: Number(e.target.value) }))}
                  required className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500" />
              </div>
              <div className="space-y-1.5">
                <Label htmlFor="item-price">Preço Unit. ()</Label>
                <Input id="item-price" type="number" min={0} step={0.01} value={itemForm.unit_price || ''}
                  onChange={(e) => setItemForm((f) => ({ ...f, unit_price: Number(e.target.value) }))}
                  className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500" />
              </div>
              <div className="space-y-1.5">
                <Label htmlFor="item-disc">Desconto %</Label>
                <Input id="item-disc" type="number" min={0} max={100} step={0.01} value={itemForm.discount_pct || ''}
                  onChange={(e) => setItemForm((f) => ({ ...f, discount_pct: Number(e.target.value) }))}
                  className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500" />
              </div>
            </div>
            <div className="flex justify-end gap-2 pt-2">
              <Button type="button" variant="outline" onClick={() => setItemOpen(false)}>Cancelar</Button>
              <Button type="submit" disabled={addItem.isPending}>Adicionar</Button>
            </div>
          </form>
        </DialogContent>
      </Dialog>

      {/* Add staff hours modal */}
      <Dialog open={hoursOpen} onOpenChange={setHoursOpen}>
        <DialogContent>
          <DialogHeader><DialogTitle>Adicionar Horas de Técnico</DialogTitle></DialogHeader>
          <form onSubmit={handleAddHours} className="space-y-4 mt-2">
            <div className="space-y-1.5">
              <Label htmlFor="sh-staff">ID do Técnico *</Label>
              <Input id="sh-staff" value={hoursForm.staff_id}
                onChange={(e) => 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" />
            </div>
            <div className="grid grid-cols-2 gap-3">
              <div className="space-y-1.5">
                <Label htmlFor="sh-hours">Horas *</Label>
                <Input id="sh-hours" type="number" min={0.01} step={0.25} value={hoursForm.hours || ''}
                  onChange={(e) => setHoursForm((f) => ({ ...f, hours: Number(e.target.value) }))}
                  required className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500" />
              </div>
              <div className="space-y-1.5">
                <Label htmlFor="sh-cost">Custo/hora ()</Label>
                <Input id="sh-cost" type="number" min={0} step={0.01} value={hoursForm.cost_per_hour || ''}
                  onChange={(e) => setHoursForm((f) => ({ ...f, cost_per_hour: Number(e.target.value) }))}
                  className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500" />
              </div>
            </div>
            <div className="flex justify-end gap-2 pt-2">
              <Button type="button" variant="outline" onClick={() => setHoursOpen(false)}>Cancelar</Button>
              <Button type="submit" disabled={addHours.isPending}>Adicionar</Button>
            </div>
          </form>
        </DialogContent>
      </Dialog>
    </div>
  )
}
  • Step 2: Build check
cd frontend && npm run build

Expected: exit 0.

  • Step 3: Commit
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 38

  • Step 1: Update App.tsx

Replace the existing /app route block with:

// 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 <Navigate to="/login" replace />
  if (user && !allowedRoles.includes(user.role)) return <Navigate to="/login" replace />
  return <>{children}</>
}

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <BrowserRouter>
        <Routes>
          <Route path="/login" element={<LoginPage />} />
          <Route path="/invite/:token" element={<InviteRedeemPage />} />

          <Route
            path="/admin"
            element={
              <RequireAuth allowedRoles={['super_admin']}>
                <AdminLayout />
              </RequireAuth>
            }
          >
            <Route index element={<AdminDashboardPage />} />
            <Route path="tenants" element={<TenantsPage />} />
          </Route>

          <Route
            path="/app"
            element={
              <RequireAuth allowedRoles={['tenant_admin', 'manager', 'technician']}>
                <AppLayout />
              </RequireAuth>
            }
          >
            <Route index element={<AppDashboardPage />} />
            <Route path="clients" element={<ClientsPage />} />
            <Route path="clients/:id" element={<ClientDetailPage />} />
            <Route path="catalog" element={<CatalogPage />} />
            <Route path="work-orders" element={<WorkOrdersPage />} />
            <Route path="work-orders/:id" element={<WorkOrderDetailPage />} />
          </Route>

          <Route path="/" element={<Navigate to="/app" replace />} />
        </Routes>
      </BrowserRouter>
    </QueryClientProvider>
  )
}
  • Step 2: Full build + test run
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
cd /path/to/project && docker compose build frontend && docker compose up -d frontend

Then verify:

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
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.