feat(frontend): app shell + login + platform dashboard + billing tarifas

Primeiro commit do frontend Next.js (agente.md secao 161-176): login
split-brand, dashboard "Visão Geral da Plataforma" com instrumentos ao
vivo, e a tela Billing > Tarifas (price books + rate decks) completa —
listagem com busca/ordenacao, criacao com itens/entradas dinamicos via
Server Actions, detalhe — ponta a ponta contra a API real de billing
(fase 22).

Corrige de quebra 2 bugs reais achados construindo Tarifas: a topbar
tinha o titulo fixo "Visao Geral" em toda pagina, e a sidebar fixa de
256px nao tinha nenhuma versao mobile (conteudo espremido em ~130px) —
agora vira drawer via Radix Dialog abaixo de lg, com titulo/descricao da
topbar resolvidos dinamicamente por rota.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWHKmcVJtstQFErbZ1AanY
This commit is contained in:
2026-08-29 00:54:29 -03:00
parent b27cfaab02
commit 673553975e
55 changed files with 2504 additions and 0 deletions

View File

@@ -0,0 +1,54 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import * as Dialog from "@radix-ui/react-dialog";
import { Menu, X } from "lucide-react";
import { NavList } from "./nav-list";
/** Drawer de navegação pra telas abaixo de `lg` (secao 175: notebook/
* tablet são prioridade, mas o próprio celular do agente precisa
* funcionar) — a sidebar fixa de 256px não cabe num viewport de celular.
* Radix Dialog dá foco preso + Escape + clique fora de graça (secao 176:
* navegação completa por teclado). */
export function MobileNavDrawer() {
const [open, setOpen] = useState(false);
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger asChild>
<button
type="button"
aria-label="Abrir menu de navegação"
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring lg:hidden"
>
<Menu className="h-5 w-5" aria-hidden />
</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-40 bg-[hsl(224_45%_11%)]/50 opacity-0 backdrop-blur-[1px] transition-opacity duration-200 data-[state=open]:opacity-100" />
<Dialog.Content
className="fixed inset-y-0 left-0 z-50 flex w-72 max-w-[85vw] -translate-x-full flex-col bg-surface shadow-pop outline-none transition-transform duration-200 ease-out data-[state=open]:translate-x-0"
aria-describedby={undefined}
>
<Dialog.Title className="sr-only">Navegação da plataforma</Dialog.Title>
<div className="flex h-16 items-center justify-between border-b border-border px-4">
<Image src="/branding/b2blogo.png" alt="B2BCall" width={110} height={27} />
<Dialog.Close asChild>
<button
type="button"
aria-label="Fechar menu"
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="h-4 w-4" aria-hidden />
</button>
</Dialog.Close>
</div>
<nav className="flex-1 overflow-y-auto px-2 py-3" aria-label="Navegação principal">
<NavList onNavigate={() => setOpen(false)} />
</nav>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -0,0 +1,95 @@
import type { LucideIcon } from "lucide-react";
import {
LayoutDashboard,
Building2,
CreditCard,
ServerCog,
Sparkles,
Settings2,
} from "lucide-react";
export interface NavLeaf {
label: string;
href?: string;
/** Subtítulo mostrado na topbar quando esta rota está ativa. */
description?: string;
}
export interface NavSection {
label: string;
icon: LucideIcon;
href?: string;
description?: string;
children?: NavLeaf[];
}
/** IA fixa do menu Platform (agente.md secao 168) — "Visão Geral" e
* "Billing > Tarifas" têm página construída até agora, o resto existe pra
* provar a arquitetura completa (Product Principle #5 em PRODUCT.md: esta
* é a implementação de referência do design system pras próximas telas),
* renderizado como indisponível em vez de virar link morto. Os outros 3
* itens de Billing (Consumo/Fechamentos/Relatórios) esperam um seletor de
* tenant real — não existe endpoint de listagem de tenants ainda (ver
* docs/BILLING.md), então ficam "em breve" até essa peça existir. */
export const PLATFORM_NAV: NavSection[] = [
{
label: "Visão Geral",
icon: LayoutDashboard,
href: "/platform",
description: "Estado agregado de todos os tenants, agora",
},
{
label: "Clientes",
icon: Building2,
children: [{ label: "Tenants" }, { label: "Planos" }, { label: "Assinaturas" }, { label: "Quotas" }],
},
{
label: "Billing",
icon: CreditCard,
children: [
{ label: "Consumo" },
{
label: "Tarifas",
href: "/platform/billing/tarifas",
description: "Price books e rate decks — catálogos globais de preço",
},
{ label: "Fechamentos" },
{ label: "Relatórios" },
],
},
{
label: "Infraestrutura",
icon: ServerCog,
children: [{ label: "FreeSWITCH" }, { label: "SIP Profiles" }, { label: "Nodes" }, { label: "Saúde" }],
},
{
label: "IA",
icon: Sparkles,
children: [{ label: "Providers" }, { label: "Modelos" }, { label: "Uso" }, { label: "Custos" }],
},
{
label: "Sistema",
icon: Settings2,
children: [{ label: "Usuários" }, { label: "Permissões" }, { label: "Auditoria" }, { label: "Configurações" }],
},
];
/** Título+descrição da topbar pra rota atual — casa pelo `href` mais longo
* (uma subrota como `/platform/billing/tarifas/price-books/123` ainda
* resolve pro item de menu "Tarifas"), nunca deixa a topbar com um título
* de outra página (bug real encontrado construindo a tela de Tarifas). */
export function getPageMeta(pathname: string): { title: string; description?: string } {
let best: { href: string; title: string; description?: string } | null = null;
for (const section of PLATFORM_NAV) {
const candidates: Array<{ href?: string; label: string; description?: string }> = [section, ...(section.children ?? [])];
for (const candidate of candidates) {
if (!candidate.href || !pathname.startsWith(candidate.href)) continue;
if (!best || candidate.href.length > best.href.length) {
best = { href: candidate.href, title: candidate.label, description: candidate.description };
}
}
}
return best ?? { title: "Platform" };
}

View File

@@ -0,0 +1,94 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { PLATFORM_NAV } from "./nav-data";
/** Lista de navegação compartilhada entre a sidebar fixa (desktop, secao
* 166) e o drawer mobile (`MobileNavDrawer`) — uma única fonte de verdade
* pra estado ativo e pro "em breve" dos itens ainda não construídos. */
export function NavList({ collapsed = false, onNavigate }: { collapsed?: boolean; onNavigate?: () => void }) {
const pathname = usePathname();
return (
<ul className="space-y-0.5">
{PLATFORM_NAV.map((section) => {
const Icon = section.icon;
const active = section.href && pathname === section.href;
if (section.href) {
return (
<li key={section.label}>
<Link
href={section.href}
onClick={onNavigate}
aria-current={active ? "page" : undefined}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
active ? "bg-primary/10 text-primary" : "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Icon className="h-4 w-4 shrink-0" aria-hidden />
{!collapsed && <span>{section.label}</span>}
</Link>
</li>
);
}
const sectionHasActiveChild = section.children?.some((leaf) => leaf.href && pathname.startsWith(leaf.href));
return (
<li key={section.label} className="pt-2">
<div
className={cn(
"flex items-center gap-3 px-3 py-1.5 text-xs font-semibold uppercase tracking-wide",
sectionHasActiveChild ? "text-foreground" : "text-muted-foreground/70",
)}
>
<Icon className="h-4 w-4 shrink-0" aria-hidden />
{!collapsed && <span>{section.label}</span>}
</div>
{!collapsed && section.children && (
<ul className="ml-[26px] space-y-0.5 border-l border-border pl-3">
{section.children.map((leaf) => {
const leafActive = leaf.href && pathname.startsWith(leaf.href);
if (leaf.href) {
return (
<li key={leaf.label}>
<Link
href={leaf.href}
onClick={onNavigate}
aria-current={leafActive ? "page" : undefined}
className={cn(
"flex items-center justify-between rounded-md px-2 py-1.5 text-sm font-medium transition-colors",
leafActive ? "bg-primary/10 text-primary" : "text-foreground hover:bg-muted",
)}
>
{leaf.label}
</Link>
</li>
);
}
return (
<li key={leaf.label}>
<span
className="flex cursor-not-allowed items-center justify-between rounded-md px-2 py-1.5 text-sm text-muted-foreground/50"
title="Ainda não construído nesta passada"
>
{leaf.label}
<span className="text-[10px] font-medium uppercase tracking-wide">em breve</span>
</span>
</li>
);
})}
</ul>
)}
</li>
);
})}
</ul>
);
}

View File

@@ -0,0 +1,40 @@
"use client";
import { useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { NavList } from "./nav-list";
/** Sidebar fixa (secao 166) — só em telas lg+; abaixo disso vira drawer
* (`MobileNavDrawer`, acionado pela topbar) porque uma coluna de 256px
* fixa não cabe num viewport de celular (bug real achado construindo a
* tela de Tarifas: o conteúdo ficava espremido em ~130px). */
export function PlatformSidebar() {
const [collapsed, setCollapsed] = useState(false);
return (
<aside
className={cn(
"hidden h-dvh shrink-0 flex-col border-r border-border bg-surface transition-[width] duration-200 lg:flex",
collapsed ? "w-[68px]" : "w-64",
)}
>
<div className="flex h-16 items-center justify-between border-b border-border px-4">
{!collapsed && <span className="text-sm font-semibold tracking-wide text-foreground">PLATFORM</span>}
<button
type="button"
onClick={() => setCollapsed((c) => !c)}
aria-label={collapsed ? "Expandir menu" : "Recolher menu"}
aria-pressed={collapsed}
className="ml-auto flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
</button>
</div>
<nav className="flex-1 overflow-y-auto px-2 py-3" aria-label="Navegação principal">
<NavList collapsed={collapsed} />
</nav>
</aside>
);
}

View File

@@ -0,0 +1,59 @@
"use client";
import { useEffect, useState } from "react";
import { Sun, Moon, Monitor } from "lucide-react";
import { cn } from "@/lib/utils";
type Theme = "light" | "dark" | "system";
const OPTIONS: { value: Theme; label: string; icon: typeof Sun }[] = [
{ value: "light", label: "Claro", icon: Sun },
{ value: "dark", label: "Escuro", icon: Moon },
{ value: "system", label: "Sistema", icon: Monitor },
];
/** Light/Dark/System (agente.md secao 174). "system" = nenhuma classe (a
* media query em globals.css decide); light/dark gravam a classe em
* <html> e persistem em localStorage, lidas de novo pelo script síncrono
* em layout.tsx antes do primeiro paint. */
export function ThemeToggle() {
const [theme, setThemeState] = useState<Theme>("system");
useEffect(() => {
const stored = localStorage.getItem("b2bcall-theme");
if (stored === "light" || stored === "dark") setThemeState(stored);
}, []);
function setTheme(next: Theme) {
setThemeState(next);
document.documentElement.classList.remove("light", "dark");
if (next === "system") {
localStorage.removeItem("b2bcall-theme");
} else {
document.documentElement.classList.add(next);
localStorage.setItem("b2bcall-theme", next);
}
}
return (
<div role="radiogroup" aria-label="Tema" className="flex items-center rounded-md border border-border bg-muted p-0.5">
{OPTIONS.map(({ value, label, icon: Icon }) => (
<button
key={value}
type="button"
role="radio"
aria-checked={theme === value}
title={label}
onClick={() => setTheme(value)}
className={cn(
"flex h-7 w-7 items-center justify-center rounded-[5px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
theme === value ? "bg-surface text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
)}
>
<Icon className="h-3.5 w-3.5" aria-hidden />
<span className="sr-only">{label}</span>
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,44 @@
"use client";
import { useRouter, usePathname } from "next/navigation";
import { LogOut } from "lucide-react";
import { ThemeToggle } from "./theme-toggle";
import { MobileNavDrawer } from "./mobile-nav-drawer";
import { getPageMeta } from "./nav-data";
export function PlatformTopbar({ userEmail }: { userEmail: string }) {
const router = useRouter();
const pathname = usePathname();
const { title, description } = getPageMeta(pathname);
async function onLogout() {
await fetch("/api/logout", { method: "POST" });
router.push("/login");
router.refresh();
}
return (
<header className="flex h-16 shrink-0 items-center justify-between gap-3 border-b border-border bg-surface px-4 sm:px-6">
<div className="flex min-w-0 items-center gap-3">
<MobileNavDrawer />
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-foreground">{title}</p>
{description && <p className="truncate text-xs text-muted-foreground">{description}</p>}
</div>
</div>
<div className="flex shrink-0 items-center gap-2 sm:gap-4">
<ThemeToggle />
<div className="hidden h-6 w-px bg-border sm:block" aria-hidden />
<span className="hidden text-sm text-muted-foreground sm:inline">{userEmail}</span>
<button
type="button"
onClick={onLogout}
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<LogOut className="h-4 w-4" aria-hidden />
<span className="hidden sm:inline">Sair</span>
</button>
</div>
</header>
);
}

View File

@@ -0,0 +1,38 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:opacity-90",
outline: "border border-border bg-transparent hover:bg-muted",
ghost: "hover:bg-muted",
destructive: "bg-destructive text-destructive-foreground hover:opacity-90",
},
size: {
default: "h-10 px-4",
sm: "h-8 px-3 text-xs",
icon: "h-9 w-9",
},
},
defaultVariants: { variant: "default", size: "default" },
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size }), className)} ref={ref} {...props} />;
},
);
Button.displayName = "Button";

View File

@@ -0,0 +1,40 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, ...props }, ref) => (
<input
ref={ref}
className={cn(
"h-9 w-full rounded-md border border-input bg-surface px-3 text-sm text-foreground outline-none ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
),
);
Input.displayName = "Input";
export const Select = React.forwardRef<HTMLSelectElement, React.SelectHTMLAttributes<HTMLSelectElement>>(
({ className, children, ...props }, ref) => (
<select
ref={ref}
className={cn(
"h-9 w-full rounded-md border border-input bg-surface px-3 text-sm text-foreground outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
{children}
</select>
),
);
Select.displayName = "Select";
export function FieldLabel({ children, htmlFor }: { children: React.ReactNode; htmlFor?: string }) {
return (
<label htmlFor={htmlFor} className="mb-1.5 block text-sm font-medium text-foreground">
{children}
</label>
);
}

View File

@@ -0,0 +1,53 @@
import { cn } from "@/lib/utils";
export interface InstrumentTileProps {
label: string;
value: number | string | null;
unit?: string;
live?: boolean;
/** Segundo valor pequeno ao lado (ex.: "de 10" pra 8/10) — nunca outra
* cor por conta própria, herda a mesma leitura neutra do label. */
suffix?: string;
pending?: string;
}
/**
* Leitura instrumento/painel (direção desta build — ver comentário no
* layout raiz): números grandes, monoespaçados, tabulares, como um
* mostrador de equipamento de telefonia. `value === null` não vira "0"
* nem some — vira um traço fantasma (segmento apagado, não inexistente),
* com `pending` explicando por que ainda não há número real (agente.md
* secao 138: nunca fingir uma estimativa como se fosse número fechado).
*/
export function InstrumentTile({ label, value, unit, live, suffix, pending }: InstrumentTileProps) {
const isGhost = value === null;
return (
<div className="relative flex flex-col justify-between rounded-lg border border-border bg-surface px-5 py-4 shadow-panel">
<div className="flex items-center justify-between">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{label}</span>
{live && !isGhost && (
<span className="flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-status-green animate-pulse-live" aria-hidden />
<span className="text-[10px] font-semibold uppercase tracking-wider text-status-green">live</span>
</span>
)}
</div>
<div className="mt-3 flex items-baseline gap-1.5">
<span
className={cn(
"font-mono text-4xl font-semibold tabular-nums leading-none",
isGhost ? "text-muted-foreground/30" : "text-foreground",
)}
>
{isGhost ? " " : value}
</span>
{unit && !isGhost && <span className="font-mono text-sm text-muted-foreground">{unit}</span>}
{suffix && !isGhost && <span className="ml-1 text-sm text-muted-foreground">{suffix}</span>}
</div>
{isGhost && pending && <p className="mt-2 text-[11px] leading-snug text-muted-foreground">{pending}</p>}
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { cn } from "@/lib/utils";
export function Panel({ className, children }: { className?: string; children: React.ReactNode }) {
return (
<div className={cn("rounded-lg border border-border bg-surface shadow-panel", className)}>{children}</div>
);
}
export function PanelHeader({ title, description }: { title: string; description?: string }) {
return (
<div className="border-b border-border px-5 py-4">
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
{description && <p className="mt-0.5 text-xs text-muted-foreground">{description}</p>}
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { cn } from "@/lib/utils";
/** Marcador neutro genérico (contagens, "padrão") — nunca usado pra status
* de domínio (campanha, período de billing etc.), que sempre passa por
* `StatusBadge` com forma + cor + rótulo (secao 176). */
export function Pill({ children, tone = "neutral" }: { children: React.ReactNode; tone?: "neutral" | "accent" }) {
return (
<span
className={cn(
"inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium",
tone === "accent"
? "border-accent/30 bg-accent/10 text-accent"
: "border-border bg-muted text-muted-foreground",
)}
>
{children}
</span>
);
}

View File

@@ -0,0 +1,45 @@
import { cn } from "@/lib/utils";
/**
* Badges de status (agente.md secao 172): mapeamento de cor fixo por
* status de campanha. Secao 176 (acessibilidade): "Status não podem
* depender apenas da cor" — por isso sempre um marcador de forma (ponto
* cheio/vazio/quadrado) ALÉM da cor e do texto, nunca só um chip colorido.
*/
const STATUS_CONFIG: Record<string, { label: string; color: string; shape: "dot" | "ring" | "square" }> = {
DRAFT: { label: "Rascunho", color: "status-gray", shape: "ring" },
READY: { label: "Pronta", color: "status-blue", shape: "ring" },
RUNNING: { label: "Em execução", color: "status-green", shape: "dot" },
PAUSED: { label: "Pausada", color: "status-yellow", shape: "square" },
DRAINING: { label: "Drenando", color: "status-orange", shape: "square" },
STOPPED: { label: "Parada", color: "status-red", shape: "dot" },
COMPLETED: { label: "Concluída", color: "status-green-dark", shape: "dot" },
ERROR: { label: "Erro", color: "status-red", shape: "square" },
};
const SHAPE_CLASS: Record<string, string> = {
dot: "rounded-full",
ring: "rounded-full border-2 bg-transparent!",
square: "rounded-[2px]",
};
export function StatusBadge({ status }: { status: string }) {
const config = STATUS_CONFIG[status] ?? { label: status, color: "status-gray", shape: "ring" as const };
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full border border-border bg-muted px-2.5 py-1 text-xs font-medium text-foreground",
)}
>
<span
aria-hidden
className={cn("h-2 w-2 shrink-0", SHAPE_CLASS[config.shape])}
style={{
backgroundColor: config.shape === "ring" ? "transparent" : `hsl(var(--${config.color}))`,
borderColor: `hsl(var(--${config.color}))`,
}}
/>
{config.label}
</span>
);
}

View File

@@ -0,0 +1,85 @@
import { cn } from "@/lib/utils";
export function Table({ children }: { children: React.ReactNode }) {
return (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm">{children}</table>
</div>
);
}
export function THead({ children }: { children: React.ReactNode }) {
return <thead className="border-b border-border">{children}</thead>;
}
export function TBody({ children }: { children: React.ReactNode }) {
return <tbody className="divide-y divide-border">{children}</tbody>;
}
export function TR({
children,
onClick,
className,
}: {
children: React.ReactNode;
onClick?: () => void;
className?: string;
}) {
return (
<tr
onClick={onClick}
className={cn(onClick && "cursor-pointer transition-colors hover:bg-muted/60", className)}
>
{children}
</tr>
);
}
export function TH({
children,
className,
onClick,
"aria-sort": ariaSort,
}: {
children: React.ReactNode;
className?: string;
onClick?: () => void;
"aria-sort"?: React.AriaAttributes["aria-sort"];
}) {
if (onClick) {
return (
<th
scope="col"
aria-sort={ariaSort}
className={cn("px-4 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-muted-foreground", className)}
>
<button
type="button"
onClick={onClick}
className="inline-flex items-center gap-1 rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{children}
</button>
</th>
);
}
return (
<th scope="col" className={cn("px-4 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-muted-foreground", className)}>
{children}
</th>
);
}
export function TD({ children, className }: { children: React.ReactNode; className?: string }) {
return <td className={cn("px-4 py-3 align-middle text-foreground", className)}>{children}</td>;
}
export function EmptyState({ title, description, action }: { title: string; description?: string; action?: React.ReactNode }) {
return (
<div className="flex flex-col items-center justify-center gap-1 px-6 py-16 text-center">
<p className="text-sm font-medium text-foreground">{title}</p>
{description && <p className="max-w-sm text-sm text-muted-foreground">{description}</p>}
{action && <div className="mt-4">{action}</div>}
</div>
);
}