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:
+4
-3
@@ -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"]
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
mkdir -p /app/storage
|
||||
chown -R app:app /app/storage
|
||||
|
||||
exec su-exec app "$@"
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<Route path="expenses" element={<ExpensesPage />} />
|
||||
<Route path="invoices" element={<InvoicesPage />} />
|
||||
<Route path="reports" element={<ReportsPage />} />
|
||||
<Route path="reports/vehicles" element={<VehicleReportsPage />} />
|
||||
<Route path="reports/expenses" element={<ExpenseReportsPage />} />
|
||||
<Route path="reports/technicians" element={<TechnicianReportsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
|
||||
@@ -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 },
|
||||
]
|
||||
|
||||
@@ -69,6 +69,7 @@ export interface WorkOrder {
|
||||
created_by: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
completed_at: string | null
|
||||
}
|
||||
|
||||
export interface WOItem {
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { WorkOrder } from '@/lib/types'
|
||||
|
||||
export const WORK_ORDER_STATUS_LABEL: Record<WorkOrder['status'], string> = {
|
||||
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<WorkOrderPhase, string> = {
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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<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 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() {
|
||||
</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
|
||||
title="Orçamento + Iniciar Trabalho"
|
||||
title="Orçamento + Em Progresso"
|
||||
value={String(metrics.activeOrders)}
|
||||
subtitle={`No período (${metrics.periodOrdersCount} OTs)`}
|
||||
subtitle={`${metrics.totalOrdersCount} OTs no total`}
|
||||
isLight={isLight}
|
||||
/>
|
||||
<DashboardCard
|
||||
@@ -253,6 +258,16 @@ export default function DashboardPage() {
|
||||
subtitle="Somatório no filtro selecionado"
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
<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'}`}>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<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-6 flex items-center justify-between">
|
||||
<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'}`}>
|
||||
Ver todas
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-5">
|
||||
{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 (
|
||||
<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={`font-mono ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>{count}</span>
|
||||
</div>
|
||||
@@ -332,9 +347,9 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</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>
|
||||
<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'}`}>
|
||||
<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>
|
||||
@@ -350,7 +365,7 @@ export default function DashboardPage() {
|
||||
<p className="text-xs uppercase tracking-wide">Ação recomendada</p>
|
||||
<p className="mt-1">
|
||||
{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.'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -358,7 +373,7 @@ export default function DashboardPage() {
|
||||
<p className="text-xs uppercase tracking-wide">Em orçamento</p>
|
||||
<p className="mt-1">
|
||||
{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.'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -377,7 +392,7 @@ export default function DashboardPage() {
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
{metrics.recentOrders.length === 0 ? (
|
||||
@@ -403,7 +418,7 @@ export default function DashboardPage() {
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<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'}`}>
|
||||
Abrir Despesas
|
||||
Mostrar tudo →
|
||||
</Link>
|
||||
</div>
|
||||
{metrics.recentExpenses.length === 0 ? (
|
||||
|
||||
@@ -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() {
|
||||
<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>Orçamento:</strong> ${kpis.quote}</div>
|
||||
<div class="kpi"><strong>Iniciar Trabalho:</strong> ${kpis.inProgress}</div>
|
||||
<div class="kpi"><strong>Concluir OT:</strong> ${kpis.completed}</div>
|
||||
<div class="kpi"><strong>Em Progresso:</strong> ${kpis.inProgress}</div>
|
||||
<div class="kpi"><strong>Concluída:</strong> ${kpis.completed}</div>
|
||||
<div class="kpi"><strong>Faturado:</strong> ${kpis.invoiced}</div>
|
||||
<div class="kpi"><strong>Resultado:</strong> ${currency(kpis.amount)}</div>
|
||||
<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>
|
||||
`)
|
||||
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]) => (
|
||||
<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>
|
||||
))}
|
||||
</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)}%`],
|
||||
['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]) => (
|
||||
<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>
|
||||
@@ -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>
|
||||
<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>
|
||||
</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'}`}>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'}`}>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'}`}>Resultado</th>
|
||||
</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'}`}>Nº 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>
|
||||
)
|
||||
}
|
||||
@@ -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' && (
|
||||
<div>
|
||||
<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>
|
||||
)}
|
||||
{(detail.status === 'completed' || detail.status === 'invoiced') && (
|
||||
|
||||
@@ -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<Client[]>('/clients'),
|
||||
})
|
||||
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 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<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>({
|
||||
resolver: zodResolver(schema) as Resolver<FormData>,
|
||||
defaultValues: emptyOrder,
|
||||
@@ -137,7 +182,7 @@ export default function WorkOrdersPage() {
|
||||
<Input
|
||||
value={search}
|
||||
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'}
|
||||
/>
|
||||
</div>
|
||||
@@ -307,10 +352,12 @@ export default function WorkOrdersPage() {
|
||||
<tr>
|
||||
<th className={`text-left px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>Nº</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'}`}>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'}`}>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>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -324,20 +371,39 @@ export default function WorkOrdersPage() {
|
||||
<td className={`px-4 py-3 ${isLight ? 'text-slate-900' : 'text-slate-300'}`}>
|
||||
{o.client_id ? (clientNameById[o.client_id] ?? '—') : '—'}
|
||||
</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">
|
||||
<Badge variant="outline" className={workOrderPhaseBadgeClass(getWorkOrderPhase(o.status))}>
|
||||
{WORK_ORDER_STATUS_LABEL[o.status]}
|
||||
</Badge>
|
||||
</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'}`}>
|
||||
{o.status !== 'quote' && o.real_deadline
|
||||
? new Intl.DateTimeFormat('pt-PT').format(new Date(o.real_deadline))
|
||||
: '—'}
|
||||
<td className="px-4 py-3">
|
||||
{o.status !== 'quote' && 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 className={`px-4 py-3 ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>
|
||||
{new Intl.DateTimeFormat('pt-PT').format(new Date(o.created_at))}
|
||||
</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">
|
||||
<Link
|
||||
to={`/app/work-orders/${o.id}`}
|
||||
|
||||
Reference in New Issue
Block a user