feat(reports): simplify dashboards, align OT flow labels, and normalize filters
This commit is contained in:
@@ -59,7 +59,7 @@ export interface WorkOrder {
|
||||
number: number
|
||||
client_id: string | null
|
||||
vehicle_id: string | null
|
||||
status: 'quote' | 'open' | 'in_progress' | 'completed' | 'invoiced' | 'cancelled'
|
||||
status: 'quote' | 'in_progress' | 'completed' | 'invoiced' | 'cancelled'
|
||||
internal_notes: string
|
||||
client_notes: string
|
||||
eta_days: number
|
||||
|
||||
@@ -2,16 +2,14 @@ import type { WorkOrder } from '@/lib/types'
|
||||
|
||||
export const WORK_ORDER_STATUS_LABEL: Record<WorkOrder['status'], string> = {
|
||||
quote: 'Orçamento',
|
||||
open: 'Orçamento Aprovado',
|
||||
in_progress: 'Em Curso',
|
||||
completed: 'Concluída',
|
||||
invoiced: 'Faturada',
|
||||
in_progress: 'Iniciar Trabalho',
|
||||
completed: 'Concluir OT',
|
||||
invoiced: 'Faturado',
|
||||
cancelled: 'Cancelada',
|
||||
}
|
||||
|
||||
const LIGHT_STATUS_BADGE: Record<WorkOrder['status'], string> = {
|
||||
quote: 'border-[var(--ui-warning)] bg-[var(--ui-warning)] text-[var(--ui-warning-text)]',
|
||||
open: 'border-sky-400 bg-sky-100 text-slate-800',
|
||||
in_progress: 'border-indigo-400 bg-indigo-100 text-slate-800',
|
||||
completed: 'border-emerald-400 bg-emerald-100 text-slate-800',
|
||||
invoiced: 'border-teal-400 bg-teal-100 text-slate-800',
|
||||
@@ -21,3 +19,30 @@ const LIGHT_STATUS_BADGE: Record<WorkOrder['status'], string> = {
|
||||
export function workOrderStatusBadgeClass(status: WorkOrder['status']) {
|
||||
return `border ${LIGHT_STATUS_BADGE[status]}`
|
||||
}
|
||||
|
||||
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',
|
||||
cancelled: 'Cancelada',
|
||||
}
|
||||
|
||||
const PHASE_BADGE: Record<WorkOrderPhase, string> = {
|
||||
draft: 'border-amber-300 bg-amber-100 text-amber-900',
|
||||
active: 'border-blue-300 bg-blue-100 text-blue-900',
|
||||
done: 'border-emerald-300 bg-emerald-100 text-emerald-900',
|
||||
cancelled: 'border-[var(--ui-danger)] bg-[var(--ui-danger)] text-[var(--ui-danger-text)]',
|
||||
}
|
||||
|
||||
export function getWorkOrderPhase(status: WorkOrder['status']): WorkOrderPhase {
|
||||
if (status === 'cancelled') return 'cancelled'
|
||||
if (status === 'quote') return 'draft'
|
||||
if (status === 'in_progress') return 'active'
|
||||
return 'done'
|
||||
}
|
||||
|
||||
export function workOrderPhaseBadgeClass(phase: WorkOrderPhase) {
|
||||
return `border ${PHASE_BADGE[phase]}`
|
||||
}
|
||||
|
||||
@@ -4,17 +4,9 @@ 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'
|
||||
|
||||
const STATUS_LABEL: Record<WorkOrder['status'], string> = {
|
||||
quote: 'Orçamento',
|
||||
open: 'Orçamento Aprovado',
|
||||
in_progress: 'Em Curso',
|
||||
completed: 'Concluída',
|
||||
invoiced: 'Faturada',
|
||||
cancelled: 'Cancelada',
|
||||
}
|
||||
|
||||
const STATUS_ORDER: WorkOrder['status'][] = ['quote', 'open', 'in_progress', 'completed', 'invoiced', 'cancelled']
|
||||
const STATUS_ORDER: WorkOrder['status'][] = ['quote', 'in_progress', 'completed', 'invoiced', 'cancelled']
|
||||
type PeriodKey = 'month' | '30d' | '90d' | 'year'
|
||||
|
||||
const PERIOD_OPTIONS: { key: PeriodKey; label: string }[] = [
|
||||
@@ -149,7 +141,8 @@ export default function DashboardPage() {
|
||||
const byStatus = Object.fromEntries(STATUS_ORDER.map((s) => [s, 0])) as Record<WorkOrder['status'], number>
|
||||
for (const wo of workOrdersPeriod) byStatus[wo.status] += 1
|
||||
|
||||
const activeOrders = byStatus.quote + byStatus.open + byStatus.in_progress
|
||||
const activeOrders = byStatus.quote + byStatus.in_progress
|
||||
const awaitingInvoice = workOrdersPeriod.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
|
||||
@@ -172,6 +165,7 @@ export default function DashboardPage() {
|
||||
return {
|
||||
byStatus,
|
||||
activeOrders,
|
||||
awaitingInvoice,
|
||||
periodExpenses,
|
||||
periodEntries,
|
||||
monthEntries,
|
||||
@@ -236,7 +230,7 @@ export default function DashboardPage() {
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<DashboardCard
|
||||
title="OTs Ativas"
|
||||
title="Orçamento + Iniciar Trabalho"
|
||||
value={String(metrics.activeOrders)}
|
||||
subtitle={`No período (${metrics.periodOrdersCount} OTs)`}
|
||||
isLight={isLight}
|
||||
@@ -326,7 +320,7 @@ export default function DashboardPage() {
|
||||
return (
|
||||
<div key={status}>
|
||||
<div className="mb-1 flex justify-between text-xs">
|
||||
<span className={isLight ? 'text-slate-700' : 'text-slate-300'}>{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>
|
||||
</div>
|
||||
<div className={`h-2 rounded-full ${isLight ? 'bg-slate-200' : 'bg-slate-800'}`}>
|
||||
@@ -355,16 +349,16 @@ export default function DashboardPage() {
|
||||
>
|
||||
<p className="text-xs uppercase tracking-wide">Ação recomendada</p>
|
||||
<p className="mt-1">
|
||||
{metrics.byStatus.completed > 0
|
||||
? `${metrics.byStatus.completed} OT(s) concluída(s) pronta(s) para faturar.`
|
||||
: 'Sem OTs concluídas pendentes de faturação.'}
|
||||
{metrics.awaitingInvoice > 0
|
||||
? `${metrics.awaitingInvoice} OT(s) em "Concluir OT" pronta(s) para faturar.`
|
||||
: 'Sem OTs finalizadas pendentes de emissão de fatura.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`rounded-lg border px-3 py-2 ${isLight ? 'border-cyan-200 bg-cyan-50 text-cyan-900' : 'border-cyan-900/70 bg-cyan-950/25 text-cyan-100'}`}>
|
||||
<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 aguardam aprovação.`
|
||||
? `${metrics.byStatus.quote} OT(s) em "Orçamento" prontas para "Iniciar Trabalho".`
|
||||
: 'Não existem OTs pendentes em orçamento.'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -394,7 +388,7 @@ export default function DashboardPage() {
|
||||
<li key={wo.id} className="flex items-center justify-between py-2">
|
||||
<div>
|
||||
<p className={`text-sm font-medium ${isLight ? 'text-slate-900' : 'text-white'}`}>OT #{wo.number}</p>
|
||||
<p className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>{STATUS_LABEL[wo.status]}</p>
|
||||
<p className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>{WORK_ORDER_STATUS_LABEL[wo.status]}</p>
|
||||
</div>
|
||||
<span className={`text-xs ${isLight ? 'text-slate-500' : 'text-slate-500'}`}>
|
||||
{new Intl.DateTimeFormat('pt-PT', { dateStyle: 'short', timeStyle: 'short' }).format(new Date(wo.updated_at))}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 type { Expense } from '@/lib/types'
|
||||
|
||||
type PeriodKey = 'month' | '30d' | '90d' | 'year' | 'all'
|
||||
@@ -51,8 +52,11 @@ function monthKey(d: Date) {
|
||||
export default function ExpenseReportsPage() {
|
||||
const { theme } = useTheme('ui_theme', 'dark')
|
||||
const isLight = theme === 'light'
|
||||
const [search, setSearch] = useState('')
|
||||
const [period, setPeriod] = useState<PeriodKey>('90d')
|
||||
const [typeFilter, setTypeFilter] = useState<ExpenseType>('')
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
|
||||
const expensesQ = useQuery<Expense[]>({
|
||||
queryKey: ['expenses', 'reports'],
|
||||
@@ -62,13 +66,26 @@ export default function ExpenseReportsPage() {
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const start = periodStart(period)
|
||||
const searchValue = search.trim().toLowerCase()
|
||||
return expenses.filter((e) => {
|
||||
if (typeFilter && e.type !== typeFilter) return false
|
||||
if (start && new Date(e.date) < start) return false
|
||||
if (dateFrom) {
|
||||
const from = new Date(`${dateFrom}T00:00:00`)
|
||||
if (new Date(e.date) < from) return false
|
||||
}
|
||||
if (dateTo) {
|
||||
const to = new Date(`${dateTo}T23:59:59`)
|
||||
if (new Date(e.date) > to) return false
|
||||
}
|
||||
if (searchValue) {
|
||||
const terms = [e.description, TYPE_LABEL[e.type]]
|
||||
if (!terms.some((term) => term.toLowerCase().includes(searchValue))) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [expenses, period, typeFilter])
|
||||
const hasFilters = period !== '90d' || !!typeFilter
|
||||
}, [expenses, period, typeFilter, search, dateFrom, dateTo])
|
||||
const hasFilters = period !== '90d' || !!typeFilter || !!search.trim() || !!dateFrom || !!dateTo
|
||||
|
||||
const kpis = useMemo(() => {
|
||||
const total = filtered.reduce((sum, e) => sum + e.amount, 0)
|
||||
@@ -149,7 +166,16 @@ export default function ExpenseReportsPage() {
|
||||
</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-3">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-7">
|
||||
<label className="text-sm xl:col-span-2">
|
||||
<span className={`mb-1 block ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Pesquisar despesa</span>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Nome/descrição da despesa"
|
||||
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'}`}>Período</span>
|
||||
<select
|
||||
@@ -176,6 +202,24 @@ export default function ExpenseReportsPage() {
|
||||
<option value="other">Outros</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
<span className={`mb-1 block ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Data início</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
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'}`}>Data fim</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
className={isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'}
|
||||
/>
|
||||
</label>
|
||||
<div className="space-y-1">
|
||||
<span className={`mb-1 block text-sm ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Ações rápidas</span>
|
||||
<Button
|
||||
@@ -183,8 +227,11 @@ export default function ExpenseReportsPage() {
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
setSearch('')
|
||||
setPeriod('90d')
|
||||
setTypeFilter('')
|
||||
setDateFrom('')
|
||||
setDateTo('')
|
||||
}}
|
||||
disabled={!hasFilters}
|
||||
>
|
||||
|
||||
@@ -35,7 +35,10 @@ export default function ExpensesPage() {
|
||||
const { theme } = useTheme('ui_theme', 'dark')
|
||||
const isLight = theme === 'light'
|
||||
const qc = useQueryClient()
|
||||
const [search, setSearch] = useState('')
|
||||
const [typeFilter, setTypeFilter] = useState<ExpenseType>('')
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
|
||||
|
||||
@@ -43,11 +46,24 @@ export default function ExpensesPage() {
|
||||
queryKey: ['expenses'],
|
||||
queryFn: () => apiFetch<Expense[]>('/expenses'),
|
||||
})
|
||||
const filteredExpenses = useMemo(
|
||||
() => (typeFilter ? expenses.filter((e) => e.type === typeFilter) : expenses),
|
||||
[expenses, typeFilter]
|
||||
)
|
||||
const hasFilters = !!typeFilter
|
||||
const filteredExpenses = useMemo(() => {
|
||||
const searchValue = search.trim().toLowerCase()
|
||||
return expenses.filter((e) => {
|
||||
if (typeFilter && e.type !== typeFilter) return false
|
||||
if (dateFrom) {
|
||||
const from = new Date(`${dateFrom}T00:00:00`)
|
||||
if (new Date(e.date) < from) return false
|
||||
}
|
||||
if (dateTo) {
|
||||
const to = new Date(`${dateTo}T23:59:59`)
|
||||
if (new Date(e.date) > to) return false
|
||||
}
|
||||
if (!searchValue) return true
|
||||
const terms = [e.description || '', TYPE_LABELS[e.type]]
|
||||
return terms.some((term) => term.toLowerCase().includes(searchValue))
|
||||
})
|
||||
}, [expenses, typeFilter, dateFrom, dateTo, search])
|
||||
const hasFilters = !!search.trim() || !!typeFilter || !!dateFrom || !!dateTo
|
||||
|
||||
const { register, handleSubmit, reset, formState: { errors } } = useForm<FormData>({
|
||||
resolver: zodResolver(schema) as Resolver<FormData>,
|
||||
@@ -83,8 +99,17 @@ export default function ExpensesPage() {
|
||||
<Button onClick={() => { reset(emptyExpense); setShowForm(true) }}>Nova Despesa</Button>
|
||||
</div>
|
||||
|
||||
<div className={`mb-4 grid gap-3 rounded-lg border p-4 md:grid-cols-3 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/50'}`}>
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<div className={`mb-4 grid gap-3 rounded-lg border p-4 md:grid-cols-2 xl:grid-cols-6 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/50'}`}>
|
||||
<div className="space-y-1 xl:col-span-2">
|
||||
<label className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Pesquisar despesa</label>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Nome/descrição da despesa"
|
||||
className={isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Tipo de despesa</label>
|
||||
<select
|
||||
value={typeFilter}
|
||||
@@ -99,13 +124,36 @@ export default function ExpensesPage() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Data início</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
className={isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Data fim</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
className={isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Ações rápidas</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setTypeFilter('')}
|
||||
onClick={() => {
|
||||
setSearch('')
|
||||
setTypeFilter('')
|
||||
setDateFrom('')
|
||||
setDateTo('')
|
||||
}}
|
||||
disabled={!hasFilters}
|
||||
>
|
||||
<FilterX className="h-4 w-4" />
|
||||
|
||||
@@ -111,7 +111,7 @@ export default function ReportsPage() {
|
||||
const byClient = useMemo(() => {
|
||||
const totals = new Map<
|
||||
string,
|
||||
{ clientName: string; ots: number; invoiced: number; completed: number; amount: number }
|
||||
{ clientName: string; ots: number; completed: number; invoiced: number; amount: number }
|
||||
>()
|
||||
const orderById = new Map(filteredOrders.map((o) => [o.id, o]))
|
||||
const clientNameById = new Map(clients.map((c) => [c.id, c.name]))
|
||||
@@ -127,14 +127,14 @@ export default function ReportsPage() {
|
||||
const row = totals.get(detail.client_id) ?? {
|
||||
clientName: clientNameById.get(detail.client_id) ?? 'Cliente',
|
||||
ots: 0,
|
||||
invoiced: 0,
|
||||
completed: 0,
|
||||
invoiced: 0,
|
||||
amount: 0,
|
||||
}
|
||||
row.ots += 1
|
||||
row.amount += amount
|
||||
if (order.status === 'completed') row.completed += 1
|
||||
if (order.status === 'invoiced') row.invoiced += 1
|
||||
if (order.status === 'completed' || order.status === 'invoiced') row.completed += 1
|
||||
totals.set(detail.client_id, row)
|
||||
}
|
||||
|
||||
@@ -151,6 +151,7 @@ export default function ReportsPage() {
|
||||
clientName: string
|
||||
vehicleLabel: string
|
||||
ots: number
|
||||
completed: number
|
||||
invoiced: number
|
||||
amount: number
|
||||
}
|
||||
@@ -175,11 +176,13 @@ export default function ReportsPage() {
|
||||
clientName: clientNameById.get(detail.client_id) ?? 'Cliente',
|
||||
vehicleLabel: vehicleLabelById.get(detail.vehicle_id) ?? 'Viatura',
|
||||
ots: 0,
|
||||
completed: 0,
|
||||
invoiced: 0,
|
||||
amount: 0,
|
||||
}
|
||||
row.ots += 1
|
||||
row.amount += amount
|
||||
if (order.status === 'completed') row.completed += 1
|
||||
if (order.status === 'invoiced') row.invoiced += 1
|
||||
totals.set(key, row)
|
||||
}
|
||||
@@ -189,14 +192,16 @@ export default function ReportsPage() {
|
||||
|
||||
const kpis = useMemo(() => {
|
||||
const total = filteredOrders.length
|
||||
const quote = filteredOrders.filter((o) => o.status === 'quote').length
|
||||
const inProgress = filteredOrders.filter((o) => o.status === 'in_progress').length
|
||||
const completed = filteredOrders.filter((o) => o.status === 'completed').length
|
||||
const invoiced = filteredOrders.filter((o) => o.status === 'invoiced').length
|
||||
const cancelled = filteredOrders.filter((o) => o.status === 'cancelled').length
|
||||
const amount = byClient.reduce((sum, c) => sum + c.amount, 0)
|
||||
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, inProgress, completed, invoiced, amount, withVehicle, invoiceRate, avgTicket }
|
||||
return { total, quote, inProgress, completed, invoiced, cancelled, amount, withVehicle, invoiceRate, avgTicket }
|
||||
}, [filteredOrders, byClient])
|
||||
|
||||
const topClientAmount = byClient[0]?.amount ?? 1
|
||||
@@ -214,19 +219,12 @@ export default function ReportsPage() {
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 5)
|
||||
const total = rows.reduce((s, r) => s + r.count, 0) || 1
|
||||
let acc = 0
|
||||
const segments = rows.map((r, idx) => {
|
||||
const start = (acc / total) * 360
|
||||
acc += r.count
|
||||
const end = (acc / total) * 360
|
||||
return { ...r, color: PIE_COLORS[idx % PIE_COLORS.length], start, end, pct: (r.count / total) * 100 }
|
||||
})
|
||||
const css = segments.length
|
||||
? `conic-gradient(${segments
|
||||
.map((s) => `${s.color} ${s.start}deg ${s.end}deg`)
|
||||
.join(', ')})`
|
||||
: 'conic-gradient(#334155 0deg 360deg)'
|
||||
return { segments, css }
|
||||
const segments = rows.map((r, idx) => ({
|
||||
...r,
|
||||
color: PIE_COLORS[idx % PIE_COLORS.length],
|
||||
pct: (r.count / total) * 100,
|
||||
}))
|
||||
return { segments, total: rows.reduce((s, r) => s + r.count, 0) }
|
||||
}, [filteredOrders, vehicles])
|
||||
|
||||
const monthly = useMemo(() => {
|
||||
@@ -260,7 +258,7 @@ export default function ReportsPage() {
|
||||
|
||||
function downloadCsv() {
|
||||
const lines: string[] = []
|
||||
lines.push('Tipo;Cliente;Viatura;OTs;Faturadas;Concluidas;Resultado;TicketMedio')
|
||||
lines.push('Tipo;Cliente;Viatura;OTs;ConcluirOT;EmitirFatura;Resultado;TicketMedio')
|
||||
byClient.forEach((row) => {
|
||||
lines.push(
|
||||
[
|
||||
@@ -268,8 +266,8 @@ export default function ReportsPage() {
|
||||
csvEscape(row.clientName),
|
||||
'',
|
||||
row.ots,
|
||||
row.invoiced,
|
||||
row.completed,
|
||||
row.invoiced,
|
||||
row.amount.toFixed(2),
|
||||
row.avgTicket.toFixed(2),
|
||||
].join(';')
|
||||
@@ -282,8 +280,8 @@ export default function ReportsPage() {
|
||||
csvEscape(row.clientName),
|
||||
csvEscape(row.vehicleLabel),
|
||||
row.ots,
|
||||
row.completed,
|
||||
row.invoiced,
|
||||
'',
|
||||
row.amount.toFixed(2),
|
||||
'',
|
||||
].join(';')
|
||||
@@ -303,7 +301,7 @@ export default function ReportsPage() {
|
||||
.slice(0, 12)
|
||||
.map(
|
||||
(r) =>
|
||||
`<tr><td>${r.clientName}</td><td>${r.ots}</td><td>${r.invoiced}</td><td>${currency(r.avgTicket)}</td><td>${currency(r.amount)}</td></tr>`
|
||||
`<tr><td>${r.clientName}</td><td>${r.ots}</td><td>${r.completed}</td><td>${r.invoiced}</td><td>${currency(r.avgTicket)}</td><td>${currency(r.amount)}</td></tr>`
|
||||
)
|
||||
.join('')
|
||||
const w = window.open('', '_blank')
|
||||
@@ -321,12 +319,13 @@ export default function ReportsPage() {
|
||||
<h1>Relatório Operacional de OTs</h1>
|
||||
<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>Em curso:</strong> ${kpis.inProgress}</div>
|
||||
<div class="kpi"><strong>Concluídas:</strong> ${kpis.completed}</div>
|
||||
<div class="kpi"><strong>Faturadas:</strong> ${kpis.invoiced}</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>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>Faturadas</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>Concluir OT</th><th>Faturado</th><th>Ticket Médio</th><th>Resultado</th></tr></thead><tbody>${rows}</tbody></table>
|
||||
</body></html>
|
||||
`)
|
||||
w.document.close()
|
||||
@@ -427,24 +426,24 @@ export default function ReportsPage() {
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
||||
{[
|
||||
['OTs no período', String(kpis.total)],
|
||||
['Em curso', String(kpis.inProgress)],
|
||||
['Concluídas', String(kpis.completed)],
|
||||
['Faturadas', String(kpis.invoiced)],
|
||||
['Resultado total', currency(kpis.amount)],
|
||||
['Orçamento', String(kpis.quote)],
|
||||
['Iniciar Trabalho', String(kpis.inProgress)],
|
||||
['Concluir OT', String(kpis.completed)],
|
||||
['Faturado', String(kpis.invoiced)],
|
||||
].map(([label, value]) => (
|
||||
<article key={label} className={`rounded-xl border p-4 ${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={`mt-1 text-2xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>{value}</p>
|
||||
<p className={`mt-1 text-xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>{value}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{[
|
||||
['Taxa de faturação', `${kpis.invoiceRate.toFixed(1)}%`],
|
||||
['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}`],
|
||||
].map(([label, value]) => (
|
||||
<article key={label} className={`rounded-xl border p-4 ${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={`mt-1 text-xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>{value}</p>
|
||||
</article>
|
||||
@@ -476,7 +475,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)} | Faturadas: {row.invoiced}
|
||||
Ticket médio: {currency(row.avgTicket)} | Concluir OT: {row.completed} | Faturado: {row.invoiced}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
@@ -497,7 +496,8 @@ 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'}`}>Faturadas</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'}`}>Faturado</th>
|
||||
<th className={`px-3 py-2 text-right ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Resultado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -507,6 +507,7 @@ export default function ReportsPage() {
|
||||
<td className={`px-3 py-2 ${isLight ? 'text-slate-800' : 'text-slate-200'}`}>{row.clientName}</td>
|
||||
<td className={`px-3 py-2 ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>{row.vehicleLabel}</td>
|
||||
<td className={`px-3 py-2 text-right ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>{row.ots}</td>
|
||||
<td className={`px-3 py-2 text-right ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>{row.completed}</td>
|
||||
<td className={`px-3 py-2 text-right ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>{row.invoiced}</td>
|
||||
<td className={`px-3 py-2 text-right font-medium ${isLight ? 'text-slate-900' : 'text-white'}`}>{currency(row.amount)}</td>
|
||||
</tr>
|
||||
@@ -541,40 +542,33 @@ export default function ReportsPage() {
|
||||
|
||||
<article className={`rounded-2xl border p-5 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
|
||||
<h2 className={`text-lg font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Principais Marcas (OTs)</h2>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-[180px_1fr]">
|
||||
<div className="mx-auto relative">
|
||||
<div
|
||||
className="h-40 w-40 rounded-full border border-slate-300/70 dark:border-slate-700 shadow-[inset_0_0_30px_rgba(0,0,0,0.08)]"
|
||||
style={{ background: brandPie.css }}
|
||||
/>
|
||||
<div className={`absolute inset-0 m-auto h-16 w-16 rounded-full flex items-center justify-center ${isLight ? 'bg-white' : 'bg-slate-900'}`}>
|
||||
<span className={`text-xs font-semibold ${isLight ? 'text-slate-700' : 'text-slate-200'}`}>
|
||||
{brandPie.segments.reduce((s, x) => s + x.count, 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{brandPie.segments.length === 0 ? (
|
||||
<p className={`text-sm ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Sem viaturas para este filtro.</p>
|
||||
) : (
|
||||
brandPie.segments.map((s) => (
|
||||
<div key={s.brand} className="rounded-md border border-slate-200/70 dark:border-slate-700 px-3 py-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-full" style={{ backgroundColor: s.color }} />
|
||||
<span className={isLight ? 'text-slate-800' : 'text-slate-200'}>{s.brand}</span>
|
||||
</div>
|
||||
<span className={isLight ? 'text-slate-600' : 'text-slate-400'}>
|
||||
{s.count} OTs ({s.pct.toFixed(1)}%)
|
||||
<p className={`mt-1 text-sm ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>
|
||||
{brandPie.total} OTs com viatura no período.
|
||||
</p>
|
||||
<div className="mt-4 space-y-2">
|
||||
{brandPie.segments.length === 0 ? (
|
||||
<p className={`text-sm ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Sem viaturas para este filtro.</p>
|
||||
) : (
|
||||
brandPie.segments.map((s, idx) => (
|
||||
<div key={s.brand} className={`rounded-lg border px-3 py-3 ${isLight ? 'border-slate-200 bg-slate-50/70' : 'border-slate-700 bg-slate-900/40'}`}>
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`inline-flex h-6 min-w-6 items-center justify-center rounded-md text-xs font-semibold ${isLight ? 'bg-slate-200 text-slate-700' : 'bg-slate-800 text-slate-200'}`}>
|
||||
{idx + 1}
|
||||
</span>
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-full" style={{ backgroundColor: s.color }} />
|
||||
<span className={isLight ? 'text-slate-800' : 'text-slate-200'}>{s.brand}</span>
|
||||
</div>
|
||||
<div className={`mt-2 h-1.5 rounded-full ${isLight ? 'bg-slate-200' : 'bg-slate-800'}`}>
|
||||
<div className="h-1.5 rounded-full" style={{ width: `${s.pct}%`, backgroundColor: s.color }} />
|
||||
</div>
|
||||
<span className={isLight ? 'text-slate-600' : 'text-slate-400'}>
|
||||
{s.count} OTs ({s.pct.toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className={`mt-2 h-1.5 rounded-full ${isLight ? 'bg-slate-200' : 'bg-slate-800'}`}>
|
||||
<div className="h-1.5 rounded-full" style={{ width: `${s.pct}%`, backgroundColor: s.color }} />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -380,7 +380,7 @@ export default function TechnicianReportsPage() {
|
||||
<div className="h-2 rounded-full bg-gradient-to-r from-sky-500 to-cyan-400" style={{ width: `${(row.total / topValue) * 100}%` }} />
|
||||
</div>
|
||||
<p className={`mt-1 text-xs ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||
OTs: {row.otsCount} | Faturadas: {row.invoiced} | Média: {currency(row.avgHour)}/h
|
||||
OTs: {row.otsCount} | Faturado: {row.invoiced} | Média: {currency(row.avgHour)}/h
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -11,7 +11,11 @@ import { ConfirmActionDialog } from '@/components/ui/confirm-action-dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useTheme } from '@/hooks/useTheme'
|
||||
import { WORK_ORDER_STATUS_LABEL, workOrderStatusBadgeClass } from '@/lib/workOrderStatus'
|
||||
import {
|
||||
WORK_ORDER_STATUS_LABEL,
|
||||
getWorkOrderPhase,
|
||||
workOrderPhaseBadgeClass,
|
||||
} from '@/lib/workOrderStatus'
|
||||
import type { WorkOrderDetail, CatalogItem, Staff, Client, Vehicle } from '@/lib/types'
|
||||
|
||||
const CATEGORY_LABEL: Record<string, string> = {
|
||||
@@ -29,14 +33,6 @@ const CATEGORY_LABEL: Record<string, string> = {
|
||||
outro: 'Outro',
|
||||
}
|
||||
|
||||
const TRANSITIONS: Record<string, string[]> = {
|
||||
quote: ['open', 'cancelled'],
|
||||
open: ['in_progress', 'cancelled'],
|
||||
in_progress: ['completed', 'cancelled'],
|
||||
completed: ['invoiced', 'cancelled'],
|
||||
invoiced: [],
|
||||
cancelled: [],
|
||||
}
|
||||
const ETA_LABEL: Record<number, string> = {
|
||||
1: '1 dia',
|
||||
2: '2 dias',
|
||||
@@ -313,8 +309,16 @@ export default function WorkOrderDetailPage() {
|
||||
const staffHours = Array.isArray(detail.staff_hours) ? detail.staff_hours : []
|
||||
const itemsTotal = items.reduce((s, i) => s + Number(i.total || 0), 0)
|
||||
const hoursTotal = staffHours.reduce((s, h) => s + Number(h.total || 0), 0)
|
||||
const nextStates = TRANSITIONS[detail.status] ?? []
|
||||
const editable = detail.status !== 'invoiced' && detail.status !== 'cancelled'
|
||||
const phase = getWorkOrderPhase(detail.status)
|
||||
const canCancel = detail.status !== 'invoiced' && detail.status !== 'cancelled'
|
||||
const primaryAction = detail.status === 'quote'
|
||||
? { label: 'Iniciar Trabalho', status: 'in_progress' as const }
|
||||
: detail.status === 'in_progress'
|
||||
? { label: 'Concluir OT', status: 'completed' as const }
|
||||
: detail.status === 'completed'
|
||||
? { label: 'Emitir Fatura', status: 'invoiced' as const }
|
||||
: null
|
||||
const clientName = detail.client_id ? (clients.find((c) => c.id === detail.client_id)?.name ?? '—') : '—'
|
||||
const woInvoices = invoices
|
||||
.filter((inv) => inv.work_order_id === detail.id)
|
||||
@@ -352,7 +356,7 @@ export default function WorkOrderDetailPage() {
|
||||
</Link>
|
||||
<span className={isLight ? 'text-slate-400' : 'text-slate-600'}>/</span>
|
||||
<h1 className={`text-2xl font-bold ${isLight ? 'text-slate-900' : 'text-white'}`}>Ordem #{detail.number}</h1>
|
||||
<Badge variant="outline" className={workOrderStatusBadgeClass(detail.status)}>
|
||||
<Badge variant="outline" className={workOrderPhaseBadgeClass(phase)}>
|
||||
{WORK_ORDER_STATUS_LABEL[detail.status]}
|
||||
</Badge>
|
||||
{selectedDoc && (
|
||||
@@ -515,28 +519,32 @@ export default function WorkOrderDetailPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Transitions */}
|
||||
{nextStates.length > 0 && (
|
||||
{/* Next action */}
|
||||
{(primaryAction || canCancel) && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<span className="text-slate-400 text-sm self-center">Transição:</span>
|
||||
{nextStates.map((s) => (
|
||||
<span className="text-slate-400 text-sm self-center">Próxima ação:</span>
|
||||
{primaryAction && (
|
||||
<Button
|
||||
key={s}
|
||||
size="sm"
|
||||
variant={s === 'cancelled' ? 'destructive' : 'default'}
|
||||
onClick={() => {
|
||||
if (s === 'cancelled') {
|
||||
setConfirmAction({ type: 'cancel' })
|
||||
return
|
||||
}
|
||||
transition.mutate(s)
|
||||
transition.mutate(primaryAction.status)
|
||||
}}
|
||||
disabled={transition.isPending}
|
||||
>
|
||||
→ {WORK_ORDER_STATUS_LABEL[s as keyof typeof WORK_ORDER_STATUS_LABEL]}
|
||||
{primaryAction.label}
|
||||
</Button>
|
||||
))}
|
||||
)}
|
||||
{canCancel && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => setConfirmAction({ type: 'cancel' })}
|
||||
disabled={transition.isPending}
|
||||
>
|
||||
Cancelar OT
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{transition.error && (
|
||||
<p className="text-sm text-[var(--ui-danger)]">{(transition.error as Error).message}</p>
|
||||
@@ -578,7 +586,7 @@ export default function WorkOrderDetailPage() {
|
||||
<Input {...regItem('description')} className="bg-slate-900 border-slate-600 text-white" />
|
||||
{itemErrors.description && <p className="text-red-400 text-xs">{itemErrors.description.message}</p>}
|
||||
</div>
|
||||
{detail.status === 'open' && (
|
||||
{detail.status === 'in_progress' && (
|
||||
<div className="col-span-3 space-y-1">
|
||||
<Label>Justificação da alteração *</Label>
|
||||
<Input
|
||||
@@ -586,7 +594,7 @@ export default function WorkOrderDetailPage() {
|
||||
placeholder="Ex: Pedido do cliente para incluir peça adicional"
|
||||
className="bg-slate-900 border-slate-600 text-white"
|
||||
/>
|
||||
<p className="text-xs text-slate-500">Obrigatório em Orçamento Aprovado para garantir transparência.</p>
|
||||
<p className="text-xs text-slate-500">Obrigatório durante o trabalho para garantir transparência.</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -11,7 +11,11 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { WORK_ORDER_STATUS_LABEL, workOrderStatusBadgeClass } from '@/lib/workOrderStatus'
|
||||
import {
|
||||
WORK_ORDER_STATUS_LABEL,
|
||||
getWorkOrderPhase,
|
||||
workOrderPhaseBadgeClass,
|
||||
} from '@/lib/workOrderStatus'
|
||||
import type { WorkOrder, Client, Vehicle } from '@/lib/types'
|
||||
|
||||
const schema = z.object({
|
||||
@@ -39,7 +43,11 @@ export default function WorkOrdersPage() {
|
||||
const { theme } = useTheme('ui_theme', 'dark')
|
||||
const isLight = theme === 'light'
|
||||
const qc = useQueryClient()
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
const [clientFilter, setClientFilter] = useState('')
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [docError, setDocError] = useState('')
|
||||
|
||||
@@ -47,17 +55,38 @@ export default function WorkOrdersPage() {
|
||||
queryKey: ['work-orders'],
|
||||
queryFn: () => apiFetch<WorkOrder[]>('/work-orders'),
|
||||
})
|
||||
const filteredOrders = useMemo(
|
||||
() => (statusFilter ? orders.filter((o) => o.status === statusFilter) : orders),
|
||||
[orders, statusFilter]
|
||||
)
|
||||
const hasFilters = statusFilter.length > 0
|
||||
|
||||
const { data: clients = [] } = useQuery<Client[]>({
|
||||
queryKey: ['clients'],
|
||||
queryFn: () => apiFetch<Client[]>('/clients'),
|
||||
})
|
||||
const clientNameById = Object.fromEntries(clients.map((c) => [c.id, c.name]))
|
||||
const filteredOrders = useMemo(() => {
|
||||
const searchValue = search.trim().toLowerCase()
|
||||
return orders.filter((o) => {
|
||||
if (statusFilter && o.status !== statusFilter) return false
|
||||
if (clientFilter && o.client_id !== clientFilter) return false
|
||||
if (dateFrom) {
|
||||
const from = new Date(`${dateFrom}T00:00:00`)
|
||||
if (new Date(o.created_at) < from) return false
|
||||
}
|
||||
if (dateTo) {
|
||||
const to = new Date(`${dateTo}T23:59:59`)
|
||||
if (new Date(o.created_at) > to) return false
|
||||
}
|
||||
if (!searchValue) return true
|
||||
const clientName = o.client_id ? (clientNameById[o.client_id] ?? '') : ''
|
||||
const terms = [
|
||||
`#${o.number}`,
|
||||
String(o.number),
|
||||
clientName,
|
||||
WORK_ORDER_STATUS_LABEL[o.status],
|
||||
o.internal_notes,
|
||||
o.client_notes,
|
||||
]
|
||||
return terms.some((term) => term.toLowerCase().includes(searchValue))
|
||||
})
|
||||
}, [orders, search, statusFilter, clientFilter, dateFrom, dateTo, clientNameById])
|
||||
const hasFilters = !!search.trim() || !!statusFilter || !!clientFilter || !!dateFrom || !!dateTo
|
||||
|
||||
const { register, handleSubmit, watch, reset } = useForm<FormData>({
|
||||
resolver: zodResolver(schema) as Resolver<FormData>,
|
||||
@@ -102,8 +131,17 @@ export default function WorkOrdersPage() {
|
||||
<Button onClick={() => { reset(emptyOrder); setShowForm(true) }}>Nova Ordem</Button>
|
||||
</div>
|
||||
|
||||
<div className={`mb-4 grid gap-3 rounded-lg border p-4 md:grid-cols-3 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/50'}`}>
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<div className={`mb-4 grid gap-3 rounded-lg border p-4 md:grid-cols-2 xl:grid-cols-7 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/50'}`}>
|
||||
<div className="space-y-1 xl:col-span-2">
|
||||
<label className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Pesquisar OT</label>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Nº OT, cliente, estado ou notas"
|
||||
className={isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Estado da OT</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
@@ -113,18 +151,57 @@ export default function WorkOrdersPage() {
|
||||
}`}
|
||||
>
|
||||
<option value="">Todos os estados</option>
|
||||
{(['quote', 'open', 'in_progress', 'completed', 'invoiced', 'cancelled'] as WorkOrder['status'][]).map((s) => (
|
||||
<option key={s} value={s}>{WORK_ORDER_STATUS_LABEL[s]}</option>
|
||||
{(['quote', 'in_progress', 'completed', 'invoiced', 'cancelled'] as WorkOrder['status'][]).map((status) => (
|
||||
<option key={status} value={status}>{WORK_ORDER_STATUS_LABEL[status]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Cliente</label>
|
||||
<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 os clientes</option>
|
||||
{clients.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Criada de</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
className={isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Criada até</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
className={isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Ações rápidas</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setStatusFilter('')}
|
||||
onClick={() => {
|
||||
setSearch('')
|
||||
setStatusFilter('')
|
||||
setClientFilter('')
|
||||
setDateFrom('')
|
||||
setDateTo('')
|
||||
}}
|
||||
disabled={!hasFilters}
|
||||
>
|
||||
<FilterX className="h-4 w-4" />
|
||||
@@ -248,7 +325,7 @@ export default function WorkOrdersPage() {
|
||||
{o.client_id ? (clientNameById[o.client_id] ?? '—') : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="outline" className={workOrderStatusBadgeClass(o.status)}>
|
||||
<Badge variant="outline" className={workOrderPhaseBadgeClass(getWorkOrderPhase(o.status))}>
|
||||
{WORK_ORDER_STATUS_LABEL[o.status]}
|
||||
</Badge>
|
||||
</td>
|
||||
|
||||
Reference in New Issue
Block a user