fix(pdf): fix storage volume permissions blocking PDF generation

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 <noreply@anthropic.com>
This commit is contained in:
Luciano Milani
2026-07-09 19:11:02 +01:00
parent 4994abf4e2
commit 76b50fec84
12 changed files with 614 additions and 51 deletions
+4 -3
View File
@@ -6,12 +6,13 @@ COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/server RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/server
FROM alpine:3.19 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 RUN addgroup -S app && adduser -S app -G app
WORKDIR /app WORKDIR /app
COPY --from=builder /app/server . COPY --from=builder /app/server .
COPY --from=builder /app/migrations ./migrations COPY --from=builder /app/migrations ./migrations
RUN mkdir -p /app/storage && chown -R app:app /app COPY docker-entrypoint.sh /docker-entrypoint.sh
USER app RUN mkdir -p /app/storage && chown -R app:app /app && chmod +x /docker-entrypoint.sh
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["./server"] CMD ["./server"]
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
set -e
mkdir -p /app/storage
chown -R app:app /app/storage
exec su-exec app "$@"
+13 -4
View File
@@ -23,8 +23,15 @@ type WorkOrder struct {
CreatedBy *string `json:"created_by"` CreatedBy *string `json:"created_by"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_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 { type WOItem struct {
ID string `json:"id"` ID string `json:"id"`
WorkOrderID string `json:"work_order_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) { func ListWorkOrders(ctx context.Context, conn *pgxpool.Conn, status string) ([]*WorkOrder, error) {
q := `SELECT id, number, client_id, vehicle_id, status, q := `SELECT id, number, client_id, vehicle_id, status,
COALESCE(internal_notes,''), COALESCE(client_notes,''), COALESCE(eta_days,1), real_deadline, 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` FROM work_orders`
args := []any{} args := []any{}
if status != "" { if status != "" {
@@ -94,7 +102,7 @@ func ListWorkOrders(ctx context.Context, conn *pgxpool.Conn, status string) ([]*
var wo WorkOrder var wo WorkOrder
if err := rows.Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID, if err := rows.Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID,
&wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.ETADays, &wo.RealDeadline, &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 return nil, err
} }
list = append(list, &wo) list = append(list, &wo)
@@ -180,11 +188,12 @@ func GetWorkOrderDetail(ctx context.Context, conn *pgxpool.Conn, id string) (*Wo
err := conn.QueryRow(ctx, ` err := conn.QueryRow(ctx, `
SELECT id, number, client_id, vehicle_id, status, SELECT id, number, client_id, vehicle_id, status,
COALESCE(internal_notes,''), COALESCE(client_notes,''), COALESCE(eta_days,1), real_deadline, 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). FROM work_orders WHERE id=$1`, id).
Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID, Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID,
&wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.ETADays, &wo.RealDeadline, &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 { if err != nil {
return nil, err return nil, err
} }
+2
View File
@@ -14,6 +14,7 @@ import ExpensesPage from '@/pages/app/ExpensesPage'
import SettingsPage from '@/pages/app/SettingsPage' import SettingsPage from '@/pages/app/SettingsPage'
import InvoicesPage from '@/pages/app/InvoicesPage' import InvoicesPage from '@/pages/app/InvoicesPage'
import ReportsPage from '@/pages/app/ReportsPage' import ReportsPage from '@/pages/app/ReportsPage'
import VehicleReportsPage from '@/pages/app/VehicleReportsPage'
import ExpenseReportsPage from '@/pages/app/ExpenseReportsPage' import ExpenseReportsPage from '@/pages/app/ExpenseReportsPage'
import TechnicianReportsPage from '@/pages/app/TechnicianReportsPage' import TechnicianReportsPage from '@/pages/app/TechnicianReportsPage'
import HelpPage from '@/pages/app/HelpPage' import HelpPage from '@/pages/app/HelpPage'
@@ -81,6 +82,7 @@ export default function App() {
<Route path="expenses" element={<ExpensesPage />} /> <Route path="expenses" element={<ExpensesPage />} />
<Route path="invoices" element={<InvoicesPage />} /> <Route path="invoices" element={<InvoicesPage />} />
<Route path="reports" element={<ReportsPage />} /> <Route path="reports" element={<ReportsPage />} />
<Route path="reports/vehicles" element={<VehicleReportsPage />} />
<Route path="reports/expenses" element={<ExpenseReportsPage />} /> <Route path="reports/expenses" element={<ExpenseReportsPage />} />
<Route path="reports/technicians" element={<TechnicianReportsPage />} /> <Route path="reports/technicians" element={<TechnicianReportsPage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
@@ -3,6 +3,7 @@ import { Outlet, NavLink, useNavigate, useLocation } from 'react-router'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { import {
BarChart3, BarChart3,
Car,
CircleHelp, CircleHelp,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
@@ -54,6 +55,7 @@ const MAIN_NAV: MainNavItem[] = [
const REPORTS_NAV: ReportNavItem[] = [ const REPORTS_NAV: ReportNavItem[] = [
{ to: '/app/reports', label: 'OTs Cliente/Viatura', icon: BarChart3 }, { 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/expenses', label: 'Relatório Despesas', icon: Fuel },
{ to: '/app/reports/technicians', label: 'Desempenho Técnicos', icon: Gauge }, { to: '/app/reports/technicians', label: 'Desempenho Técnicos', icon: Gauge },
] ]
+1
View File
@@ -69,6 +69,7 @@ export interface WorkOrder {
created_by: string | null created_by: string | null
created_at: string created_at: string
updated_at: string updated_at: string
completed_at: string | null
} }
export interface WOItem { export interface WOItem {
+54 -4
View File
@@ -2,8 +2,8 @@ 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',
in_progress: 'Iniciar Trabalho', in_progress: 'Em Progresso',
completed: 'Concluir OT', completed: 'Concluída',
invoiced: 'Faturado', invoiced: 'Faturado',
cancelled: 'Cancelada', cancelled: 'Cancelada',
} }
@@ -24,8 +24,8 @@ export type WorkOrderPhase = 'draft' | 'active' | 'done' | 'cancelled'
export const WORK_ORDER_PHASE_LABEL: Record<WorkOrderPhase, string> = { export const WORK_ORDER_PHASE_LABEL: Record<WorkOrderPhase, string> = {
draft: 'Orçamento', draft: 'Orçamento',
active: 'Iniciar Trabalho', active: 'Em Progresso',
done: 'Concluir OT', done: 'Concluída',
cancelled: 'Cancelada', cancelled: 'Cancelada',
} }
@@ -46,3 +46,53 @@ export function getWorkOrderPhase(status: WorkOrder['status']): WorkOrderPhase {
export function workOrderPhaseBadgeClass(phase: WorkOrderPhase) { export function workOrderPhaseBadgeClass(phase: WorkOrderPhase) {
return `border ${PHASE_BADGE[phase]}` 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 }
}
+37 -22
View File
@@ -4,7 +4,7 @@ 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' import { WORK_ORDER_STATUS_LABEL, onTimeDeliveryStats } from '@/lib/workOrderStatus'
const STATUS_ORDER: WorkOrder['status'][] = ['quote', 'in_progress', 'completed', 'invoiced', 'cancelled'] const STATUS_ORDER: WorkOrder['status'][] = ['quote', 'in_progress', 'completed', 'invoiced', 'cancelled']
type PeriodKey = 'month' | '30d' | '90d' | 'year' type PeriodKey = 'month' | '30d' | '90d' | 'year'
@@ -132,17 +132,19 @@ export default function DashboardPage() {
const now = new Date() const now = new Date()
const periodStart = getPeriodStart(period, now) 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 invoicesPeriod = invoices.filter((inv) => new Date(inv.issued_at) >= periodStart)
const expensesPeriod = expenses.filter((exp) => new Date(exp.date) >= 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 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 expensesMonth = expenses.filter((exp) => monthKey(new Date(exp.date)) === monthKey(now))
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 workOrders) byStatus[wo.status] += 1
const activeOrders = byStatus.quote + byStatus.in_progress 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 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
@@ -154,13 +156,15 @@ export default function DashboardPage() {
.filter((inv) => inv.type === 'invoice') .filter((inv) => inv.type === 'invoice')
.reduce((sum, inv) => sum + (workOrderTotals[inv.work_order_id] ?? 0), 0) .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)) .sort((a, b) => +new Date(b.updated_at) - +new Date(a.updated_at))
.slice(0, 6) .slice(0, 3)
const recentExpenses = [...expensesPeriod] const recentExpenses = [...expensesPeriod]
.sort((a, b) => +new Date(b.date) - +new Date(a.date)) .sort((a, b) => +new Date(b.date) - +new Date(a.date))
.slice(0, 6) .slice(0, 3)
const delivery = onTimeDeliveryStats(workOrders)
return { return {
byStatus, byStatus,
@@ -174,7 +178,8 @@ export default function DashboardPage() {
activeStaff, activeStaff,
recentOrders, recentOrders,
recentExpenses, recentExpenses,
periodOrdersCount: workOrdersPeriod.length, delivery,
totalOrdersCount: workOrders.length,
periodDocsCount: invoicesPeriod.length, periodDocsCount: invoicesPeriod.length,
} }
}, [period, workOrders, invoices, expenses, staff, workOrderTotals]) }, [period, workOrders, invoices, expenses, staff, workOrderTotals])
@@ -228,11 +233,11 @@ export default function DashboardPage() {
</p> </p>
)} )}
<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-5">
<DashboardCard <DashboardCard
title="Orçamento + Iniciar Trabalho" title="Orçamento + Em Progresso"
value={String(metrics.activeOrders)} value={String(metrics.activeOrders)}
subtitle={`No período (${metrics.periodOrdersCount} OTs)`} subtitle={`${metrics.totalOrdersCount} OTs no total`}
isLight={isLight} isLight={isLight}
/> />
<DashboardCard <DashboardCard
@@ -253,6 +258,16 @@ export default function DashboardPage() {
subtitle="Somatório no filtro selecionado" subtitle="Somatório no filtro selecionado"
isLight={isLight} isLight={isLight}
/> />
<DashboardCard
title="Eficiência de Entrega"
value={metrics.delivery.rate === null ? '—' : `${metrics.delivery.rate.toFixed(0)}%`}
subtitle={
metrics.delivery.rate === null
? 'Sem OTs concluídas com prazo'
: `${metrics.delivery.onTimeCount}/${metrics.delivery.eligibleCount} dentro do prazo`
}
isLight={isLight}
/>
</div> </div>
<article className={`rounded-2xl border p-5 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700/70 bg-slate-900/65'}`}> <article className={`rounded-2xl border p-5 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700/70 bg-slate-900/65'}`}>
@@ -305,21 +320,21 @@ export default function DashboardPage() {
</article> </article>
<div className="grid items-stretch gap-4 xl:grid-cols-2"> <div className="grid items-stretch gap-4 xl:grid-cols-2">
<article className={`h-full rounded-2xl border p-5 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700/70 bg-slate-900/65'}`}> <article className={`h-full rounded-2xl border p-6 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700/70 bg-slate-900/65'}`}>
<div className="mb-4 flex items-center justify-between"> <div className="mb-6 flex items-center justify-between">
<h2 className={`text-base font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Fluxo das Ordens de Trabalho</h2> <h2 className={`text-base font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Fluxo das Ordens de Trabalho</h2>
<Link to="/app/work-orders" className={`text-xs ${isLight ? 'text-sky-700 hover:text-sky-800' : 'text-cyan-300 hover:text-cyan-200'}`}> <Link to="/app/work-orders" className={`text-xs ${isLight ? 'text-sky-700 hover:text-sky-800' : 'text-cyan-300 hover:text-cyan-200'}`}>
Ver todas Ver todas
</Link> </Link>
</div> </div>
<div className="space-y-3"> <div className="space-y-5">
{STATUS_ORDER.map((status) => { {STATUS_ORDER.map((status) => {
const count = metrics.byStatus[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) const width = Math.max((count / total) * 100, count > 0 ? 6 : 0)
return ( return (
<div key={status}> <div key={status}>
<div className="mb-1 flex justify-between text-xs"> <div className="mb-1.5 flex justify-between text-xs">
<span className={isLight ? 'text-slate-700' : 'text-slate-300'}>{WORK_ORDER_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>
@@ -332,9 +347,9 @@ export default function DashboardPage() {
</div> </div>
</article> </article>
<article className={`h-full rounded-2xl border p-5 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700/70 bg-slate-900/65'}`}> <article className={`h-full rounded-2xl border p-6 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700/70 bg-slate-900/65'}`}>
<h2 className={`text-base font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Faturação e Prioridades</h2> <h2 className={`text-base font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Faturação e Prioridades</h2>
<div className="mt-4 space-y-3 text-sm"> <div className="mt-5 space-y-3 text-sm">
<div className={`rounded-lg border px-3 py-2 ${isLight ? 'border-slate-200 bg-slate-100' : 'border-slate-700 bg-slate-900/70'}`}> <div className={`rounded-lg border px-3 py-2 ${isLight ? 'border-slate-200 bg-slate-100' : 'border-slate-700 bg-slate-900/70'}`}>
<p className={isLight ? 'text-slate-600' : 'text-slate-400'}>Documentos emitidos no período</p> <p className={isLight ? 'text-slate-600' : 'text-slate-400'}>Documentos emitidos no período</p>
<p className={`text-xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>{metrics.periodDocsCount}</p> <p className={`text-xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>{metrics.periodDocsCount}</p>
@@ -350,7 +365,7 @@ 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.awaitingInvoice > 0 {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.'} : 'Sem OTs finalizadas pendentes de emissão de fatura.'}
</p> </p>
</div> </div>
@@ -358,7 +373,7 @@ export default function DashboardPage() {
<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" prontas para "Iniciar Trabalho".` ? `${metrics.byStatus.quote} OT(s) em "Orçamento" prontas para "Em Progresso".`
: 'Não existem OTs pendentes em orçamento.'} : 'Não existem OTs pendentes em orçamento.'}
</p> </p>
</div> </div>
@@ -377,7 +392,7 @@ export default function DashboardPage() {
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<h2 className={`text-base font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Atividade Recente de OTs</h2> <h2 className={`text-base font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Atividade Recente de OTs</h2>
<Link to="/app/work-orders" className={`text-xs ${isLight ? 'text-sky-700 hover:text-sky-800' : 'text-cyan-300 hover:text-cyan-200'}`}> <Link to="/app/work-orders" className={`text-xs ${isLight ? 'text-sky-700 hover:text-sky-800' : 'text-cyan-300 hover:text-cyan-200'}`}>
Abrir OTs Mostrar tudo
</Link> </Link>
</div> </div>
{metrics.recentOrders.length === 0 ? ( {metrics.recentOrders.length === 0 ? (
@@ -403,7 +418,7 @@ export default function DashboardPage() {
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<h2 className={`text-base font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Despesas Recentes</h2> <h2 className={`text-base font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Despesas Recentes</h2>
<Link to="/app/expenses" className={`text-xs ${isLight ? 'text-sky-700 hover:text-sky-800' : 'text-cyan-300 hover:text-cyan-200'}`}> <Link to="/app/expenses" className={`text-xs ${isLight ? 'text-sky-700 hover:text-sky-800' : 'text-cyan-300 hover:text-cyan-200'}`}>
Abrir Despesas Mostrar tudo
</Link> </Link>
</div> </div>
{metrics.recentExpenses.length === 0 ? ( {metrics.recentExpenses.length === 0 ? (
+17 -9
View File
@@ -6,6 +6,7 @@ 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 type { Client, Vehicle, WorkOrder, WorkOrderDetail } from '@/lib/types' import type { Client, Vehicle, WorkOrder, WorkOrderDetail } from '@/lib/types'
import { onTimeDeliveryStats } from '@/lib/workOrderStatus'
type PeriodKey = '30d' | '90d' | 'year' | 'all' type PeriodKey = '30d' | '90d' | 'year' | 'all'
@@ -201,7 +202,8 @@ export default function ReportsPage() {
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, 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]) }, [filteredOrders, byClient])
const topClientAmount = byClient[0]?.amount ?? 1 const topClientAmount = byClient[0]?.amount ?? 1
@@ -320,12 +322,12 @@ export default function ReportsPage() {
<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>Orçamento:</strong> ${kpis.quote}</div> <div class="kpi"><strong>Orçamento:</strong> ${kpis.quote}</div>
<div class="kpi"><strong>Iniciar Trabalho:</strong> ${kpis.inProgress}</div> <div class="kpi"><strong>Em Progresso:</strong> ${kpis.inProgress}</div>
<div class="kpi"><strong>Concluir OT:</strong> ${kpis.completed}</div> <div class="kpi"><strong>Concluída:</strong> ${kpis.completed}</div>
<div class="kpi"><strong>Faturado:</strong> ${kpis.invoiced}</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>Concluir OT</th><th>Faturado</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>Concluída</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,8 +429,8 @@ export default function ReportsPage() {
{[ {[
['OTs no período', String(kpis.total)], ['OTs no período', String(kpis.total)],
['Orçamento', String(kpis.quote)], ['Orçamento', String(kpis.quote)],
['Iniciar Trabalho', String(kpis.inProgress)], ['Em Progresso', String(kpis.inProgress)],
['Concluir OT', String(kpis.completed)], ['Concluída', String(kpis.completed)],
['Faturado', String(kpis.invoiced)], ['Faturado', String(kpis.invoiced)],
].map(([label, value]) => ( ].map(([label, value]) => (
<article key={label} className={`rounded-xl border p-3 ${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'}`}>
@@ -437,11 +439,17 @@ export default function ReportsPage() {
</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-4">
{[ {[
['Taxa de emissão de fatura', `${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}`],
[
'Entrega dentro do prazo',
kpis.delivery.rate === null
? 'Sem dados'
: `${kpis.delivery.rate.toFixed(0)}% (${kpis.delivery.onTimeCount}/${kpis.delivery.eligibleCount})`,
],
].map(([label, value]) => ( ].map(([label, value]) => (
<article key={label} className={`rounded-xl border p-3 ${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>
@@ -475,7 +483,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)} | Concluir OT: {row.completed} | Faturado: {row.invoiced} Ticket médio: {currency(row.avgTicket)} | Concluída: {row.completed} | Faturado: {row.invoiced}
</p> </p>
</div> </div>
) )
@@ -496,7 +504,7 @@ 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'}`}>Concluir OT</th> <th className={`px-3 py-2 text-right ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Concluída</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'}`}>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>
@@ -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<Client[]>({
queryKey: ['clients'],
queryFn: () => apiFetch<Client[]>('/clients'),
})
const ordersQ = useQuery<WorkOrder[]>({
queryKey: ['work-orders', 'vehicle-reports'],
queryFn: () => apiFetch<WorkOrder[]>('/work-orders'),
})
const staffQ = useQuery<Staff[]>({
queryKey: ['staff'],
queryFn: () => apiFetch<Staff[]>('/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<Vehicle[]>({
queryKey: ['vehicles', 'all-clients', 'vehicle-reports'],
queryFn: async () => {
const perClient = await Promise.all(
clients.map((c) => apiFetch<Vehicle[]>(`/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<WorkOrderDetail>(`/work-orders/${o.id}`),
staleTime: 5 * 60 * 1000,
})),
})
const detailsLoading = detailsQ.some((q) => q.isLoading)
const detailByOrderId = useMemo(() => {
const map = new Map<string, WorkOrderDetail>()
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) => `<tr><td>${i.description}</td><td>${i.qty}</td><td>${currency(i.unit_price)}</td><td>${currency(i.total)}</td></tr>`)
.join('')
const hourRows = hours
.map((h) => `<tr><td>Mão de obra — ${staffNameById[h.staff_id] ?? 'Técnico'}</td><td>${h.hours}h</td><td>${currency(h.cost_per_hour)}</td><td>${currency(h.total)}</td></tr>`)
.join('')
const subtotal = orderTotal(o)
return `
<div class="ot-block">
<h3>OT #${o.number}${formatDate(o.created_at)}${WORK_ORDER_STATUS_LABEL[o.status]}</h3>
<table>
<thead><tr><th>Descrição</th><th>Qtd/Horas</th><th>Preço</th><th>Total</th></tr></thead>
<tbody>${itemRows}${hourRows || ''}</tbody>
</table>
<p class="ot-total">Subtotal OT: ${currency(subtotal)}</p>
</div>`
})
.join('')
const w = window.open('', '_blank')
if (!w) return
w.document.write(`
<html><head><title>Ficha da Viatura ${selected.vehicle.plate}</title>
<style>
body{font-family:Arial,sans-serif;padding:24px;color:#0f172a}
h1{margin:0 0 4px 0}
h3{margin:20px 0 6px 0;font-size:14px}
p{margin:0 0 8px 0;color:#334155}
table{width:100%;border-collapse:collapse;margin-bottom:4px}
th,td{border:1px solid #cbd5e1;padding:6px 8px;text-align:left;font-size:12px}
th{background:#f1f5f9}
.ot-block{margin-bottom:20px;page-break-inside:avoid}
.ot-total{text-align:right;font-weight:bold;font-size:13px}
.grand-total{margin-top:20px;font-size:16px;font-weight:bold;text-align:right;border-top:2px solid #0f172a;padding-top:8px}
</style></head><body>
<h1>Ficha de Histórico da Viatura</h1>
<p><strong>${selected.vehicle.plate}</strong> — ${selected.vehicle.brand} ${selected.vehicle.model}${selected.vehicle.year ? ` (${selected.vehicle.year})` : ''}</p>
<p>Cliente: ${selected.clientName} | Total de OTs: ${selected.otCount}</p>
${rows || '<p>Sem ordens de trabalho registadas.</p>'}
<p class="grand-total">Total acumulado: ${currency(selected.total)}</p>
</body></html>
`)
w.document.close()
w.focus()
w.print()
}
if (selected) {
return (
<section className="space-y-6">
<header className={`rounded-2xl border p-6 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<button
onClick={() => setSelectedVehicleId('')}
className={`mb-3 inline-flex items-center gap-1 text-xs ${isLight ? 'text-sky-700 hover:text-sky-800' : 'text-cyan-300 hover:text-cyan-200'}`}
>
<ChevronLeft className="h-3.5 w-3.5" /> Voltar à lista de viaturas
</button>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className={`text-xs uppercase tracking-[0.2em] ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>Ficha da Viatura</p>
<h1 className={`mt-1 text-2xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>
{selected.vehicle.plate} {selected.vehicle.brand} {selected.vehicle.model}
</h1>
<p className={`mt-1 text-sm ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>
Cliente: {selected.clientName} {selected.vehicle.year ? `· Ano: ${selected.vehicle.year}` : ''} {selected.vehicle.vin ? `· VIN: ${selected.vehicle.vin}` : ''}
</p>
</div>
<Button type="button" onClick={printVehicleHistory}>
<Printer className="h-4 w-4" /> Imprimir Ficha
</Button>
</div>
</header>
<div className="grid gap-3 md:grid-cols-3">
<article 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'}`}>Total de OTs</p>
<p className={`mt-1 text-xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>{selected.otCount}</p>
</article>
<article 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'}`}>Total acumulado</p>
<p className={`mt-1 text-xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>{currency(selected.total)}</p>
</article>
<article 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'}`}>Última visita</p>
<p className={`mt-1 text-xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>
{selected.lastVisit ? formatDate(selected.lastVisit) : '—'}
</p>
</article>
</div>
{detailsLoading && (
<p className={isLight ? 'text-sm text-slate-600' : 'text-sm text-slate-400'}>A carregar histórico de serviços...</p>
)}
<div className="space-y-4">
{selectedOrders.length === 0 ? (
<p className={`text-sm ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Sem ordens de trabalho para esta viatura.</p>
) : (
selectedOrders.map((o) => {
const d = detailByOrderId.get(o.id)
const items = d?.items ?? []
const hours = d?.staff_hours ?? []
return (
<article key={o.id} className={`rounded-2xl border p-5 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className={`font-mono text-sm ${isLight ? 'text-slate-900' : 'text-white'}`}>OT #{o.number}</span>
<Badge variant="outline" className={workOrderPhaseBadgeClass(getWorkOrderPhase(o.status))}>
{WORK_ORDER_STATUS_LABEL[o.status]}
</Badge>
</div>
<span className={`text-xs ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>{formatDate(o.created_at)}</span>
</div>
{items.length === 0 && hours.length === 0 ? (
<p className={`text-sm ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Sem itens ou horas registadas.</p>
) : (
<div className="overflow-hidden rounded-lg border border-slate-300/70 dark:border-slate-700">
<table className="w-full text-sm">
<thead className={isLight ? 'bg-slate-100' : 'bg-slate-800'}>
<tr>
<th className={`px-3 py-2 text-left ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Descrição</th>
<th className={`px-3 py-2 text-right ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Qtd/Horas</th>
<th className={`px-3 py-2 text-right ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Preço</th>
<th className={`px-3 py-2 text-right ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Total</th>
</tr>
</thead>
<tbody>
{items.map((i) => (
<tr key={i.id} className={`border-t ${isLight ? 'border-slate-200' : 'border-slate-700'}`}>
<td className={`px-3 py-2 ${isLight ? 'text-slate-800' : 'text-slate-200'}`}>{i.description}</td>
<td className={`px-3 py-2 text-right ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>{i.qty}</td>
<td className={`px-3 py-2 text-right ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>{currency(i.unit_price)}</td>
<td className={`px-3 py-2 text-right font-medium ${isLight ? 'text-slate-900' : 'text-white'}`}>{currency(i.total)}</td>
</tr>
))}
{hours.map((h) => (
<tr key={h.id} className={`border-t ${isLight ? 'border-slate-200' : 'border-slate-700'}`}>
<td className={`px-3 py-2 ${isLight ? 'text-slate-800' : 'text-slate-200'}`}>Mão de obra {staffNameById[h.staff_id] ?? 'Técnico'}</td>
<td className={`px-3 py-2 text-right ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>{h.hours}h</td>
<td className={`px-3 py-2 text-right ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>{currency(h.cost_per_hour)}</td>
<td className={`px-3 py-2 text-right font-medium ${isLight ? 'text-slate-900' : 'text-white'}`}>{currency(h.total)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<p className={`mt-2 text-right text-sm font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>
Subtotal: {currency(orderTotal(o))}
</p>
</article>
)
})
)}
</div>
</section>
)
}
return (
<section className="space-y-6">
<header className={`rounded-2xl border p-6 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<p className={`text-xs uppercase tracking-[0.2em] ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>Relatórios Operacionais</p>
<h1 className={`mt-1 text-3xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Relatório de Viaturas</h1>
<p className={`mt-2 text-sm ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>
Resumo de serviços executados por viatura clica numa viatura para ver o histórico completo e imprimir a ficha.
</p>
</header>
<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-2 xl:grid-cols-[minmax(220px,1fr)_minmax(220px,1fr)_auto]">
<label className="text-sm">
<span className={`mb-1 block ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Pesquisar viatura</span>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Matrícula, marca, modelo ou cliente"
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'}`}>Cliente</span>
<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</option>
{clients.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</label>
<div className="flex items-end">
<Button
type="button"
variant="outline"
onClick={() => {
setSearch('')
setClientFilter('')
}}
disabled={!hasFilters}
>
<FilterX className="h-4 w-4" />
Limpar filtros
</Button>
</div>
</div>
</div>
<div className="flex items-center gap-3 text-sm">
<Badge variant="secondary">{vehicles.length} viaturas total</Badge>
<Badge>{filteredVehicles.length} no filtro atual</Badge>
</div>
{(isLoading || detailsLoading) && (
<p className={isLight ? 'text-sm text-slate-600' : 'text-sm text-slate-400'}>A calcular resumo por viatura...</p>
)}
{filteredVehicles.length === 0 ? (
<p className={`text-sm ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Nenhuma viatura encontrada.</p>
) : (
<div className={`rounded-lg border overflow-hidden ${isLight ? 'border-slate-300' : 'border-slate-700'}`}>
<table className="w-full text-sm">
<thead className={isLight ? 'bg-slate-200' : 'bg-slate-800'}>
<tr>
<th className={`text-left px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>Matrícula</th>
<th className={`text-left px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>Marca / Modelo</th>
<th className={`text-left px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>Cliente</th>
<th className={`text-right px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}> OTs</th>
<th className={`text-left px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>Última visita</th>
<th className={`text-right px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>Total gasto</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{filteredVehicles.map((vs) => (
<tr
key={vs.vehicle.id}
className={`${isLight ? 'border-t border-slate-300 hover:bg-slate-100' : 'border-t border-slate-700 hover:bg-slate-800/50'}`}
>
<td className={`px-4 py-3 font-mono ${isLight ? 'text-slate-900' : 'text-white'}`}>{vs.vehicle.plate}</td>
<td className={`px-4 py-3 ${isLight ? 'text-slate-900' : 'text-slate-300'}`}>{vs.vehicle.brand} {vs.vehicle.model}</td>
<td className={`px-4 py-3 ${isLight ? 'text-slate-800' : 'text-slate-300'}`}>{vs.clientName}</td>
<td className={`px-4 py-3 text-right ${isLight ? 'text-slate-800' : 'text-slate-300'}`}>{vs.otCount}</td>
<td className={`px-4 py-3 ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>{vs.lastVisit ? formatDate(vs.lastVisit) : '—'}</td>
<td className={`px-4 py-3 text-right font-medium ${isLight ? 'text-slate-900' : 'text-white'}`}>{currency(vs.total)}</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => setSelectedVehicleId(vs.vehicle.id)}
className={`text-xs ${isLight ? 'text-blue-700 hover:text-blue-800' : 'text-blue-400 hover:text-blue-300'}`}
>
Ver histórico
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)
}
+12 -1
View File
@@ -15,6 +15,10 @@ import {
WORK_ORDER_STATUS_LABEL, WORK_ORDER_STATUS_LABEL,
getWorkOrderPhase, getWorkOrderPhase,
workOrderPhaseBadgeClass, workOrderPhaseBadgeClass,
deadlineColorClass,
deadlineSaldoDias,
deadlineSaldoLabel,
deadlineSaldoColorClass,
} from '@/lib/workOrderStatus' } from '@/lib/workOrderStatus'
import type { WorkOrderDetail, CatalogItem, Staff, Client, Vehicle } from '@/lib/types' import type { WorkOrderDetail, CatalogItem, Staff, Client, Vehicle } from '@/lib/types'
@@ -389,7 +393,14 @@ export default function WorkOrderDetailPage() {
{detail.status !== 'quote' && ( {detail.status !== 'quote' && (
<div> <div>
<p className={isLight ? 'text-slate-500' : 'text-slate-500'}>Prazo real (deadline)</p> <p className={isLight ? 'text-slate-500' : 'text-slate-500'}>Prazo real (deadline)</p>
<p className={isLight ? 'text-slate-800' : 'text-slate-200'}>{formatDateSafe(detail.real_deadline)}</p> <p className={detail.real_deadline ? deadlineColorClass(detail.real_deadline, isLight, detail.status) : (isLight ? 'text-slate-800' : 'text-slate-200')}>
{formatDateSafe(detail.real_deadline)}
</p>
{detail.real_deadline && detail.completed_at && detail.status !== 'cancelled' && (
<p className={`mt-0.5 text-xs ${deadlineSaldoColorClass(deadlineSaldoDias(detail.real_deadline, detail.completed_at), isLight)}`}>
{deadlineSaldoLabel(deadlineSaldoDias(detail.real_deadline, detail.completed_at))}
</p>
)}
</div> </div>
)} )}
{(detail.status === 'completed' || detail.status === 'invoiced') && ( {(detail.status === 'completed' || detail.status === 'invoiced') && (
+74 -8
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react' 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 { Link } from 'react-router'
import { useForm, type Resolver } from 'react-hook-form' import { useForm, type Resolver } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
@@ -15,8 +15,17 @@ import {
WORK_ORDER_STATUS_LABEL, WORK_ORDER_STATUS_LABEL,
getWorkOrderPhase, getWorkOrderPhase,
workOrderPhaseBadgeClass, workOrderPhaseBadgeClass,
deadlineColorClass,
deadlineSaldoDias,
deadlineSaldoLabel,
deadlineSaldoColorClass,
} from '@/lib/workOrderStatus' } 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({ const schema = z.object({
client_id: z.string(), client_id: z.string(),
@@ -60,6 +69,18 @@ export default function WorkOrdersPage() {
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 { data: allVehicles = [] } = useQuery<Vehicle[]>({
queryKey: ['vehicles', 'all-clients'],
queryFn: async () => {
const perClient = await Promise.all(
clients.map((c) => apiFetch<Vehicle[]>(`/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 filteredOrders = useMemo(() => {
const searchValue = search.trim().toLowerCase() const searchValue = search.trim().toLowerCase()
return orders.filter((o) => { return orders.filter((o) => {
@@ -75,6 +96,7 @@ export default function WorkOrdersPage() {
} }
if (!searchValue) return true if (!searchValue) return true
const clientName = o.client_id ? (clientNameById[o.client_id] ?? '') : '' const clientName = o.client_id ? (clientNameById[o.client_id] ?? '') : ''
const vehicle = o.vehicle_id ? vehicleById[o.vehicle_id] : undefined
const terms = [ const terms = [
`#${o.number}`, `#${o.number}`,
String(o.number), String(o.number),
@@ -82,12 +104,35 @@ export default function WorkOrdersPage() {
WORK_ORDER_STATUS_LABEL[o.status], WORK_ORDER_STATUS_LABEL[o.status],
o.internal_notes, o.internal_notes,
o.client_notes, o.client_notes,
vehicle?.plate ?? '',
vehicle?.brand ?? '',
vehicle?.model ?? '',
] ]
return terms.some((term) => term.toLowerCase().includes(searchValue)) 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 hasFilters = !!search.trim() || !!statusFilter || !!clientFilter || !!dateFrom || !!dateTo
const detailsQ = useQueries({
queries: filteredOrders.map((o) => ({
queryKey: ['work-order-detail', o.id, 'list-total'],
queryFn: () => apiFetch<WorkOrderDetail>(`/work-orders/${o.id}`),
staleTime: 5 * 60 * 1000,
})),
})
const totalByOrderId = useMemo(() => {
const totals: Record<string, number> = {}
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<FormData>({ const { register, handleSubmit, watch, reset } = useForm<FormData>({
resolver: zodResolver(schema) as Resolver<FormData>, resolver: zodResolver(schema) as Resolver<FormData>,
defaultValues: emptyOrder, defaultValues: emptyOrder,
@@ -137,7 +182,7 @@ export default function WorkOrdersPage() {
<Input <Input
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => 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'} className={isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'}
/> />
</div> </div>
@@ -307,10 +352,12 @@ export default function WorkOrdersPage() {
<tr> <tr>
<th className={`text-left px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}></th> <th className={`text-left px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}></th>
<th className={`text-left px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>Cliente</th> <th className={`text-left px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>Cliente</th>
<th className={`text-left px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>Viatura</th>
<th className={`text-left px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>Estado</th> <th className={`text-left px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>Estado</th>
<th className={`text-left px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>Previsão</th> <th className={`text-left px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>Previsão</th>
<th className={`text-left px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>Prazo real</th> <th className={`text-left px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>Prazo real</th>
<th className={`text-left px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>Criada</th> <th className={`text-left px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>Criada</th>
<th className={`text-right px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>Total</th>
<th className="px-4 py-3"></th> <th className="px-4 py-3"></th>
</tr> </tr>
</thead> </thead>
@@ -324,20 +371,39 @@ export default function WorkOrdersPage() {
<td className={`px-4 py-3 ${isLight ? 'text-slate-900' : 'text-slate-300'}`}> <td className={`px-4 py-3 ${isLight ? 'text-slate-900' : 'text-slate-300'}`}>
{o.client_id ? (clientNameById[o.client_id] ?? '—') : '—'} {o.client_id ? (clientNameById[o.client_id] ?? '—') : '—'}
</td> </td>
<td className={`px-4 py-3 ${isLight ? 'text-slate-800' : 'text-slate-300'}`}>
{o.vehicle_id && vehicleById[o.vehicle_id]
? `${vehicleById[o.vehicle_id].plate}${vehicleById[o.vehicle_id].brand} ${vehicleById[o.vehicle_id].model}`
: '—'}
</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<Badge variant="outline" className={workOrderPhaseBadgeClass(getWorkOrderPhase(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>
<td className={`px-4 py-3 ${isLight ? 'text-slate-800' : 'text-slate-300'}`}>{ETA_LABEL[o.eta_days] ?? `${o.eta_days} dias`}</td> <td className={`px-4 py-3 ${isLight ? 'text-slate-800' : 'text-slate-300'}`}>{ETA_LABEL[o.eta_days] ?? `${o.eta_days} dias`}</td>
<td className={`px-4 py-3 ${isLight ? 'text-slate-800' : 'text-slate-300'}`}> <td className="px-4 py-3">
{o.status !== 'quote' && o.real_deadline {o.status !== 'quote' && o.real_deadline ? (
? new Intl.DateTimeFormat('pt-PT').format(new Date(o.real_deadline)) <>
: '—'} <p className={deadlineColorClass(o.real_deadline, isLight, o.status)}>
{new Intl.DateTimeFormat('pt-PT').format(new Date(o.real_deadline))}
</p>
{o.completed_at && o.status !== 'cancelled' && (
<p className={`mt-0.5 text-[11px] ${deadlineSaldoColorClass(deadlineSaldoDias(o.real_deadline, o.completed_at), isLight)}`}>
{deadlineSaldoLabel(deadlineSaldoDias(o.real_deadline, o.completed_at))}
</p>
)}
</>
) : (
<p className={isLight ? 'text-slate-800' : 'text-slate-300'}></p>
)}
</td> </td>
<td className={`px-4 py-3 ${isLight ? 'text-slate-800' : 'text-slate-400'}`}> <td className={`px-4 py-3 ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>
{new Intl.DateTimeFormat('pt-PT').format(new Date(o.created_at))} {new Intl.DateTimeFormat('pt-PT').format(new Date(o.created_at))}
</td> </td>
<td className={`px-4 py-3 text-right font-medium ${isLight ? 'text-slate-900' : 'text-white'}`}>
{currency(totalByOrderId[o.id] ?? 0)}
</td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right">
<Link <Link
to={`/app/work-orders/${o.id}`} to={`/app/work-orders/${o.id}`}