256 lines
10 KiB
TypeScript
256 lines
10 KiB
TypeScript
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, 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 [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 { data: workOrders = [] } = useQuery<WorkOrder[]>({
|
|
queryKey: ['work-orders-for-transactions'],
|
|
queryFn: () => apiFetch<WorkOrder[]>('/work-orders'),
|
|
})
|
|
const { data: clients = [] } = useQuery<Client[]>({
|
|
queryKey: ['clients'],
|
|
queryFn: () => apiFetch<Client[]>('/clients'),
|
|
})
|
|
|
|
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 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('')
|
|
try {
|
|
const blob = await apiFetchBlob(`/invoices/${inv.id}/pdf`)
|
|
const url = URL.createObjectURL(blob)
|
|
window.open(url, '_blank', 'noopener,noreferrer')
|
|
setTimeout(() => URL.revokeObjectURL(url), 60_000)
|
|
} catch (err) {
|
|
setViewError((err as Error).message)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<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="mt-0.5 text-sm text-slate-400">
|
|
Consulta e impressão de documentos gerados pelo fluxo das OTs.
|
|
</p>
|
|
</div>
|
|
<Link to="/app/work-orders" className="text-xs text-cyan-300 hover:text-cyan-200">
|
|
Ir para Ordens de Trabalho →
|
|
</Link>
|
|
</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>
|
|
|
|
<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>
|
|
) : sorted.length === 0 ? (
|
|
<p className="text-sm text-slate-500">Nenhum documento encontrado com os filtros selecionados.</p>
|
|
) : (
|
|
<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>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|