Template genérico de checkout com ASAAS, parametrizado via env vars. Inclui fluxo completo: checkout → pedido → polling → webhook → admin. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
198 lines
7.2 KiB
TypeScript
198 lines
7.2 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useMemo } from "react"
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Button } from "@/components/ui/button"
|
|
import { PedidoDetailModal } from "@/components/pedido-detail-modal"
|
|
import { Search, Download } from "lucide-react"
|
|
import type { Pedido } from "@/lib/supabase"
|
|
|
|
const statusColors: Record<string, string> = {
|
|
PENDING: "bg-yellow-100 text-yellow-800",
|
|
RECEIVED: "bg-green-100 text-green-800",
|
|
CONFIRMED: "bg-green-100 text-green-800",
|
|
OVERDUE: "bg-red-100 text-red-800",
|
|
REFUNDED: "bg-gray-100 text-gray-800",
|
|
CANCELLED: "bg-red-100 text-red-800",
|
|
}
|
|
|
|
const statusLabel: Record<string, string> = {
|
|
PENDING: "Pendente",
|
|
RECEIVED: "Pago",
|
|
CONFIRMED: "Confirmado",
|
|
OVERDUE: "Vencido",
|
|
REFUNDED: "Estornado",
|
|
CANCELLED: "Cancelado",
|
|
}
|
|
|
|
const ALL_STATUSES = ["PENDING", "RECEIVED", "CONFIRMED", "OVERDUE", "REFUNDED", "CANCELLED"]
|
|
|
|
type PedidoRich = Pedido & {
|
|
clientes?: { nome: string; email: string; telefone?: string | null; cpf_cnpj?: string | null } | null
|
|
produtos?: { nome: string; tipo: string; validade?: string; midia?: string | null } | null
|
|
asaas_invoice_url?: string | null
|
|
pix_copia_cola?: string | null
|
|
pix_qrcode_url?: string | null
|
|
paid_at?: string | null
|
|
}
|
|
|
|
export function TransactionTable({ transactions }: { transactions: PedidoRich[] }) {
|
|
const [busca, setBusca] = useState("")
|
|
const [filtroStatus, setFiltroStatus] = useState<string | null>(null)
|
|
const [pedidoSelecionado, setPedidoSelecionado] = useState<PedidoRich | null>(null)
|
|
|
|
const fmt = (v: number) =>
|
|
(v / 100).toLocaleString("pt-BR", { style: "currency", currency: "BRL" })
|
|
|
|
const filtrados = useMemo(() => {
|
|
return transactions.filter((t) => {
|
|
const matchStatus = !filtroStatus || t.status === filtroStatus
|
|
const q = busca.toLowerCase()
|
|
const matchBusca =
|
|
!q ||
|
|
t.id.toLowerCase().includes(q) ||
|
|
(t.clientes?.nome ?? "").toLowerCase().includes(q) ||
|
|
(t.clientes?.email ?? "").toLowerCase().includes(q) ||
|
|
(t.produtos?.nome ?? "").toLowerCase().includes(q)
|
|
return matchStatus && matchBusca
|
|
})
|
|
}, [transactions, busca, filtroStatus])
|
|
|
|
function exportCSV() {
|
|
const header = ["ID", "Cliente", "Email", "Produto", "Valor", "Método", "Status", "Data"]
|
|
const rows = filtrados.map((t) => [
|
|
t.id,
|
|
t.clientes?.nome ?? "",
|
|
t.clientes?.email ?? "",
|
|
t.produtos?.nome ?? "",
|
|
(t.valor_centavos / 100).toFixed(2),
|
|
t.metodo_pagamento ?? "",
|
|
t.status,
|
|
new Date(t.created_at).toLocaleDateString("pt-BR"),
|
|
])
|
|
const csv = [header, ...rows].map((r) => r.map((c) => `"${c}"`).join(",")).join("\n")
|
|
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" })
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement("a")
|
|
a.href = url
|
|
a.download = `pedidos-${new Date().toISOString().slice(0, 10)}.csv`
|
|
a.click()
|
|
URL.revokeObjectURL(url)
|
|
}
|
|
|
|
if (transactions.length === 0) {
|
|
return (
|
|
<div className="rounded-md border p-8 text-center text-muted-foreground">
|
|
Nenhum pedido encontrado.
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{/* Toolbar */}
|
|
<div className="flex flex-wrap gap-2 items-center justify-between">
|
|
<div className="flex gap-2 flex-wrap">
|
|
<div className="relative">
|
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Buscar cliente, produto, ID..."
|
|
className="pl-8 h-9 w-64"
|
|
value={busca}
|
|
onChange={(e) => setBusca(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="flex gap-1 flex-wrap">
|
|
<Button
|
|
size="sm"
|
|
variant={filtroStatus === null ? "default" : "outline"}
|
|
className="h-9 text-xs"
|
|
onClick={() => setFiltroStatus(null)}
|
|
>
|
|
Todos
|
|
</Button>
|
|
{ALL_STATUSES.map((s) => (
|
|
<Button
|
|
key={s}
|
|
size="sm"
|
|
variant={filtroStatus === s ? "default" : "outline"}
|
|
className="h-9 text-xs"
|
|
onClick={() => setFiltroStatus(filtroStatus === s ? null : s)}
|
|
>
|
|
{statusLabel[s]}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<Button size="sm" variant="outline" className="h-9" onClick={exportCSV}>
|
|
<Download className="mr-2 h-4 w-4" />
|
|
Exportar CSV
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Contagem */}
|
|
<p className="text-xs text-muted-foreground">
|
|
{filtrados.length} pedido{filtrados.length !== 1 ? "s" : ""} encontrado{filtrados.length !== 1 ? "s" : ""}
|
|
</p>
|
|
|
|
{/* Tabela */}
|
|
<div className="rounded-md border">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>ID</TableHead>
|
|
<TableHead>Cliente</TableHead>
|
|
<TableHead>Produto</TableHead>
|
|
<TableHead>Valor</TableHead>
|
|
<TableHead>Método</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Data</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filtrados.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
|
|
Nenhum pedido encontrado com esses filtros.
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
filtrados.map((t) => (
|
|
<TableRow
|
|
key={t.id}
|
|
className="cursor-pointer hover:bg-muted/50"
|
|
onClick={() => setPedidoSelecionado(t)}
|
|
>
|
|
<TableCell className="font-mono text-xs">{t.id.slice(0, 8).toUpperCase()}</TableCell>
|
|
<TableCell className="max-w-[120px] truncate">{t.clientes?.nome ?? "—"}</TableCell>
|
|
<TableCell className="max-w-[140px] truncate">{t.produtos?.nome ?? "—"}</TableCell>
|
|
<TableCell className="font-medium">{fmt(t.valor_centavos)}</TableCell>
|
|
<TableCell className="text-xs">{t.metodo_pagamento ?? "—"}</TableCell>
|
|
<TableCell>
|
|
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[t.status] ?? ""}`}>
|
|
{statusLabel[t.status] ?? t.status}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
|
|
{new Date(t.created_at).toLocaleDateString("pt-BR")}
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
{/* Modal detalhe */}
|
|
{pedidoSelecionado && (
|
|
<PedidoDetailModal
|
|
pedido={pedidoSelecionado as Parameters<typeof PedidoDetailModal>[0]["pedido"]}
|
|
open={!!pedidoSelecionado}
|
|
onClose={() => setPedidoSelecionado(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|