import { useEffect, 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, apiFetchBlob } from '@/lib/api' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { ConfirmActionDialog } from '@/components/ui/confirm-action-dialog' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { useTheme } from '@/hooks/useTheme' import { WORK_ORDER_STATUS_LABEL, workOrderStatusBadgeClass } from '@/lib/workOrderStatus' import type { WorkOrderDetail, CatalogItem, Staff, Client, Vehicle } from '@/lib/types' const CATEGORY_LABEL: Record = { 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 TRANSITIONS: Record = { quote: ['open', 'cancelled'], open: ['in_progress', 'cancelled'], in_progress: ['completed', 'cancelled'], completed: ['invoiced', 'cancelled'], invoiced: [], cancelled: [], } const ETA_LABEL: Record = { 1: '1 dia', 2: '2 dias', 3: '3 dias', 7: '7 dias', 15: '15 dias', 30: '30 dias', 31: '+ de 30 dias', } const ETA_OPTIONS = [1, 2, 3, 7, 15, 30, 31] const PAYMENT_METHOD_OPTIONS = [ { value: 'numerario', label: 'Numerário' }, { value: 'multibanco', label: 'Multibanco' }, { value: 'transferencia_bancaria', label: 'Transferência Bancária' }, { value: 'mb_way', label: 'MB WAY' }, { value: 'cartao_debito', label: 'Cartão de Débito' }, { value: 'cartao_credito', label: 'Cartão de Crédito' }, { value: 'cheque', label: 'Cheque' }, { value: 'outro', label: 'Outro' }, ] const PAYMENT_METHOD_LABEL: Record = Object.fromEntries(PAYMENT_METHOD_OPTIONS.map((o) => [o.value, o.label])) function formatDateSafe(value?: string | null, withTime = false): string { if (!value) return '—' const d = new Date(value) if (Number.isNaN(d.getTime())) return '—' return withTime ? new Intl.DateTimeFormat('pt-PT', { dateStyle: 'short', timeStyle: 'short' }).format(d) : new Intl.DateTimeFormat('pt-PT').format(d) } const metaSchema = z.object({ client_id: z.string(), vehicle_id: z.string(), internal_notes: z.string(), client_notes: z.string(), eta_days: z.coerce.number().int(), real_deadline: z.string(), payment_method: z.string(), payment_date: z.string(), }) type MetaForm = z.infer // ─── Item form ──────────────────────────────────────────────────────────────── const itemSchema = z.object({ catalog_item_id: z.string(), description: z.string().min(1, 'Descrição obrigatória'), change_justification: z.string(), 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 const emptyItem: ItemForm = { catalog_item_id: '', description: '', change_justification: '', 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 const emptySH: SHForm = { staff_id: '', hours: 1, cost_per_hour: 0 } // ─── Page ───────────────────────────────────────────────────────────────────── export default function WorkOrderDetailPage() { const { id } = useParams<{ id: string }>() const { theme } = useTheme('ui_theme', 'dark') const isLight = theme === 'light' const qc = useQueryClient() const [showAddItem, setShowAddItem] = useState(false) const [showAddStaff, setShowAddStaff] = useState(false) const [showMetaEdit, setShowMetaEdit] = useState(false) const [confirmAction, setConfirmAction] = useState(null) const { data: detail, isLoading, error: detailError } = useQuery({ queryKey: ['work-order', id], queryFn: () => apiFetch(`/work-orders/${id}`), }) const { data: catalogItems = [] } = useQuery({ queryKey: ['catalog'], queryFn: () => apiFetch('/catalog'), }) const { data: staffList = [] } = useQuery({ queryKey: ['staff'], queryFn: () => apiFetch('/staff'), }) const { data: invoices = [] } = useQuery<{ id: string; work_order_id: string; type: 'quote' | 'invoice'; issued_at: string }[]>({ queryKey: ['invoices', 'work-order-detail'], queryFn: () => apiFetch('/invoices'), }) const { data: clients = [] } = useQuery({ queryKey: ['clients'], queryFn: () => apiFetch('/clients'), }) const { register: regMeta, handleSubmit: handleMeta, watch: watchMeta, reset: resetMeta, formState: { errors: metaErrors }, } = useForm({ resolver: zodResolver(metaSchema) as Resolver, defaultValues: { client_id: '', vehicle_id: '', internal_notes: '', client_notes: '', eta_days: 1, real_deadline: '', payment_method: '', payment_date: '', }, }) const selectedMetaClient = watchMeta('client_id') const { data: vehicles = [] } = useQuery({ queryKey: ['vehicles', selectedMetaClient || detail?.client_id || '', 'wo-meta'], queryFn: () => apiFetch(`/clients/${selectedMetaClient || detail?.client_id}/vehicles`), enabled: !!(selectedMetaClient || detail?.client_id), }) // 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) => { if (status === 'invoiced') { return apiFetch('/invoices', { method: 'POST', body: JSON.stringify({ work_order_id: id, type: 'invoice' }), }) } return apiFetch(`/work-orders/${id}/transition`, { method: 'POST', body: JSON.stringify({ status, payment_method: '', payment_date: '', }), }) }, onSuccess: () => { qc.invalidateQueries({ queryKey: ['work-order', id] }) qc.invalidateQueries({ queryKey: ['work-orders'] }) qc.invalidateQueries({ queryKey: ['invoices'] }) }, }) const updateMeta = useMutation({ mutationFn: (data: MetaForm) => apiFetch(`/work-orders/${id}`, { method: 'PUT', body: JSON.stringify({ ...data, real_deadline: detail?.status === 'quote' ? '' : data.real_deadline, payment_method: data.payment_method, payment_date: data.payment_date, }), }), onSuccess: () => { qc.invalidateQueries({ queryKey: ['work-order', id] }) qc.invalidateQueries({ queryKey: ['work-orders'] }) setShowMetaEdit(false) }, }) // ── Items ───────────────────────────────────────────────────────────────────── const { register: regItem, handleSubmit: handleItem, setValue: setItemVal, reset: resetItem, formState: { errors: itemErrors }, } = useForm({ resolver: zodResolver(itemSchema) as Resolver, defaultValues: emptyItem, }) function onCatalogSelect(e: React.ChangeEvent) { 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({ resolver: zodResolver(shSchema) as Resolver, defaultValues: emptySH, }) function onStaffSelect(e: React.ChangeEvent) { 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] }), }) useEffect(() => { if (!detail || showMetaEdit) return resetMeta({ client_id: detail.client_id ?? '', vehicle_id: detail.vehicle_id ?? '', internal_notes: detail.internal_notes ?? '', client_notes: detail.client_notes ?? '', eta_days: detail.eta_days ?? 1, real_deadline: detail.real_deadline ? detail.real_deadline.split('T')[0] : '', payment_method: detail.payment_method ?? '', payment_date: detail.payment_date ? detail.payment_date.split('T')[0] : '', }) }, [detail, showMetaEdit, resetMeta]) // ── Render ──────────────────────────────────────────────────────────────────── if (isLoading) return

A carregar...

if (detailError) return

Erro ao abrir a ordem: {(detailError as Error).message}

if (!detail) return

Ordem não encontrada.

const items = Array.isArray(detail.items) ? detail.items : [] const staffHours = Array.isArray(detail.staff_hours) ? detail.staff_hours : [] const itemsTotal = items.reduce((s, i) => s + Number(i.total || 0), 0) const hoursTotal = staffHours.reduce((s, h) => s + Number(h.total || 0), 0) const nextStates = TRANSITIONS[detail.status] ?? [] const editable = detail.status !== 'invoiced' && detail.status !== 'cancelled' const clientName = detail.client_id ? (clients.find((c) => c.id === detail.client_id)?.name ?? '—') : '—' const woInvoices = invoices .filter((inv) => inv.work_order_id === detail.id) .sort((a, b) => new Date(b.issued_at).getTime() - new Date(a.issued_at).getTime()) const preferredType = detail.status === 'invoiced' ? 'invoice' : 'quote' const selectedDoc = woInvoices.find((inv) => inv.type === preferredType) ?? woInvoices[0] async function openWOInvoicePDF() { if (!selectedDoc) return try { const blob = await apiFetchBlob(`/invoices/${selectedDoc.id}/pdf`) const url = URL.createObjectURL(blob) window.open(url, '_blank', 'noopener,noreferrer') setTimeout(() => URL.revokeObjectURL(url), 60_000) } catch { // transition/update errors already shown elsewhere } } const vehicleName = detail.vehicle_id ? (() => { const v = vehicles.find((it) => it.id === detail.vehicle_id) return v ? `${v.plate} — ${v.brand} ${v.model}` : '—' })() : '—' return (
{/* Header */}
← Ordens /

Ordem #{detail.number}

{WORK_ORDER_STATUS_LABEL[detail.status]} {selectedDoc && ( )}

Dados da OT

Cliente

{clientName}

Viatura

{vehicleName}

Criada em

{formatDateSafe(detail.created_at, true)}

Previsão de conclusão

{ETA_LABEL[detail.eta_days] ?? `${detail.eta_days} dias`}

{detail.status !== 'quote' && (

Prazo real (deadline)

{formatDateSafe(detail.real_deadline)}

)} {(detail.status === 'completed' || detail.status === 'invoiced') && (

Meio de pagamento

{PAYMENT_METHOD_LABEL[detail.payment_method] ?? '—'}

)} {(detail.status === 'completed' || detail.status === 'invoiced') && (

Data de pagamento

{formatDateSafe(detail.payment_date)}

)}

Observações

{detail.client_notes || '—'}

Notas internas

{detail.internal_notes || '—'}

Editar Dados da OT

{editable && !showMetaEdit && ( )}
{!showMetaEdit && editable && (

Clique em editar para atualizar cliente, viatura, prazos e observações.

)} {!editable && (

OT faturada/cancelada não permite edição.

)} {editable && showMetaEdit && (
updateMeta.mutate(d))} className="space-y-3">
{detail.status !== 'quote' && (
)} {(detail.status === 'completed' || detail.status === 'invoiced') && (
)} {(detail.status === 'completed' || detail.status === 'invoiced') && (
)}
{metaErrors.eta_days &&

{metaErrors.eta_days.message}

} {updateMeta.error &&

{(updateMeta.error as Error).message}

}
)}
{/* Transitions */} {nextStates.length > 0 && (
Transição: {nextStates.map((s) => ( ))}
{transition.error && (

{(transition.error as Error).message}

)}
)} {/* ── Items ─────────────────────────────────────────────────────────────── */}

Itens

{editable && ( )}
{showAddItem && (
addItem.mutate(d))} className="grid grid-cols-3 gap-3">
{itemErrors.description &&

{itemErrors.description.message}

}
{detail.status === 'open' && (

Obrigatório em Orçamento Aprovado para garantir transparência.

)}
{itemErrors.qty &&

{itemErrors.qty.message}

}
{addItem.error &&

{(addItem.error as Error).message}

}
)} {items.length === 0 ? (

Sem itens.

) : (
{items.map((item) => { const cat = item.catalog_item_id ? catalogMap[item.catalog_item_id] : null return ( ) })} {hoursTotal > 0 && ( )}
Artigo Qtd. Preço Unit. Desc.% Total
{item.description}
{cat && (
{CATEGORY_LABEL[cat.category] ?? cat.category}
)} {item.change_justification && (
Justificação: {item.change_justification}
)}
{item.qty} {Number(item.unit_price || 0).toFixed(2)} € {item.discount_pct}% {Number(item.total || 0).toFixed(2)} € {editable && ( )}
Subtotal itens {itemsTotal.toFixed(2)} €
Mão de obra {hoursTotal.toFixed(2)} €
Total {(itemsTotal + hoursTotal).toFixed(2)} €
)}
{/* ── Staff hours ───────────────────────────────────────────────────────── */}

Técnicos

{editable && ( )}
{showAddStaff && (
addSH.mutate(d))} className="grid grid-cols-3 gap-3">
{shErrors.staff_id &&

{shErrors.staff_id.message}

}
{shErrors.hours &&

{shErrors.hours.message}

}

Preço preenchido automaticamente
do cadastro do técnico

{addSH.error &&

{(addSH.error as Error).message}

}
)} {staffHours.length === 0 ? (

Sem técnicos registados nesta ordem.

) : (
{staffHours.map((sh) => { const staff = staffMap[sh.staff_id] return ( ) })}
Técnico Horas €/hora Total
{staff?.name ?? '—'}
{staff && (
{staff.type === 'internal' ? 'Interno' : 'Externo'}
)}
{sh.hours} {Number(sh.cost_per_hour || 0).toFixed(2)} € {Number(sh.total || 0).toFixed(2)} € {editable && ( )}
)}
!open && setConfirmAction(null)} title={ confirmAction?.type === 'cancel' ? 'Cancelar ordem de trabalho' : confirmAction?.type === 'remove_item' ? 'Eliminar item' : 'Eliminar técnico' } description={ confirmAction?.type === 'cancel' ? 'A ordem será marcada como cancelada e deixará de ser editável.' : confirmAction?.type === 'remove_item' ? 'Este item será removido da OT.' : 'Este registo de horas será removido da OT.' } confirmLabel={confirmAction?.type === 'cancel' ? 'Cancelar OT' : 'Eliminar'} pending={transition.isPending || removeItem.isPending || removeSH.isPending} onConfirm={() => { if (!confirmAction) return if (confirmAction.type === 'cancel') { transition.mutate('cancelled', { onSettled: () => setConfirmAction(null), }) return } if (confirmAction.type === 'remove_item' && confirmAction.id) { removeItem.mutate(confirmAction.id, { onSettled: () => setConfirmAction(null), }) return } if (confirmAction.type === 'remove_staff' && confirmAction.id) { removeSH.mutate(confirmAction.id, { onSettled: () => setConfirmAction(null), }) } }} />
) }