Inicial
This commit is contained in:
@@ -0,0 +1,465 @@
|
||||
import { useState } from 'react'
|
||||
import { useParams, Link } from 'react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useForm, type Resolver } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import type { WorkOrderDetail, CatalogItem, Staff } from '@/lib/types'
|
||||
|
||||
const CATEGORY_LABEL: Record<string, string> = {
|
||||
mao_de_obra: 'Mão de Obra',
|
||||
diagnostico: 'Diagnóstico',
|
||||
manutencao: 'Manutenção',
|
||||
pecas_mecanicas: 'Peças Mecânicas',
|
||||
pneus_jantes: 'Pneus / Jantes',
|
||||
eletrica: 'Elétrica / Eletrónica',
|
||||
fluidos: 'Fluidos / Óleos',
|
||||
ar_condicionado: 'Ar Condicionado',
|
||||
carrocaria: 'Carroçaria / Pintura',
|
||||
consumiveis: 'Consumíveis',
|
||||
acessorios: 'Acessórios',
|
||||
outro: 'Outro',
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
open: 'Aberta',
|
||||
in_progress: 'Em Curso',
|
||||
completed: 'Concluída',
|
||||
invoiced: 'Faturada',
|
||||
cancelled: 'Cancelada',
|
||||
}
|
||||
|
||||
const TRANSITIONS: Record<string, string[]> = {
|
||||
open: ['in_progress', 'cancelled'],
|
||||
in_progress: ['completed', 'cancelled'],
|
||||
completed: ['invoiced', 'cancelled'],
|
||||
invoiced: [],
|
||||
cancelled: [],
|
||||
}
|
||||
|
||||
// ─── Item form ────────────────────────────────────────────────────────────────
|
||||
|
||||
const itemSchema = z.object({
|
||||
catalog_item_id: z.string(),
|
||||
description: z.string().min(1, 'Descrição obrigatória'),
|
||||
qty: z.coerce.number().positive('Quantidade deve ser positiva'),
|
||||
unit_price: z.coerce.number().min(0),
|
||||
discount_pct: z.coerce.number().min(0).max(100),
|
||||
})
|
||||
type ItemForm = z.infer<typeof itemSchema>
|
||||
const emptyItem: ItemForm = { catalog_item_id: '', description: '', qty: 1, unit_price: 0, discount_pct: 0 }
|
||||
|
||||
// ─── Staff hours form ─────────────────────────────────────────────────────────
|
||||
|
||||
const shSchema = z.object({
|
||||
staff_id: z.string().min(1, 'Seleccione um técnico'),
|
||||
hours: z.coerce.number().positive('Horas deve ser positivo'),
|
||||
cost_per_hour: z.coerce.number().min(0),
|
||||
})
|
||||
type SHForm = z.infer<typeof shSchema>
|
||||
const emptySH: SHForm = { staff_id: '', hours: 1, cost_per_hour: 0 }
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function WorkOrderDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const qc = useQueryClient()
|
||||
const [showAddItem, setShowAddItem] = useState(false)
|
||||
const [showAddStaff, setShowAddStaff] = useState(false)
|
||||
|
||||
const { data: detail, isLoading } = useQuery<WorkOrderDetail>({
|
||||
queryKey: ['work-order', id],
|
||||
queryFn: () => apiFetch<WorkOrderDetail>(`/work-orders/${id}`),
|
||||
})
|
||||
|
||||
const { data: catalogItems = [] } = useQuery<CatalogItem[]>({
|
||||
queryKey: ['catalog'],
|
||||
queryFn: () => apiFetch<CatalogItem[]>('/catalog'),
|
||||
})
|
||||
|
||||
const { data: staffList = [] } = useQuery<Staff[]>({
|
||||
queryKey: ['staff'],
|
||||
queryFn: () => apiFetch<Staff[]>('/staff'),
|
||||
})
|
||||
|
||||
// lookup maps
|
||||
const catalogMap = Object.fromEntries(catalogItems.map(c => [c.id, c]))
|
||||
const staffMap = Object.fromEntries(staffList.map(s => [s.id, s]))
|
||||
|
||||
// ── Transitions ──────────────────────────────────────────────────────────────
|
||||
|
||||
const transition = useMutation({
|
||||
mutationFn: (status: string) =>
|
||||
apiFetch(`/work-orders/${id}/transition`, { method: 'POST', body: JSON.stringify({ status }) }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['work-order', id] }),
|
||||
})
|
||||
|
||||
// ── Items ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const {
|
||||
register: regItem,
|
||||
handleSubmit: handleItem,
|
||||
setValue: setItemVal,
|
||||
reset: resetItem,
|
||||
formState: { errors: itemErrors },
|
||||
} = useForm<ItemForm>({
|
||||
resolver: zodResolver(itemSchema) as Resolver<ItemForm>,
|
||||
defaultValues: emptyItem,
|
||||
})
|
||||
|
||||
function onCatalogSelect(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||
const cid = e.target.value
|
||||
setItemVal('catalog_item_id', cid)
|
||||
if (cid) {
|
||||
const found = catalogMap[cid]
|
||||
if (found) {
|
||||
setItemVal('description', found.name)
|
||||
setItemVal('unit_price', found.base_price)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const addItem = useMutation({
|
||||
mutationFn: (data: ItemForm) =>
|
||||
apiFetch(`/work-orders/${id}/items`, { method: 'POST', body: JSON.stringify(data) }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['work-order', id] })
|
||||
setShowAddItem(false)
|
||||
resetItem(emptyItem)
|
||||
},
|
||||
})
|
||||
|
||||
const removeItem = useMutation({
|
||||
mutationFn: (itemId: string) =>
|
||||
apiFetch(`/work-orders/${id}/items/${itemId}`, { method: 'DELETE' }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['work-order', id] }),
|
||||
})
|
||||
|
||||
// ── Staff hours ───────────────────────────────────────────────────────────────
|
||||
|
||||
const {
|
||||
register: regSH,
|
||||
handleSubmit: handleSH,
|
||||
setValue: setSHVal,
|
||||
reset: resetSH,
|
||||
formState: { errors: shErrors },
|
||||
} = useForm<SHForm>({
|
||||
resolver: zodResolver(shSchema) as Resolver<SHForm>,
|
||||
defaultValues: emptySH,
|
||||
})
|
||||
|
||||
function onStaffSelect(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||
const sid = e.target.value
|
||||
setSHVal('staff_id', sid)
|
||||
if (sid) {
|
||||
const found = staffMap[sid]
|
||||
if (found) setSHVal('cost_per_hour', found.hourly_rate)
|
||||
}
|
||||
}
|
||||
|
||||
const addSH = useMutation({
|
||||
mutationFn: (data: SHForm) =>
|
||||
apiFetch(`/work-orders/${id}/staff-hours`, { method: 'POST', body: JSON.stringify(data) }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['work-order', id] })
|
||||
setShowAddStaff(false)
|
||||
resetSH(emptySH)
|
||||
},
|
||||
})
|
||||
|
||||
const removeSH = useMutation({
|
||||
mutationFn: (shId: string) =>
|
||||
apiFetch(`/work-orders/${id}/staff-hours/${shId}`, { method: 'DELETE' }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['work-order', id] }),
|
||||
})
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────────
|
||||
|
||||
if (isLoading) return <p className="text-slate-400">A carregar...</p>
|
||||
if (!detail) return <p className="text-slate-400">Ordem não encontrada.</p>
|
||||
|
||||
const itemsTotal = detail.items.reduce((s, i) => s + i.total, 0)
|
||||
const hoursTotal = detail.staff_hours.reduce((s, h) => s + h.total, 0)
|
||||
const nextStates = TRANSITIONS[detail.status] ?? []
|
||||
const editable = detail.status !== 'invoiced' && detail.status !== 'cancelled'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/app/work-orders" className="text-slate-400 hover:text-white text-sm">
|
||||
← Ordens
|
||||
</Link>
|
||||
<span className="text-slate-600">/</span>
|
||||
<h1 className="text-2xl font-bold text-white">Ordem #{detail.number}</h1>
|
||||
<Badge variant={detail.status === 'cancelled' ? 'destructive' : detail.status === 'completed' || detail.status === 'invoiced' ? 'secondary' : 'default'}>
|
||||
{STATUS_LABELS[detail.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Transitions */}
|
||||
{nextStates.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-slate-400 text-sm self-center">Transição:</span>
|
||||
{nextStates.map((s) => (
|
||||
<Button
|
||||
key={s}
|
||||
size="sm"
|
||||
variant={s === 'cancelled' ? 'destructive' : 'default'}
|
||||
onClick={() => {
|
||||
if (s === 'cancelled' && !confirm('Cancelar esta ordem?')) return
|
||||
transition.mutate(s)
|
||||
}}
|
||||
disabled={transition.isPending}
|
||||
>
|
||||
→ {STATUS_LABELS[s]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Items ─────────────────────────────────────────────────────────────── */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold text-white">Itens</h2>
|
||||
{editable && (
|
||||
<Button size="sm" onClick={() => { resetItem(emptyItem); setShowAddItem(true) }}>
|
||||
Adicionar Item
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showAddItem && (
|
||||
<div className="mb-4 bg-slate-800 rounded-lg border border-slate-700 p-4">
|
||||
<form onSubmit={handleItem((d) => addItem.mutate(d))} className="grid grid-cols-3 gap-3">
|
||||
<div className="col-span-3 space-y-1">
|
||||
<Label>Catálogo (opcional)</Label>
|
||||
<select
|
||||
{...regItem('catalog_item_id')}
|
||||
onChange={onCatalogSelect}
|
||||
className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">— Seleccionar do catálogo —</option>
|
||||
{catalogItems.filter(i => i.active).map(i => (
|
||||
<option key={i.id} value={i.id}>
|
||||
{i.code} — {i.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-span-3 space-y-1">
|
||||
<Label>Descrição *</Label>
|
||||
<Input {...regItem('description')} className="bg-slate-900 border-slate-600 text-white" />
|
||||
{itemErrors.description && <p className="text-red-400 text-xs">{itemErrors.description.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Qtd. *</Label>
|
||||
<Input type="number" step="0.001" {...regItem('qty')} className="bg-slate-900 border-slate-600 text-white" />
|
||||
{itemErrors.qty && <p className="text-red-400 text-xs">{itemErrors.qty.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Preço Unit. (€)</Label>
|
||||
<Input type="number" step="0.01" {...regItem('unit_price')} className="bg-slate-900 border-slate-600 text-white" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Desconto (%)</Label>
|
||||
<Input type="number" step="0.01" {...regItem('discount_pct')} className="bg-slate-900 border-slate-600 text-white" />
|
||||
</div>
|
||||
{addItem.error && <p className="col-span-3 text-red-400 text-sm">{(addItem.error as Error).message}</p>}
|
||||
<div className="col-span-3 flex gap-2 justify-end">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setShowAddItem(false)}>Cancelar</Button>
|
||||
<Button type="submit" size="sm" disabled={addItem.isPending}>
|
||||
{addItem.isPending ? 'A adicionar...' : 'Adicionar'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.items.length === 0 ? (
|
||||
<p className="text-slate-500 text-sm">Sem itens.</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-slate-700 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-800">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2 text-slate-400 font-medium">Artigo</th>
|
||||
<th className="text-right px-4 py-2 text-slate-400 font-medium">Qtd.</th>
|
||||
<th className="text-right px-4 py-2 text-slate-400 font-medium">Preço Unit.</th>
|
||||
<th className="text-right px-4 py-2 text-slate-400 font-medium">Desc.%</th>
|
||||
<th className="text-right px-4 py-2 text-slate-400 font-medium">Total</th>
|
||||
<th className="px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{detail.items.map((item) => {
|
||||
const cat = item.catalog_item_id ? catalogMap[item.catalog_item_id] : null
|
||||
return (
|
||||
<tr key={item.id} className="border-t border-slate-700">
|
||||
<td className="px-4 py-2">
|
||||
<div className="text-white">{item.description}</div>
|
||||
{cat && (
|
||||
<div className="text-xs text-slate-500 mt-0.5">
|
||||
{CATEGORY_LABEL[cat.category] ?? cat.category}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-slate-300 text-right">{item.qty}</td>
|
||||
<td className="px-4 py-2 text-slate-300 text-right">{item.unit_price.toFixed(2)} €</td>
|
||||
<td className="px-4 py-2 text-slate-300 text-right">{item.discount_pct}%</td>
|
||||
<td className="px-4 py-2 text-white font-medium text-right">{item.total.toFixed(2)} €</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
{editable && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-red-400 hover:text-red-300 h-7 px-2"
|
||||
onClick={() => removeItem.mutate(item.id)}
|
||||
disabled={removeItem.isPending}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot className="bg-slate-800/50">
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-2 text-slate-400 text-right font-medium">Subtotal itens</td>
|
||||
<td className="px-4 py-2 text-white font-bold text-right">{itemsTotal.toFixed(2)} €</td>
|
||||
<td />
|
||||
</tr>
|
||||
{hoursTotal > 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-2 text-slate-400 text-right font-medium">Mão de obra</td>
|
||||
<td className="px-4 py-2 text-white font-bold text-right">{hoursTotal.toFixed(2)} €</td>
|
||||
<td />
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-2 text-slate-300 text-right font-bold">Total</td>
|
||||
<td className="px-4 py-2 text-blue-400 font-bold text-right text-base">
|
||||
{(itemsTotal + hoursTotal).toFixed(2)} €
|
||||
</td>
|
||||
<td />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── Staff hours ───────────────────────────────────────────────────────── */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold text-white">Técnicos</h2>
|
||||
{editable && (
|
||||
<Button size="sm" onClick={() => { resetSH(emptySH); setShowAddStaff(true) }}>
|
||||
Adicionar Técnico
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showAddStaff && (
|
||||
<div className="mb-4 bg-slate-800 rounded-lg border border-slate-700 p-4">
|
||||
<form onSubmit={handleSH((d) => addSH.mutate(d))} className="grid grid-cols-3 gap-3">
|
||||
<div className="col-span-3 space-y-1">
|
||||
<Label>Técnico *</Label>
|
||||
<select
|
||||
{...regSH('staff_id')}
|
||||
onChange={onStaffSelect}
|
||||
className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">— Seleccionar técnico —</option>
|
||||
{staffList.filter(s => s.active).map(s => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name} ({s.type === 'internal' ? 'Interno' : 'Externo'})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{shErrors.staff_id && <p className="text-red-400 text-xs">{shErrors.staff_id.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Horas *</Label>
|
||||
<Input type="number" step="0.25" {...regSH('hours')} className="bg-slate-900 border-slate-600 text-white" />
|
||||
{shErrors.hours && <p className="text-red-400 text-xs">{shErrors.hours.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>€/hora</Label>
|
||||
<Input type="number" step="0.01" {...regSH('cost_per_hour')} className="bg-slate-900 border-slate-600 text-white" />
|
||||
</div>
|
||||
<div className="flex items-end pb-0.5">
|
||||
<p className="text-slate-500 text-xs">Preço preenchido automaticamente<br/>do cadastro do técnico</p>
|
||||
</div>
|
||||
{addSH.error && <p className="col-span-3 text-red-400 text-sm">{(addSH.error as Error).message}</p>}
|
||||
<div className="col-span-3 flex gap-2 justify-end">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setShowAddStaff(false)}>Cancelar</Button>
|
||||
<Button type="submit" size="sm" disabled={addSH.isPending}>
|
||||
{addSH.isPending ? 'A adicionar...' : 'Adicionar'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.staff_hours.length === 0 ? (
|
||||
<p className="text-slate-500 text-sm">Sem técnicos registados nesta ordem.</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-slate-700 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-800">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2 text-slate-400 font-medium">Técnico</th>
|
||||
<th className="text-right px-4 py-2 text-slate-400 font-medium">Horas</th>
|
||||
<th className="text-right px-4 py-2 text-slate-400 font-medium">€/hora</th>
|
||||
<th className="text-right px-4 py-2 text-slate-400 font-medium">Total</th>
|
||||
<th className="px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{detail.staff_hours.map((sh) => {
|
||||
const staff = staffMap[sh.staff_id]
|
||||
return (
|
||||
<tr key={sh.id} className="border-t border-slate-700">
|
||||
<td className="px-4 py-2">
|
||||
<div className="text-white">{staff?.name ?? '—'}</div>
|
||||
{staff && (
|
||||
<div className="text-xs text-slate-500 mt-0.5">
|
||||
{staff.type === 'internal' ? 'Interno' : 'Externo'}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-white text-right">{sh.hours}</td>
|
||||
<td className="px-4 py-2 text-slate-300 text-right">{sh.cost_per_hour.toFixed(2)} €</td>
|
||||
<td className="px-4 py-2 text-white font-medium text-right">{sh.total.toFixed(2)} €</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
{editable && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-red-400 hover:text-red-300 h-7 px-2"
|
||||
onClick={() => removeSH.mutate(sh.id)}
|
||||
disabled={removeSH.isPending}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user