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

2220 lines
88 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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**
```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**
```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<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:
```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<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**
```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<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**
```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<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**
```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<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**
```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<CatalogItem[]>`
- `useCreateCatalogItem(): UseMutationResult<CatalogItem, Error, CatalogPayload>`
- `useUpdateCatalogItem(): UseMutationResult<CatalogItem, Error, CatalogUpdatePayload>`
- `useDeleteCatalogItem(): UseMutationResult<unknown, Error, string>`
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<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**
```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<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**
```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<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**
```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<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:
```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<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**
```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<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**
```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<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**
```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<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**
```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 38
- [ ] **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 <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**
```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.