88 KiB
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, panelsbg-slate-900, bordersborder-slate-700/border-slate-800, texttext-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
apiFetchfrom@/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.invalidateQuerieson 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 buildfromfrontend/— must exit 0 - After tasks with tests: run
npm run test:runfromfrontend/— must exit 0 - Working directory for all npm commands:
frontend/
File Map
Create:
frontend/src/components/ui/dialog.tsxfrontend/src/hooks/useClients.tsfrontend/src/hooks/useClients.test.tsfrontend/src/hooks/useCatalog.tsfrontend/src/hooks/useCatalog.test.tsfrontend/src/hooks/useWorkOrders.tsfrontend/src/hooks/useWorkOrders.test.tsfrontend/src/pages/app/clients/ClientsPage.tsxfrontend/src/pages/app/clients/ClientDetailPage.tsxfrontend/src/pages/app/catalog/CatalogPage.tsxfrontend/src/pages/app/work-orders/WorkOrdersPage.tsxfrontend/src/pages/app/work-orders/WorkOrderDetailPage.tsx
Modify:
frontend/src/App.tsx— add 5 new routes
Task 1: Shared Dialog component
Files:
- Create:
frontend/src/components/ui/dialog.tsx
Interfaces:
-
Produces:
Dialog,DialogTrigger,DialogContent,DialogHeader,DialogTitle,DialogClose— used by all pages in Tasks 3–9 -
Step 1: Create dialog.tsx
// 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:
apiFetchfrom@/lib/api,queryClientfrom@/lib/queryClient, typesClient,Vehiclefrom@/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,useUpdateClientfrom@/hooks/useClients;Dialog,DialogContent,DialogHeader,DialogTitlefrom@/components/ui/dialog;Buttonfrom@/components/ui/button;Inputfrom@/components/ui/input;Labelfrom@/components/ui/label;Linkfromreact-router;Pencilfromlucide-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 VehiclePayloadfrom@/hooks/useClients;useParams,Linkfromreact-router;Pencil,ArrowLeftfromlucide-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 WOPayloadfrom@/hooks/useWorkOrders;useClients,useClientVehiclesfrom@/hooks/useClients;useNavigatefromreact-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 StaffHoursPayloadfrom@/hooks/useWorkOrders;useCatalogfrom@/hooks/useCatalog;useParams,Linkfromreact-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
// 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 3–8
-
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.