feat: refine OT flow, reports, theming and sidebar UX
This commit is contained in:
@@ -1,56 +1,109 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
import { useQuery, useQueries } from '@tanstack/react-query'
|
||||
import { apiFetch, apiFetchBlob } from '@/lib/api'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { Invoice, WorkOrder } from '@/lib/types'
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
quote: 'Orçamento',
|
||||
open: 'Aberta',
|
||||
in_progress: 'Em Curso',
|
||||
completed: 'Concluída',
|
||||
invoiced: 'Faturada',
|
||||
cancelled: 'Cancelada',
|
||||
import type { Invoice, WorkOrder, Client, Vehicle } from '@/lib/types'
|
||||
|
||||
type PeriodKey = 'month' | '30d' | '90d' | 'year' | 'all'
|
||||
type DocFilter = 'all' | 'quote' | 'invoice'
|
||||
|
||||
const PERIOD_OPTIONS: { key: PeriodKey; label: string }[] = [
|
||||
{ key: 'month', label: 'Mês atual' },
|
||||
{ key: '30d', label: '30 dias' },
|
||||
{ key: '90d', label: '90 dias' },
|
||||
{ key: 'year', label: '12 meses' },
|
||||
{ key: 'all', label: 'Todo o período' },
|
||||
]
|
||||
|
||||
function getPeriodStart(period: PeriodKey, now = new Date()) {
|
||||
const start = new Date(now)
|
||||
if (period === 'all') return null
|
||||
if (period === 'month') {
|
||||
start.setDate(1)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
return start
|
||||
}
|
||||
if (period === '30d') {
|
||||
start.setDate(now.getDate() - 30)
|
||||
return start
|
||||
}
|
||||
if (period === '90d') {
|
||||
start.setDate(now.getDate() - 90)
|
||||
return start
|
||||
}
|
||||
start.setMonth(now.getMonth() - 12)
|
||||
return start
|
||||
}
|
||||
|
||||
export default function InvoicesPage() {
|
||||
const qc = useQueryClient()
|
||||
const [showGenerate, setShowGenerate] = useState(false)
|
||||
const [selectedWO, setSelectedWO] = useState('')
|
||||
const [docType, setDocType] = useState<'quote' | 'invoice'>('quote')
|
||||
const [viewError, setViewError] = useState('')
|
||||
const [period, setPeriod] = useState<PeriodKey>('month')
|
||||
const [clientFilter, setClientFilter] = useState('all')
|
||||
const [docFilter, setDocFilter] = useState<DocFilter>('all')
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
|
||||
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
|
||||
queryKey: ['invoices'],
|
||||
queryFn: () => apiFetch<Invoice[]>('/invoices'),
|
||||
})
|
||||
const quoteDocs = invoices.filter((i) => i.type === 'quote')
|
||||
const invoiceDocs = invoices.filter((i) => i.type === 'invoice')
|
||||
|
||||
const { data: workOrders = [] } = useQuery<WorkOrder[]>({
|
||||
queryKey: ['work-orders-for-invoice'],
|
||||
queryKey: ['work-orders-for-transactions'],
|
||||
queryFn: () => apiFetch<WorkOrder[]>('/work-orders'),
|
||||
enabled: showGenerate,
|
||||
})
|
||||
const { data: clients = [] } = useQuery<Client[]>({
|
||||
queryKey: ['clients'],
|
||||
queryFn: () => apiFetch<Client[]>('/clients'),
|
||||
})
|
||||
|
||||
const eligibleWOs = workOrders.filter((wo) =>
|
||||
wo.status === 'quote' || wo.status === 'open' || wo.status === 'in_progress' || wo.status === 'completed'
|
||||
const clientNameById = Object.fromEntries(clients.map((c) => [c.id, c.name]))
|
||||
const woById = Object.fromEntries(workOrders.map((wo) => [wo.id, wo]))
|
||||
const uniqueClientIds = Array.from(
|
||||
new Set(workOrders.map((wo) => wo.client_id).filter((id): id is string => !!id))
|
||||
)
|
||||
const eligibleSorted = [...eligibleWOs].sort((a, b) => b.number - a.number)
|
||||
|
||||
const generate = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch<Invoice>('/invoices', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ work_order_id: selectedWO, type: docType }),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['invoices'] })
|
||||
qc.invalidateQueries({ queryKey: ['work-orders'] })
|
||||
setShowGenerate(false)
|
||||
setSelectedWO('')
|
||||
},
|
||||
const vehiclesByClientQ = useQueries({
|
||||
queries: uniqueClientIds.map((clientId) => ({
|
||||
queryKey: ['vehicles', clientId, 'transactions'],
|
||||
queryFn: () => apiFetch<Vehicle[]>(`/clients/${clientId}/vehicles`),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})),
|
||||
})
|
||||
const vehicleById = useMemo(() => {
|
||||
const map: Record<string, Vehicle> = {}
|
||||
for (const q of vehiclesByClientQ) {
|
||||
for (const v of (q.data ?? [])) map[v.id] = v
|
||||
}
|
||||
return map
|
||||
}, [vehiclesByClientQ])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const start = getPeriodStart(period)
|
||||
return invoices.filter((inv) => {
|
||||
if (docFilter !== 'all' && inv.type !== docFilter) return false
|
||||
const wo = woById[inv.work_order_id]
|
||||
if (clientFilter !== 'all' && wo?.client_id !== clientFilter) return false
|
||||
const issued = new Date(inv.issued_at)
|
||||
if (start && issued < start) return false
|
||||
if (dateFrom) {
|
||||
const from = new Date(`${dateFrom}T00:00:00`)
|
||||
if (issued < from) return false
|
||||
}
|
||||
if (dateTo) {
|
||||
const to = new Date(`${dateTo}T23:59:59`)
|
||||
if (issued > to) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [invoices, woById, period, clientFilter, docFilter, dateFrom, dateTo])
|
||||
|
||||
const sorted = [...filtered].sort(
|
||||
(a, b) => new Date(b.issued_at).getTime() - new Date(a.issued_at).getTime()
|
||||
)
|
||||
const totalQuotes = sorted.filter((d) => d.type === 'quote').length
|
||||
const totalInvoices = sorted.filter((d) => d.type === 'invoice').length
|
||||
|
||||
async function openPDF(inv: Invoice) {
|
||||
setViewError('')
|
||||
@@ -66,143 +119,135 @@ export default function InvoicesPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="mb-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Transações</h1>
|
||||
<p className="text-slate-400 text-sm mt-0.5">
|
||||
{invoices.length} documentos emitidos (orçamentos e faturas)
|
||||
<p className="mt-0.5 text-sm text-slate-400">
|
||||
Consulta e impressão de documentos gerados pelo fluxo das OTs.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowGenerate(true)}>Nova Transação</Button>
|
||||
<Link to="/app/work-orders" className="text-xs text-cyan-300 hover:text-cyan-200">
|
||||
Ir para Ordens de Trabalho →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{showGenerate && (
|
||||
<div className="mb-6 bg-slate-800 rounded-lg border border-slate-700 p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Gerar Documento</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-slate-300">Tipo</label>
|
||||
<select
|
||||
value={docType}
|
||||
onChange={(e) => setDocType(e.target.value as 'quote' | 'invoice')}
|
||||
className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="quote">Orçamento</option>
|
||||
<option value="invoice">Fatura</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-slate-300">Ordem de Trabalho</label>
|
||||
<select
|
||||
value={selectedWO}
|
||||
onChange={(e) => setSelectedWO(e.target.value)}
|
||||
className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">— Seleccionar OT —</option>
|
||||
{eligibleSorted.map((wo) => (
|
||||
<option key={wo.id} value={wo.id}>
|
||||
#{wo.number} ({STATUS_LABELS[wo.status] ?? wo.status})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{generate.error && (
|
||||
<p className="text-red-400 text-sm mt-3">{(generate.error as Error).message}</p>
|
||||
)}
|
||||
<div className="flex gap-2 justify-end mt-4">
|
||||
<Button variant="outline" onClick={() => { setShowGenerate(false); setSelectedWO('') }}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => generate.mutate()}
|
||||
disabled={!selectedWO || generate.isPending}
|
||||
>
|
||||
{generate.isPending ? 'A gerar...' : 'Gerar PDF'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-4 grid gap-3 rounded-lg border border-slate-700 bg-slate-900/50 p-4 md:grid-cols-5">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-slate-400">Período</label>
|
||||
<select
|
||||
value={period}
|
||||
onChange={(e) => setPeriod(e.target.value as PeriodKey)}
|
||||
className="w-full rounded-md border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-white"
|
||||
>
|
||||
{PERIOD_OPTIONS.map((opt) => (
|
||||
<option key={opt.key} value={opt.key}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-slate-400">Cliente</label>
|
||||
<select
|
||||
value={clientFilter}
|
||||
onChange={(e) => setClientFilter(e.target.value)}
|
||||
className="w-full rounded-md border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-white"
|
||||
>
|
||||
<option value="all">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 text-slate-400">Tipo de documento</label>
|
||||
<select
|
||||
value={docFilter}
|
||||
onChange={(e) => setDocFilter(e.target.value as DocFilter)}
|
||||
className="w-full rounded-md border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-white"
|
||||
>
|
||||
<option value="all">Todos</option>
|
||||
<option value="quote">Orçamentos</option>
|
||||
<option value="invoice">Faturas</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-slate-400">Data início</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
className="w-full rounded-md border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-slate-400">Data fim</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
className="w-full rounded-md border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewError && (
|
||||
<p className="mb-4 text-red-400 text-sm">{viewError}</p>
|
||||
)}
|
||||
<div className="mb-4 flex items-center gap-3 text-sm">
|
||||
<Badge variant="secondary">{totalQuotes} orçamentos</Badge>
|
||||
<Badge>{totalInvoices} faturas</Badge>
|
||||
<span className="text-slate-400">{sorted.length} documento(s) no filtro atual</span>
|
||||
</div>
|
||||
|
||||
{viewError && <p className="mb-4 text-sm text-red-400">{viewError}</p>}
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-slate-400">A carregar...</p>
|
||||
) : invoices.length === 0 ? (
|
||||
<p className="text-slate-500 text-sm">Nenhum documento gerado.</p>
|
||||
) : sorted.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">Nenhum documento encontrado com os filtros selecionados.</p>
|
||||
) : (
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<div className="rounded-lg border border-slate-700 overflow-hidden">
|
||||
<div className="bg-slate-800 px-4 py-3 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-white">Orçamentos</h2>
|
||||
<Badge variant="secondary">{quoteDocs.length}</Badge>
|
||||
</div>
|
||||
{quoteDocs.length === 0 ? (
|
||||
<p className="px-4 py-5 text-slate-500 text-sm">Sem orçamentos gerados.</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-900/80">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2 text-slate-400 font-medium">Nº</th>
|
||||
<th className="text-left px-4 py-2 text-slate-400 font-medium">OT</th>
|
||||
<th className="text-left px-4 py-2 text-slate-400 font-medium">Emitida</th>
|
||||
<th className="px-4 py-2"></th>
|
||||
<div className="overflow-hidden rounded-lg border border-slate-700">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-800">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-medium text-slate-400">Tipo</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-slate-400">Nº</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-slate-400">OT / Cliente / Viatura</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-slate-400">Emitida</th>
|
||||
<th className="px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((inv) => {
|
||||
const wo = woById[inv.work_order_id]
|
||||
const clientName = wo?.client_id ? (clientNameById[wo.client_id] ?? '—') : '—'
|
||||
const vehiclePlate = wo?.vehicle_id ? (vehicleById[wo.vehicle_id]?.plate ?? '—') : '—'
|
||||
return (
|
||||
<tr key={inv.id} className="border-t border-slate-700 hover:bg-slate-800/50">
|
||||
<td className="px-4 py-2">
|
||||
{inv.type === 'quote' ? (
|
||||
<span className="inline-flex rounded-full border border-amber-400 bg-amber-100 px-2 py-0.5 text-xs font-medium text-slate-800">
|
||||
Orçamento
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex rounded-full border border-emerald-400 bg-emerald-100 px-2 py-0.5 text-xs font-medium text-slate-800">
|
||||
Fatura
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 font-mono text-white">#{inv.number}</td>
|
||||
<td className="px-4 py-2 text-xs text-slate-400">
|
||||
<div className="font-mono text-slate-300">#{wo?.number ?? '—'}</div>
|
||||
<div>{clientName}</div>
|
||||
<div>{vehiclePlate}</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-slate-400">
|
||||
{new Intl.DateTimeFormat('pt-PT').format(new Date(inv.issued_at))}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<Button size="sm" variant="outline" onClick={() => openPDF(inv)}>Ver PDF</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{quoteDocs.map((inv) => (
|
||||
<tr key={inv.id} className="border-t border-slate-700 hover:bg-slate-800/50">
|
||||
<td className="px-4 py-2 text-white font-mono">#{inv.number}</td>
|
||||
<td className="px-4 py-2 text-slate-400 font-mono text-xs">{inv.work_order_id.slice(0, 8)}…</td>
|
||||
<td className="px-4 py-2 text-slate-400">
|
||||
{new Intl.DateTimeFormat('pt-PT').format(new Date(inv.issued_at))}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<Button size="sm" variant="outline" onClick={() => openPDF(inv)}>Ver PDF</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-slate-700 overflow-hidden">
|
||||
<div className="bg-slate-800 px-4 py-3 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-white">Faturas</h2>
|
||||
<Badge>{invoiceDocs.length}</Badge>
|
||||
</div>
|
||||
{invoiceDocs.length === 0 ? (
|
||||
<p className="px-4 py-5 text-slate-500 text-sm">Sem faturas geradas.</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-900/80">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2 text-slate-400 font-medium">Nº</th>
|
||||
<th className="text-left px-4 py-2 text-slate-400 font-medium">OT</th>
|
||||
<th className="text-left px-4 py-2 text-slate-400 font-medium">Emitida</th>
|
||||
<th className="px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoiceDocs.map((inv) => (
|
||||
<tr key={inv.id} className="border-t border-slate-700 hover:bg-slate-800/50">
|
||||
<td className="px-4 py-2 text-white font-mono">#{inv.number}</td>
|
||||
<td className="px-4 py-2 text-slate-400 font-mono text-xs">{inv.work_order_id.slice(0, 8)}…</td>
|
||||
<td className="px-4 py-2 text-slate-400">
|
||||
{new Intl.DateTimeFormat('pt-PT').format(new Date(inv.issued_at))}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<Button size="sm" variant="outline" onClick={() => openPDF(inv)}>Ver PDF</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user