feat(reports): simplify dashboards, align OT flow labels, and normalize filters

This commit is contained in:
Luciano Milani
2026-07-04 20:54:13 +01:00
parent dc8c1bd69a
commit 4994abf4e2
14 changed files with 358 additions and 154 deletions
+2 -2
View File
@@ -234,8 +234,8 @@ func addItemH() fiber.Handler {
} }
return fiber.NewError(500, "erro ao validar ordem") return fiber.NewError(500, "erro ao validar ordem")
} }
if status == "open" && b.ChangeJustification == "" { if status == "in_progress" && b.ChangeJustification == "" {
return fiber.NewError(400, "justificação da alteração é obrigatória em orçamento aprovado") return fiber.NewError(400, "justificação da alteração é obrigatória durante o trabalho")
} }
item, err := AddItem(c.Context(), conn, c.Params("id"), b.CatalogItemID, b.Description, b.ChangeJustification, b.Qty, b.UnitPrice, b.DiscountPct) item, err := AddItem(c.Context(), conn, c.Params("id"), b.CatalogItemID, b.Description, b.ChangeJustification, b.Qty, b.UnitPrice, b.DiscountPct)
if err != nil { if err != nil {
+2 -3
View File
@@ -53,8 +53,7 @@ type WorkOrderDetail struct {
} }
var allowedTransitions = map[string][]string{ var allowedTransitions = map[string][]string{
"quote": {"open", "cancelled"}, "quote": {"in_progress", "cancelled"},
"open": {"in_progress", "cancelled"},
"in_progress": {"completed", "cancelled"}, "in_progress": {"completed", "cancelled"},
"completed": {"invoiced", "cancelled"}, "completed": {"invoiced", "cancelled"},
"invoiced": {}, "invoiced": {},
@@ -236,7 +235,7 @@ func AddItem(ctx context.Context, conn *pgxpool.Conn, woID, catalogItemID, descr
FROM work_orders FROM work_orders
WHERE id=$1 WHERE id=$1
AND status NOT IN ('cancelled','invoiced') AND status NOT IN ('cancelled','invoiced')
AND (status <> 'open' OR NULLIF($4,'') IS NOT NULL) AND (status <> 'in_progress' OR NULLIF($4,'') IS NOT NULL)
RETURNING id, work_order_id, catalog_item_id, description, COALESCE(change_justification,''), qty, unit_price, discount_pct, total`, RETURNING id, work_order_id, catalog_item_id, description, COALESCE(change_justification,''), qty, unit_price, discount_pct, total`,
woID, catalogItemID, description, changeJustification, qty, unitPrice, discountPct). woID, catalogItemID, description, changeJustification, qty, unitPrice, discountPct).
Scan(&i.ID, &i.WorkOrderID, &i.CatalogItemID, &i.Description, &i.ChangeJustification, &i.Qty, &i.UnitPrice, &i.DiscountPct, &i.Total) Scan(&i.ID, &i.WorkOrderID, &i.CatalogItemID, &i.Description, &i.ChangeJustification, &i.Qty, &i.UnitPrice, &i.DiscountPct, &i.Total)
@@ -55,9 +55,9 @@ func TestWorkOrderTransition(t *testing.T) {
wo, err := workorder.CreateWorkOrder(ctx, conn, "", "", "", "", 1, "", "") wo, err := workorder.CreateWorkOrder(ctx, conn, "", "", "", "", 1, "", "")
require.NoError(t, err) require.NoError(t, err)
wo2, err := workorder.TransitionStatus(ctx, conn, wo.ID, "open", "", "", "") wo2, err := workorder.TransitionStatus(ctx, conn, wo.ID, "in_progress", "", "", "")
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "open", wo2.Status) assert.Equal(t, "in_progress", wo2.Status)
_, err = workorder.TransitionStatus(ctx, conn, wo.ID, "cancelled_invalid", "", "", "") _, err = workorder.TransitionStatus(ctx, conn, wo.ID, "cancelled_invalid", "", "", "")
assert.Error(t, err, "invalid transition should error") assert.Error(t, err, "invalid transition should error")
@@ -69,15 +69,11 @@ func TestAllowedTransitions(t *testing.T) {
to string to string
valid bool valid bool
}{ }{
{"quote", "open", true}, {"quote", "in_progress", true},
{"quote", "cancelled", true}, {"quote", "cancelled", true},
{"quote", "in_progress", false}, {"quote", "open", false},
{"open", "in_progress", true},
{"open", "cancelled", true},
{"open", "completed", false},
{"in_progress", "completed", true}, {"in_progress", "completed", true},
{"in_progress", "cancelled", true}, {"in_progress", "cancelled", true},
{"in_progress", "open", false},
{"completed", "invoiced", true}, {"completed", "invoiced", true},
{"completed", "cancelled", true}, {"completed", "cancelled", true},
{"invoiced", "cancelled", false}, {"invoiced", "cancelled", false},
@@ -0,0 +1,6 @@
ALTER TABLE work_orders
DROP CONSTRAINT IF EXISTS work_orders_status_check;
ALTER TABLE work_orders
ADD CONSTRAINT work_orders_status_check
CHECK (status IN ('quote', 'open', 'in_progress', 'completed', 'invoiced', 'cancelled'));
@@ -0,0 +1,10 @@
UPDATE work_orders
SET status = 'in_progress'
WHERE status = 'open';
ALTER TABLE work_orders
DROP CONSTRAINT IF EXISTS work_orders_status_check;
ALTER TABLE work_orders
ADD CONSTRAINT work_orders_status_check
CHECK (status IN ('quote', 'in_progress', 'completed', 'invoiced', 'cancelled'));
+1 -1
View File
@@ -59,7 +59,7 @@ export interface WorkOrder {
number: number number: number
client_id: string | null client_id: string | null
vehicle_id: string | null vehicle_id: string | null
status: 'quote' | 'open' | 'in_progress' | 'completed' | 'invoiced' | 'cancelled' status: 'quote' | 'in_progress' | 'completed' | 'invoiced' | 'cancelled'
internal_notes: string internal_notes: string
client_notes: string client_notes: string
eta_days: number eta_days: number
+30 -5
View File
@@ -2,16 +2,14 @@ import type { WorkOrder } from '@/lib/types'
export const WORK_ORDER_STATUS_LABEL: Record<WorkOrder['status'], string> = { export const WORK_ORDER_STATUS_LABEL: Record<WorkOrder['status'], string> = {
quote: 'Orçamento', quote: 'Orçamento',
open: 'Orçamento Aprovado', in_progress: 'Iniciar Trabalho',
in_progress: 'Em Curso', completed: 'Concluir OT',
completed: 'Concluída', invoiced: 'Faturado',
invoiced: 'Faturada',
cancelled: 'Cancelada', cancelled: 'Cancelada',
} }
const LIGHT_STATUS_BADGE: Record<WorkOrder['status'], string> = { const LIGHT_STATUS_BADGE: Record<WorkOrder['status'], string> = {
quote: 'border-[var(--ui-warning)] bg-[var(--ui-warning)] text-[var(--ui-warning-text)]', quote: 'border-[var(--ui-warning)] bg-[var(--ui-warning)] text-[var(--ui-warning-text)]',
open: 'border-sky-400 bg-sky-100 text-slate-800',
in_progress: 'border-indigo-400 bg-indigo-100 text-slate-800', in_progress: 'border-indigo-400 bg-indigo-100 text-slate-800',
completed: 'border-emerald-400 bg-emerald-100 text-slate-800', completed: 'border-emerald-400 bg-emerald-100 text-slate-800',
invoiced: 'border-teal-400 bg-teal-100 text-slate-800', invoiced: 'border-teal-400 bg-teal-100 text-slate-800',
@@ -21,3 +19,30 @@ const LIGHT_STATUS_BADGE: Record<WorkOrder['status'], string> = {
export function workOrderStatusBadgeClass(status: WorkOrder['status']) { export function workOrderStatusBadgeClass(status: WorkOrder['status']) {
return `border ${LIGHT_STATUS_BADGE[status]}` return `border ${LIGHT_STATUS_BADGE[status]}`
} }
export type WorkOrderPhase = 'draft' | 'active' | 'done' | 'cancelled'
export const WORK_ORDER_PHASE_LABEL: Record<WorkOrderPhase, string> = {
draft: 'Orçamento',
active: 'Iniciar Trabalho',
done: 'Concluir OT',
cancelled: 'Cancelada',
}
const PHASE_BADGE: Record<WorkOrderPhase, string> = {
draft: 'border-amber-300 bg-amber-100 text-amber-900',
active: 'border-blue-300 bg-blue-100 text-blue-900',
done: 'border-emerald-300 bg-emerald-100 text-emerald-900',
cancelled: 'border-[var(--ui-danger)] bg-[var(--ui-danger)] text-[var(--ui-danger-text)]',
}
export function getWorkOrderPhase(status: WorkOrder['status']): WorkOrderPhase {
if (status === 'cancelled') return 'cancelled'
if (status === 'quote') return 'draft'
if (status === 'in_progress') return 'active'
return 'done'
}
export function workOrderPhaseBadgeClass(phase: WorkOrderPhase) {
return `border ${PHASE_BADGE[phase]}`
}
+12 -18
View File
@@ -4,17 +4,9 @@ import { useQueries, useQuery } from '@tanstack/react-query'
import { useTheme } from '@/hooks/useTheme' import { useTheme } from '@/hooks/useTheme'
import { apiFetch } from '@/lib/api' import { apiFetch } from '@/lib/api'
import type { Client, Expense, Invoice, Staff, WorkOrder, WorkOrderDetail } from '@/lib/types' import type { Client, Expense, Invoice, Staff, WorkOrder, WorkOrderDetail } from '@/lib/types'
import { WORK_ORDER_STATUS_LABEL } from '@/lib/workOrderStatus'
const STATUS_LABEL: Record<WorkOrder['status'], string> = { const STATUS_ORDER: WorkOrder['status'][] = ['quote', 'in_progress', 'completed', 'invoiced', 'cancelled']
quote: 'Orçamento',
open: 'Orçamento Aprovado',
in_progress: 'Em Curso',
completed: 'Concluída',
invoiced: 'Faturada',
cancelled: 'Cancelada',
}
const STATUS_ORDER: WorkOrder['status'][] = ['quote', 'open', 'in_progress', 'completed', 'invoiced', 'cancelled']
type PeriodKey = 'month' | '30d' | '90d' | 'year' type PeriodKey = 'month' | '30d' | '90d' | 'year'
const PERIOD_OPTIONS: { key: PeriodKey; label: string }[] = [ const PERIOD_OPTIONS: { key: PeriodKey; label: string }[] = [
@@ -149,7 +141,8 @@ export default function DashboardPage() {
const byStatus = Object.fromEntries(STATUS_ORDER.map((s) => [s, 0])) as Record<WorkOrder['status'], number> const byStatus = Object.fromEntries(STATUS_ORDER.map((s) => [s, 0])) as Record<WorkOrder['status'], number>
for (const wo of workOrdersPeriod) byStatus[wo.status] += 1 for (const wo of workOrdersPeriod) byStatus[wo.status] += 1
const activeOrders = byStatus.quote + byStatus.open + byStatus.in_progress const activeOrders = byStatus.quote + byStatus.in_progress
const awaitingInvoice = workOrdersPeriod.filter((wo) => wo.status === 'completed').length
const periodExpenses = expensesPeriod.reduce((sum, e) => sum + e.amount, 0) const periodExpenses = expensesPeriod.reduce((sum, e) => sum + e.amount, 0)
const monthExpenses = expensesMonth.reduce((sum, e) => sum + e.amount, 0) const monthExpenses = expensesMonth.reduce((sum, e) => sum + e.amount, 0)
const monthDocs = invoicesMonth.length const monthDocs = invoicesMonth.length
@@ -172,6 +165,7 @@ export default function DashboardPage() {
return { return {
byStatus, byStatus,
activeOrders, activeOrders,
awaitingInvoice,
periodExpenses, periodExpenses,
periodEntries, periodEntries,
monthEntries, monthEntries,
@@ -236,7 +230,7 @@ export default function DashboardPage() {
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<DashboardCard <DashboardCard
title="OTs Ativas" title="Orçamento + Iniciar Trabalho"
value={String(metrics.activeOrders)} value={String(metrics.activeOrders)}
subtitle={`No período (${metrics.periodOrdersCount} OTs)`} subtitle={`No período (${metrics.periodOrdersCount} OTs)`}
isLight={isLight} isLight={isLight}
@@ -326,7 +320,7 @@ export default function DashboardPage() {
return ( return (
<div key={status}> <div key={status}>
<div className="mb-1 flex justify-between text-xs"> <div className="mb-1 flex justify-between text-xs">
<span className={isLight ? 'text-slate-700' : 'text-slate-300'}>{STATUS_LABEL[status]}</span> <span className={isLight ? 'text-slate-700' : 'text-slate-300'}>{WORK_ORDER_STATUS_LABEL[status]}</span>
<span className={`font-mono ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>{count}</span> <span className={`font-mono ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>{count}</span>
</div> </div>
<div className={`h-2 rounded-full ${isLight ? 'bg-slate-200' : 'bg-slate-800'}`}> <div className={`h-2 rounded-full ${isLight ? 'bg-slate-200' : 'bg-slate-800'}`}>
@@ -355,16 +349,16 @@ export default function DashboardPage() {
> >
<p className="text-xs uppercase tracking-wide">Ação recomendada</p> <p className="text-xs uppercase tracking-wide">Ação recomendada</p>
<p className="mt-1"> <p className="mt-1">
{metrics.byStatus.completed > 0 {metrics.awaitingInvoice > 0
? `${metrics.byStatus.completed} OT(s) concluída(s) pronta(s) para faturar.` ? `${metrics.awaitingInvoice} OT(s) em "Concluir OT" pronta(s) para faturar.`
: 'Sem OTs concluídas pendentes de faturação.'} : 'Sem OTs finalizadas pendentes de emissão de fatura.'}
</p> </p>
</div> </div>
<div className={`rounded-lg border px-3 py-2 ${isLight ? 'border-cyan-200 bg-cyan-50 text-cyan-900' : 'border-cyan-900/70 bg-cyan-950/25 text-cyan-100'}`}> <div className={`rounded-lg border px-3 py-2 ${isLight ? 'border-cyan-200 bg-cyan-50 text-cyan-900' : 'border-cyan-900/70 bg-cyan-950/25 text-cyan-100'}`}>
<p className="text-xs uppercase tracking-wide">Em orçamento</p> <p className="text-xs uppercase tracking-wide">Em orçamento</p>
<p className="mt-1"> <p className="mt-1">
{metrics.byStatus.quote > 0 {metrics.byStatus.quote > 0
? `${metrics.byStatus.quote} OT(s) em orçamento aguardam aprovação.` ? `${metrics.byStatus.quote} OT(s) em "Orçamento" prontas para "Iniciar Trabalho".`
: 'Não existem OTs pendentes em orçamento.'} : 'Não existem OTs pendentes em orçamento.'}
</p> </p>
</div> </div>
@@ -394,7 +388,7 @@ export default function DashboardPage() {
<li key={wo.id} className="flex items-center justify-between py-2"> <li key={wo.id} className="flex items-center justify-between py-2">
<div> <div>
<p className={`text-sm font-medium ${isLight ? 'text-slate-900' : 'text-white'}`}>OT #{wo.number}</p> <p className={`text-sm font-medium ${isLight ? 'text-slate-900' : 'text-white'}`}>OT #{wo.number}</p>
<p className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>{STATUS_LABEL[wo.status]}</p> <p className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>{WORK_ORDER_STATUS_LABEL[wo.status]}</p>
</div> </div>
<span className={`text-xs ${isLight ? 'text-slate-500' : 'text-slate-500'}`}> <span className={`text-xs ${isLight ? 'text-slate-500' : 'text-slate-500'}`}>
{new Intl.DateTimeFormat('pt-PT', { dateStyle: 'short', timeStyle: 'short' }).format(new Date(wo.updated_at))} {new Intl.DateTimeFormat('pt-PT', { dateStyle: 'short', timeStyle: 'short' }).format(new Date(wo.updated_at))}
+50 -3
View File
@@ -5,6 +5,7 @@ import { apiFetch } from '@/lib/api'
import { useTheme } from '@/hooks/useTheme' import { useTheme } from '@/hooks/useTheme'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import type { Expense } from '@/lib/types' import type { Expense } from '@/lib/types'
type PeriodKey = 'month' | '30d' | '90d' | 'year' | 'all' type PeriodKey = 'month' | '30d' | '90d' | 'year' | 'all'
@@ -51,8 +52,11 @@ function monthKey(d: Date) {
export default function ExpenseReportsPage() { export default function ExpenseReportsPage() {
const { theme } = useTheme('ui_theme', 'dark') const { theme } = useTheme('ui_theme', 'dark')
const isLight = theme === 'light' const isLight = theme === 'light'
const [search, setSearch] = useState('')
const [period, setPeriod] = useState<PeriodKey>('90d') const [period, setPeriod] = useState<PeriodKey>('90d')
const [typeFilter, setTypeFilter] = useState<ExpenseType>('') const [typeFilter, setTypeFilter] = useState<ExpenseType>('')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const expensesQ = useQuery<Expense[]>({ const expensesQ = useQuery<Expense[]>({
queryKey: ['expenses', 'reports'], queryKey: ['expenses', 'reports'],
@@ -62,13 +66,26 @@ export default function ExpenseReportsPage() {
const filtered = useMemo(() => { const filtered = useMemo(() => {
const start = periodStart(period) const start = periodStart(period)
const searchValue = search.trim().toLowerCase()
return expenses.filter((e) => { return expenses.filter((e) => {
if (typeFilter && e.type !== typeFilter) return false if (typeFilter && e.type !== typeFilter) return false
if (start && new Date(e.date) < start) return false if (start && new Date(e.date) < start) return false
if (dateFrom) {
const from = new Date(`${dateFrom}T00:00:00`)
if (new Date(e.date) < from) return false
}
if (dateTo) {
const to = new Date(`${dateTo}T23:59:59`)
if (new Date(e.date) > to) return false
}
if (searchValue) {
const terms = [e.description, TYPE_LABEL[e.type]]
if (!terms.some((term) => term.toLowerCase().includes(searchValue))) return false
}
return true return true
}) })
}, [expenses, period, typeFilter]) }, [expenses, period, typeFilter, search, dateFrom, dateTo])
const hasFilters = period !== '90d' || !!typeFilter const hasFilters = period !== '90d' || !!typeFilter || !!search.trim() || !!dateFrom || !!dateTo
const kpis = useMemo(() => { const kpis = useMemo(() => {
const total = filtered.reduce((sum, e) => sum + e.amount, 0) const total = filtered.reduce((sum, e) => sum + e.amount, 0)
@@ -149,7 +166,16 @@ export default function ExpenseReportsPage() {
</header> </header>
<div className={`rounded-2xl border p-4 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}> <div className={`rounded-2xl border p-4 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<div className="grid gap-3 md:grid-cols-3"> <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-7">
<label className="text-sm xl:col-span-2">
<span className={`mb-1 block ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Pesquisar despesa</span>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Nome/descrição da despesa"
className={isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'}
/>
</label>
<label className="text-sm"> <label className="text-sm">
<span className={`mb-1 block ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Período</span> <span className={`mb-1 block ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Período</span>
<select <select
@@ -176,6 +202,24 @@ export default function ExpenseReportsPage() {
<option value="other">Outros</option> <option value="other">Outros</option>
</select> </select>
</label> </label>
<label className="text-sm">
<span className={`mb-1 block ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Data início</span>
<Input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className={isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'}
/>
</label>
<label className="text-sm">
<span className={`mb-1 block ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Data fim</span>
<Input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className={isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'}
/>
</label>
<div className="space-y-1"> <div className="space-y-1">
<span className={`mb-1 block text-sm ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Ações rápidas</span> <span className={`mb-1 block text-sm ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Ações rápidas</span>
<Button <Button
@@ -183,8 +227,11 @@ export default function ExpenseReportsPage() {
variant="outline" variant="outline"
className="w-full" className="w-full"
onClick={() => { onClick={() => {
setSearch('')
setPeriod('90d') setPeriod('90d')
setTypeFilter('') setTypeFilter('')
setDateFrom('')
setDateTo('')
}} }}
disabled={!hasFilters} disabled={!hasFilters}
> >
+56 -8
View File
@@ -35,7 +35,10 @@ export default function ExpensesPage() {
const { theme } = useTheme('ui_theme', 'dark') const { theme } = useTheme('ui_theme', 'dark')
const isLight = theme === 'light' const isLight = theme === 'light'
const qc = useQueryClient() const qc = useQueryClient()
const [search, setSearch] = useState('')
const [typeFilter, setTypeFilter] = useState<ExpenseType>('') const [typeFilter, setTypeFilter] = useState<ExpenseType>('')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [showForm, setShowForm] = useState(false) const [showForm, setShowForm] = useState(false)
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null) const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
@@ -43,11 +46,24 @@ export default function ExpensesPage() {
queryKey: ['expenses'], queryKey: ['expenses'],
queryFn: () => apiFetch<Expense[]>('/expenses'), queryFn: () => apiFetch<Expense[]>('/expenses'),
}) })
const filteredExpenses = useMemo( const filteredExpenses = useMemo(() => {
() => (typeFilter ? expenses.filter((e) => e.type === typeFilter) : expenses), const searchValue = search.trim().toLowerCase()
[expenses, typeFilter] return expenses.filter((e) => {
) if (typeFilter && e.type !== typeFilter) return false
const hasFilters = !!typeFilter if (dateFrom) {
const from = new Date(`${dateFrom}T00:00:00`)
if (new Date(e.date) < from) return false
}
if (dateTo) {
const to = new Date(`${dateTo}T23:59:59`)
if (new Date(e.date) > to) return false
}
if (!searchValue) return true
const terms = [e.description || '', TYPE_LABELS[e.type]]
return terms.some((term) => term.toLowerCase().includes(searchValue))
})
}, [expenses, typeFilter, dateFrom, dateTo, search])
const hasFilters = !!search.trim() || !!typeFilter || !!dateFrom || !!dateTo
const { register, handleSubmit, reset, formState: { errors } } = useForm<FormData>({ const { register, handleSubmit, reset, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema) as Resolver<FormData>, resolver: zodResolver(schema) as Resolver<FormData>,
@@ -83,8 +99,17 @@ export default function ExpensesPage() {
<Button onClick={() => { reset(emptyExpense); setShowForm(true) }}>Nova Despesa</Button> <Button onClick={() => { reset(emptyExpense); setShowForm(true) }}>Nova Despesa</Button>
</div> </div>
<div className={`mb-4 grid gap-3 rounded-lg border p-4 md:grid-cols-3 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/50'}`}> <div className={`mb-4 grid gap-3 rounded-lg border p-4 md:grid-cols-2 xl:grid-cols-6 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/50'}`}>
<div className="space-y-1 md:col-span-2"> <div className="space-y-1 xl:col-span-2">
<label className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Pesquisar despesa</label>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Nome/descrição da despesa"
className={isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'}
/>
</div>
<div className="space-y-1">
<label className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Tipo de despesa</label> <label className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Tipo de despesa</label>
<select <select
value={typeFilter} value={typeFilter}
@@ -99,13 +124,36 @@ export default function ExpensesPage() {
))} ))}
</select> </select>
</div> </div>
<div className="space-y-1">
<label className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Data início</label>
<Input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className={isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'}
/>
</div>
<div className="space-y-1">
<label className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Data fim</label>
<Input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className={isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'}
/>
</div>
<div className="space-y-1"> <div className="space-y-1">
<label className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Ações rápidas</label> <label className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Ações rápidas</label>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
className="w-full" className="w-full"
onClick={() => setTypeFilter('')} onClick={() => {
setSearch('')
setTypeFilter('')
setDateFrom('')
setDateTo('')
}}
disabled={!hasFilters} disabled={!hasFilters}
> >
<FilterX className="h-4 w-4" /> <FilterX className="h-4 w-4" />
+47 -53
View File
@@ -111,7 +111,7 @@ export default function ReportsPage() {
const byClient = useMemo(() => { const byClient = useMemo(() => {
const totals = new Map< const totals = new Map<
string, string,
{ clientName: string; ots: number; invoiced: number; completed: number; amount: number } { clientName: string; ots: number; completed: number; invoiced: number; amount: number }
>() >()
const orderById = new Map(filteredOrders.map((o) => [o.id, o])) const orderById = new Map(filteredOrders.map((o) => [o.id, o]))
const clientNameById = new Map(clients.map((c) => [c.id, c.name])) const clientNameById = new Map(clients.map((c) => [c.id, c.name]))
@@ -127,14 +127,14 @@ export default function ReportsPage() {
const row = totals.get(detail.client_id) ?? { const row = totals.get(detail.client_id) ?? {
clientName: clientNameById.get(detail.client_id) ?? 'Cliente', clientName: clientNameById.get(detail.client_id) ?? 'Cliente',
ots: 0, ots: 0,
invoiced: 0,
completed: 0, completed: 0,
invoiced: 0,
amount: 0, amount: 0,
} }
row.ots += 1 row.ots += 1
row.amount += amount row.amount += amount
if (order.status === 'completed') row.completed += 1
if (order.status === 'invoiced') row.invoiced += 1 if (order.status === 'invoiced') row.invoiced += 1
if (order.status === 'completed' || order.status === 'invoiced') row.completed += 1
totals.set(detail.client_id, row) totals.set(detail.client_id, row)
} }
@@ -151,6 +151,7 @@ export default function ReportsPage() {
clientName: string clientName: string
vehicleLabel: string vehicleLabel: string
ots: number ots: number
completed: number
invoiced: number invoiced: number
amount: number amount: number
} }
@@ -175,11 +176,13 @@ export default function ReportsPage() {
clientName: clientNameById.get(detail.client_id) ?? 'Cliente', clientName: clientNameById.get(detail.client_id) ?? 'Cliente',
vehicleLabel: vehicleLabelById.get(detail.vehicle_id) ?? 'Viatura', vehicleLabel: vehicleLabelById.get(detail.vehicle_id) ?? 'Viatura',
ots: 0, ots: 0,
completed: 0,
invoiced: 0, invoiced: 0,
amount: 0, amount: 0,
} }
row.ots += 1 row.ots += 1
row.amount += amount row.amount += amount
if (order.status === 'completed') row.completed += 1
if (order.status === 'invoiced') row.invoiced += 1 if (order.status === 'invoiced') row.invoiced += 1
totals.set(key, row) totals.set(key, row)
} }
@@ -189,14 +192,16 @@ export default function ReportsPage() {
const kpis = useMemo(() => { const kpis = useMemo(() => {
const total = filteredOrders.length const total = filteredOrders.length
const quote = filteredOrders.filter((o) => o.status === 'quote').length
const inProgress = filteredOrders.filter((o) => o.status === 'in_progress').length const inProgress = filteredOrders.filter((o) => o.status === 'in_progress').length
const completed = filteredOrders.filter((o) => o.status === 'completed').length const completed = filteredOrders.filter((o) => o.status === 'completed').length
const invoiced = filteredOrders.filter((o) => o.status === 'invoiced').length const invoiced = filteredOrders.filter((o) => o.status === 'invoiced').length
const cancelled = filteredOrders.filter((o) => o.status === 'cancelled').length
const amount = byClient.reduce((sum, c) => sum + c.amount, 0) const amount = byClient.reduce((sum, c) => sum + c.amount, 0)
const withVehicle = filteredOrders.filter((o) => !!o.vehicle_id).length const withVehicle = filteredOrders.filter((o) => !!o.vehicle_id).length
const invoiceRate = total > 0 ? (invoiced / total) * 100 : 0 const invoiceRate = total > 0 ? (invoiced / total) * 100 : 0
const avgTicket = total > 0 ? amount / total : 0 const avgTicket = total > 0 ? amount / total : 0
return { total, inProgress, completed, invoiced, amount, withVehicle, invoiceRate, avgTicket } return { total, quote, inProgress, completed, invoiced, cancelled, amount, withVehicle, invoiceRate, avgTicket }
}, [filteredOrders, byClient]) }, [filteredOrders, byClient])
const topClientAmount = byClient[0]?.amount ?? 1 const topClientAmount = byClient[0]?.amount ?? 1
@@ -214,19 +219,12 @@ export default function ReportsPage() {
.sort((a, b) => b.count - a.count) .sort((a, b) => b.count - a.count)
.slice(0, 5) .slice(0, 5)
const total = rows.reduce((s, r) => s + r.count, 0) || 1 const total = rows.reduce((s, r) => s + r.count, 0) || 1
let acc = 0 const segments = rows.map((r, idx) => ({
const segments = rows.map((r, idx) => { ...r,
const start = (acc / total) * 360 color: PIE_COLORS[idx % PIE_COLORS.length],
acc += r.count pct: (r.count / total) * 100,
const end = (acc / total) * 360 }))
return { ...r, color: PIE_COLORS[idx % PIE_COLORS.length], start, end, pct: (r.count / total) * 100 } return { segments, total: rows.reduce((s, r) => s + r.count, 0) }
})
const css = segments.length
? `conic-gradient(${segments
.map((s) => `${s.color} ${s.start}deg ${s.end}deg`)
.join(', ')})`
: 'conic-gradient(#334155 0deg 360deg)'
return { segments, css }
}, [filteredOrders, vehicles]) }, [filteredOrders, vehicles])
const monthly = useMemo(() => { const monthly = useMemo(() => {
@@ -260,7 +258,7 @@ export default function ReportsPage() {
function downloadCsv() { function downloadCsv() {
const lines: string[] = [] const lines: string[] = []
lines.push('Tipo;Cliente;Viatura;OTs;Faturadas;Concluidas;Resultado;TicketMedio') lines.push('Tipo;Cliente;Viatura;OTs;ConcluirOT;EmitirFatura;Resultado;TicketMedio')
byClient.forEach((row) => { byClient.forEach((row) => {
lines.push( lines.push(
[ [
@@ -268,8 +266,8 @@ export default function ReportsPage() {
csvEscape(row.clientName), csvEscape(row.clientName),
'', '',
row.ots, row.ots,
row.invoiced,
row.completed, row.completed,
row.invoiced,
row.amount.toFixed(2), row.amount.toFixed(2),
row.avgTicket.toFixed(2), row.avgTicket.toFixed(2),
].join(';') ].join(';')
@@ -282,8 +280,8 @@ export default function ReportsPage() {
csvEscape(row.clientName), csvEscape(row.clientName),
csvEscape(row.vehicleLabel), csvEscape(row.vehicleLabel),
row.ots, row.ots,
row.completed,
row.invoiced, row.invoiced,
'',
row.amount.toFixed(2), row.amount.toFixed(2),
'', '',
].join(';') ].join(';')
@@ -303,7 +301,7 @@ export default function ReportsPage() {
.slice(0, 12) .slice(0, 12)
.map( .map(
(r) => (r) =>
`<tr><td>${r.clientName}</td><td>${r.ots}</td><td>${r.invoiced}</td><td>${currency(r.avgTicket)}</td><td>${currency(r.amount)}</td></tr>` `<tr><td>${r.clientName}</td><td>${r.ots}</td><td>${r.completed}</td><td>${r.invoiced}</td><td>${currency(r.avgTicket)}</td><td>${currency(r.amount)}</td></tr>`
) )
.join('') .join('')
const w = window.open('', '_blank') const w = window.open('', '_blank')
@@ -321,12 +319,13 @@ export default function ReportsPage() {
<h1>Relatório Operacional de OTs</h1> <h1>Relatório Operacional de OTs</h1>
<p>Período: ${PERIOD_OPTIONS.find((p) => p.key === period)?.label ?? period}</p> <p>Período: ${PERIOD_OPTIONS.find((p) => p.key === period)?.label ?? period}</p>
<div class="kpi"><strong>OTs:</strong> ${kpis.total}</div> <div class="kpi"><strong>OTs:</strong> ${kpis.total}</div>
<div class="kpi"><strong>Em curso:</strong> ${kpis.inProgress}</div> <div class="kpi"><strong>Orçamento:</strong> ${kpis.quote}</div>
<div class="kpi"><strong>Concluídas:</strong> ${kpis.completed}</div> <div class="kpi"><strong>Iniciar Trabalho:</strong> ${kpis.inProgress}</div>
<div class="kpi"><strong>Faturadas:</strong> ${kpis.invoiced}</div> <div class="kpi"><strong>Concluir OT:</strong> ${kpis.completed}</div>
<div class="kpi"><strong>Faturado:</strong> ${kpis.invoiced}</div>
<div class="kpi"><strong>Resultado:</strong> ${currency(kpis.amount)}</div> <div class="kpi"><strong>Resultado:</strong> ${currency(kpis.amount)}</div>
<h3>Top Clientes</h3> <h3>Top Clientes</h3>
<table><thead><tr><th>Cliente</th><th>OTs</th><th>Faturadas</th><th>Ticket Médio</th><th>Resultado</th></tr></thead><tbody>${rows}</tbody></table> <table><thead><tr><th>Cliente</th><th>OTs</th><th>Concluir OT</th><th>Faturado</th><th>Ticket Médio</th><th>Resultado</th></tr></thead><tbody>${rows}</tbody></table>
</body></html> </body></html>
`) `)
w.document.close() w.document.close()
@@ -427,24 +426,24 @@ export default function ReportsPage() {
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5"> <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
{[ {[
['OTs no período', String(kpis.total)], ['OTs no período', String(kpis.total)],
['Em curso', String(kpis.inProgress)], ['Orçamento', String(kpis.quote)],
['Concluídas', String(kpis.completed)], ['Iniciar Trabalho', String(kpis.inProgress)],
['Faturadas', String(kpis.invoiced)], ['Concluir OT', String(kpis.completed)],
['Resultado total', currency(kpis.amount)], ['Faturado', String(kpis.invoiced)],
].map(([label, value]) => ( ].map(([label, value]) => (
<article key={label} className={`rounded-xl border p-4 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}> <article key={label} className={`rounded-xl border p-3 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<p className={`text-xs uppercase tracking-wide ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>{label}</p> <p className={`text-xs uppercase tracking-wide ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>{label}</p>
<p className={`mt-1 text-2xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>{value}</p> <p className={`mt-1 text-xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>{value}</p>
</article> </article>
))} ))}
</div> </div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{[ {[
['Taxa de faturação', `${kpis.invoiceRate.toFixed(1)}%`], ['Taxa de emissão de fatura', `${kpis.invoiceRate.toFixed(1)}%`],
['Ticket médio por OT', currency(kpis.avgTicket)], ['Ticket médio por OT', currency(kpis.avgTicket)],
['OTs com viatura', `${kpis.withVehicle}/${kpis.total}`], ['OTs com viatura', `${kpis.withVehicle}/${kpis.total}`],
].map(([label, value]) => ( ].map(([label, value]) => (
<article key={label} className={`rounded-xl border p-4 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}> <article key={label} className={`rounded-xl border p-3 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<p className={`text-xs uppercase tracking-wide ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>{label}</p> <p className={`text-xs uppercase tracking-wide ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>{label}</p>
<p className={`mt-1 text-xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>{value}</p> <p className={`mt-1 text-xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>{value}</p>
</article> </article>
@@ -476,7 +475,7 @@ export default function ReportsPage() {
<div className="h-2 rounded-full bg-gradient-to-r from-sky-500 to-cyan-400" style={{ width: `${width}%` }} /> <div className="h-2 rounded-full bg-gradient-to-r from-sky-500 to-cyan-400" style={{ width: `${width}%` }} />
</div> </div>
<p className={`mt-1 text-xs ${isLight ? 'text-slate-500' : 'text-slate-400'}`}> <p className={`mt-1 text-xs ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>
Ticket médio: {currency(row.avgTicket)} | Faturadas: {row.invoiced} Ticket médio: {currency(row.avgTicket)} | Concluir OT: {row.completed} | Faturado: {row.invoiced}
</p> </p>
</div> </div>
) )
@@ -497,7 +496,8 @@ export default function ReportsPage() {
<th className={`px-3 py-2 text-left ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Cliente</th> <th className={`px-3 py-2 text-left ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Cliente</th>
<th className={`px-3 py-2 text-left ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Viatura</th> <th className={`px-3 py-2 text-left ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Viatura</th>
<th className={`px-3 py-2 text-right ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>OTs</th> <th className={`px-3 py-2 text-right ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>OTs</th>
<th className={`px-3 py-2 text-right ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Faturadas</th> <th className={`px-3 py-2 text-right ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Concluir OT</th>
<th className={`px-3 py-2 text-right ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Faturado</th>
<th className={`px-3 py-2 text-right ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Resultado</th> <th className={`px-3 py-2 text-right ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Resultado</th>
</tr> </tr>
</thead> </thead>
@@ -507,6 +507,7 @@ export default function ReportsPage() {
<td className={`px-3 py-2 ${isLight ? 'text-slate-800' : 'text-slate-200'}`}>{row.clientName}</td> <td className={`px-3 py-2 ${isLight ? 'text-slate-800' : 'text-slate-200'}`}>{row.clientName}</td>
<td className={`px-3 py-2 ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>{row.vehicleLabel}</td> <td className={`px-3 py-2 ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>{row.vehicleLabel}</td>
<td className={`px-3 py-2 text-right ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>{row.ots}</td> <td className={`px-3 py-2 text-right ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>{row.ots}</td>
<td className={`px-3 py-2 text-right ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>{row.completed}</td>
<td className={`px-3 py-2 text-right ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>{row.invoiced}</td> <td className={`px-3 py-2 text-right ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>{row.invoiced}</td>
<td className={`px-3 py-2 text-right font-medium ${isLight ? 'text-slate-900' : 'text-white'}`}>{currency(row.amount)}</td> <td className={`px-3 py-2 text-right font-medium ${isLight ? 'text-slate-900' : 'text-white'}`}>{currency(row.amount)}</td>
</tr> </tr>
@@ -541,26 +542,20 @@ export default function ReportsPage() {
<article className={`rounded-2xl border p-5 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}> <article className={`rounded-2xl border p-5 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<h2 className={`text-lg font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Principais Marcas (OTs)</h2> <h2 className={`text-lg font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Principais Marcas (OTs)</h2>
<div className="mt-4 grid gap-4 md:grid-cols-[180px_1fr]"> <p className={`mt-1 text-sm ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>
<div className="mx-auto relative"> {brandPie.total} OTs com viatura no período.
<div </p>
className="h-40 w-40 rounded-full border border-slate-300/70 dark:border-slate-700 shadow-[inset_0_0_30px_rgba(0,0,0,0.08)]" <div className="mt-4 space-y-2">
style={{ background: brandPie.css }}
/>
<div className={`absolute inset-0 m-auto h-16 w-16 rounded-full flex items-center justify-center ${isLight ? 'bg-white' : 'bg-slate-900'}`}>
<span className={`text-xs font-semibold ${isLight ? 'text-slate-700' : 'text-slate-200'}`}>
{brandPie.segments.reduce((s, x) => s + x.count, 0)}
</span>
</div>
</div>
<div className="space-y-2">
{brandPie.segments.length === 0 ? ( {brandPie.segments.length === 0 ? (
<p className={`text-sm ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Sem viaturas para este filtro.</p> <p className={`text-sm ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Sem viaturas para este filtro.</p>
) : ( ) : (
brandPie.segments.map((s) => ( brandPie.segments.map((s, idx) => (
<div key={s.brand} className="rounded-md border border-slate-200/70 dark:border-slate-700 px-3 py-2"> <div key={s.brand} className={`rounded-lg border px-3 py-3 ${isLight ? 'border-slate-200 bg-slate-50/70' : 'border-slate-700 bg-slate-900/40'}`}>
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between gap-2 text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-3">
<span className={`inline-flex h-6 min-w-6 items-center justify-center rounded-md text-xs font-semibold ${isLight ? 'bg-slate-200 text-slate-700' : 'bg-slate-800 text-slate-200'}`}>
{idx + 1}
</span>
<span className="inline-block h-2.5 w-2.5 rounded-full" style={{ backgroundColor: s.color }} /> <span className="inline-block h-2.5 w-2.5 rounded-full" style={{ backgroundColor: s.color }} />
<span className={isLight ? 'text-slate-800' : 'text-slate-200'}>{s.brand}</span> <span className={isLight ? 'text-slate-800' : 'text-slate-200'}>{s.brand}</span>
</div> </div>
@@ -575,7 +570,6 @@ export default function ReportsPage() {
)) ))
)} )}
</div> </div>
</div>
</article> </article>
</div> </div>
</section> </section>
@@ -380,7 +380,7 @@ export default function TechnicianReportsPage() {
<div className="h-2 rounded-full bg-gradient-to-r from-sky-500 to-cyan-400" style={{ width: `${(row.total / topValue) * 100}%` }} /> <div className="h-2 rounded-full bg-gradient-to-r from-sky-500 to-cyan-400" style={{ width: `${(row.total / topValue) * 100}%` }} />
</div> </div>
<p className={`mt-1 text-xs ${isLight ? 'text-slate-500' : 'text-slate-400'}`}> <p className={`mt-1 text-xs ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>
OTs: {row.otsCount} | Faturadas: {row.invoiced} | Média: {currency(row.avgHour)}/h OTs: {row.otsCount} | Faturado: {row.invoiced} | Média: {currency(row.avgHour)}/h
</p> </p>
</div> </div>
))} ))}
+34 -26
View File
@@ -11,7 +11,11 @@ import { ConfirmActionDialog } from '@/components/ui/confirm-action-dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { useTheme } from '@/hooks/useTheme' import { useTheme } from '@/hooks/useTheme'
import { WORK_ORDER_STATUS_LABEL, workOrderStatusBadgeClass } from '@/lib/workOrderStatus' import {
WORK_ORDER_STATUS_LABEL,
getWorkOrderPhase,
workOrderPhaseBadgeClass,
} from '@/lib/workOrderStatus'
import type { WorkOrderDetail, CatalogItem, Staff, Client, Vehicle } from '@/lib/types' import type { WorkOrderDetail, CatalogItem, Staff, Client, Vehicle } from '@/lib/types'
const CATEGORY_LABEL: Record<string, string> = { const CATEGORY_LABEL: Record<string, string> = {
@@ -29,14 +33,6 @@ const CATEGORY_LABEL: Record<string, string> = {
outro: 'Outro', outro: 'Outro',
} }
const TRANSITIONS: Record<string, string[]> = {
quote: ['open', 'cancelled'],
open: ['in_progress', 'cancelled'],
in_progress: ['completed', 'cancelled'],
completed: ['invoiced', 'cancelled'],
invoiced: [],
cancelled: [],
}
const ETA_LABEL: Record<number, string> = { const ETA_LABEL: Record<number, string> = {
1: '1 dia', 1: '1 dia',
2: '2 dias', 2: '2 dias',
@@ -313,8 +309,16 @@ export default function WorkOrderDetailPage() {
const staffHours = Array.isArray(detail.staff_hours) ? detail.staff_hours : [] const staffHours = Array.isArray(detail.staff_hours) ? detail.staff_hours : []
const itemsTotal = items.reduce((s, i) => s + Number(i.total || 0), 0) 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 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 editable = detail.status !== 'invoiced' && detail.status !== 'cancelled'
const phase = getWorkOrderPhase(detail.status)
const canCancel = detail.status !== 'invoiced' && detail.status !== 'cancelled'
const primaryAction = detail.status === 'quote'
? { label: 'Iniciar Trabalho', status: 'in_progress' as const }
: detail.status === 'in_progress'
? { label: 'Concluir OT', status: 'completed' as const }
: detail.status === 'completed'
? { label: 'Emitir Fatura', status: 'invoiced' as const }
: null
const clientName = detail.client_id ? (clients.find((c) => c.id === detail.client_id)?.name ?? '—') : '—' const clientName = detail.client_id ? (clients.find((c) => c.id === detail.client_id)?.name ?? '—') : '—'
const woInvoices = invoices const woInvoices = invoices
.filter((inv) => inv.work_order_id === detail.id) .filter((inv) => inv.work_order_id === detail.id)
@@ -352,7 +356,7 @@ export default function WorkOrderDetailPage() {
</Link> </Link>
<span className={isLight ? 'text-slate-400' : 'text-slate-600'}>/</span> <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> <h1 className={`text-2xl font-bold ${isLight ? 'text-slate-900' : 'text-white'}`}>Ordem #{detail.number}</h1>
<Badge variant="outline" className={workOrderStatusBadgeClass(detail.status)}> <Badge variant="outline" className={workOrderPhaseBadgeClass(phase)}>
{WORK_ORDER_STATUS_LABEL[detail.status]} {WORK_ORDER_STATUS_LABEL[detail.status]}
</Badge> </Badge>
{selectedDoc && ( {selectedDoc && (
@@ -515,28 +519,32 @@ export default function WorkOrderDetailPage() {
</div> </div>
</section> </section>
{/* Transitions */} {/* Next action */}
{nextStates.length > 0 && ( {(primaryAction || canCancel) && (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex gap-2"> <div className="flex gap-2">
<span className="text-slate-400 text-sm self-center">Transição:</span> <span className="text-slate-400 text-sm self-center">Próxima ação:</span>
{nextStates.map((s) => ( {primaryAction && (
<Button <Button
key={s}
size="sm" size="sm"
variant={s === 'cancelled' ? 'destructive' : 'default'}
onClick={() => { onClick={() => {
if (s === 'cancelled') { transition.mutate(primaryAction.status)
setConfirmAction({ type: 'cancel' })
return
}
transition.mutate(s)
}} }}
disabled={transition.isPending} disabled={transition.isPending}
> >
{WORK_ORDER_STATUS_LABEL[s as keyof typeof WORK_ORDER_STATUS_LABEL]} {primaryAction.label}
</Button> </Button>
))} )}
{canCancel && (
<Button
size="sm"
variant="destructive"
onClick={() => setConfirmAction({ type: 'cancel' })}
disabled={transition.isPending}
>
Cancelar OT
</Button>
)}
</div> </div>
{transition.error && ( {transition.error && (
<p className="text-sm text-[var(--ui-danger)]">{(transition.error as Error).message}</p> <p className="text-sm text-[var(--ui-danger)]">{(transition.error as Error).message}</p>
@@ -578,7 +586,7 @@ export default function WorkOrderDetailPage() {
<Input {...regItem('description')} className="bg-slate-900 border-slate-600 text-white" /> <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>} {itemErrors.description && <p className="text-red-400 text-xs">{itemErrors.description.message}</p>}
</div> </div>
{detail.status === 'open' && ( {detail.status === 'in_progress' && (
<div className="col-span-3 space-y-1"> <div className="col-span-3 space-y-1">
<Label>Justificação da alteração *</Label> <Label>Justificação da alteração *</Label>
<Input <Input
@@ -586,7 +594,7 @@ export default function WorkOrderDetailPage() {
placeholder="Ex: Pedido do cliente para incluir peça adicional" placeholder="Ex: Pedido do cliente para incluir peça adicional"
className="bg-slate-900 border-slate-600 text-white" 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> <p className="text-xs text-slate-500">Obrigatório durante o trabalho para garantir transparência.</p>
</div> </div>
)} )}
<div className="space-y-1"> <div className="space-y-1">
+90 -13
View File
@@ -11,7 +11,11 @@ import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { WORK_ORDER_STATUS_LABEL, workOrderStatusBadgeClass } from '@/lib/workOrderStatus' import {
WORK_ORDER_STATUS_LABEL,
getWorkOrderPhase,
workOrderPhaseBadgeClass,
} from '@/lib/workOrderStatus'
import type { WorkOrder, Client, Vehicle } from '@/lib/types' import type { WorkOrder, Client, Vehicle } from '@/lib/types'
const schema = z.object({ const schema = z.object({
@@ -39,7 +43,11 @@ export default function WorkOrdersPage() {
const { theme } = useTheme('ui_theme', 'dark') const { theme } = useTheme('ui_theme', 'dark')
const isLight = theme === 'light' const isLight = theme === 'light'
const qc = useQueryClient() const qc = useQueryClient()
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState('') const [statusFilter, setStatusFilter] = useState('')
const [clientFilter, setClientFilter] = useState('')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [showForm, setShowForm] = useState(false) const [showForm, setShowForm] = useState(false)
const [docError, setDocError] = useState('') const [docError, setDocError] = useState('')
@@ -47,17 +55,38 @@ export default function WorkOrdersPage() {
queryKey: ['work-orders'], queryKey: ['work-orders'],
queryFn: () => apiFetch<WorkOrder[]>('/work-orders'), queryFn: () => apiFetch<WorkOrder[]>('/work-orders'),
}) })
const filteredOrders = useMemo(
() => (statusFilter ? orders.filter((o) => o.status === statusFilter) : orders),
[orders, statusFilter]
)
const hasFilters = statusFilter.length > 0
const { data: clients = [] } = useQuery<Client[]>({ const { data: clients = [] } = useQuery<Client[]>({
queryKey: ['clients'], queryKey: ['clients'],
queryFn: () => apiFetch<Client[]>('/clients'), queryFn: () => apiFetch<Client[]>('/clients'),
}) })
const clientNameById = Object.fromEntries(clients.map((c) => [c.id, c.name])) const clientNameById = Object.fromEntries(clients.map((c) => [c.id, c.name]))
const filteredOrders = useMemo(() => {
const searchValue = search.trim().toLowerCase()
return orders.filter((o) => {
if (statusFilter && o.status !== statusFilter) return false
if (clientFilter && o.client_id !== clientFilter) return false
if (dateFrom) {
const from = new Date(`${dateFrom}T00:00:00`)
if (new Date(o.created_at) < from) return false
}
if (dateTo) {
const to = new Date(`${dateTo}T23:59:59`)
if (new Date(o.created_at) > to) return false
}
if (!searchValue) return true
const clientName = o.client_id ? (clientNameById[o.client_id] ?? '') : ''
const terms = [
`#${o.number}`,
String(o.number),
clientName,
WORK_ORDER_STATUS_LABEL[o.status],
o.internal_notes,
o.client_notes,
]
return terms.some((term) => term.toLowerCase().includes(searchValue))
})
}, [orders, search, statusFilter, clientFilter, dateFrom, dateTo, clientNameById])
const hasFilters = !!search.trim() || !!statusFilter || !!clientFilter || !!dateFrom || !!dateTo
const { register, handleSubmit, watch, reset } = useForm<FormData>({ const { register, handleSubmit, watch, reset } = useForm<FormData>({
resolver: zodResolver(schema) as Resolver<FormData>, resolver: zodResolver(schema) as Resolver<FormData>,
@@ -102,8 +131,17 @@ export default function WorkOrdersPage() {
<Button onClick={() => { reset(emptyOrder); setShowForm(true) }}>Nova Ordem</Button> <Button onClick={() => { reset(emptyOrder); setShowForm(true) }}>Nova Ordem</Button>
</div> </div>
<div className={`mb-4 grid gap-3 rounded-lg border p-4 md:grid-cols-3 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/50'}`}> <div className={`mb-4 grid gap-3 rounded-lg border p-4 md:grid-cols-2 xl:grid-cols-7 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/50'}`}>
<div className="space-y-1 md:col-span-2"> <div className="space-y-1 xl:col-span-2">
<label className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Pesquisar OT</label>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Nº OT, cliente, estado ou notas"
className={isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'}
/>
</div>
<div className="space-y-1">
<label className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Estado da OT</label> <label className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Estado da OT</label>
<select <select
value={statusFilter} value={statusFilter}
@@ -113,18 +151,57 @@ export default function WorkOrdersPage() {
}`} }`}
> >
<option value="">Todos os estados</option> <option value="">Todos os estados</option>
{(['quote', 'open', 'in_progress', 'completed', 'invoiced', 'cancelled'] as WorkOrder['status'][]).map((s) => ( {(['quote', 'in_progress', 'completed', 'invoiced', 'cancelled'] as WorkOrder['status'][]).map((status) => (
<option key={s} value={s}>{WORK_ORDER_STATUS_LABEL[s]}</option> <option key={status} value={status}>{WORK_ORDER_STATUS_LABEL[status]}</option>
))} ))}
</select> </select>
</div> </div>
<div className="space-y-1">
<label className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Cliente</label>
<select
value={clientFilter}
onChange={(e) => setClientFilter(e.target.value)}
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="">Todos os clientes</option>
{clients.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div className="space-y-1">
<label className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Criada de</label>
<Input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className={isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'}
/>
</div>
<div className="space-y-1">
<label className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Criada até</label>
<Input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className={isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'}
/>
</div>
<div className="space-y-1"> <div className="space-y-1">
<label className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Ações rápidas</label> <label className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Ações rápidas</label>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
className="w-full" className="w-full"
onClick={() => setStatusFilter('')} onClick={() => {
setSearch('')
setStatusFilter('')
setClientFilter('')
setDateFrom('')
setDateTo('')
}}
disabled={!hasFilters} disabled={!hasFilters}
> >
<FilterX className="h-4 w-4" /> <FilterX className="h-4 w-4" />
@@ -248,7 +325,7 @@ export default function WorkOrdersPage() {
{o.client_id ? (clientNameById[o.client_id] ?? '—') : '—'} {o.client_id ? (clientNameById[o.client_id] ?? '—') : '—'}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<Badge variant="outline" className={workOrderStatusBadgeClass(o.status)}> <Badge variant="outline" className={workOrderPhaseBadgeClass(getWorkOrderPhase(o.status))}>
{WORK_ORDER_STATUS_LABEL[o.status]} {WORK_ORDER_STATUS_LABEL[o.status]}
</Badge> </Badge>
</td> </td>