From 4994abf4e2c6431c31b4ab89db32e47a2fafc1e2 Mon Sep 17 00:00:00 2001 From: Luciano Milani Date: Sat, 4 Jul 2026 20:54:13 +0100 Subject: [PATCH] feat(reports): simplify dashboards, align OT flow labels, and normalize filters --- backend/internal/workorder/handler.go | 4 +- backend/internal/workorder/repository.go | 5 +- backend/internal/workorder/repository_test.go | 12 +- ...08_work_orders_remove_open_status.down.sql | 6 + ...0008_work_orders_remove_open_status.up.sql | 10 ++ frontend/src/lib/types.ts | 2 +- frontend/src/lib/workOrderStatus.ts | 35 ++++- frontend/src/pages/app/DashboardPage.tsx | 30 ++--- frontend/src/pages/app/ExpenseReportsPage.tsx | 53 +++++++- frontend/src/pages/app/ExpensesPage.tsx | 64 +++++++-- frontend/src/pages/app/ReportsPage.tsx | 126 +++++++++--------- .../src/pages/app/TechnicianReportsPage.tsx | 2 +- .../src/pages/app/WorkOrderDetailPage.tsx | 60 +++++---- frontend/src/pages/app/WorkOrdersPage.tsx | 103 ++++++++++++-- 14 files changed, 358 insertions(+), 154 deletions(-) create mode 100644 backend/migrations/tenant/000008_work_orders_remove_open_status.down.sql create mode 100644 backend/migrations/tenant/000008_work_orders_remove_open_status.up.sql diff --git a/backend/internal/workorder/handler.go b/backend/internal/workorder/handler.go index 9f35ecb..3a0c695 100644 --- a/backend/internal/workorder/handler.go +++ b/backend/internal/workorder/handler.go @@ -234,8 +234,8 @@ func addItemH() fiber.Handler { } return fiber.NewError(500, "erro ao validar ordem") } - if status == "open" && b.ChangeJustification == "" { - return fiber.NewError(400, "justificação da alteração é obrigatória em orçamento aprovado") + if status == "in_progress" && b.ChangeJustification == "" { + 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) if err != nil { diff --git a/backend/internal/workorder/repository.go b/backend/internal/workorder/repository.go index 1cde5da..238dc88 100644 --- a/backend/internal/workorder/repository.go +++ b/backend/internal/workorder/repository.go @@ -53,8 +53,7 @@ type WorkOrderDetail struct { } var allowedTransitions = map[string][]string{ - "quote": {"open", "cancelled"}, - "open": {"in_progress", "cancelled"}, + "quote": {"in_progress", "cancelled"}, "in_progress": {"completed", "cancelled"}, "completed": {"invoiced", "cancelled"}, "invoiced": {}, @@ -236,7 +235,7 @@ func AddItem(ctx context.Context, conn *pgxpool.Conn, woID, catalogItemID, descr FROM work_orders WHERE id=$1 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`, 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) diff --git a/backend/internal/workorder/repository_test.go b/backend/internal/workorder/repository_test.go index 8723511..a8fa6bf 100644 --- a/backend/internal/workorder/repository_test.go +++ b/backend/internal/workorder/repository_test.go @@ -55,9 +55,9 @@ func TestWorkOrderTransition(t *testing.T) { wo, err := workorder.CreateWorkOrder(ctx, conn, "", "", "", "", 1, "", "") 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) - assert.Equal(t, "open", wo2.Status) + assert.Equal(t, "in_progress", wo2.Status) _, err = workorder.TransitionStatus(ctx, conn, wo.ID, "cancelled_invalid", "", "", "") assert.Error(t, err, "invalid transition should error") @@ -69,15 +69,11 @@ func TestAllowedTransitions(t *testing.T) { to string valid bool }{ - {"quote", "open", true}, + {"quote", "in_progress", true}, {"quote", "cancelled", true}, - {"quote", "in_progress", false}, - {"open", "in_progress", true}, - {"open", "cancelled", true}, - {"open", "completed", false}, + {"quote", "open", false}, {"in_progress", "completed", true}, {"in_progress", "cancelled", true}, - {"in_progress", "open", false}, {"completed", "invoiced", true}, {"completed", "cancelled", true}, {"invoiced", "cancelled", false}, diff --git a/backend/migrations/tenant/000008_work_orders_remove_open_status.down.sql b/backend/migrations/tenant/000008_work_orders_remove_open_status.down.sql new file mode 100644 index 0000000..d5d0c4b --- /dev/null +++ b/backend/migrations/tenant/000008_work_orders_remove_open_status.down.sql @@ -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')); diff --git a/backend/migrations/tenant/000008_work_orders_remove_open_status.up.sql b/backend/migrations/tenant/000008_work_orders_remove_open_status.up.sql new file mode 100644 index 0000000..cba4d7c --- /dev/null +++ b/backend/migrations/tenant/000008_work_orders_remove_open_status.up.sql @@ -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')); diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index d07527f..fc0c82e 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -59,7 +59,7 @@ export interface WorkOrder { number: number client_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 client_notes: string eta_days: number diff --git a/frontend/src/lib/workOrderStatus.ts b/frontend/src/lib/workOrderStatus.ts index 35324dc..7bac614 100644 --- a/frontend/src/lib/workOrderStatus.ts +++ b/frontend/src/lib/workOrderStatus.ts @@ -2,16 +2,14 @@ import type { WorkOrder } from '@/lib/types' export const WORK_ORDER_STATUS_LABEL: Record = { quote: 'Orçamento', - open: 'Orçamento Aprovado', - in_progress: 'Em Curso', - completed: 'Concluída', - invoiced: 'Faturada', + in_progress: 'Iniciar Trabalho', + completed: 'Concluir OT', + invoiced: 'Faturado', cancelled: 'Cancelada', } const LIGHT_STATUS_BADGE: Record = { 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', completed: 'border-emerald-400 bg-emerald-100 text-slate-800', invoiced: 'border-teal-400 bg-teal-100 text-slate-800', @@ -21,3 +19,30 @@ const LIGHT_STATUS_BADGE: Record = { export function workOrderStatusBadgeClass(status: WorkOrder['status']) { return `border ${LIGHT_STATUS_BADGE[status]}` } + +export type WorkOrderPhase = 'draft' | 'active' | 'done' | 'cancelled' + +export const WORK_ORDER_PHASE_LABEL: Record = { + draft: 'Orçamento', + active: 'Iniciar Trabalho', + done: 'Concluir OT', + cancelled: 'Cancelada', +} + +const PHASE_BADGE: Record = { + 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]}` +} diff --git a/frontend/src/pages/app/DashboardPage.tsx b/frontend/src/pages/app/DashboardPage.tsx index ef5fb0a..13f84e6 100644 --- a/frontend/src/pages/app/DashboardPage.tsx +++ b/frontend/src/pages/app/DashboardPage.tsx @@ -4,17 +4,9 @@ import { useQueries, useQuery } from '@tanstack/react-query' import { useTheme } from '@/hooks/useTheme' import { apiFetch } from '@/lib/api' import type { Client, Expense, Invoice, Staff, WorkOrder, WorkOrderDetail } from '@/lib/types' +import { WORK_ORDER_STATUS_LABEL } from '@/lib/workOrderStatus' -const STATUS_LABEL: Record = { - 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'] +const STATUS_ORDER: WorkOrder['status'][] = ['quote', 'in_progress', 'completed', 'invoiced', 'cancelled'] type PeriodKey = 'month' | '30d' | '90d' | 'year' 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 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 monthExpenses = expensesMonth.reduce((sum, e) => sum + e.amount, 0) const monthDocs = invoicesMonth.length @@ -172,6 +165,7 @@ export default function DashboardPage() { return { byStatus, activeOrders, + awaitingInvoice, periodExpenses, periodEntries, monthEntries, @@ -236,7 +230,7 @@ export default function DashboardPage() {
- {STATUS_LABEL[status]} + {WORK_ORDER_STATUS_LABEL[status]} {count}
@@ -355,16 +349,16 @@ export default function DashboardPage() { >

Ação recomendada

- {metrics.byStatus.completed > 0 - ? `${metrics.byStatus.completed} OT(s) concluída(s) pronta(s) para faturar.` - : 'Sem OTs concluídas pendentes de faturação.'} + {metrics.awaitingInvoice > 0 + ? `${metrics.awaitingInvoice} OT(s) em "Concluir OT" pronta(s) para faturar.` + : 'Sem OTs finalizadas pendentes de emissão de fatura.'}

Em orçamento

{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.'}

@@ -394,7 +388,7 @@ export default function DashboardPage() {
  • OT #{wo.number}

    -

    {STATUS_LABEL[wo.status]}

    +

    {WORK_ORDER_STATUS_LABEL[wo.status]}

    {new Intl.DateTimeFormat('pt-PT', { dateStyle: 'short', timeStyle: 'short' }).format(new Date(wo.updated_at))} diff --git a/frontend/src/pages/app/ExpenseReportsPage.tsx b/frontend/src/pages/app/ExpenseReportsPage.tsx index 94b1500..b7e0789 100644 --- a/frontend/src/pages/app/ExpenseReportsPage.tsx +++ b/frontend/src/pages/app/ExpenseReportsPage.tsx @@ -5,6 +5,7 @@ import { apiFetch } from '@/lib/api' import { useTheme } from '@/hooks/useTheme' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' import type { Expense } from '@/lib/types' type PeriodKey = 'month' | '30d' | '90d' | 'year' | 'all' @@ -51,8 +52,11 @@ function monthKey(d: Date) { export default function ExpenseReportsPage() { const { theme } = useTheme('ui_theme', 'dark') const isLight = theme === 'light' + const [search, setSearch] = useState('') const [period, setPeriod] = useState('90d') const [typeFilter, setTypeFilter] = useState('') + const [dateFrom, setDateFrom] = useState('') + const [dateTo, setDateTo] = useState('') const expensesQ = useQuery({ queryKey: ['expenses', 'reports'], @@ -62,13 +66,26 @@ export default function ExpenseReportsPage() { const filtered = useMemo(() => { const start = periodStart(period) + const searchValue = search.trim().toLowerCase() return expenses.filter((e) => { if (typeFilter && e.type !== typeFilter) 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 }) - }, [expenses, period, typeFilter]) - const hasFilters = period !== '90d' || !!typeFilter + }, [expenses, period, typeFilter, search, dateFrom, dateTo]) + const hasFilters = period !== '90d' || !!typeFilter || !!search.trim() || !!dateFrom || !!dateTo const kpis = useMemo(() => { const total = filtered.reduce((sum, e) => sum + e.amount, 0) @@ -149,7 +166,16 @@ export default function ExpenseReportsPage() {
    -
    +
    + + +
    Ações rápidas
    -
    -
    +
    +
    + + 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'} + /> +
    +
    setDateFrom(e.target.value)} + className={isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'} + /> +
    +
    + + setDateTo(e.target.value)} + className={isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'} + /> +
    diff --git a/frontend/src/pages/app/TechnicianReportsPage.tsx b/frontend/src/pages/app/TechnicianReportsPage.tsx index dfeff48..4739102 100644 --- a/frontend/src/pages/app/TechnicianReportsPage.tsx +++ b/frontend/src/pages/app/TechnicianReportsPage.tsx @@ -380,7 +380,7 @@ export default function TechnicianReportsPage() {

    - OTs: {row.otsCount} | Faturadas: {row.invoiced} | Média: {currency(row.avgHour)}/h + OTs: {row.otsCount} | Faturado: {row.invoiced} | Média: {currency(row.avgHour)}/h

    ))} diff --git a/frontend/src/pages/app/WorkOrderDetailPage.tsx b/frontend/src/pages/app/WorkOrderDetailPage.tsx index 4dc029c..34e5295 100644 --- a/frontend/src/pages/app/WorkOrderDetailPage.tsx +++ b/frontend/src/pages/app/WorkOrderDetailPage.tsx @@ -11,7 +11,11 @@ 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 { + WORK_ORDER_STATUS_LABEL, + getWorkOrderPhase, + workOrderPhaseBadgeClass, +} from '@/lib/workOrderStatus' import type { WorkOrderDetail, CatalogItem, Staff, Client, Vehicle } from '@/lib/types' const CATEGORY_LABEL: Record = { @@ -29,14 +33,6 @@ const CATEGORY_LABEL: Record = { 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', @@ -313,8 +309,16 @@ export default function WorkOrderDetailPage() { 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 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 woInvoices = invoices .filter((inv) => inv.work_order_id === detail.id) @@ -352,7 +356,7 @@ export default function WorkOrderDetailPage() { /

    Ordem #{detail.number}

    - + {WORK_ORDER_STATUS_LABEL[detail.status]} {selectedDoc && ( @@ -515,28 +519,32 @@ export default function WorkOrderDetailPage() {
    - {/* Transitions */} - {nextStates.length > 0 && ( + {/* Next action */} + {(primaryAction || canCancel) && (
    - Transição: - {nextStates.map((s) => ( + Próxima ação: + {primaryAction && ( - ))} + )} + {canCancel && ( + + )}
    {transition.error && (

    {(transition.error as Error).message}

    @@ -578,7 +586,7 @@ export default function WorkOrderDetailPage() { {itemErrors.description &&

    {itemErrors.description.message}

    }
    - {detail.status === 'open' && ( + {detail.status === 'in_progress' && (
    -

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

    +

    Obrigatório durante o trabalho para garantir transparência.

    )}
    diff --git a/frontend/src/pages/app/WorkOrdersPage.tsx b/frontend/src/pages/app/WorkOrdersPage.tsx index 8b241a7..d4e80be 100644 --- a/frontend/src/pages/app/WorkOrdersPage.tsx +++ b/frontend/src/pages/app/WorkOrdersPage.tsx @@ -11,7 +11,11 @@ import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { 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' const schema = z.object({ @@ -39,7 +43,11 @@ export default function WorkOrdersPage() { const { theme } = useTheme('ui_theme', 'dark') const isLight = theme === 'light' const qc = useQueryClient() + const [search, setSearch] = useState('') const [statusFilter, setStatusFilter] = useState('') + const [clientFilter, setClientFilter] = useState('') + const [dateFrom, setDateFrom] = useState('') + const [dateTo, setDateTo] = useState('') const [showForm, setShowForm] = useState(false) const [docError, setDocError] = useState('') @@ -47,17 +55,38 @@ export default function WorkOrdersPage() { queryKey: ['work-orders'], queryFn: () => apiFetch('/work-orders'), }) - const filteredOrders = useMemo( - () => (statusFilter ? orders.filter((o) => o.status === statusFilter) : orders), - [orders, statusFilter] - ) - const hasFilters = statusFilter.length > 0 - const { data: clients = [] } = useQuery({ queryKey: ['clients'], queryFn: () => apiFetch('/clients'), }) 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({ resolver: zodResolver(schema) as Resolver, @@ -102,8 +131,17 @@ export default function WorkOrdersPage() {
    -
    -
    +
    +
    + + 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'} + /> +
    +
    +
    + + +
    +
    + + setDateFrom(e.target.value)} + className={isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'} + /> +
    +
    + + setDateTo(e.target.value)} + className={isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'} + /> +