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
-
| Cliente | OTs | Concluir OT | Faturado | Ticket Médio | Resultado |
${rows}
+
| Cliente | OTs | Concluída | Faturado | Ticket Médio | Resultado |
${rows}