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>
409 lines
14 KiB
TypeScript
409 lines
14 KiB
TypeScript
'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>
|
|
);
|
|
}
|