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:
@@ -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>
|
||||
);
|
||||
}
|
||||
95
apps/frontend/src/components/platform-shell/nav-data.ts
Normal file
95
apps/frontend/src/components/platform-shell/nav-data.ts
Normal 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" };
|
||||
}
|
||||
94
apps/frontend/src/components/platform-shell/nav-list.tsx
Normal file
94
apps/frontend/src/components/platform-shell/nav-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
apps/frontend/src/components/platform-shell/sidebar.tsx
Normal file
40
apps/frontend/src/components/platform-shell/sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
apps/frontend/src/components/platform-shell/theme-toggle.tsx
Normal file
59
apps/frontend/src/components/platform-shell/theme-toggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
apps/frontend/src/components/platform-shell/topbar.tsx
Normal file
44
apps/frontend/src/components/platform-shell/topbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
apps/frontend/src/components/ui/button.tsx
Normal file
38
apps/frontend/src/components/ui/button.tsx
Normal 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";
|
||||
40
apps/frontend/src/components/ui/input.tsx
Normal file
40
apps/frontend/src/components/ui/input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
apps/frontend/src/components/ui/instrument-tile.tsx
Normal file
53
apps/frontend/src/components/ui/instrument-tile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
apps/frontend/src/components/ui/panel.tsx
Normal file
16
apps/frontend/src/components/ui/panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
apps/frontend/src/components/ui/pill.tsx
Normal file
19
apps/frontend/src/components/ui/pill.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
apps/frontend/src/components/ui/status-badge.tsx
Normal file
45
apps/frontend/src/components/ui/status-badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
apps/frontend/src/components/ui/table.tsx
Normal file
85
apps/frontend/src/components/ui/table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user