feat: refine OT flow, reports, theming and sidebar UX
This commit is contained in:
@@ -1,15 +1,18 @@
|
||||
import { useState } from 'react'
|
||||
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 } from '@/lib/api'
|
||||
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 type { WorkOrderDetail, CatalogItem, Staff } from '@/lib/types'
|
||||
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<string, string> = {
|
||||
mao_de_obra: 'Mão de Obra',
|
||||
@@ -26,15 +29,6 @@ const CATEGORY_LABEL: Record<string, string> = {
|
||||
outro: 'Outro',
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
quote: 'Orçamento',
|
||||
open: 'Aberta',
|
||||
in_progress: 'Em Curso',
|
||||
completed: 'Concluída',
|
||||
invoiced: 'Faturada',
|
||||
cancelled: 'Cancelada',
|
||||
}
|
||||
|
||||
const TRANSITIONS: Record<string, string[]> = {
|
||||
quote: ['open', 'cancelled'],
|
||||
open: ['in_progress', 'cancelled'],
|
||||
@@ -43,18 +37,61 @@ const TRANSITIONS: Record<string, string[]> = {
|
||||
invoiced: [],
|
||||
cancelled: [],
|
||||
}
|
||||
const ETA_LABEL: Record<number, string> = {
|
||||
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<string, string> = 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<typeof metaSchema>
|
||||
|
||||
// ─── 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<typeof itemSchema>
|
||||
const emptyItem: ItemForm = { catalog_item_id: '', description: '', qty: 1, unit_price: 0, discount_pct: 0 }
|
||||
const emptyItem: ItemForm = { catalog_item_id: '', description: '', change_justification: '', qty: 1, unit_price: 0, discount_pct: 0 }
|
||||
|
||||
// ─── Staff hours form ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -70,11 +107,15 @@ const emptySH: SHForm = { staff_id: '', hours: 1, cost_per_hour: 0 }
|
||||
|
||||
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 | { type: 'cancel' | 'remove_item' | 'remove_staff'; id?: string }>(null)
|
||||
|
||||
const { data: detail, isLoading } = useQuery<WorkOrderDetail>({
|
||||
const { data: detail, isLoading, error: detailError } = useQuery<WorkOrderDetail>({
|
||||
queryKey: ['work-order', id],
|
||||
queryFn: () => apiFetch<WorkOrderDetail>(`/work-orders/${id}`),
|
||||
})
|
||||
@@ -88,6 +129,39 @@ export default function WorkOrderDetailPage() {
|
||||
queryKey: ['staff'],
|
||||
queryFn: () => apiFetch<Staff[]>('/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<Client[]>({
|
||||
queryKey: ['clients'],
|
||||
queryFn: () => apiFetch<Client[]>('/clients'),
|
||||
})
|
||||
const {
|
||||
register: regMeta,
|
||||
handleSubmit: handleMeta,
|
||||
watch: watchMeta,
|
||||
reset: resetMeta,
|
||||
formState: { errors: metaErrors },
|
||||
} = useForm<MetaForm>({
|
||||
resolver: zodResolver(metaSchema) as Resolver<MetaForm>,
|
||||
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<Vehicle[]>({
|
||||
queryKey: ['vehicles', selectedMetaClient || detail?.client_id || '', 'wo-meta'],
|
||||
queryFn: () => apiFetch<Vehicle[]>(`/clients/${selectedMetaClient || detail?.client_id}/vehicles`),
|
||||
enabled: !!(selectedMetaClient || detail?.client_id),
|
||||
})
|
||||
|
||||
// lookup maps
|
||||
const catalogMap = Object.fromEntries(catalogItems.map(c => [c.id, c]))
|
||||
@@ -96,9 +170,44 @@ export default function WorkOrderDetailPage() {
|
||||
// ── 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] }),
|
||||
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 ─────────────────────────────────────────────────────────────────────
|
||||
@@ -180,33 +289,236 @@ export default function WorkOrderDetailPage() {
|
||||
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 <p className="text-slate-400">A carregar...</p>
|
||||
if (detailError) return <p className="text-red-400">Erro ao abrir a ordem: {(detailError as Error).message}</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 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 (
|
||||
<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">
|
||||
<Link
|
||||
to="/app/work-orders"
|
||||
className={`text-sm ${isLight ? 'text-slate-600 hover:text-slate-900' : 'text-slate-400 hover:text-white'}`}
|
||||
>
|
||||
← 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]}
|
||||
<span className={isLight ? 'text-slate-400' : 'text-slate-600'}>/</span>
|
||||
<h1 className={`text-2xl font-bold ${isLight ? 'text-slate-900' : 'text-white'}`}>Ordem #{detail.number}</h1>
|
||||
<Badge variant="outline" className={workOrderStatusBadgeClass(detail.status)}>
|
||||
{WORK_ORDER_STATUS_LABEL[detail.status]}
|
||||
</Badge>
|
||||
{selectedDoc && (
|
||||
<Button size="sm" variant="outline" onClick={openWOInvoicePDF}>
|
||||
Ver PDF
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<section className={`rounded-lg border p-4 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/50'}`}>
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<div className={`lg:col-span-2 rounded-md border p-3 ${isLight ? 'border-slate-300 bg-slate-50' : 'border-slate-800 bg-slate-950/40'}`}>
|
||||
<h2 className={`mb-3 text-base font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Dados da OT</h2>
|
||||
<div className="grid gap-3 text-sm grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<p className={isLight ? 'text-slate-500' : 'text-slate-500'}>Cliente</p>
|
||||
<p className={isLight ? 'text-slate-800' : 'text-slate-200'}>{clientName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className={isLight ? 'text-slate-500' : 'text-slate-500'}>Viatura</p>
|
||||
<p className={isLight ? 'text-slate-800' : 'text-slate-200'}>{vehicleName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className={isLight ? 'text-slate-500' : 'text-slate-500'}>Criada em</p>
|
||||
<p className={isLight ? 'text-slate-800' : 'text-slate-200'}>{formatDateSafe(detail.created_at, true)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className={isLight ? 'text-slate-500' : 'text-slate-500'}>Previsão de conclusão</p>
|
||||
<p className={isLight ? 'text-slate-800' : 'text-slate-200'}>{ETA_LABEL[detail.eta_days] ?? `${detail.eta_days} dias`}</p>
|
||||
</div>
|
||||
{detail.status !== 'quote' && (
|
||||
<div>
|
||||
<p className={isLight ? 'text-slate-500' : 'text-slate-500'}>Prazo real (deadline)</p>
|
||||
<p className={isLight ? 'text-slate-800' : 'text-slate-200'}>{formatDateSafe(detail.real_deadline)}</p>
|
||||
</div>
|
||||
)}
|
||||
{(detail.status === 'completed' || detail.status === 'invoiced') && (
|
||||
<div>
|
||||
<p className={isLight ? 'text-slate-500' : 'text-slate-500'}>Meio de pagamento</p>
|
||||
<p className={isLight ? 'text-slate-800' : 'text-slate-200'}>{PAYMENT_METHOD_LABEL[detail.payment_method] ?? '—'}</p>
|
||||
</div>
|
||||
)}
|
||||
{(detail.status === 'completed' || detail.status === 'invoiced') && (
|
||||
<div>
|
||||
<p className={isLight ? 'text-slate-500' : 'text-slate-500'}>Data de pagamento</p>
|
||||
<p className={isLight ? 'text-slate-800' : 'text-slate-200'}>{formatDateSafe(detail.payment_date)}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className={isLight ? 'text-slate-500' : 'text-slate-500'}>Observações</p>
|
||||
<p className={isLight ? 'text-slate-800' : 'text-slate-200'}>{detail.client_notes || '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className={isLight ? 'text-slate-500' : 'text-slate-500'}>Notas internas</p>
|
||||
<p className={isLight ? 'text-slate-800' : 'text-slate-200'}>{detail.internal_notes || '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`rounded-md border p-3 ${isLight ? 'border-slate-300 bg-slate-50' : 'border-slate-800 bg-slate-950/40'}`}>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className={`text-base font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Editar Dados da OT</h3>
|
||||
{editable && !showMetaEdit && (
|
||||
<Button size="sm" onClick={() => setShowMetaEdit(true)}>Editar</Button>
|
||||
)}
|
||||
</div>
|
||||
{!showMetaEdit && editable && (
|
||||
<p className={`text-sm ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Clique em editar para atualizar cliente, viatura, prazos e observações.</p>
|
||||
)}
|
||||
{!editable && (
|
||||
<p className={`text-sm ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>OT faturada/cancelada não permite edição.</p>
|
||||
)}
|
||||
{editable && showMetaEdit && (
|
||||
<form onSubmit={handleMeta((d) => updateMeta.mutate(d))} className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Cliente</Label>
|
||||
<select
|
||||
{...regMeta('client_id')}
|
||||
className={`w-full rounded-md border px-3 py-2 text-sm ${isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'}`}
|
||||
>
|
||||
<option value="">— Sem cliente —</option>
|
||||
{clients.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Viatura</Label>
|
||||
<select
|
||||
{...regMeta('vehicle_id')}
|
||||
className={`w-full rounded-md border px-3 py-2 text-sm ${isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'}`}
|
||||
>
|
||||
<option value="">— Sem viatura —</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">
|
||||
<Label>Previsão de conclusão</Label>
|
||||
<select
|
||||
{...regMeta('eta_days')}
|
||||
className={`w-full rounded-md border px-3 py-2 text-sm ${isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'}`}
|
||||
>
|
||||
{ETA_OPTIONS.map((o) => <option key={o} value={o}>{ETA_LABEL[o]}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{detail.status !== 'quote' && (
|
||||
<div className="space-y-1">
|
||||
<Label>Prazo real (deadline)</Label>
|
||||
<Input
|
||||
type="date"
|
||||
{...regMeta('real_deadline')}
|
||||
className={isLight ? 'bg-white border-slate-300 text-slate-900' : 'bg-slate-900 border-slate-600 text-white'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(detail.status === 'completed' || detail.status === 'invoiced') && (
|
||||
<div className="space-y-1">
|
||||
<Label>Meio de pagamento</Label>
|
||||
<select
|
||||
{...regMeta('payment_method')}
|
||||
className={`w-full rounded-md border px-3 py-2 text-sm ${isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'}`}
|
||||
>
|
||||
<option value="">— Selecionar —</option>
|
||||
{PAYMENT_METHOD_OPTIONS.map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{(detail.status === 'completed' || detail.status === 'invoiced') && (
|
||||
<div className="space-y-1">
|
||||
<Label>Data de pagamento</Label>
|
||||
<Input
|
||||
type="date"
|
||||
{...regMeta('payment_date')}
|
||||
className={isLight ? 'bg-white border-slate-300 text-slate-900' : 'bg-slate-900 border-slate-600 text-white'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<Label>Observações</Label>
|
||||
<Input
|
||||
{...regMeta('client_notes')}
|
||||
className={isLight ? 'bg-white border-slate-300 text-slate-900' : 'bg-slate-900 border-slate-600 text-white'}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Notas internas</Label>
|
||||
<Input
|
||||
{...regMeta('internal_notes')}
|
||||
className={isLight ? 'bg-white border-slate-300 text-slate-900' : 'bg-slate-900 border-slate-600 text-white'}
|
||||
/>
|
||||
</div>
|
||||
{metaErrors.eta_days && <p className="text-red-400 text-sm">{metaErrors.eta_days.message}</p>}
|
||||
{updateMeta.error && <p className="text-red-400 text-sm">{(updateMeta.error as Error).message}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setShowMetaEdit(false)}>Cancelar</Button>
|
||||
<Button type="submit" disabled={updateMeta.isPending}>
|
||||
{updateMeta.isPending ? 'A guardar...' : 'Guardar alterações'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Transitions */}
|
||||
{nextStates.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<span className="text-slate-400 text-sm self-center">Transição:</span>
|
||||
{nextStates.map((s) => (
|
||||
<Button
|
||||
@@ -214,14 +526,21 @@ export default function WorkOrderDetailPage() {
|
||||
size="sm"
|
||||
variant={s === 'cancelled' ? 'destructive' : 'default'}
|
||||
onClick={() => {
|
||||
if (s === 'cancelled' && !confirm('Cancelar esta ordem?')) return
|
||||
if (s === 'cancelled') {
|
||||
setConfirmAction({ type: 'cancel' })
|
||||
return
|
||||
}
|
||||
transition.mutate(s)
|
||||
}}
|
||||
disabled={transition.isPending}
|
||||
>
|
||||
→ {STATUS_LABELS[s]}
|
||||
→ {WORK_ORDER_STATUS_LABEL[s as keyof typeof WORK_ORDER_STATUS_LABEL]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{transition.error && (
|
||||
<p className="text-sm text-red-400">{(transition.error as Error).message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -259,6 +578,17 @@ export default function WorkOrderDetailPage() {
|
||||
<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>
|
||||
{detail.status === 'open' && (
|
||||
<div className="col-span-3 space-y-1">
|
||||
<Label>Justificação da alteração *</Label>
|
||||
<Input
|
||||
{...regItem('change_justification')}
|
||||
placeholder="Ex: Pedido do cliente para incluir peça adicional"
|
||||
className="bg-slate-900 border-slate-600 text-white"
|
||||
/>
|
||||
<p className="text-xs text-slate-500">Obrigatório em Orçamento Aprovado para garantir transparência.</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" />
|
||||
@@ -283,7 +613,7 @@ export default function WorkOrderDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.items.length === 0 ? (
|
||||
{items.length === 0 ? (
|
||||
<p className="text-slate-500 text-sm">Sem itens.</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-slate-700 overflow-hidden">
|
||||
@@ -299,7 +629,7 @@ export default function WorkOrderDetailPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{detail.items.map((item) => {
|
||||
{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">
|
||||
@@ -310,18 +640,23 @@ export default function WorkOrderDetailPage() {
|
||||
{CATEGORY_LABEL[cat.category] ?? cat.category}
|
||||
</div>
|
||||
)}
|
||||
{item.change_justification && (
|
||||
<div className="text-xs text-amber-300/90 mt-1">
|
||||
Justificação: {item.change_justification}
|
||||
</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">{Number(item.unit_price || 0).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-white font-medium text-right">{Number(item.total || 0).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)}
|
||||
onClick={() => setConfirmAction({ type: 'remove_item', id: item.id })}
|
||||
disabled={removeItem.isPending}
|
||||
>
|
||||
✕
|
||||
@@ -411,7 +746,7 @@ export default function WorkOrderDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.staff_hours.length === 0 ? (
|
||||
{staffHours.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">
|
||||
@@ -426,7 +761,7 @@ export default function WorkOrderDetailPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{detail.staff_hours.map((sh) => {
|
||||
{staffHours.map((sh) => {
|
||||
const staff = staffMap[sh.staff_id]
|
||||
return (
|
||||
<tr key={sh.id} className="border-t border-slate-700">
|
||||
@@ -439,15 +774,15 @@ export default function WorkOrderDetailPage() {
|
||||
)}
|
||||
</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-slate-300 text-right">{Number(sh.cost_per_hour || 0).toFixed(2)} €</td>
|
||||
<td className="px-4 py-2 text-white font-medium text-right">{Number(sh.total || 0).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)}
|
||||
onClick={() => setConfirmAction({ type: 'remove_staff', id: sh.id })}
|
||||
disabled={removeSH.isPending}
|
||||
>
|
||||
✕
|
||||
@@ -462,6 +797,46 @@ export default function WorkOrderDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<ConfirmActionDialog
|
||||
open={!!confirmAction}
|
||||
onOpenChange={(open) => !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),
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user