feat: atualizar fluxo OT, dashboard, transacoes e PDFs
This commit is contained in:
@@ -1,22 +1,31 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
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 TYPE_LABELS: Record<string, string> = { quote: 'Orçamento', invoice: 'Fatura' }
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
quote: 'Orçamento',
|
||||
open: 'Aberta',
|
||||
in_progress: 'Em Curso',
|
||||
completed: 'Concluída',
|
||||
invoiced: 'Faturada',
|
||||
cancelled: 'Cancelada',
|
||||
}
|
||||
|
||||
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 { 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'],
|
||||
@@ -25,8 +34,9 @@ export default function InvoicesPage() {
|
||||
})
|
||||
|
||||
const eligibleWOs = workOrders.filter((wo) =>
|
||||
wo.status === 'open' || wo.status === 'in_progress' || wo.status === 'completed'
|
||||
wo.status === 'quote' || wo.status === 'open' || wo.status === 'in_progress' || wo.status === 'completed'
|
||||
)
|
||||
const eligibleSorted = [...eligibleWOs].sort((a, b) => b.number - a.number)
|
||||
|
||||
const generate = useMutation({
|
||||
mutationFn: () =>
|
||||
@@ -42,14 +52,28 @@ export default function InvoicesPage() {
|
||||
},
|
||||
})
|
||||
|
||||
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="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Faturação</h1>
|
||||
<p className="text-slate-400 text-sm mt-0.5">{invoices.length} documentos</p>
|
||||
<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>
|
||||
</div>
|
||||
<Button onClick={() => setShowGenerate(true)}>Gerar Documento</Button>
|
||||
<Button onClick={() => setShowGenerate(true)}>Nova Transação</Button>
|
||||
</div>
|
||||
|
||||
{showGenerate && (
|
||||
@@ -75,9 +99,9 @@ export default function InvoicesPage() {
|
||||
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>
|
||||
{eligibleWOs.map((wo) => (
|
||||
{eligibleSorted.map((wo) => (
|
||||
<option key={wo.id} value={wo.id}>
|
||||
#{wo.number} ({wo.status})
|
||||
#{wo.number} ({STATUS_LABELS[wo.status] ?? wo.status})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -100,50 +124,85 @@ export default function InvoicesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewError && (
|
||||
<p className="mb-4 text-red-400 text-sm">{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>
|
||||
) : (
|
||||
<div className="rounded-lg border border-slate-700 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-800">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 text-slate-400 font-medium">Nº</th>
|
||||
<th className="text-left px-4 py-3 text-slate-400 font-medium">Tipo</th>
|
||||
<th className="text-left px-4 py-3 text-slate-400 font-medium">OT</th>
|
||||
<th className="text-left px-4 py-3 text-slate-400 font-medium">Emitida</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoices.map((inv) => (
|
||||
<tr key={inv.id} className="border-t border-slate-700 hover:bg-slate-800/50">
|
||||
<td className="px-4 py-3 text-white font-mono">#{inv.number}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={inv.type === 'invoice' ? 'default' : 'secondary'}>
|
||||
{TYPE_LABELS[inv.type]}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-400 font-mono text-xs">
|
||||
{inv.work_order_id.slice(0, 8)}…
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-400">
|
||||
{new Intl.DateTimeFormat('pt-PT').format(new Date(inv.issued_at))}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => window.open(`/api/v1/invoices/${inv.id}/pdf`, '_blank')}
|
||||
>
|
||||
Descarregar PDF
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user