feat: initial commit — asaas-checkout template white-label
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>
This commit is contained in:
197
components/transaction-table.tsx
Normal file
197
components/transaction-table.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user