feat(reports): simplify dashboards, align OT flow labels, and normalize filters
This commit is contained in:
@@ -234,8 +234,8 @@ func addItemH() fiber.Handler {
|
|||||||
}
|
}
|
||||||
return fiber.NewError(500, "erro ao validar ordem")
|
return fiber.NewError(500, "erro ao validar ordem")
|
||||||
}
|
}
|
||||||
if status == "open" && b.ChangeJustification == "" {
|
if status == "in_progress" && b.ChangeJustification == "" {
|
||||||
return fiber.NewError(400, "justificação da alteração é obrigatória em orçamento aprovado")
|
return fiber.NewError(400, "justificação da alteração é obrigatória durante o trabalho")
|
||||||
}
|
}
|
||||||
item, err := AddItem(c.Context(), conn, c.Params("id"), b.CatalogItemID, b.Description, b.ChangeJustification, b.Qty, b.UnitPrice, b.DiscountPct)
|
item, err := AddItem(c.Context(), conn, c.Params("id"), b.CatalogItemID, b.Description, b.ChangeJustification, b.Qty, b.UnitPrice, b.DiscountPct)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -53,8 +53,7 @@ type WorkOrderDetail struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var allowedTransitions = map[string][]string{
|
var allowedTransitions = map[string][]string{
|
||||||
"quote": {"open", "cancelled"},
|
"quote": {"in_progress", "cancelled"},
|
||||||
"open": {"in_progress", "cancelled"},
|
|
||||||
"in_progress": {"completed", "cancelled"},
|
"in_progress": {"completed", "cancelled"},
|
||||||
"completed": {"invoiced", "cancelled"},
|
"completed": {"invoiced", "cancelled"},
|
||||||
"invoiced": {},
|
"invoiced": {},
|
||||||
@@ -236,7 +235,7 @@ func AddItem(ctx context.Context, conn *pgxpool.Conn, woID, catalogItemID, descr
|
|||||||
FROM work_orders
|
FROM work_orders
|
||||||
WHERE id=$1
|
WHERE id=$1
|
||||||
AND status NOT IN ('cancelled','invoiced')
|
AND status NOT IN ('cancelled','invoiced')
|
||||||
AND (status <> 'open' OR NULLIF($4,'') IS NOT NULL)
|
AND (status <> 'in_progress' OR NULLIF($4,'') IS NOT NULL)
|
||||||
RETURNING id, work_order_id, catalog_item_id, description, COALESCE(change_justification,''), qty, unit_price, discount_pct, total`,
|
RETURNING id, work_order_id, catalog_item_id, description, COALESCE(change_justification,''), qty, unit_price, discount_pct, total`,
|
||||||
woID, catalogItemID, description, changeJustification, qty, unitPrice, discountPct).
|
woID, catalogItemID, description, changeJustification, qty, unitPrice, discountPct).
|
||||||
Scan(&i.ID, &i.WorkOrderID, &i.CatalogItemID, &i.Description, &i.ChangeJustification, &i.Qty, &i.UnitPrice, &i.DiscountPct, &i.Total)
|
Scan(&i.ID, &i.WorkOrderID, &i.CatalogItemID, &i.Description, &i.ChangeJustification, &i.Qty, &i.UnitPrice, &i.DiscountPct, &i.Total)
|
||||||
|
|||||||
@@ -55,9 +55,9 @@ func TestWorkOrderTransition(t *testing.T) {
|
|||||||
wo, err := workorder.CreateWorkOrder(ctx, conn, "", "", "", "", 1, "", "")
|
wo, err := workorder.CreateWorkOrder(ctx, conn, "", "", "", "", 1, "", "")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
wo2, err := workorder.TransitionStatus(ctx, conn, wo.ID, "open", "", "", "")
|
wo2, err := workorder.TransitionStatus(ctx, conn, wo.ID, "in_progress", "", "", "")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "open", wo2.Status)
|
assert.Equal(t, "in_progress", wo2.Status)
|
||||||
|
|
||||||
_, err = workorder.TransitionStatus(ctx, conn, wo.ID, "cancelled_invalid", "", "", "")
|
_, err = workorder.TransitionStatus(ctx, conn, wo.ID, "cancelled_invalid", "", "", "")
|
||||||
assert.Error(t, err, "invalid transition should error")
|
assert.Error(t, err, "invalid transition should error")
|
||||||
@@ -69,15 +69,11 @@ func TestAllowedTransitions(t *testing.T) {
|
|||||||
to string
|
to string
|
||||||
valid bool
|
valid bool
|
||||||
}{
|
}{
|
||||||
{"quote", "open", true},
|
{"quote", "in_progress", true},
|
||||||
{"quote", "cancelled", true},
|
{"quote", "cancelled", true},
|
||||||
{"quote", "in_progress", false},
|
{"quote", "open", false},
|
||||||
{"open", "in_progress", true},
|
|
||||||
{"open", "cancelled", true},
|
|
||||||
{"open", "completed", false},
|
|
||||||
{"in_progress", "completed", true},
|
{"in_progress", "completed", true},
|
||||||
{"in_progress", "cancelled", true},
|
{"in_progress", "cancelled", true},
|
||||||
{"in_progress", "open", false},
|
|
||||||
{"completed", "invoiced", true},
|
{"completed", "invoiced", true},
|
||||||
{"completed", "cancelled", true},
|
{"completed", "cancelled", true},
|
||||||
{"invoiced", "cancelled", false},
|
{"invoiced", "cancelled", false},
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE work_orders
|
||||||
|
DROP CONSTRAINT IF EXISTS work_orders_status_check;
|
||||||
|
|
||||||
|
ALTER TABLE work_orders
|
||||||
|
ADD CONSTRAINT work_orders_status_check
|
||||||
|
CHECK (status IN ('quote', 'open', 'in_progress', 'completed', 'invoiced', 'cancelled'));
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
UPDATE work_orders
|
||||||
|
SET status = 'in_progress'
|
||||||
|
WHERE status = 'open';
|
||||||
|
|
||||||
|
ALTER TABLE work_orders
|
||||||
|
DROP CONSTRAINT IF EXISTS work_orders_status_check;
|
||||||
|
|
||||||
|
ALTER TABLE work_orders
|
||||||
|
ADD CONSTRAINT work_orders_status_check
|
||||||
|
CHECK (status IN ('quote', 'in_progress', 'completed', 'invoiced', 'cancelled'));
|
||||||
@@ -59,7 +59,7 @@ export interface WorkOrder {
|
|||||||
number: number
|
number: number
|
||||||
client_id: string | null
|
client_id: string | null
|
||||||
vehicle_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
|
internal_notes: string
|
||||||
client_notes: string
|
client_notes: string
|
||||||
eta_days: number
|
eta_days: number
|
||||||
|
|||||||
@@ -2,16 +2,14 @@ import type { WorkOrder } from '@/lib/types'
|
|||||||
|
|
||||||
export const WORK_ORDER_STATUS_LABEL: Record<WorkOrder['status'], string> = {
|
export const WORK_ORDER_STATUS_LABEL: Record<WorkOrder['status'], string> = {
|
||||||
quote: 'Orçamento',
|
quote: 'Orçamento',
|
||||||
open: 'Orçamento Aprovado',
|
in_progress: 'Iniciar Trabalho',
|
||||||
in_progress: 'Em Curso',
|
completed: 'Concluir OT',
|
||||||
completed: 'Concluída',
|
invoiced: 'Faturado',
|
||||||
invoiced: 'Faturada',
|
|
||||||
cancelled: 'Cancelada',
|
cancelled: 'Cancelada',
|
||||||
}
|
}
|
||||||
|
|
||||||
const LIGHT_STATUS_BADGE: Record<WorkOrder['status'], string> = {
|
const LIGHT_STATUS_BADGE: Record<WorkOrder['status'], string> = {
|
||||||
quote: 'border-[var(--ui-warning)] bg-[var(--ui-warning)] text-[var(--ui-warning-text)]',
|
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',
|
in_progress: 'border-indigo-400 bg-indigo-100 text-slate-800',
|
||||||
completed: 'border-emerald-400 bg-emerald-100 text-slate-800',
|
completed: 'border-emerald-400 bg-emerald-100 text-slate-800',
|
||||||
invoiced: 'border-teal-400 bg-teal-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']) {
|
export function workOrderStatusBadgeClass(status: WorkOrder['status']) {
|
||||||
return `border ${LIGHT_STATUS_BADGE[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 { useTheme } from '@/hooks/useTheme'
|
||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch } from '@/lib/api'
|
||||||
import type { Client, Expense, Invoice, Staff, WorkOrder, WorkOrderDetail } from '@/lib/types'
|
import type { Client, Expense, Invoice, Staff, WorkOrder, WorkOrderDetail } from '@/lib/types'
|
||||||
|
import { WORK_ORDER_STATUS_LABEL } from '@/lib/workOrderStatus'
|
||||||
|
|
||||||
const STATUS_LABEL: Record<WorkOrder['status'], string> = {
|
const STATUS_ORDER: WorkOrder['status'][] = ['quote', 'in_progress', 'completed', 'invoiced', 'cancelled']
|
||||||
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']
|
|
||||||
type PeriodKey = 'month' | '30d' | '90d' | 'year'
|
type PeriodKey = 'month' | '30d' | '90d' | 'year'
|
||||||
|
|
||||||
const PERIOD_OPTIONS: { key: PeriodKey; label: string }[] = [
|
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>
|
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 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 periodExpenses = expensesPeriod.reduce((sum, e) => sum + e.amount, 0)
|
||||||
const monthExpenses = expensesMonth.reduce((sum, e) => sum + e.amount, 0)
|
const monthExpenses = expensesMonth.reduce((sum, e) => sum + e.amount, 0)
|
||||||
const monthDocs = invoicesMonth.length
|
const monthDocs = invoicesMonth.length
|
||||||
@@ -172,6 +165,7 @@ export default function DashboardPage() {
|
|||||||
return {
|
return {
|
||||||
byStatus,
|
byStatus,
|
||||||
activeOrders,
|
activeOrders,
|
||||||
|
awaitingInvoice,
|
||||||
periodExpenses,
|
periodExpenses,
|
||||||
periodEntries,
|
periodEntries,
|
||||||
monthEntries,
|
monthEntries,
|
||||||
@@ -236,7 +230,7 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
<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-4">
|
||||||
<DashboardCard
|
<DashboardCard
|
||||||
title="OTs Ativas"
|
title="Orçamento + Iniciar Trabalho"
|
||||||
value={String(metrics.activeOrders)}
|
value={String(metrics.activeOrders)}
|
||||||
subtitle={`No período (${metrics.periodOrdersCount} OTs)`}
|
subtitle={`No período (${metrics.periodOrdersCount} OTs)`}
|
||||||
isLight={isLight}
|
isLight={isLight}
|
||||||
@@ -326,7 +320,7 @@ export default function DashboardPage() {
|
|||||||
return (
|
return (
|
||||||
<div key={status}>
|
<div key={status}>
|
||||||
<div className="mb-1 flex justify-between text-xs">
|
<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>
|
<span className={`font-mono ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>{count}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={`h-2 rounded-full ${isLight ? 'bg-slate-200' : 'bg-slate-800'}`}>
|
<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="text-xs uppercase tracking-wide">Ação recomendada</p>
|
||||||
<p className="mt-1">
|
<p className="mt-1">
|
||||||
{metrics.byStatus.completed > 0
|
{metrics.awaitingInvoice > 0
|
||||||
? `${metrics.byStatus.completed} OT(s) concluída(s) pronta(s) para faturar.`
|
? `${metrics.awaitingInvoice} OT(s) em "Concluir OT" pronta(s) para faturar.`
|
||||||
: 'Sem OTs concluídas pendentes de faturação.'}
|
: 'Sem OTs finalizadas pendentes de emissão de fatura.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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'}`}>
|
<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="text-xs uppercase tracking-wide">Em orçamento</p>
|
||||||
<p className="mt-1">
|
<p className="mt-1">
|
||||||
{metrics.byStatus.quote > 0
|
{metrics.byStatus.quote > 0
|
||||||
? `${metrics.byStatus.quote} OT(s) em orçamento aguardam aprovação.`
|
? `${metrics.byStatus.quote} OT(s) em "Orçamento" prontas para "Iniciar Trabalho".`
|
||||||
: 'Não existem OTs pendentes em orçamento.'}
|
: 'Não existem OTs pendentes em orçamento.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -394,7 +388,7 @@ export default function DashboardPage() {
|
|||||||
<li key={wo.id} className="flex items-center justify-between py-2">
|
<li key={wo.id} className="flex items-center justify-between py-2">
|
||||||
<div>
|
<div>
|
||||||
<p className={`text-sm font-medium ${isLight ? 'text-slate-900' : 'text-white'}`}>OT #{wo.number}</p>
|
<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>
|
</div>
|
||||||
<span className={`text-xs ${isLight ? 'text-slate-500' : 'text-slate-500'}`}>
|
<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))}
|
{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 { useTheme } from '@/hooks/useTheme'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
import type { Expense } from '@/lib/types'
|
import type { Expense } from '@/lib/types'
|
||||||
|
|
||||||
type PeriodKey = 'month' | '30d' | '90d' | 'year' | 'all'
|
type PeriodKey = 'month' | '30d' | '90d' | 'year' | 'all'
|
||||||
@@ -51,8 +52,11 @@ function monthKey(d: Date) {
|
|||||||
export default function ExpenseReportsPage() {
|
export default function ExpenseReportsPage() {
|
||||||
const { theme } = useTheme('ui_theme', 'dark')
|
const { theme } = useTheme('ui_theme', 'dark')
|
||||||
const isLight = theme === 'light'
|
const isLight = theme === 'light'
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
const [period, setPeriod] = useState<PeriodKey>('90d')
|
const [period, setPeriod] = useState<PeriodKey>('90d')
|
||||||
const [typeFilter, setTypeFilter] = useState<ExpenseType>('')
|
const [typeFilter, setTypeFilter] = useState<ExpenseType>('')
|
||||||
|
const [dateFrom, setDateFrom] = useState('')
|
||||||
|
const [dateTo, setDateTo] = useState('')
|
||||||
|
|
||||||
const expensesQ = useQuery<Expense[]>({
|
const expensesQ = useQuery<Expense[]>({
|
||||||
queryKey: ['expenses', 'reports'],
|
queryKey: ['expenses', 'reports'],
|
||||||
@@ -62,13 +66,26 @@ export default function ExpenseReportsPage() {
|
|||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const start = periodStart(period)
|
const start = periodStart(period)
|
||||||
|
const searchValue = search.trim().toLowerCase()
|
||||||
return expenses.filter((e) => {
|
return expenses.filter((e) => {
|
||||||
if (typeFilter && e.type !== typeFilter) return false
|
if (typeFilter && e.type !== typeFilter) return false
|
||||||
if (start && new Date(e.date) < start) 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
|
return true
|
||||||
})
|
})
|
||||||
}, [expenses, period, typeFilter])
|
}, [expenses, period, typeFilter, search, dateFrom, dateTo])
|
||||||
const hasFilters = period !== '90d' || !!typeFilter
|
const hasFilters = period !== '90d' || !!typeFilter || !!search.trim() || !!dateFrom || !!dateTo
|
||||||
|
|
||||||
const kpis = useMemo(() => {
|
const kpis = useMemo(() => {
|
||||||
const total = filtered.reduce((sum, e) => sum + e.amount, 0)
|
const total = filtered.reduce((sum, e) => sum + e.amount, 0)
|
||||||
@@ -149,7 +166,16 @@ export default function ExpenseReportsPage() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className={`rounded-2xl border p-4 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
|
<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">
|
<label className="text-sm">
|
||||||
<span className={`mb-1 block ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Período</span>
|
<span className={`mb-1 block ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Período</span>
|
||||||
<select
|
<select
|
||||||
@@ -176,6 +202,24 @@ export default function ExpenseReportsPage() {
|
|||||||
<option value="other">Outros</option>
|
<option value="other">Outros</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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">
|
<div className="space-y-1">
|
||||||
<span className={`mb-1 block text-sm ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Ações rápidas</span>
|
<span className={`mb-1 block text-sm ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Ações rápidas</span>
|
||||||
<Button
|
<Button
|
||||||
@@ -183,8 +227,11 @@ export default function ExpenseReportsPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
setSearch('')
|
||||||
setPeriod('90d')
|
setPeriod('90d')
|
||||||
setTypeFilter('')
|
setTypeFilter('')
|
||||||
|
setDateFrom('')
|
||||||
|
setDateTo('')
|
||||||
}}
|
}}
|
||||||
disabled={!hasFilters}
|
disabled={!hasFilters}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -35,7 +35,10 @@ export default function ExpensesPage() {
|
|||||||
const { theme } = useTheme('ui_theme', 'dark')
|
const { theme } = useTheme('ui_theme', 'dark')
|
||||||
const isLight = theme === 'light'
|
const isLight = theme === 'light'
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
const [typeFilter, setTypeFilter] = useState<ExpenseType>('')
|
const [typeFilter, setTypeFilter] = useState<ExpenseType>('')
|
||||||
|
const [dateFrom, setDateFrom] = useState('')
|
||||||
|
const [dateTo, setDateTo] = useState('')
|
||||||
const [showForm, setShowForm] = useState(false)
|
const [showForm, setShowForm] = useState(false)
|
||||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
|
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -43,11 +46,24 @@ export default function ExpensesPage() {
|
|||||||
queryKey: ['expenses'],
|
queryKey: ['expenses'],
|
||||||
queryFn: () => apiFetch<Expense[]>('/expenses'),
|
queryFn: () => apiFetch<Expense[]>('/expenses'),
|
||||||
})
|
})
|
||||||
const filteredExpenses = useMemo(
|
const filteredExpenses = useMemo(() => {
|
||||||
() => (typeFilter ? expenses.filter((e) => e.type === typeFilter) : expenses),
|
const searchValue = search.trim().toLowerCase()
|
||||||
[expenses, typeFilter]
|
return expenses.filter((e) => {
|
||||||
)
|
if (typeFilter && e.type !== typeFilter) return false
|
||||||
const hasFilters = !!typeFilter
|
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>({
|
const { register, handleSubmit, reset, formState: { errors } } = useForm<FormData>({
|
||||||
resolver: zodResolver(schema) as Resolver<FormData>,
|
resolver: zodResolver(schema) as Resolver<FormData>,
|
||||||
@@ -83,8 +99,17 @@ export default function ExpensesPage() {
|
|||||||
<Button onClick={() => { reset(emptyExpense); setShowForm(true) }}>Nova Despesa</Button>
|
<Button onClick={() => { reset(emptyExpense); setShowForm(true) }}>Nova Despesa</Button>
|
||||||
</div>
|
</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={`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 md:col-span-2">
|
<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>
|
<label className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Tipo de despesa</label>
|
||||||
<select
|
<select
|
||||||
value={typeFilter}
|
value={typeFilter}
|
||||||
@@ -99,13 +124,36 @@ export default function ExpensesPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div className="space-y-1">
|
||||||
<label className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Ações rápidas</label>
|
<label className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Ações rápidas</label>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={() => setTypeFilter('')}
|
onClick={() => {
|
||||||
|
setSearch('')
|
||||||
|
setTypeFilter('')
|
||||||
|
setDateFrom('')
|
||||||
|
setDateTo('')
|
||||||
|
}}
|
||||||
disabled={!hasFilters}
|
disabled={!hasFilters}
|
||||||
>
|
>
|
||||||
<FilterX className="h-4 w-4" />
|
<FilterX className="h-4 w-4" />
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export default function ReportsPage() {
|
|||||||
const byClient = useMemo(() => {
|
const byClient = useMemo(() => {
|
||||||
const totals = new Map<
|
const totals = new Map<
|
||||||
string,
|
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 orderById = new Map(filteredOrders.map((o) => [o.id, o]))
|
||||||
const clientNameById = new Map(clients.map((c) => [c.id, c.name]))
|
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) ?? {
|
const row = totals.get(detail.client_id) ?? {
|
||||||
clientName: clientNameById.get(detail.client_id) ?? 'Cliente',
|
clientName: clientNameById.get(detail.client_id) ?? 'Cliente',
|
||||||
ots: 0,
|
ots: 0,
|
||||||
invoiced: 0,
|
|
||||||
completed: 0,
|
completed: 0,
|
||||||
|
invoiced: 0,
|
||||||
amount: 0,
|
amount: 0,
|
||||||
}
|
}
|
||||||
row.ots += 1
|
row.ots += 1
|
||||||
row.amount += amount
|
row.amount += amount
|
||||||
|
if (order.status === 'completed') row.completed += 1
|
||||||
if (order.status === 'invoiced') row.invoiced += 1
|
if (order.status === 'invoiced') row.invoiced += 1
|
||||||
if (order.status === 'completed' || order.status === 'invoiced') row.completed += 1
|
|
||||||
totals.set(detail.client_id, row)
|
totals.set(detail.client_id, row)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +151,7 @@ export default function ReportsPage() {
|
|||||||
clientName: string
|
clientName: string
|
||||||
vehicleLabel: string
|
vehicleLabel: string
|
||||||
ots: number
|
ots: number
|
||||||
|
completed: number
|
||||||
invoiced: number
|
invoiced: number
|
||||||
amount: number
|
amount: number
|
||||||
}
|
}
|
||||||
@@ -175,11 +176,13 @@ export default function ReportsPage() {
|
|||||||
clientName: clientNameById.get(detail.client_id) ?? 'Cliente',
|
clientName: clientNameById.get(detail.client_id) ?? 'Cliente',
|
||||||
vehicleLabel: vehicleLabelById.get(detail.vehicle_id) ?? 'Viatura',
|
vehicleLabel: vehicleLabelById.get(detail.vehicle_id) ?? 'Viatura',
|
||||||
ots: 0,
|
ots: 0,
|
||||||
|
completed: 0,
|
||||||
invoiced: 0,
|
invoiced: 0,
|
||||||
amount: 0,
|
amount: 0,
|
||||||
}
|
}
|
||||||
row.ots += 1
|
row.ots += 1
|
||||||
row.amount += amount
|
row.amount += amount
|
||||||
|
if (order.status === 'completed') row.completed += 1
|
||||||
if (order.status === 'invoiced') row.invoiced += 1
|
if (order.status === 'invoiced') row.invoiced += 1
|
||||||
totals.set(key, row)
|
totals.set(key, row)
|
||||||
}
|
}
|
||||||
@@ -189,14 +192,16 @@ export default function ReportsPage() {
|
|||||||
|
|
||||||
const kpis = useMemo(() => {
|
const kpis = useMemo(() => {
|
||||||
const total = filteredOrders.length
|
const total = filteredOrders.length
|
||||||
|
const quote = filteredOrders.filter((o) => o.status === 'quote').length
|
||||||
const inProgress = filteredOrders.filter((o) => o.status === 'in_progress').length
|
const inProgress = filteredOrders.filter((o) => o.status === 'in_progress').length
|
||||||
const completed = filteredOrders.filter((o) => o.status === 'completed').length
|
const completed = filteredOrders.filter((o) => o.status === 'completed').length
|
||||||
const invoiced = filteredOrders.filter((o) => o.status === 'invoiced').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 amount = byClient.reduce((sum, c) => sum + c.amount, 0)
|
||||||
const withVehicle = filteredOrders.filter((o) => !!o.vehicle_id).length
|
const withVehicle = filteredOrders.filter((o) => !!o.vehicle_id).length
|
||||||
const invoiceRate = total > 0 ? (invoiced / total) * 100 : 0
|
const invoiceRate = total > 0 ? (invoiced / total) * 100 : 0
|
||||||
const avgTicket = total > 0 ? amount / total : 0
|
const avgTicket = total > 0 ? amount / total : 0
|
||||||
return { total, inProgress, completed, invoiced, amount, withVehicle, invoiceRate, avgTicket }
|
return { total, quote, inProgress, completed, invoiced, cancelled, amount, withVehicle, invoiceRate, avgTicket }
|
||||||
}, [filteredOrders, byClient])
|
}, [filteredOrders, byClient])
|
||||||
|
|
||||||
const topClientAmount = byClient[0]?.amount ?? 1
|
const topClientAmount = byClient[0]?.amount ?? 1
|
||||||
@@ -214,19 +219,12 @@ export default function ReportsPage() {
|
|||||||
.sort((a, b) => b.count - a.count)
|
.sort((a, b) => b.count - a.count)
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
const total = rows.reduce((s, r) => s + r.count, 0) || 1
|
const total = rows.reduce((s, r) => s + r.count, 0) || 1
|
||||||
let acc = 0
|
const segments = rows.map((r, idx) => ({
|
||||||
const segments = rows.map((r, idx) => {
|
...r,
|
||||||
const start = (acc / total) * 360
|
color: PIE_COLORS[idx % PIE_COLORS.length],
|
||||||
acc += r.count
|
pct: (r.count / total) * 100,
|
||||||
const end = (acc / total) * 360
|
}))
|
||||||
return { ...r, color: PIE_COLORS[idx % PIE_COLORS.length], start, end, pct: (r.count / total) * 100 }
|
return { segments, total: rows.reduce((s, r) => s + r.count, 0) }
|
||||||
})
|
|
||||||
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 }
|
|
||||||
}, [filteredOrders, vehicles])
|
}, [filteredOrders, vehicles])
|
||||||
|
|
||||||
const monthly = useMemo(() => {
|
const monthly = useMemo(() => {
|
||||||
@@ -260,7 +258,7 @@ export default function ReportsPage() {
|
|||||||
|
|
||||||
function downloadCsv() {
|
function downloadCsv() {
|
||||||
const lines: string[] = []
|
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) => {
|
byClient.forEach((row) => {
|
||||||
lines.push(
|
lines.push(
|
||||||
[
|
[
|
||||||
@@ -268,8 +266,8 @@ export default function ReportsPage() {
|
|||||||
csvEscape(row.clientName),
|
csvEscape(row.clientName),
|
||||||
'',
|
'',
|
||||||
row.ots,
|
row.ots,
|
||||||
row.invoiced,
|
|
||||||
row.completed,
|
row.completed,
|
||||||
|
row.invoiced,
|
||||||
row.amount.toFixed(2),
|
row.amount.toFixed(2),
|
||||||
row.avgTicket.toFixed(2),
|
row.avgTicket.toFixed(2),
|
||||||
].join(';')
|
].join(';')
|
||||||
@@ -282,8 +280,8 @@ export default function ReportsPage() {
|
|||||||
csvEscape(row.clientName),
|
csvEscape(row.clientName),
|
||||||
csvEscape(row.vehicleLabel),
|
csvEscape(row.vehicleLabel),
|
||||||
row.ots,
|
row.ots,
|
||||||
|
row.completed,
|
||||||
row.invoiced,
|
row.invoiced,
|
||||||
'',
|
|
||||||
row.amount.toFixed(2),
|
row.amount.toFixed(2),
|
||||||
'',
|
'',
|
||||||
].join(';')
|
].join(';')
|
||||||
@@ -303,7 +301,7 @@ export default function ReportsPage() {
|
|||||||
.slice(0, 12)
|
.slice(0, 12)
|
||||||
.map(
|
.map(
|
||||||
(r) =>
|
(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('')
|
.join('')
|
||||||
const w = window.open('', '_blank')
|
const w = window.open('', '_blank')
|
||||||
@@ -321,12 +319,13 @@ export default function ReportsPage() {
|
|||||||
<h1>Relatório Operacional de OTs</h1>
|
<h1>Relatório Operacional de OTs</h1>
|
||||||
<p>Período: ${PERIOD_OPTIONS.find((p) => p.key === period)?.label ?? period}</p>
|
<p>Período: ${PERIOD_OPTIONS.find((p) => p.key === period)?.label ?? period}</p>
|
||||||
<div class="kpi"><strong>OTs:</strong> ${kpis.total}</div>
|
<div class="kpi"><strong>OTs:</strong> ${kpis.total}</div>
|
||||||
<div class="kpi"><strong>Em curso:</strong> ${kpis.inProgress}</div>
|
<div class="kpi"><strong>Orçamento:</strong> ${kpis.quote}</div>
|
||||||
<div class="kpi"><strong>Concluídas:</strong> ${kpis.completed}</div>
|
<div class="kpi"><strong>Iniciar Trabalho:</strong> ${kpis.inProgress}</div>
|
||||||
<div class="kpi"><strong>Faturadas:</strong> ${kpis.invoiced}</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>
|
<div class="kpi"><strong>Resultado:</strong> ${currency(kpis.amount)}</div>
|
||||||
<h3>Top Clientes</h3>
|
<h3>Top Clientes</h3>
|
||||||
<table><thead><tr><th>Cliente</th><th>OTs</th><th>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>
|
</body></html>
|
||||||
`)
|
`)
|
||||||
w.document.close()
|
w.document.close()
|
||||||
@@ -427,24 +426,24 @@ export default function ReportsPage() {
|
|||||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
||||||
{[
|
{[
|
||||||
['OTs no período', String(kpis.total)],
|
['OTs no período', String(kpis.total)],
|
||||||
['Em curso', String(kpis.inProgress)],
|
['Orçamento', String(kpis.quote)],
|
||||||
['Concluídas', String(kpis.completed)],
|
['Iniciar Trabalho', String(kpis.inProgress)],
|
||||||
['Faturadas', String(kpis.invoiced)],
|
['Concluir OT', String(kpis.completed)],
|
||||||
['Resultado total', currency(kpis.amount)],
|
['Faturado', String(kpis.invoiced)],
|
||||||
].map(([label, value]) => (
|
].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={`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>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-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)],
|
['Ticket médio por OT', currency(kpis.avgTicket)],
|
||||||
['OTs com viatura', `${kpis.withVehicle}/${kpis.total}`],
|
['OTs com viatura', `${kpis.withVehicle}/${kpis.total}`],
|
||||||
].map(([label, value]) => (
|
].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={`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>
|
<p className={`mt-1 text-xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>{value}</p>
|
||||||
</article>
|
</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 className="h-2 rounded-full bg-gradient-to-r from-sky-500 to-cyan-400" style={{ width: `${width}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<p className={`mt-1 text-xs ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>
|
<p className={`mt-1 text-xs ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||||
Ticket médio: {currency(row.avgTicket)} | Faturadas: {row.invoiced}
|
Ticket médio: {currency(row.avgTicket)} | Concluir OT: {row.completed} | Faturado: {row.invoiced}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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'}`}>Cliente</th>
|
||||||
<th className={`px-3 py-2 text-left ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Viatura</th>
|
<th className={`px-3 py-2 text-left ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Viatura</th>
|
||||||
<th className={`px-3 py-2 text-right ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>OTs</th>
|
<th className={`px-3 py-2 text-right ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>OTs</th>
|
||||||
<th className={`px-3 py-2 text-right ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>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>
|
<th className={`px-3 py-2 text-right ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Resultado</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</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-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 ${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.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 ${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>
|
<td className={`px-3 py-2 text-right font-medium ${isLight ? 'text-slate-900' : 'text-white'}`}>{currency(row.amount)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -541,26 +542,20 @@ export default function ReportsPage() {
|
|||||||
|
|
||||||
<article className={`rounded-2xl border p-5 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
|
<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>
|
<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]">
|
<p className={`mt-1 text-sm ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>
|
||||||
<div className="mx-auto relative">
|
{brandPie.total} OTs com viatura no período.
|
||||||
<div
|
</p>
|
||||||
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)]"
|
<div className="mt-4 space-y-2">
|
||||||
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 ? (
|
{brandPie.segments.length === 0 ? (
|
||||||
<p className={`text-sm ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Sem viaturas para este filtro.</p>
|
<p className={`text-sm ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Sem viaturas para este filtro.</p>
|
||||||
) : (
|
) : (
|
||||||
brandPie.segments.map((s) => (
|
brandPie.segments.map((s, idx) => (
|
||||||
<div key={s.brand} className="rounded-md border border-slate-200/70 dark:border-slate-700 px-3 py-2">
|
<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 text-sm">
|
<div className="flex items-center justify-between gap-2 text-sm">
|
||||||
<div className="flex items-center gap-2">
|
<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="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>
|
<span className={isLight ? 'text-slate-800' : 'text-slate-200'}>{s.brand}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -575,7 +570,6 @@ export default function ReportsPage() {
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -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 className="h-2 rounded-full bg-gradient-to-r from-sky-500 to-cyan-400" style={{ width: `${(row.total / topValue) * 100}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<p className={`mt-1 text-xs ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>
|
<p className={`mt-1 text-xs ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||||
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import { ConfirmActionDialog } from '@/components/ui/confirm-action-dialog'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { useTheme } from '@/hooks/useTheme'
|
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'
|
import type { WorkOrderDetail, CatalogItem, Staff, Client, Vehicle } from '@/lib/types'
|
||||||
|
|
||||||
const CATEGORY_LABEL: Record<string, string> = {
|
const CATEGORY_LABEL: Record<string, string> = {
|
||||||
@@ -29,14 +33,6 @@ const CATEGORY_LABEL: Record<string, string> = {
|
|||||||
outro: 'Outro',
|
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> = {
|
const ETA_LABEL: Record<number, string> = {
|
||||||
1: '1 dia',
|
1: '1 dia',
|
||||||
2: '2 dias',
|
2: '2 dias',
|
||||||
@@ -313,8 +309,16 @@ export default function WorkOrderDetailPage() {
|
|||||||
const staffHours = Array.isArray(detail.staff_hours) ? detail.staff_hours : []
|
const staffHours = Array.isArray(detail.staff_hours) ? detail.staff_hours : []
|
||||||
const itemsTotal = items.reduce((s, i) => s + Number(i.total || 0), 0)
|
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 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 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 clientName = detail.client_id ? (clients.find((c) => c.id === detail.client_id)?.name ?? '—') : '—'
|
||||||
const woInvoices = invoices
|
const woInvoices = invoices
|
||||||
.filter((inv) => inv.work_order_id === detail.id)
|
.filter((inv) => inv.work_order_id === detail.id)
|
||||||
@@ -352,7 +356,7 @@ export default function WorkOrderDetailPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
<span className={isLight ? 'text-slate-400' : 'text-slate-600'}>/</span>
|
<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>
|
<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]}
|
{WORK_ORDER_STATUS_LABEL[detail.status]}
|
||||||
</Badge>
|
</Badge>
|
||||||
{selectedDoc && (
|
{selectedDoc && (
|
||||||
@@ -515,28 +519,32 @@ export default function WorkOrderDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Transitions */}
|
{/* Next action */}
|
||||||
{nextStates.length > 0 && (
|
{(primaryAction || canCancel) && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<span className="text-slate-400 text-sm self-center">Transição:</span>
|
<span className="text-slate-400 text-sm self-center">Próxima ação:</span>
|
||||||
{nextStates.map((s) => (
|
{primaryAction && (
|
||||||
<Button
|
<Button
|
||||||
key={s}
|
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={s === 'cancelled' ? 'destructive' : 'default'}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (s === 'cancelled') {
|
transition.mutate(primaryAction.status)
|
||||||
setConfirmAction({ type: 'cancel' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
transition.mutate(s)
|
|
||||||
}}
|
}}
|
||||||
disabled={transition.isPending}
|
disabled={transition.isPending}
|
||||||
>
|
>
|
||||||
→ {WORK_ORDER_STATUS_LABEL[s as keyof typeof WORK_ORDER_STATUS_LABEL]}
|
{primaryAction.label}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
)}
|
||||||
|
{canCancel && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setConfirmAction({ type: 'cancel' })}
|
||||||
|
disabled={transition.isPending}
|
||||||
|
>
|
||||||
|
Cancelar OT
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{transition.error && (
|
{transition.error && (
|
||||||
<p className="text-sm text-[var(--ui-danger)]">{(transition.error as Error).message}</p>
|
<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" />
|
<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>}
|
{itemErrors.description && <p className="text-red-400 text-xs">{itemErrors.description.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
{detail.status === 'open' && (
|
{detail.status === 'in_progress' && (
|
||||||
<div className="col-span-3 space-y-1">
|
<div className="col-span-3 space-y-1">
|
||||||
<Label>Justificação da alteração *</Label>
|
<Label>Justificação da alteração *</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -586,7 +594,7 @@ export default function WorkOrderDetailPage() {
|
|||||||
placeholder="Ex: Pedido do cliente para incluir peça adicional"
|
placeholder="Ex: Pedido do cliente para incluir peça adicional"
|
||||||
className="bg-slate-900 border-slate-600 text-white"
|
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>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
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'
|
import type { WorkOrder, Client, Vehicle } from '@/lib/types'
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
@@ -39,7 +43,11 @@ export default function WorkOrdersPage() {
|
|||||||
const { theme } = useTheme('ui_theme', 'dark')
|
const { theme } = useTheme('ui_theme', 'dark')
|
||||||
const isLight = theme === 'light'
|
const isLight = theme === 'light'
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
const [statusFilter, setStatusFilter] = useState('')
|
const [statusFilter, setStatusFilter] = useState('')
|
||||||
|
const [clientFilter, setClientFilter] = useState('')
|
||||||
|
const [dateFrom, setDateFrom] = useState('')
|
||||||
|
const [dateTo, setDateTo] = useState('')
|
||||||
const [showForm, setShowForm] = useState(false)
|
const [showForm, setShowForm] = useState(false)
|
||||||
const [docError, setDocError] = useState('')
|
const [docError, setDocError] = useState('')
|
||||||
|
|
||||||
@@ -47,17 +55,38 @@ export default function WorkOrdersPage() {
|
|||||||
queryKey: ['work-orders'],
|
queryKey: ['work-orders'],
|
||||||
queryFn: () => apiFetch<WorkOrder[]>('/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[]>({
|
const { data: clients = [] } = useQuery<Client[]>({
|
||||||
queryKey: ['clients'],
|
queryKey: ['clients'],
|
||||||
queryFn: () => apiFetch<Client[]>('/clients'),
|
queryFn: () => apiFetch<Client[]>('/clients'),
|
||||||
})
|
})
|
||||||
const clientNameById = Object.fromEntries(clients.map((c) => [c.id, c.name]))
|
const clientNameById = Object.fromEntries(clients.map((c) => [c.id, c.name]))
|
||||||
|
const 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>({
|
const { register, handleSubmit, watch, reset } = useForm<FormData>({
|
||||||
resolver: zodResolver(schema) as Resolver<FormData>,
|
resolver: zodResolver(schema) as Resolver<FormData>,
|
||||||
@@ -102,8 +131,17 @@ export default function WorkOrdersPage() {
|
|||||||
<Button onClick={() => { reset(emptyOrder); setShowForm(true) }}>Nova Ordem</Button>
|
<Button onClick={() => { reset(emptyOrder); setShowForm(true) }}>Nova Ordem</Button>
|
||||||
</div>
|
</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={`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 md:col-span-2">
|
<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>
|
<label className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Estado da OT</label>
|
||||||
<select
|
<select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
@@ -113,18 +151,57 @@ export default function WorkOrdersPage() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<option value="">Todos os estados</option>
|
<option value="">Todos os estados</option>
|
||||||
{(['quote', 'open', 'in_progress', 'completed', 'invoiced', 'cancelled'] as WorkOrder['status'][]).map((s) => (
|
{(['quote', 'in_progress', 'completed', 'invoiced', 'cancelled'] as WorkOrder['status'][]).map((status) => (
|
||||||
<option key={s} value={s}>{WORK_ORDER_STATUS_LABEL[s]}</option>
|
<option key={status} value={status}>{WORK_ORDER_STATUS_LABEL[status]}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div className="space-y-1">
|
||||||
<label className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Ações rápidas</label>
|
<label className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Ações rápidas</label>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={() => setStatusFilter('')}
|
onClick={() => {
|
||||||
|
setSearch('')
|
||||||
|
setStatusFilter('')
|
||||||
|
setClientFilter('')
|
||||||
|
setDateFrom('')
|
||||||
|
setDateTo('')
|
||||||
|
}}
|
||||||
disabled={!hasFilters}
|
disabled={!hasFilters}
|
||||||
>
|
>
|
||||||
<FilterX className="h-4 w-4" />
|
<FilterX className="h-4 w-4" />
|
||||||
@@ -248,7 +325,7 @@ export default function WorkOrdersPage() {
|
|||||||
{o.client_id ? (clientNameById[o.client_id] ?? '—') : '—'}
|
{o.client_id ? (clientNameById[o.client_id] ?? '—') : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<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]}
|
{WORK_ORDER_STATUS_LABEL[o.status]}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
Reference in New Issue
Block a user