From 76b50fec84710e366c09ab77bb089bdd3da2f855 Mon Sep 17 00:00:00 2001 From: Luciano Milani Date: Thu, 9 Jul 2026 19:11:02 +0100 Subject: [PATCH] fix(pdf): fix storage volume permissions blocking PDF generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Named volume mounted root-owned over the app user's chown, so every PDF (orçamento, fatura, download) failed with 500. Entrypoint now fixes ownership on container start and drops to the app user. feat(dashboard): decouple pipeline state from period filter Status counts and recent OTs no longer disappear when an open order was created before the selected period window. feat(workorders): add vehicle/total columns, deadline urgency colors, on-time delivery saldo and efficiency metric, plate search Expose completed_at from wo_status_log so the frontend can compare actual completion date against the deadline. Add "Eficiência de Entrega" metric to Dashboard and Reports. feat(reports): add dedicated vehicle service history report New page with per-vehicle summary (OT count, total spent, last visit) and full itemized service history with print output. chore: relabel OT status "Concluir OT" -> "Concluída" and "Iniciar Trabalho" -> "Em Progresso" (action buttons unchanged) Co-Authored-By: Claude Sonnet 5 --- backend/Dockerfile | 7 +- backend/docker-entrypoint.sh | 7 + backend/internal/workorder/repository.go | 17 +- frontend/src/App.tsx | 2 + frontend/src/components/layout/AppLayout.tsx | 2 + frontend/src/lib/types.ts | 1 + frontend/src/lib/workOrderStatus.ts | 58 ++- frontend/src/pages/app/DashboardPage.tsx | 59 ++- frontend/src/pages/app/ReportsPage.tsx | 26 +- frontend/src/pages/app/VehicleReportsPage.tsx | 391 ++++++++++++++++++ .../src/pages/app/WorkOrderDetailPage.tsx | 13 +- frontend/src/pages/app/WorkOrdersPage.tsx | 82 +++- 12 files changed, 614 insertions(+), 51 deletions(-) create mode 100644 backend/docker-entrypoint.sh create mode 100644 frontend/src/pages/app/VehicleReportsPage.tsx diff --git a/backend/Dockerfile b/backend/Dockerfile index 7e522c2..84507fa 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -6,12 +6,13 @@ COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/server FROM alpine:3.19 -RUN apk --no-cache add ca-certificates tzdata +RUN apk --no-cache add ca-certificates tzdata su-exec RUN addgroup -S app && adduser -S app -G app WORKDIR /app COPY --from=builder /app/server . COPY --from=builder /app/migrations ./migrations -RUN mkdir -p /app/storage && chown -R app:app /app -USER app +COPY docker-entrypoint.sh /docker-entrypoint.sh +RUN mkdir -p /app/storage && chown -R app:app /app && chmod +x /docker-entrypoint.sh EXPOSE 8080 +ENTRYPOINT ["/docker-entrypoint.sh"] CMD ["./server"] diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh new file mode 100644 index 0000000..d0259fa --- /dev/null +++ b/backend/docker-entrypoint.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -e + +mkdir -p /app/storage +chown -R app:app /app/storage + +exec su-exec app "$@" diff --git a/backend/internal/workorder/repository.go b/backend/internal/workorder/repository.go index 238dc88..6a330b0 100644 --- a/backend/internal/workorder/repository.go +++ b/backend/internal/workorder/repository.go @@ -23,8 +23,15 @@ type WorkOrder struct { CreatedBy *string `json:"created_by"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + CompletedAt *time.Time `json:"completed_at"` } +const completedAtSubquery = `( + SELECT changed_at FROM wo_status_log + WHERE work_order_id = work_orders.id AND to_status = 'completed' + ORDER BY changed_at DESC LIMIT 1 +)` + type WOItem struct { ID string `json:"id"` WorkOrderID string `json:"work_order_id"` @@ -76,7 +83,8 @@ func ValidateTransition(from, to string) error { func ListWorkOrders(ctx context.Context, conn *pgxpool.Conn, status string) ([]*WorkOrder, error) { q := `SELECT id, number, client_id, vehicle_id, status, COALESCE(internal_notes,''), COALESCE(client_notes,''), COALESCE(eta_days,1), real_deadline, - COALESCE(payment_method,''), payment_date, created_by, created_at, updated_at + COALESCE(payment_method,''), payment_date, created_by, created_at, updated_at, + ` + completedAtSubquery + ` FROM work_orders` args := []any{} if status != "" { @@ -94,7 +102,7 @@ func ListWorkOrders(ctx context.Context, conn *pgxpool.Conn, status string) ([]* var wo WorkOrder if err := rows.Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID, &wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.ETADays, &wo.RealDeadline, - &wo.PaymentMethod, &wo.PaymentDate, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt); err != nil { + &wo.PaymentMethod, &wo.PaymentDate, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt, &wo.CompletedAt); err != nil { return nil, err } list = append(list, &wo) @@ -180,11 +188,12 @@ func GetWorkOrderDetail(ctx context.Context, conn *pgxpool.Conn, id string) (*Wo err := conn.QueryRow(ctx, ` SELECT id, number, client_id, vehicle_id, status, COALESCE(internal_notes,''), COALESCE(client_notes,''), COALESCE(eta_days,1), real_deadline, - COALESCE(payment_method,''), payment_date, created_by, created_at, updated_at + COALESCE(payment_method,''), payment_date, created_by, created_at, updated_at, + `+completedAtSubquery+` FROM work_orders WHERE id=$1`, id). Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID, &wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.ETADays, &wo.RealDeadline, - &wo.PaymentMethod, &wo.PaymentDate, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt) + &wo.PaymentMethod, &wo.PaymentDate, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt, &wo.CompletedAt) if err != nil { return nil, err } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5e6fc51..5fe6eeb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import ExpensesPage from '@/pages/app/ExpensesPage' import SettingsPage from '@/pages/app/SettingsPage' import InvoicesPage from '@/pages/app/InvoicesPage' import ReportsPage from '@/pages/app/ReportsPage' +import VehicleReportsPage from '@/pages/app/VehicleReportsPage' import ExpenseReportsPage from '@/pages/app/ExpenseReportsPage' import TechnicianReportsPage from '@/pages/app/TechnicianReportsPage' import HelpPage from '@/pages/app/HelpPage' @@ -81,6 +82,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 0c1b78f..372a1cd 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -3,6 +3,7 @@ import { Outlet, NavLink, useNavigate, useLocation } from 'react-router' import { useQuery } from '@tanstack/react-query' import { BarChart3, + Car, CircleHelp, ChevronDown, ChevronRight, @@ -54,6 +55,7 @@ const MAIN_NAV: MainNavItem[] = [ const REPORTS_NAV: ReportNavItem[] = [ { to: '/app/reports', label: 'OTs Cliente/Viatura', icon: BarChart3 }, + { to: '/app/reports/vehicles', label: 'Relatório de Viaturas', icon: Car }, { to: '/app/reports/expenses', label: 'Relatório Despesas', icon: Fuel }, { to: '/app/reports/technicians', label: 'Desempenho Técnicos', icon: Gauge }, ] diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index fc0c82e..77f2411 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -69,6 +69,7 @@ export interface WorkOrder { created_by: string | null created_at: string updated_at: string + completed_at: string | null } export interface WOItem { diff --git a/frontend/src/lib/workOrderStatus.ts b/frontend/src/lib/workOrderStatus.ts index 7bac614..e7cb5a3 100644 --- a/frontend/src/lib/workOrderStatus.ts +++ b/frontend/src/lib/workOrderStatus.ts @@ -2,8 +2,8 @@ import type { WorkOrder } from '@/lib/types' export const WORK_ORDER_STATUS_LABEL: Record = { quote: 'Orçamento', - in_progress: 'Iniciar Trabalho', - completed: 'Concluir OT', + in_progress: 'Em Progresso', + completed: 'Concluída', invoiced: 'Faturado', cancelled: 'Cancelada', } @@ -24,8 +24,8 @@ export type WorkOrderPhase = 'draft' | 'active' | 'done' | 'cancelled' export const WORK_ORDER_PHASE_LABEL: Record = { draft: 'Orçamento', - active: 'Iniciar Trabalho', - done: 'Concluir OT', + active: 'Em Progresso', + done: 'Concluída', cancelled: 'Cancelada', } @@ -46,3 +46,53 @@ export function getWorkOrderPhase(status: WorkOrder['status']): WorkOrderPhase { export function workOrderPhaseBadgeClass(phase: WorkOrderPhase) { return `border ${PHASE_BADGE[phase]}` } + +function daysUntil(dateStr: string) { + const today = new Date() + today.setHours(0, 0, 0, 0) + const target = new Date(dateStr) + target.setHours(0, 0, 0, 0) + return Math.round((target.getTime() - today.getTime()) / 86400000) +} + +// Urgency vs today: overdue = red, 0-1 day left = dark orange, 2+ days = default. +// Only meaningful while the OT is still open (in_progress) — once it reaches a +// terminal state the deadline is history, not a live risk. +export function deadlineColorClass(dateStr: string, isLight: boolean, status?: WorkOrder['status']) { + if (status && status !== 'in_progress') return isLight ? 'text-slate-800' : 'text-slate-300' + const daysLeft = daysUntil(dateStr) + if (daysLeft < 0) return isLight ? 'text-red-700 font-semibold' : 'text-red-400 font-semibold' + if (daysLeft <= 1) return isLight ? 'text-orange-800 font-semibold' : 'text-orange-400 font-semibold' + return isLight ? 'text-slate-800' : 'text-slate-300' +} + +// Days between the actual completion date and the target deadline. +// Positive = finished with days to spare, negative = finished late. +export function deadlineSaldoDias(realDeadline: string, completedAt: string) { + const deadline = new Date(realDeadline) + deadline.setHours(0, 0, 0, 0) + const completed = new Date(completedAt) + completed.setHours(0, 0, 0, 0) + return Math.round((deadline.getTime() - completed.getTime()) / 86400000) +} + +export function deadlineSaldoColorClass(saldo: number, isLight: boolean) { + if (saldo < 0) return isLight ? 'text-red-600/80' : 'text-red-400/70' + return isLight ? 'text-slate-500' : 'text-slate-500' +} + +export function deadlineSaldoLabel(saldo: number) { + if (saldo === 0) return 'concluída no dia previsto' + if (saldo > 0) return `concluída ${saldo} dia(s) antes do prazo` + return `concluída ${Math.abs(saldo)} dia(s) depois do prazo` +} + +// On-time delivery rate: share of orders with both a deadline and an actual +// completion date that finished on or before that deadline. Cancelled orders +// are excluded — a cancelled OT never really "delivered" on a deadline. +export function onTimeDeliveryStats(orders: WorkOrder[]) { + const eligible = orders.filter((o) => o.status !== 'cancelled' && o.real_deadline && o.completed_at) + const onTime = eligible.filter((o) => deadlineSaldoDias(o.real_deadline!, o.completed_at!) >= 0) + const rate = eligible.length > 0 ? (onTime.length / eligible.length) * 100 : null + return { eligibleCount: eligible.length, onTimeCount: onTime.length, lateCount: eligible.length - onTime.length, rate } +} diff --git a/frontend/src/pages/app/DashboardPage.tsx b/frontend/src/pages/app/DashboardPage.tsx index 13f84e6..e6dcb78 100644 --- a/frontend/src/pages/app/DashboardPage.tsx +++ b/frontend/src/pages/app/DashboardPage.tsx @@ -4,7 +4,7 @@ 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' +import { WORK_ORDER_STATUS_LABEL, onTimeDeliveryStats } from '@/lib/workOrderStatus' const STATUS_ORDER: WorkOrder['status'][] = ['quote', 'in_progress', 'completed', 'invoiced', 'cancelled'] type PeriodKey = 'month' | '30d' | '90d' | 'year' @@ -132,17 +132,19 @@ export default function DashboardPage() { const now = new Date() const periodStart = getPeriodStart(period, now) - const workOrdersPeriod = workOrders.filter((wo) => new Date(wo.created_at) >= periodStart) + // Pipeline state (byStatus, activeOrders, awaitingInvoice, recentOrders) reflects + // ALL work orders regardless of period — an open OT started before the period + // window must not disappear from the dashboard just because it's "old". const invoicesPeriod = invoices.filter((inv) => new Date(inv.issued_at) >= periodStart) const expensesPeriod = expenses.filter((exp) => new Date(exp.date) >= periodStart) const invoicesMonth = invoices.filter((inv) => monthKey(new Date(inv.issued_at)) === monthKey(now)) const expensesMonth = expenses.filter((exp) => monthKey(new Date(exp.date)) === monthKey(now)) const byStatus = Object.fromEntries(STATUS_ORDER.map((s) => [s, 0])) as Record - for (const wo of workOrdersPeriod) byStatus[wo.status] += 1 + for (const wo of workOrders) byStatus[wo.status] += 1 const activeOrders = byStatus.quote + byStatus.in_progress - const awaitingInvoice = workOrdersPeriod.filter((wo) => wo.status === 'completed').length + const awaitingInvoice = workOrders.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 @@ -154,13 +156,15 @@ export default function DashboardPage() { .filter((inv) => inv.type === 'invoice') .reduce((sum, inv) => sum + (workOrderTotals[inv.work_order_id] ?? 0), 0) - const recentOrders = [...workOrdersPeriod] + const recentOrders = [...workOrders] .sort((a, b) => +new Date(b.updated_at) - +new Date(a.updated_at)) - .slice(0, 6) + .slice(0, 3) const recentExpenses = [...expensesPeriod] .sort((a, b) => +new Date(b.date) - +new Date(a.date)) - .slice(0, 6) + .slice(0, 3) + + const delivery = onTimeDeliveryStats(workOrders) return { byStatus, @@ -174,7 +178,8 @@ export default function DashboardPage() { activeStaff, recentOrders, recentExpenses, - periodOrdersCount: workOrdersPeriod.length, + delivery, + totalOrdersCount: workOrders.length, periodDocsCount: invoicesPeriod.length, } }, [period, workOrders, invoices, expenses, staff, workOrderTotals]) @@ -228,11 +233,11 @@ export default function DashboardPage() {

)} -
+
+
@@ -305,21 +320,21 @@ export default function DashboardPage() {
-
-
+
+

Fluxo das Ordens de Trabalho

Ver todas
-
+
{STATUS_ORDER.map((status) => { const count = metrics.byStatus[status] - const total = Math.max(metrics.periodOrdersCount, 1) + const total = Math.max(metrics.totalOrdersCount, 1) const width = Math.max((count / total) * 100, count > 0 ? 6 : 0) return (
-
+
{WORK_ORDER_STATUS_LABEL[status]} {count}
@@ -332,9 +347,9 @@ export default function DashboardPage() {
-
+

Faturação e Prioridades

-
+

Documentos emitidos no período

{metrics.periodDocsCount}

@@ -350,7 +365,7 @@ export default function DashboardPage() {

Ação recomendada

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

@@ -358,7 +373,7 @@ export default function DashboardPage() {

Em orçamento

{metrics.byStatus.quote > 0 - ? `${metrics.byStatus.quote} OT(s) em "Orçamento" prontas para "Iniciar Trabalho".` + ? `${metrics.byStatus.quote} OT(s) em "Orçamento" prontas para "Em Progresso".` : 'Não existem OTs pendentes em orçamento.'}

@@ -377,7 +392,7 @@ export default function DashboardPage() {

Atividade Recente de OTs

- Abrir OTs + Mostrar tudo →
{metrics.recentOrders.length === 0 ? ( @@ -403,7 +418,7 @@ export default function DashboardPage() {

Despesas Recentes

- Abrir Despesas + Mostrar tudo →
{metrics.recentExpenses.length === 0 ? ( diff --git a/frontend/src/pages/app/ReportsPage.tsx b/frontend/src/pages/app/ReportsPage.tsx index d34d825..02c042a 100644 --- a/frontend/src/pages/app/ReportsPage.tsx +++ b/frontend/src/pages/app/ReportsPage.tsx @@ -6,6 +6,7 @@ import { useTheme } from '@/hooks/useTheme' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import type { Client, Vehicle, WorkOrder, WorkOrderDetail } from '@/lib/types' +import { onTimeDeliveryStats } from '@/lib/workOrderStatus' type PeriodKey = '30d' | '90d' | 'year' | 'all' @@ -201,7 +202,8 @@ export default function ReportsPage() { const withVehicle = filteredOrders.filter((o) => !!o.vehicle_id).length const invoiceRate = total > 0 ? (invoiced / total) * 100 : 0 const avgTicket = total > 0 ? amount / total : 0 - return { total, quote, inProgress, completed, invoiced, cancelled, amount, withVehicle, invoiceRate, avgTicket } + const delivery = onTimeDeliveryStats(filteredOrders) + return { total, quote, inProgress, completed, invoiced, cancelled, amount, withVehicle, invoiceRate, avgTicket, delivery } }, [filteredOrders, byClient]) const topClientAmount = byClient[0]?.amount ?? 1 @@ -320,12 +322,12 @@ export default function ReportsPage() {

Período: ${PERIOD_OPTIONS.find((p) => p.key === period)?.label ?? period}

OTs: ${kpis.total}
Orçamento: ${kpis.quote}
-
Iniciar Trabalho: ${kpis.inProgress}
-
Concluir OT: ${kpis.completed}
+
Em Progresso: ${kpis.inProgress}
+
Concluída: ${kpis.completed}
Faturado: ${kpis.invoiced}
Resultado: ${currency(kpis.amount)}

Top Clientes

- ${rows}
ClienteOTsConcluir OTFaturadoTicket MédioResultado
+ ${rows}
ClienteOTsConcluídaFaturadoTicket MédioResultado
`) w.document.close() @@ -427,8 +429,8 @@ export default function ReportsPage() { {[ ['OTs no período', String(kpis.total)], ['Orçamento', String(kpis.quote)], - ['Iniciar Trabalho', String(kpis.inProgress)], - ['Concluir OT', String(kpis.completed)], + ['Em Progresso', String(kpis.inProgress)], + ['Concluída', String(kpis.completed)], ['Faturado', String(kpis.invoiced)], ].map(([label, value]) => (
@@ -437,11 +439,17 @@ export default function ReportsPage() {
))}
-
+
{[ ['Taxa de emissão de fatura', `${kpis.invoiceRate.toFixed(1)}%`], ['Ticket médio por OT', currency(kpis.avgTicket)], ['OTs com viatura', `${kpis.withVehicle}/${kpis.total}`], + [ + 'Entrega dentro do prazo', + kpis.delivery.rate === null + ? 'Sem dados' + : `${kpis.delivery.rate.toFixed(0)}% (${kpis.delivery.onTimeCount}/${kpis.delivery.eligibleCount})`, + ], ].map(([label, value]) => (

{label}

@@ -475,7 +483,7 @@ export default function ReportsPage() {

- Ticket médio: {currency(row.avgTicket)} | Concluir OT: {row.completed} | Faturado: {row.invoiced} + Ticket médio: {currency(row.avgTicket)} | Concluída: {row.completed} | Faturado: {row.invoiced}

) @@ -496,7 +504,7 @@ export default function ReportsPage() { Cliente Viatura OTs - Concluir OT + Concluída Faturado Resultado diff --git a/frontend/src/pages/app/VehicleReportsPage.tsx b/frontend/src/pages/app/VehicleReportsPage.tsx new file mode 100644 index 0000000..c7a00e7 --- /dev/null +++ b/frontend/src/pages/app/VehicleReportsPage.tsx @@ -0,0 +1,391 @@ +import { useMemo, useState } from 'react' +import { useQuery, useQueries } from '@tanstack/react-query' +import { ChevronLeft, FilterX, Printer } from 'lucide-react' +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 { WORK_ORDER_STATUS_LABEL, getWorkOrderPhase, workOrderPhaseBadgeClass } from '@/lib/workOrderStatus' +import type { Client, Vehicle, WorkOrder, WorkOrderDetail, Staff } from '@/lib/types' + +function currency(v: number) { + return new Intl.NumberFormat('pt-PT', { style: 'currency', currency: 'EUR' }).format(v) +} + +function formatDate(d: string) { + return new Intl.DateTimeFormat('pt-PT').format(new Date(d)) +} + +export default function VehicleReportsPage() { + const { theme } = useTheme('ui_theme', 'dark') + const isLight = theme === 'light' + const [search, setSearch] = useState('') + const [clientFilter, setClientFilter] = useState('') + const [selectedVehicleId, setSelectedVehicleId] = useState('') + + const clientsQ = useQuery({ + queryKey: ['clients'], + queryFn: () => apiFetch('/clients'), + }) + const ordersQ = useQuery({ + queryKey: ['work-orders', 'vehicle-reports'], + queryFn: () => apiFetch('/work-orders'), + }) + const staffQ = useQuery({ + queryKey: ['staff'], + queryFn: () => apiFetch('/staff'), + }) + + const clients = clientsQ.data ?? [] + const orders = ordersQ.data ?? [] + const staff = staffQ.data ?? [] + const staffNameById = Object.fromEntries(staff.map((s) => [s.id, s.name])) + const clientNameById = Object.fromEntries(clients.map((c) => [c.id, c.name])) + + const vehiclesQ = useQuery({ + queryKey: ['vehicles', 'all-clients', 'vehicle-reports'], + queryFn: async () => { + const perClient = await Promise.all( + clients.map((c) => apiFetch(`/clients/${c.id}/vehicles`).catch(() => [] as Vehicle[])) + ) + return perClient.flat() + }, + enabled: clients.length > 0, + }) + const vehicles = vehiclesQ.data ?? [] + + const ordersWithVehicle = useMemo(() => orders.filter((o) => !!o.vehicle_id), [orders]) + + const detailsQ = useQueries({ + queries: ordersWithVehicle.map((o) => ({ + queryKey: ['work-order-detail', o.id, 'vehicle-report'], + queryFn: () => apiFetch(`/work-orders/${o.id}`), + staleTime: 5 * 60 * 1000, + })), + }) + const detailsLoading = detailsQ.some((q) => q.isLoading) + + const detailByOrderId = useMemo(() => { + const map = new Map() + for (const q of detailsQ) { + if (q.data) map.set(q.data.id, q.data) + } + return map + }, [detailsQ]) + + function orderTotal(o: WorkOrder) { + const d = detailByOrderId.get(o.id) + if (!d) return 0 + return d.items.reduce((s, i) => s + i.total, 0) + d.staff_hours.reduce((s, h) => s + h.total, 0) + } + + const vehicleSummaries = useMemo(() => { + return vehicles.map((v) => { + const vOrders = ordersWithVehicle.filter((o) => o.vehicle_id === v.id) + const total = vOrders.reduce((sum, o) => sum + orderTotal(o), 0) + const lastVisit = vOrders.length > 0 + ? vOrders.reduce((latest, o) => (new Date(o.created_at) > new Date(latest.created_at) ? o : latest)).created_at + : null + return { + vehicle: v, + clientName: v.client_id ? (clientNameById[v.client_id] ?? '—') : '—', + otCount: vOrders.length, + total, + lastVisit, + } + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [vehicles, ordersWithVehicle, detailByOrderId, clientNameById]) + + const filteredVehicles = useMemo(() => { + const searchValue = search.trim().toLowerCase() + return vehicleSummaries + .filter((vs) => { + if (clientFilter && vs.vehicle.client_id !== clientFilter) return false + if (!searchValue) return true + const terms = [vs.vehicle.plate, vs.vehicle.brand, vs.vehicle.model, vs.clientName] + return terms.some((t) => t.toLowerCase().includes(searchValue)) + }) + .sort((a, b) => b.total - a.total) + }, [vehicleSummaries, search, clientFilter]) + + const hasFilters = !!search.trim() || !!clientFilter + const isLoading = clientsQ.isLoading || ordersQ.isLoading || vehiclesQ.isLoading || staffQ.isLoading + + const selected = vehicleSummaries.find((vs) => vs.vehicle.id === selectedVehicleId) + const selectedOrders = selected + ? [...ordersWithVehicle.filter((o) => o.vehicle_id === selected.vehicle.id)] + .sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at)) + : [] + + function printVehicleHistory() { + if (!selected) return + const rows = selectedOrders + .map((o) => { + const d = detailByOrderId.get(o.id) + const items = d?.items ?? [] + const hours = d?.staff_hours ?? [] + const itemRows = items + .map((i) => `${i.description}${i.qty}${currency(i.unit_price)}${currency(i.total)}`) + .join('') + const hourRows = hours + .map((h) => `Mão de obra — ${staffNameById[h.staff_id] ?? 'Técnico'}${h.hours}h${currency(h.cost_per_hour)}${currency(h.total)}`) + .join('') + const subtotal = orderTotal(o) + return ` +
+

OT #${o.number} — ${formatDate(o.created_at)} — ${WORK_ORDER_STATUS_LABEL[o.status]}

+ + + ${itemRows}${hourRows || ''} +
DescriçãoQtd/HorasPreçoTotal
+

Subtotal OT: ${currency(subtotal)}

+
` + }) + .join('') + const w = window.open('', '_blank') + if (!w) return + w.document.write(` + Ficha da Viatura ${selected.vehicle.plate} + +

Ficha de Histórico da Viatura

+

${selected.vehicle.plate} — ${selected.vehicle.brand} ${selected.vehicle.model}${selected.vehicle.year ? ` (${selected.vehicle.year})` : ''}

+

Cliente: ${selected.clientName} | Total de OTs: ${selected.otCount}

+ ${rows || '

Sem ordens de trabalho registadas.

'} +

Total acumulado: ${currency(selected.total)}

+ + `) + w.document.close() + w.focus() + w.print() + } + + if (selected) { + return ( +
+
+ +
+
+

Ficha da Viatura

+

+ {selected.vehicle.plate} — {selected.vehicle.brand} {selected.vehicle.model} +

+

+ Cliente: {selected.clientName} {selected.vehicle.year ? `· Ano: ${selected.vehicle.year}` : ''} {selected.vehicle.vin ? `· VIN: ${selected.vehicle.vin}` : ''} +

+
+ +
+
+ +
+
+

Total de OTs

+

{selected.otCount}

+
+
+

Total acumulado

+

{currency(selected.total)}

+
+
+

Última visita

+

+ {selected.lastVisit ? formatDate(selected.lastVisit) : '—'} +

+
+
+ + {detailsLoading && ( +

A carregar histórico de serviços...

+ )} + +
+ {selectedOrders.length === 0 ? ( +

Sem ordens de trabalho para esta viatura.

+ ) : ( + selectedOrders.map((o) => { + const d = detailByOrderId.get(o.id) + const items = d?.items ?? [] + const hours = d?.staff_hours ?? [] + return ( +
+
+
+ OT #{o.number} + + {WORK_ORDER_STATUS_LABEL[o.status]} + +
+ {formatDate(o.created_at)} +
+ {items.length === 0 && hours.length === 0 ? ( +

Sem itens ou horas registadas.

+ ) : ( +
+ + + + + + + + + + + {items.map((i) => ( + + + + + + + ))} + {hours.map((h) => ( + + + + + + + ))} + +
DescriçãoQtd/HorasPreçoTotal
{i.description}{i.qty}{currency(i.unit_price)}{currency(i.total)}
Mão de obra — {staffNameById[h.staff_id] ?? 'Técnico'}{h.hours}h{currency(h.cost_per_hour)}{currency(h.total)}
+
+ )} +

+ Subtotal: {currency(orderTotal(o))} +

+
+ ) + }) + )} +
+
+ ) + } + + return ( +
+
+

Relatórios Operacionais

+

Relatório de Viaturas

+

+ Resumo de serviços executados por viatura — clica numa viatura para ver o histórico completo e imprimir a ficha. +

+
+ +
+
+ + +
+ +
+
+
+ +
+ {vehicles.length} viaturas total + {filteredVehicles.length} no filtro atual +
+ + {(isLoading || detailsLoading) && ( +

A calcular resumo por viatura...

+ )} + + {filteredVehicles.length === 0 ? ( +

Nenhuma viatura encontrada.

+ ) : ( +
+ + + + + + + + + + + + + + {filteredVehicles.map((vs) => ( + + + + + + + + + + ))} + +
MatrículaMarca / ModeloClienteNº OTsÚltima visitaTotal gasto
{vs.vehicle.plate}{vs.vehicle.brand} {vs.vehicle.model}{vs.clientName}{vs.otCount}{vs.lastVisit ? formatDate(vs.lastVisit) : '—'}{currency(vs.total)} + +
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/app/WorkOrderDetailPage.tsx b/frontend/src/pages/app/WorkOrderDetailPage.tsx index 34e5295..9a2b2f8 100644 --- a/frontend/src/pages/app/WorkOrderDetailPage.tsx +++ b/frontend/src/pages/app/WorkOrderDetailPage.tsx @@ -15,6 +15,10 @@ import { WORK_ORDER_STATUS_LABEL, getWorkOrderPhase, workOrderPhaseBadgeClass, + deadlineColorClass, + deadlineSaldoDias, + deadlineSaldoLabel, + deadlineSaldoColorClass, } from '@/lib/workOrderStatus' import type { WorkOrderDetail, CatalogItem, Staff, Client, Vehicle } from '@/lib/types' @@ -389,7 +393,14 @@ export default function WorkOrderDetailPage() { {detail.status !== 'quote' && (

Prazo real (deadline)

-

{formatDateSafe(detail.real_deadline)}

+

+ {formatDateSafe(detail.real_deadline)} +

+ {detail.real_deadline && detail.completed_at && detail.status !== 'cancelled' && ( +

+ {deadlineSaldoLabel(deadlineSaldoDias(detail.real_deadline, detail.completed_at))} +

+ )}
)} {(detail.status === 'completed' || detail.status === 'invoiced') && ( diff --git a/frontend/src/pages/app/WorkOrdersPage.tsx b/frontend/src/pages/app/WorkOrdersPage.tsx index d4e80be..6658af1 100644 --- a/frontend/src/pages/app/WorkOrdersPage.tsx +++ b/frontend/src/pages/app/WorkOrdersPage.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from 'react' -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query' import { Link } from 'react-router' import { useForm, type Resolver } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' @@ -15,8 +15,17 @@ import { WORK_ORDER_STATUS_LABEL, getWorkOrderPhase, workOrderPhaseBadgeClass, + deadlineColorClass, + deadlineSaldoDias, + deadlineSaldoLabel, + deadlineSaldoColorClass, } from '@/lib/workOrderStatus' -import type { WorkOrder, Client, Vehicle } from '@/lib/types' +import type { WorkOrder, WorkOrderDetail, Client, Vehicle } from '@/lib/types' + +function currency(v: number) { + return new Intl.NumberFormat('pt-PT', { style: 'currency', currency: 'EUR' }).format(v) +} + const schema = z.object({ client_id: z.string(), @@ -60,6 +69,18 @@ export default function WorkOrdersPage() { queryFn: () => apiFetch('/clients'), }) const clientNameById = Object.fromEntries(clients.map((c) => [c.id, c.name])) + + const { data: allVehicles = [] } = useQuery({ + queryKey: ['vehicles', 'all-clients'], + queryFn: async () => { + const perClient = await Promise.all( + clients.map((c) => apiFetch(`/clients/${c.id}/vehicles`).catch(() => [] as Vehicle[])) + ) + return perClient.flat() + }, + enabled: clients.length > 0, + }) + const vehicleById = Object.fromEntries(allVehicles.map((v) => [v.id, v])) const filteredOrders = useMemo(() => { const searchValue = search.trim().toLowerCase() return orders.filter((o) => { @@ -75,6 +96,7 @@ export default function WorkOrdersPage() { } if (!searchValue) return true const clientName = o.client_id ? (clientNameById[o.client_id] ?? '') : '' + const vehicle = o.vehicle_id ? vehicleById[o.vehicle_id] : undefined const terms = [ `#${o.number}`, String(o.number), @@ -82,12 +104,35 @@ export default function WorkOrdersPage() { WORK_ORDER_STATUS_LABEL[o.status], o.internal_notes, o.client_notes, + vehicle?.plate ?? '', + vehicle?.brand ?? '', + vehicle?.model ?? '', ] return terms.some((term) => term.toLowerCase().includes(searchValue)) }) - }, [orders, search, statusFilter, clientFilter, dateFrom, dateTo, clientNameById]) + }, [orders, search, statusFilter, clientFilter, dateFrom, dateTo, clientNameById, vehicleById]) const hasFilters = !!search.trim() || !!statusFilter || !!clientFilter || !!dateFrom || !!dateTo + const detailsQ = useQueries({ + queries: filteredOrders.map((o) => ({ + queryKey: ['work-order-detail', o.id, 'list-total'], + queryFn: () => apiFetch(`/work-orders/${o.id}`), + staleTime: 5 * 60 * 1000, + })), + }) + const totalByOrderId = useMemo(() => { + const totals: Record = {} + for (let i = 0; i < filteredOrders.length; i++) { + const detail = detailsQ[i]?.data + const order = filteredOrders[i] + if (!detail || !order) continue + const itemsTotal = detail.items.reduce((sum, it) => sum + it.total, 0) + const hoursTotal = detail.staff_hours.reduce((sum, h) => sum + h.total, 0) + totals[order.id] = itemsTotal + hoursTotal + } + return totals + }, [filteredOrders, detailsQ]) + const { register, handleSubmit, watch, reset } = useForm({ resolver: zodResolver(schema) as Resolver, defaultValues: emptyOrder, @@ -137,7 +182,7 @@ export default function WorkOrdersPage() { setSearch(e.target.value)} - placeholder="Nº OT, cliente, estado ou notas" + placeholder="Nº OT, cliente, matrícula, estado ou notas" className={isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'} />
@@ -307,10 +352,12 @@ export default function WorkOrdersPage() { Nº Cliente + Viatura Estado Previsão Prazo real Criada + Total @@ -324,20 +371,39 @@ export default function WorkOrdersPage() { {o.client_id ? (clientNameById[o.client_id] ?? '—') : '—'} + + {o.vehicle_id && vehicleById[o.vehicle_id] + ? `${vehicleById[o.vehicle_id].plate} — ${vehicleById[o.vehicle_id].brand} ${vehicleById[o.vehicle_id].model}` + : '—'} + {WORK_ORDER_STATUS_LABEL[o.status]} {ETA_LABEL[o.eta_days] ?? `${o.eta_days} dias`} - - {o.status !== 'quote' && o.real_deadline - ? new Intl.DateTimeFormat('pt-PT').format(new Date(o.real_deadline)) - : '—'} + + {o.status !== 'quote' && o.real_deadline ? ( + <> +

+ {new Intl.DateTimeFormat('pt-PT').format(new Date(o.real_deadline))} +

+ {o.completed_at && o.status !== 'cancelled' && ( +

+ {deadlineSaldoLabel(deadlineSaldoDias(o.real_deadline, o.completed_at))} +

+ )} + + ) : ( +

+ )} {new Intl.DateTimeFormat('pt-PT').format(new Date(o.created_at))} + + {currency(totalByOrderId[o.id] ?? 0)} +