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,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 ? (
|
||||
|
||||
Reference in New Issue
Block a user