Files
techxcar/frontend/src/pages/app/InvoicesPage.tsx
T
2026-07-02 22:19:03 +01:00

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"></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>
)
}