Frontend completo (Etapas 11-18) e extensões de backend (Etapas 13 e 18)
Adiciona o frontend Next.js em frontend/ (setup, design system, autenticação/tema, layout/navegação, área master, área tenant completa: clientes, produtos, vendas, recebimentos, whatsapp, e-mail, alertas, equipe e perfil). Estende o backend de forma aditiva e autorizada: cookie httpOnly para refresh token (Etapa 13) e CRUD de equipe + edição de perfil do tenant (Etapa 18, módulo src/modules/equipe/ novo e PUT /tenant/perfil). Detalhes completos em Etapas/V2/*. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ComponentesShowcase } from './showcase-client';
|
||||
|
||||
/** Rota de desenvolvimento (Etapa 12) — nunca acessível fora de dev. */
|
||||
export default function ComponentesPage() {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <ComponentesShowcase />;
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import type { DateRange } from 'react-day-picker';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { FormField } from '@/components/ui/form-field';
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
} from '@/components/ui/input-group';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { DatePicker, DateRangePicker } from '@/components/ui/date-picker';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { STATUS_PILL_MAP, StatusPill, type BusinessStatus } from '@/components/ui/status-pill';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Skeleton, SkeletonCard, SkeletonText } from '@/components/ui/skeleton';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
import { DataPagination } from '@/components/ui/data-pagination';
|
||||
import { ResponsiveDialog } from '@/components/ui/responsive-dialog';
|
||||
import { DataTable, type DataTableColumn, type DataTableSort } from '@/components/ui/data-table';
|
||||
import { toast } from '@/lib/toast';
|
||||
|
||||
interface DemoCliente {
|
||||
id: string;
|
||||
nome: string;
|
||||
documento: string;
|
||||
telefone: string;
|
||||
cidade: string;
|
||||
status: BusinessStatus;
|
||||
valor: number;
|
||||
}
|
||||
|
||||
const DEMO_CLIENTES: DemoCliente[] = [
|
||||
{
|
||||
id: '1',
|
||||
nome: 'Ateliê Boa Vista Ltda',
|
||||
documento: '12.345.678/0001-90',
|
||||
telefone: '(11) 98765-4321',
|
||||
cidade: 'São Paulo/SP',
|
||||
status: 'ativo',
|
||||
valor: 4230.5,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
nome: 'Comércio Silva & Filhos',
|
||||
documento: '98.765.432/0001-10',
|
||||
telefone: '(21) 91234-5678',
|
||||
cidade: 'Rio de Janeiro/RJ',
|
||||
status: 'inadimplente',
|
||||
valor: 1890,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
nome: 'Padaria Pão Dourado',
|
||||
documento: '11.222.333/0001-44',
|
||||
telefone: '(31) 99887-6655',
|
||||
cidade: 'Belo Horizonte/MG',
|
||||
status: 'ativo',
|
||||
valor: 750.9,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
nome: 'Distribuidora Nordeste',
|
||||
documento: '55.666.777/0001-88',
|
||||
telefone: '(85) 98111-2233',
|
||||
cidade: 'Fortaleza/CE',
|
||||
status: 'trial',
|
||||
valor: 0,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
nome: 'Oficina Central Auto Peças',
|
||||
documento: '22.333.444/0001-55',
|
||||
telefone: '(41) 97654-3210',
|
||||
cidade: 'Curitiba/PR',
|
||||
status: 'suspenso',
|
||||
valor: 320,
|
||||
},
|
||||
];
|
||||
|
||||
const CLIENTE_COLUMNS: DataTableColumn<DemoCliente>[] = [
|
||||
{ id: 'nome', header: 'Cliente', primary: true, sortable: true, cell: (row) => row.nome },
|
||||
{ id: 'documento', header: 'Documento', cell: (row) => row.documento },
|
||||
{ id: 'telefone', header: 'Telefone', cell: (row) => row.telefone },
|
||||
{ id: 'cidade', header: 'Cidade', cell: (row) => row.cidade },
|
||||
{
|
||||
id: 'valor',
|
||||
header: 'Valor em aberto',
|
||||
sortable: true,
|
||||
className: 'text-right',
|
||||
cell: (row) => row.valor.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Status',
|
||||
primary: true,
|
||||
cell: (row) => <StatusPill status={row.status} />,
|
||||
},
|
||||
];
|
||||
|
||||
const PRODUTO_OPTIONS = [
|
||||
{ value: 'p1', label: 'Consultoria mensal' },
|
||||
{ value: 'p2', label: 'Licença de software' },
|
||||
{ value: 'p3', label: 'Suporte técnico avançado' },
|
||||
{ value: 'p4', label: 'Treinamento presencial' },
|
||||
];
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="flex flex-col gap-4 border-t border-border pt-8 first:border-0 first:pt-0">
|
||||
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ComponentesShowcase() {
|
||||
const [tableLoading, setTableLoading] = React.useState(false);
|
||||
const [tableEmpty, setTableEmpty] = React.useState(false);
|
||||
const [sort, setSort] = React.useState<DataTableSort | undefined>();
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||
const [comboboxValue, setComboboxValue] = React.useState<string>();
|
||||
const [date, setDate] = React.useState<Date>();
|
||||
const [range, setRange] = React.useState<DateRange>();
|
||||
const [paginationDemoPage, setPaginationDemoPage] = React.useState(1);
|
||||
|
||||
const sortedData = React.useMemo(() => {
|
||||
if (!sort) return DEMO_CLIENTES;
|
||||
const factor = sort.direction === 'asc' ? 1 : -1;
|
||||
return [...DEMO_CLIENTES].sort((a, b) => {
|
||||
const av = a[sort.columnId as keyof DemoCliente];
|
||||
const bv = b[sort.columnId as keyof DemoCliente];
|
||||
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * factor;
|
||||
return String(av).localeCompare(String(bv)) * factor;
|
||||
});
|
||||
}, [sort]);
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex max-w-5xl flex-col gap-10 p-6 pb-24 sm:p-10">
|
||||
<header className="flex flex-col gap-1">
|
||||
<p className="text-sm text-muted-foreground">/dev/componentes — Etapa 12</p>
|
||||
<h1 className="text-2xl font-semibold text-foreground">
|
||||
Design system — SeConectai Vendas
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Rota só de desenvolvimento (404 em produção). Redimensione a janela para ver o
|
||||
comportamento responsivo.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<Section title="Button">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="primary">Primary</Button>
|
||||
<Button variant="secondary">Secondary</Button>
|
||||
<Button variant="destructive">Destructive</Button>
|
||||
<Button variant="ghost">Ghost</Button>
|
||||
<Button variant="outline">Outline</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button size="sm">Small (denso, ex: linha de tabela)</Button>
|
||||
<Button size="md">Medium (padrão, 44px)</Button>
|
||||
<Button size="lg">Large</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button loading>Salvando…</Button>
|
||||
<Button disabled>Desabilitado</Button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Input / Textarea / FormField">
|
||||
<div className="grid max-w-md gap-4">
|
||||
<FormField label="Nome fantasia" helpText="Como o cliente é conhecido no dia a dia.">
|
||||
<Input placeholder="Ex: Padaria Pão Dourado" />
|
||||
</FormField>
|
||||
<FormField label="E-mail" error="E-mail inválido" required>
|
||||
<Input type="email" defaultValue="nao-e-um-email" />
|
||||
</FormField>
|
||||
<FormField label="Valor de venda">
|
||||
<InputGroup>
|
||||
<InputGroupAddon>
|
||||
<InputGroupText>R$</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput placeholder="0,00" inputMode="decimal" />
|
||||
</InputGroup>
|
||||
</FormField>
|
||||
<FormField label="Observações">
|
||||
<Textarea placeholder="Notas internas sobre o cliente…" />
|
||||
</FormField>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Select / Combobox (busca)">
|
||||
<div className="grid max-w-md gap-4">
|
||||
<FormField label="Papel do usuário">
|
||||
<Select defaultValue="tenant_admin">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tenant_admin">Administrador</SelectItem>
|
||||
<SelectItem value="vendedor">Vendedor</SelectItem>
|
||||
<SelectItem value="financeiro">Financeiro</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField label="Produto/serviço" helpText="Busca por nome ou SKU.">
|
||||
<Combobox
|
||||
options={PRODUTO_OPTIONS}
|
||||
value={comboboxValue}
|
||||
onChange={setComboboxValue}
|
||||
placeholder="Selecionar produto…"
|
||||
searchPlaceholder="Buscar produto…"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="DatePicker">
|
||||
<div className="grid max-w-md gap-4 sm:grid-cols-2">
|
||||
<FormField label="Data de vencimento">
|
||||
<DatePicker value={date} onChange={setDate} />
|
||||
</FormField>
|
||||
<FormField label="Período">
|
||||
<DateRangePicker value={range} onChange={setRange} />
|
||||
</FormField>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Table (responsiva — vira Cards abaixo de 768px)">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setTableLoading((v) => !v)}>
|
||||
{tableLoading ? 'Parar loading' : 'Simular loading'}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setTableEmpty((v) => !v)}>
|
||||
{tableEmpty ? 'Mostrar dados' : 'Simular vazio'}
|
||||
</Button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={CLIENTE_COLUMNS}
|
||||
data={tableEmpty ? [] : sortedData}
|
||||
keyField={(row) => row.id}
|
||||
loading={tableLoading}
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
emptyTitle="Nenhum cliente cadastrado ainda"
|
||||
emptyDescription="Cadastre o primeiro cliente para começar a vender."
|
||||
emptyAction={<Button size="sm">Cadastrar cliente</Button>}
|
||||
rowActions={() => (
|
||||
<Button variant="ghost" size="sm">
|
||||
Ver
|
||||
</Button>
|
||||
)}
|
||||
pagination={{ page, limit: 5, total: 23, onPageChange: setPage }}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Pagination (isolada)">
|
||||
<DataPagination
|
||||
page={paginationDemoPage}
|
||||
limit={10}
|
||||
total={97}
|
||||
onPageChange={setPaginationDemoPage}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Modal / Sheet responsivo">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Vira modal centralizado no desktop e bottom sheet no mobile (mesmo breakpoint da Table).
|
||||
</p>
|
||||
<Button className="w-fit" onClick={() => setDialogOpen(true)}>
|
||||
Abrir exemplo
|
||||
</Button>
|
||||
<ResponsiveDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
title="Cadastro rápido de cliente"
|
||||
description="Usado no fluxo de nova venda quando o cliente ainda não existe."
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={() => setDialogOpen(false)}>Salvar</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4 px-4 pb-4 sm:px-0 sm:pb-0">
|
||||
<FormField label="Nome">
|
||||
<Input placeholder="Nome do cliente" />
|
||||
</FormField>
|
||||
<FormField label="Telefone/WhatsApp">
|
||||
<Input placeholder="(00) 00000-0000" />
|
||||
</FormField>
|
||||
</div>
|
||||
</ResponsiveDialog>
|
||||
</Section>
|
||||
|
||||
<Section title="Toast">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => toast.success('Cliente salvo com sucesso.')}
|
||||
>
|
||||
Sucesso
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => toast.error('Não foi possível salvar. Tente novamente.')}
|
||||
>
|
||||
Erro
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => toast.warning('Limite do plano quase atingido.')}
|
||||
>
|
||||
Aviso
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => toast.info('Sessão renovada automaticamente.')}
|
||||
>
|
||||
Info
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Badge / StatusPill">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge>Default</Badge>
|
||||
<Badge variant="secondary">Secondary</Badge>
|
||||
<Badge variant="destructive">Destructive</Badge>
|
||||
<Badge variant="outline">Outline</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(Object.keys(STATUS_PILL_MAP) as BusinessStatus[]).map((status) => (
|
||||
<StatusPill key={status} status={status} />
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Card">
|
||||
<Card className="max-w-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Total recebido no mês</CardTitle>
|
||||
<CardDescription>Atualizado há 2 minutos</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-semibold tabular-nums text-foreground">R$ 18.420,00</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
<Section title="EmptyState">
|
||||
<EmptyState
|
||||
title="Nenhuma venda registrada"
|
||||
description="As vendas confirmadas aparecerão aqui."
|
||||
action={<Button size="sm">Nova venda</Button>}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Skeleton">
|
||||
<div className="flex max-w-md flex-col gap-3">
|
||||
<SkeletonText />
|
||||
<SkeletonText className="w-2/3" />
|
||||
<SkeletonCard />
|
||||
<Skeleton className="size-11 rounded-full" />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Tabs">
|
||||
<Tabs defaultValue="itens" className="max-w-md">
|
||||
<TabsList>
|
||||
<TabsTrigger value="itens">Itens</TabsTrigger>
|
||||
<TabsTrigger value="parcelas">Parcelas</TabsTrigger>
|
||||
<TabsTrigger value="historico">Histórico</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="itens" className="text-sm text-muted-foreground">
|
||||
Lista de itens da venda.
|
||||
</TabsContent>
|
||||
<TabsContent value="parcelas" className="text-sm text-muted-foreground">
|
||||
Lista de parcelas geradas.
|
||||
</TabsContent>
|
||||
<TabsContent value="historico" className="text-sm text-muted-foreground">
|
||||
Histórico de mudanças de status.
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user