2220 lines
88 KiB
Markdown
2220 lines
88 KiB
Markdown
# 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 3–9
|
||
|
||
- [ ] **Step 1: Create dialog.tsx**
|
||
|
||
```tsx
|
||
// frontend/src/components/ui/dialog.tsx
|
||
import * as React from 'react'
|
||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||
import { X } from 'lucide-react'
|
||
import { cn } from '@/lib/utils'
|
||
|
||
const Dialog = DialogPrimitive.Root
|
||
const DialogTrigger = DialogPrimitive.Trigger
|
||
const DialogPortal = DialogPrimitive.Portal
|
||
const DialogClose = DialogPrimitive.Close
|
||
|
||
const DialogOverlay = React.forwardRef<
|
||
React.ComponentRef<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 3–8
|
||
|
||
- [ ] **Step 1: Update App.tsx**
|
||
|
||
Replace the existing `/app` route block with:
|
||
|
||
```tsx
|
||
// frontend/src/App.tsx
|
||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router'
|
||
import { QueryClientProvider } from '@tanstack/react-query'
|
||
import { queryClient } from '@/lib/queryClient'
|
||
import { useAuthStore } from '@/store/authStore'
|
||
import type { UserRole } from '@/store/authStore'
|
||
import LoginPage from '@/pages/auth/LoginPage'
|
||
import AppDashboardPage from '@/pages/app/DashboardPage'
|
||
import AdminDashboardPage from '@/pages/admin/DashboardPage'
|
||
import TenantsPage from '@/pages/admin/TenantsPage'
|
||
import InviteRedeemPage from '@/pages/public/InviteRedeemPage'
|
||
import ClientsPage from '@/pages/app/clients/ClientsPage'
|
||
import ClientDetailPage from '@/pages/app/clients/ClientDetailPage'
|
||
import CatalogPage from '@/pages/app/catalog/CatalogPage'
|
||
import WorkOrdersPage from '@/pages/app/work-orders/WorkOrdersPage'
|
||
import WorkOrderDetailPage from '@/pages/app/work-orders/WorkOrderDetailPage'
|
||
import AppLayout from '@/components/layout/AppLayout'
|
||
import AdminLayout from '@/components/layout/AdminLayout'
|
||
|
||
function RequireAuth({
|
||
children,
|
||
allowedRoles,
|
||
}: {
|
||
children: React.ReactNode
|
||
allowedRoles: UserRole[]
|
||
}) {
|
||
const { isAuthenticated, user } = useAuthStore()
|
||
if (!isAuthenticated) return <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.
|