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:
2026-08-16 09:41:13 -03:00
co-authored by Claude Sonnet 5
parent f9738e54da
commit 323912748f
179 changed files with 29312 additions and 22 deletions
+1
View File
@@ -0,0 +1 @@
cd frontend && npx lint-staged
+20
View File
@@ -181,6 +181,26 @@ propositalmente para depois — detalhar em Observações).
`npm run lint`, `tsc --noEmit` e `npm run build` (produção) também
passam sem erros; smoke test manual via `curl` contra a imagem
`production` real confirmou login + guards funcionando fim a fim.
- **[2026-08-15, revisitado pela Etapa 13 do frontend]** Adicionado
suporte a cookie httpOnly para o refresh token (`security.util.ts`,
`auth.controller.ts`, `RefreshDto`/`LogoutDto`) — mudança aditiva, os
21 testes originais continuam passando sem alteração, mais 4 novos
cobrindo o cookie (total 24/24 em `auth.e2e-spec.ts`). Detalhe completo
em `Etapas/V2/20-controle-de-progresso-frontend.md`, Etapa 13.
- **[2026-08-16, revisitado pela Etapa 18 do frontend]** `tenant_users`
(criada nesta etapa) ganhou CRUD real do lado do tenant: novo módulo
`src/modules/equipe/` (`GET/POST /tenant/equipe`, `PATCH
/tenant/equipe/:id`, só `tenant_admin`, RLS via `TenantContextService`
como `customers`/`products` da Etapa 5) — o `GET /auth/tenant/users`
deste módulo continua exatamente como estava, só leitura/demonstração,
não foi tocado. Também aditivo: `PUT /tenant/perfil`
(`nome_fantasia`/`razao_social`) em `src/modules/tenant-profile/`, ao
lado do `PUT /tenant/perfil/cor` já existente da Etapa 8. Mudança
autorizada explicitamente pelo usuário antes de implementar (a Etapa 18
do frontend precisava de CRUD real de equipe e edição de perfil, que
não existiam). Detalhe completo — DTOs, regras de limite de plano,
auto-bloqueio, testes e2e — em
`Etapas/V2/20-controle-de-progresso-frontend.md`, Etapa 18.
---
@@ -0,0 +1,81 @@
# SeConectai Vendas — Prompts de Build Faseado
Este pacote contém 8 prompts sequenciais e autocontidos para entregar a uma
IA de geração de código (Claude Code, Cursor, Windsurf etc). Cada arquivo
pode ser colado como um prompt único — eles já trazem o contexto necessário
para não depender de memória de conversa anterior.
## Ordem obrigatória — Backend
1. `01-setup-projeto.md` — estrutura base, Docker Compose, Postgres, Prisma
2. `02-autenticacao-rls.md` — login master/tenant, JWT, RLS
3. `03-painel-master-tenants.md` — CRUD de tenants, planos, faturas
4. `04-integracoes-evolution-email.md` — Evolution API, instâncias WhatsApp, SMTP
5. `05-painel-tenant-customers-produtos.md` — CRUD de clientes e produtos/serviços
6. `06-vendas-dashboards.md` — vendas, parcelas, resumos
7. `07-motor-de-alertas.md` — BullMQ, templates, disparo e logs
8. `08-personalizacao-whitelabel.md` — logo/cor por tenant, ajustes finais
Controle de progresso: `09-controle-de-progresso.md`
## Ordem obrigatória — Frontend
Pré-requisito: backend concluído (ou ao menos as etapas cujos endpoints
cada tela consome). Arquitetura de referência:
`10-frontend-schema-arquitetura.md`.
11. `11-frontend-setup-projeto.md` — Next.js, Tailwind, tokens, estrutura
12. `12-frontend-design-system.md` — biblioteca de componentes base
13. `13-frontend-autenticacao-tema.md` — login, sessão, tema dinâmico whitelabel
14. `14-frontend-layout-navegacao.md` — sidebar/topbar/drawer/bottom-nav
15. `15-frontend-area-master.md` — telas de negócio do painel master
16. `16-frontend-area-tenant-cadastros.md` — dashboard, clientes, produtos
17. `17-frontend-area-tenant-vendas.md` — vendas, parcelas, financeiro
18. `18-frontend-area-tenant-integracoes-perfil.md` — WhatsApp, e-mail, alertas, equipe, whitelabel
19. `19-frontend-polimento-testes.md` — responsividade, acessibilidade, E2E
Controle de progresso: `20-controle-de-progresso-frontend.md`
## Arquivos de controle de progresso (obrigatórios)
`09-controle-de-progresso.md` (backend) e
`20-controle-de-progresso-frontend.md` (frontend) são o estado oficial
de cada parte do projeto. Regras:
- **Sempre envie o arquivo correspondente junto com o prompt de cada
etapa** (o de backend para as etapas 1–8, o de frontend para as
etapas 11–19).
- A IA deve lê-lo antes de começar e recusar iniciar uma etapa se a
anterior não estiver 100% concluída na checklist.
- Ao final de cada etapa, a IA deve devolver o arquivo atualizado (itens
marcados, status geral e observações preenchidas) — é esse arquivo
atualizado que você guarda e reenvia na próxima etapa/sessão.
- Sem esse arquivo, uma IA nova (em outra sessão) não tem como saber o
que já foi feito — por isso ele substitui a necessidade de repetir todo
o histórico da conversa.
## Como usar cada prompt
- Rode nessa ordem — cada etapa assume que a anterior está pronta e
funcionando (migrations aplicadas, testes passando).
- Ao final de cada etapa, confira o checklist de "Critérios de aceite"
antes de avançar para a próxima. Se algo não for atendido, peça correção
antes de seguir.
- O arquivo `seconectai-vendas-schema.sql` é a fonte de verdade do modelo
de dados — qualquer etapa que crie migration deve refletir exatamente
essas tabelas (a IA pode adaptar para sintaxe Prisma, mas a estrutura,
nomes de campo e tipos devem ser preservados).
- Cada prompt já repete o essencial do contexto (stack, convenções), então
eles funcionam mesmo em uma conversa nova/sessão nova com a IA.
## Stack fixada em todas as etapas
- Node.js 20+ / TypeScript
- NestJS (modular por domínio)
- PostgreSQL 16 + Prisma ORM
- Redis + BullMQ (a partir da etapa 7)
- Docker + Docker Compose
- Autenticação: JWT (access curto) + refresh token com rotação, senha com
argon2id
- Multi-tenancy: banco único, isolamento por `tenant_id` + Row Level
Security do Postgres
+261
View File
@@ -0,0 +1,261 @@
# SeConectai Vendas — Schema de Frontend (Web Responsivo)
Este documento define a arquitetura do frontend que vai consumir a API
já especificada nas etapas 1–8. Cobre estrutura de telas, navegação,
sistema de design e a estratégia de responsividade para funcionar bem
tanto em tela de PC quanto em smartphone.
## 1. Visão geral
Uma única aplicação, com duas áreas autenticadas separadas:
- **Área master** (`/master/...`) — seu painel, gestão dos tenants.
- **Área tenant** (`/app/...`) — painel que seus clientes usam no dia a
dia (vendas, clientes, produtos, alertas).
Ambas consomem a mesma API REST do backend. O layout de cada área é
diferente (menu, cores, permissões), mas compartilham o mesmo sistema de
design (componentes, tokens, grid responsivo).
## 2. Stack sugerida
- **Next.js 14+ (App Router)** + React 18 + TypeScript
- **Tailwind CSS** para estilos utilitários, com tokens de design via
CSS variables (necessário para o whitelabel: cor primária muda por
tenant em tempo de execução, não pode ser fixa no build)
- **shadcn/ui** como base de componentes acessíveis, customizados por
cima (não usar como está "fora da caixa" — ajustar aos tokens do
projeto)
- **TanStack Query (React Query)** para cache/estado de dados vindos da
API (listagens, dashboards)
- **React Hook Form + Zod** para formulários e validação — os schemas
Zod devem espelhar os DTOs do backend
- **Recharts** para os gráficos de dashboard (resumo de vendas etc)
- Estado de UI leve (sidebar aberta/fechada, tema) via Context/Zustand —
não precisa de Redux para este escopo
## 3. Estratégia de responsividade
Mobile-first. Breakpoints (padrão Tailwind, manter):
| Breakpoint | Largura | Uso principal |
|------------|-----------|----------------|
| base | < 640px | smartphone |
| `sm` | ≥ 640px | smartphone grande / phablet |
| `md` | ≥ 768px | tablet |
| `lg` | ≥ 1024px | desktop |
| `xl` | ≥ 1280px | desktop grande |
Regras de layout por faixa:
- **Desktop (`lg` e acima):** sidebar fixa lateral (~240px) + topbar.
Conteúdo em grid de 2 colunas quando fizer sentido (ex: formulário +
resumo lateral).
- **Tablet (`md`):** sidebar colapsada só com ícones (expande em hover
ou toque), conteúdo em coluna única ou 2 colunas conforme espaço.
- **Mobile (abaixo de `md`):**
- Sidebar vira **drawer** (menu lateral que desliza, acionado por
ícone de hambúrguer na topbar).
- **Bottom tab bar fixa** com os 4–5 itens mais usados de cada área
(ex. no tenant: Dashboard, Clientes, Vendas, Recebimentos, Mais).
- Tabelas **não** fazem scroll horizontal como solução padrão — cada
linha vira um **card empilhado** (rótulo: valor), com as
informações mais importantes em destaque e as secundárias
colapsadas/expansíveis.
- Formulários em coluna única; campos grandes o suficiente para touch
(mínimo 44x44px de área de toque).
- Modais viram **tela cheia** ou **bottom sheet** (desliza de baixo),
nunca uma caixa pequena centralizada como no desktop.
- Filtros de listagem (que no desktop ficam visíveis lateralmente)
viram um botão "Filtros" que abre um bottom sheet no mobile.
Checklist de teste obrigatório em **toda** tela construída: 375px
(mobile pequeno), 768px (tablet) e 1280px (desktop), sem scroll
horizontal indevido em nenhuma delas.
## 4. Mapa de rotas
### Público (sem autenticação)
```
/login → detecta master ou tenant pelo e-mail/domínio
/esqueci-senha
/redefinir-senha/:token
```
### Área master — `/master`
```
/master/dashboard
/master/tenants (lista)
/master/tenants/novo
/master/tenants/:id (detalhe, edição, faturas do tenant)
/master/planos
/master/faturas (visão consolidada de todas as mensalidades)
/master/evolution-config
/master/alertas/templates
/master/alertas/logs
/master/configuracoes (perfil do admin master, senha, MFA)
```
### Área tenant — `/app`
```
/app/dashboard
/app/clientes (lista)
/app/clientes/novo
/app/clientes/:id (detalhe, contatos, histórico de vendas)
/app/produtos (produtos e serviços)
/app/produtos/novo
/app/produtos/:id
/app/vendas (lista)
/app/vendas/nova
/app/vendas/:id (detalhe, itens, parcelas)
/app/recebimentos (contas a receber, marcar pago)
/app/whatsapp (instâncias Evolution, QR code, status)
/app/email-config
/app/alertas/templates
/app/alertas/logs
/app/equipe (usuários do tenant, só para tenant_admin)
/app/perfil (logo, cor, dados da empresa)
```
## 5. Navegação e layout por área
**Desktop:**
- Sidebar: logo do tenant (ou logo master) no topo, itens de menu com
ícone + label, indicador do item ativo, avatar do usuário + logout no
rodapé.
- Topbar: breadcrumb da página atual, sino de notificações (alertas
recentes), busca rápida (clientes/vendas).
**Mobile:**
- Topbar fixa: logo compacta (só o símbolo, não o nome completo) +
ícone de menu (drawer) + sino de notificações.
- Drawer: réplica do menu completo da sidebar desktop, ocupa a tela
inteira ou 80% da largura.
- Bottom tab bar: 4 itens fixos mais "Mais" (que abre o restante do
menu). Ícone + label curto embaixo.
## 6. Sistema de design (tokens)
Paleta **neutra da plataforma** (usada em textos, fundos, bordas —
igual em master e em todos os tenants):
- `--neutral-950: #12141A` — texto principal
- `--neutral-500: #6B7280` — texto secundário
- `--neutral-200: #E5E7EB` — bordas
- `--neutral-50: #F8F9FB` — fundo de página
- `--surface: #FFFFFF` — fundo de cards/painéis
- `--danger: #DC2626` — erros, valores atrasados
- `--success: #16A34A` — pagos, confirmados
- `--warning: #D97706` — pendências, avisos
Cor de marca **do painel master** (fixa, identidade do SeConectai
Vendas): `--brand-master: #1E3A5F` (azul petróleo escuro) com acento
`--brand-master-accent: #2DD4BF` (verde-água) — usada apenas na área
`/master`.
Cor de marca **de cada tenant** (dinâmica, whitelabel): variável
`--color-primary`, resolvida em runtime a partir de
`tenants.cor_primaria` (endpoint público de tema, já definido na Etapa
8 do backend) e aplicada via CSS variable no layout de `/app`. Se o
tenant não definiu cor própria, cair para um azul neutro padrão
(`#2563EB`). Validar contraste mínimo (WCAG AA) contra branco antes de
aplicar — se a cor escolhida pelo tenant não passar, usar uma versão
escurecida automaticamente para texto sobre fundo colorido.
Tipografia: uma família para UI (ex: Inter ou similar, boa legibilidade
em telas pequenas e números tabulares para valores monetários) e uso de
`font-variant-numeric: tabular-nums` em qualquer coluna de valores para
alinhamento visual em tabelas/cards.
## 7. Componentes de design system (biblioteca a construir)
Base (`components/ui/`): Button, Input, Select, DatePicker, Textarea,
Table (com variante que vira lista de Cards abaixo do breakpoint `md`),
Modal/Sheet (adaptável tela-cheia no mobile), Toast, Badge/StatusPill
(para status de fatura/parcela/venda), Card, Pagination, EmptyState,
Skeleton (loading), Tabs, FormField (wrapper de label+erro+input).
Layout (`components/layout/`): Sidebar, Topbar, Drawer, BottomTabBar,
Breadcrumb.
Domínio (`components/domain/`): ClienteCard, VendaForm, ParcelaRow,
ProdutoForm, StatusFaturaBadge, ResumoVendasChart, InstanciaWhatsappCard.
## 8. Dados e autenticação no client
- **TanStack Query** para toda chamada de leitura (listagens,
dashboards, detalhes), com invalidação automática após mutations
(criar/editar/excluir).
- **Zod schemas** espelhando os DTOs do backend — usados tanto na
validação de formulário quanto na tipagem das respostas da API (fonte
única de verdade dos tipos no frontend).
- **Access token em memória** (não em `localStorage`, para reduzir
superfície de XSS); **refresh token em cookie `httpOnly`**, setado
pelo backend. Interceptor de requisição renova o access token
automaticamente em caso de 401 e repete a chamada original.
- **Middleware do Next.js** protegendo `/master/*` e `/app/*`,
verificando sessão válida antes de renderizar a página (evita "flash"
de conteúdo protegido).
- Tema (cor primária + logo) resolvido no login: chama o endpoint
público de tema com o identificador do tenant, aplica as CSS
variables antes mesmo do usuário autenticar (tela de login já com a
cara do tenant certo).
## 9. Estrutura de pastas sugerida
```
frontend/
├── app/
│ ├── (public)/
│ │ ├── login/
│ │ ├── esqueci-senha/
│ │ └── redefinir-senha/[token]/
│ ├── master/
│ │ ├── layout.tsx
│ │ ├── dashboard/
│ │ ├── tenants/
│ │ ├── planos/
│ │ ├── faturas/
│ │ ├── evolution-config/
│ │ └── alertas/
│ └── app/
│ ├── layout.tsx
│ ├── dashboard/
│ ├── clientes/
│ ├── produtos/
│ ├── vendas/
│ ├── recebimentos/
│ ├── whatsapp/
│ ├── email-config/
│ ├── alertas/
│ ├── equipe/
│ └── perfil/
├── components/
│ ├── ui/
│ ├── layout/
│ └── domain/
├── lib/
│ ├── api/ (client HTTP + funções por domínio)
│ ├── auth/
│ ├── theme/
│ └── validators/ (schemas Zod)
├── hooks/
└── styles/
└── tokens.css (CSS variables da paleta e tipografia)
```
## 10. Checklist de responsividade (aplicar em toda tela nova)
- [ ] Testado em 375px, 768px e 1280px sem overflow horizontal indevido
- [ ] Tabelas de listagem viram cards empilhados abaixo de `md`
- [ ] Navegação principal acessível via bottom tab bar ou drawer no mobile
- [ ] Touch targets ≥ 44x44px em elementos interativos no mobile
- [ ] Formulário utilizável com teclado virtual aberto (campo em foco
não fica escondido atrás do teclado)
- [ ] Modais/diálogos viram tela cheia ou bottom sheet no mobile
- [ ] Cor primária dinâmica do tenant aplicada corretamente em
botões/links/destaques, com contraste validado
- [ ] Foco de teclado visível em todos os elementos interativos
(acessibilidade, não só mobile)
- [ ] Estados de carregamento (skeleton) e vazio (empty state) tratados
em toda listagem
+92
View File
@@ -0,0 +1,92 @@
# Etapa 11 — Setup do Projeto Frontend
## Contexto do projeto
Estou construindo o frontend do **SeConectai Vendas** — CRM/vendas
multi-tenant. O backend (API REST em NestJS) já está pronto, seguindo o
schema e as etapas descritas em documentos separados. O frontend consome
essa API e tem duas áreas: painel **master** (`/master`) e painel
**tenant** (`/app`).
A arquitetura completa (rotas, breakpoints, tokens de design, estrutura
de pastas) está definida no arquivo `10-frontend-schema-arquitetura.md`
— **use esse arquivo como fonte de verdade** para tudo que for construído
nesta e nas próximas etapas.
Esta é a **Etapa 1 de 9 do frontend** (numerada como Etapa 11 no conjunto
geral do projeto, para não colidir com as 8 etapas do backend). Aqui é
só a fundação — nenhuma tela de negócio ainda.
## Objetivo desta etapa
Inicializar o projeto Next.js com toda a configuração base: Tailwind com
os tokens de design do schema, estrutura de pastas, variáveis de
ambiente, lint/format, e uma página inicial validando a conexão com a
API do backend.
## Tarefas
1. **Inicialização do projeto**
- Next.js 14+ com App Router, TypeScript, ESLint já integrado.
- Configurar Tailwind CSS.
- Configurar Prettier + regra de integração com ESLint (sem conflito
de formatação).
2. **Tokens de design**
- Criar `styles/tokens.css` com todas as CSS variables definidas na
seção 6 do schema (`--neutral-*`, `--surface`, `--danger`,
`--success`, `--warning`, `--brand-master`, `--brand-master-accent`,
`--color-primary` com fallback `#2563EB`).
- Configurar `tailwind.config.ts` para expor essas variáveis como
cores utilitárias (ex: `bg-primary`, `text-danger`), em vez de usar
`var(--...)` espalhado pelo código.
- Configurar fonte (Inter ou equivalente definida no schema), incluindo
`font-variant-numeric: tabular-nums` disponível como classe utilitária
para colunas de valores monetários.
3. **Estrutura de pastas**
- Criar exatamente a estrutura de pastas da seção 9 do schema:
`app/(public)/`, `app/master/`, `app/app/`, `components/ui/`,
`components/layout/`, `components/domain/`, `lib/api/`,
`lib/auth/`, `lib/theme/`, `lib/validators/`, `hooks/`, `styles/`.
- Cada pasta de rota deve existir com um `page.tsx` placeholder (ex:
"Em construção — Etapa X") para validar que o roteamento do mapa de
rotas inteiro já resolve sem erro 404, mesmo antes das telas reais
existirem.
4. **Variáveis de ambiente e cliente HTTP base**
- `.env.local.example` com `NEXT_PUBLIC_API_URL` e demais chaves que
forem necessárias.
- Criar `lib/api/client.ts`: wrapper mínimo sobre `fetch` (ou axios,
justificar a escolha) com base URL configurável, tratamento de erro
padronizado (parse de erro da API em formato consistente), e um
ponto único de onde o interceptor de refresh token será plugado na
Etapa 13 (deixar o gancho pronto, sem implementar auth ainda).
5. **Página de diagnóstico**
- Uma rota simples (ex: `/diagnostico`, pode ser removida depois) que
chama `GET /health` do backend e mostra o resultado — só para
confirmar que a integração frontend↔backend está funcionando de
ponta a ponta antes de seguir.
6. **Qualidade**
- Scripts no `package.json`: `dev`, `build`, `start`, `lint`,
`format`.
- Configurar Husky + lint-staged (ou equivalente) para rodar lint no
pre-commit, se fizer sentido para o fluxo do projeto.
## Critérios de aceite
- [ ] `npm run dev` sobe o projeto sem erro
- [ ] Todas as rotas do mapa de rotas (seção 4 do schema) resolvem, com
página placeholder, sem 404
- [ ] `tokens.css` contém todas as variáveis do schema, aplicadas
corretamente (inspecionar no DevTools que os valores computados
batem)
- [ ] Classes utilitárias do Tailwind (`bg-primary`, `text-danger` etc)
funcionam e refletem os tokens
- [ ] `/diagnostico` mostra o retorno real de `GET /health` do backend
(com o backend rodando local)
- [ ] `npm run lint` roda sem erro
- [ ] Estrutura de pastas idêntica à definida na seção 9 do schema de
frontend
+105
View File
@@ -0,0 +1,105 @@
# Etapa 12 — Sistema de Design e Componentes Base
## Contexto do projeto
Continuação do frontend do **SeConectai Vendas**. A Etapa 11 entregou o
projeto Next.js configurado com Tailwind, tokens de design e estrutura
de pastas. Nenhum componente de UI reutilizável existe ainda.
Referência obrigatória: seção 6 (tokens) e seção 7 (lista de
componentes) do `10-frontend-schema-arquitetura.md`.
## Objetivo desta etapa
Construir a biblioteca de componentes base (`components/ui/`) que será
usada por **todas** as telas de negócio das próximas etapas. Tudo aqui
deve já nascer responsivo e acessível — as telas de negócio só vão
compor esses componentes, não reimplementar comportamento responsivo
caso a caso.
## Tarefas
1. **Base a partir do shadcn/ui**
- Instalar e configurar shadcn/ui, mas customizar cada componente
usado para consumir os tokens do projeto (não deixar cores/spacing
hardcoded vindos do template padrão).
- Justificar no código (comentário curto) qualquer componente que
fugir do padrão shadcn por necessidade específica do projeto.
2. **Componentes a construir** (todos em `components/ui/`, cada um com
suas variantes documentadas via comentário ou arquivo `.stories`
opcional):
- **Button**: variantes `primary` (usa `--color-primary`),
`secondary`, `destructive`, `ghost`; tamanhos `sm`/`md`/`lg`; estado
`loading` (spinner substituindo o label, sem mudar a largura do
botão); estado `disabled`.
- **Input / Textarea**: com label, texto de ajuda, estado de erro
(borda + mensagem), prefixo/sufixo opcional (ex: "R$" para campos
de valor).
- **Select**: nativo estilizado ou baseado em Radix (via shadcn),
com busca para listas longas (ex: seleção de cliente/produto).
- **DatePicker**: seleção de data única e, quando necessário, período
(usado nos filtros de dashboard/listagens).
- **FormField**: wrapper que padroniza label + input + mensagem de
erro + texto de ajuda — todo formulário do sistema deve usar esse
wrapper, nunca montar label/erro manualmente.
- **Table**: **componente central desta etapa**. Recebe colunas e
dados de forma genérica (tipado com generics em TypeScript).
Abaixo do breakpoint `md`, renderiza automaticamente como lista de
**Cards empilhados** (cada linha vira um card com pares
rótulo:valor, permitindo destacar 1–2 campos principais e colapsar
o resto). Suporta: ordenação por coluna, estado de loading
(skeleton), estado vazio (usa o `EmptyState`), paginação integrada.
- **Modal / Sheet**: um único componente que se comporta como modal
centralizado no desktop e como tela cheia (ou bottom sheet, definir
e justificar qual) no mobile, controlado pelo mesmo breakpoint `md`.
- **Toast**: sistema de notificação temporária (sucesso, erro, aviso,
info), empilhável, com auto-dismiss configurável.
- **Badge / StatusPill**: variantes semânticas mapeadas para os
status de negócio já existentes no backend (`pendente`, `pago`,
`atrasado`, `cancelado`, `ativo`, `inadimplente`, `suspenso`,
`trial`, `confirmada`, `concluida`, `orcamento`) — cada status com
cor e label em português já resolvidos aqui, para não repetir esse
mapeamento em cada tela.
- **Card**: contêiner base usado em dashboards e nas listas
responsivas da Table.
- **Pagination**: controles de página, compatível com os parâmetros
de paginação da API do backend.
- **EmptyState**: ilustração/texto + ação sugerida, usado em toda
listagem vazia (ex: "Nenhum cliente cadastrado ainda" + botão
"Cadastrar cliente").
- **Skeleton**: blocos de carregamento reutilizáveis (linha de texto,
card, linha de tabela).
- **Tabs**: navegação em abas (usado, por exemplo, no detalhe de
venda: Itens / Parcelas / Histórico).
3. **Acessibilidade (obrigatório, não opcional)**
- Todos os componentes interativos com foco de teclado visível
(`outline` customizado com a cor de foco do design system, não o
padrão do navegador nem `outline: none` sem substituto).
- Componentes baseados em Radix/shadcn já trazem boa parte da
semântica ARIA — não remover isso ao customizar.
- Respeitar `prefers-reduced-motion` em qualquer transição/animação.
- Touch targets mínimos de 44x44px nos componentes interativos.
4. **Página de showcase**
- Criar uma rota de desenvolvimento (ex: `/dev/componentes`, não
exposta em produção) que renderiza todos os componentes acima com
suas variantes, para revisão visual rápida em qualquer breakpoint
sem precisar montar uma tela de negócio primeiro.
## Critérios de aceite
- [ ] Todos os componentes listados existem, tipados em TypeScript, com
as variantes descritas
- [ ] `Table` alterna corretamente entre tabela (≥768px) e cards
empilhados (<768px) na página de showcase
- [ ] `Modal/Sheet` alterna corretamente entre modal centralizado
(desktop) e tela cheia/bottom sheet (mobile)
- [ ] `StatusPill` cobre todos os status de negócio listados, com cor e
label em português
- [ ] Navegação por teclado (Tab) percorre todos os componentes
interativos da página de showcase com foco visível
- [ ] Nenhum componente quebra ou tem overflow horizontal em 375px
- [ ] Página de showcase acessível apenas em ambiente de desenvolvimento
+102
View File
@@ -0,0 +1,102 @@
# Etapa 13 — Autenticação e Tema Dinâmico (Whitelabel)
## Contexto do projeto
Continuação do frontend do **SeConectai Vendas**. Já existem: projeto
configurado (Etapa 11) e a biblioteca de componentes base (Etapa 12). O
backend já expõe toda a API de autenticação (login master/tenant,
refresh, esqueci senha) e o endpoint público de tema por tenant
(definidos nas Etapas 2 e 8 do backend).
## Objetivo desta etapa
Implementar as telas públicas de autenticação, o mecanismo de sessão no
client (armazenamento de token, renovação automática, proteção de
rotas), e a resolução do tema (logo + cor) por tenant antes mesmo do
login.
## Tarefas
1. **Telas públicas (`app/(public)/`)**
- **`/login`**: formulário de e-mail + senha. Deve detectar se o
login é de master ou de tenant (definir estratégia: campo
oculto/toggle, ou dois formulários, ou detecção automática pelo
backend a partir do e-mail — escolher a mais simples de implementar
e documentar a decisão). Erros de credencial inválida devem ser
claros mas não revelar se o e-mail existe ou não (mensagem genérica
"e-mail ou senha inválidos").
- **`/esqueci-senha`**: formulário de e-mail, chama o endpoint de
recuperação, sempre mostra mensagem de sucesso genérica (não
revelar se o e-mail existe no sistema).
- **`/redefinir-senha/[token]`**: formulário de nova senha (com
confirmação), valida força mínima no client espelhando a regra do
backend, trata token expirado/inválido com mensagem clara.
- Todas essas telas devem ser **totalmente responsivas**: em mobile,
formulário ocupa a largura da tela com padding adequado; em
desktop, formulário centralizado com largura máxima (ex: 420px) e
fundo com identidade visual do tema resolvido (ver item 3).
2. **Gerenciamento de sessão (`lib/auth/`)**
- Access token guardado **em memória** (ex: em um Context/store, não
em `localStorage`/`sessionStorage`).
- Refresh token: o backend deve setar como cookie `httpOnly`; o
client não manipula esse cookie diretamente, apenas confia que ele
é enviado automaticamente nas chamadas relevantes.
- Interceptor no `lib/api/client.ts` (gancho deixado pronto na Etapa
11): ao receber 401, tenta chamar o endpoint de refresh
automaticamente; se conseguir, repete a requisição original com o
novo token; se falhar, desloga e redireciona para `/login`.
- Função `logout()` que limpa o estado em memória e chama o endpoint
de logout do backend (revoga o refresh token no servidor).
3. **Middleware de proteção de rotas**
- Middleware do Next.js protegendo `/master/*` e `/app/*`: verifica
se existe sessão válida (ex: checando um cookie de sinalização não
sensível, já que o access token real fica só em memória no client —
definir estratégia de verificação no servidor sem expor o token) e
redireciona para `/login` se não houver.
- Usuário master tentando acessar `/app/*` (ou vice-versa) deve ser
redirecionado para a área correta, não para login.
- Após login bem-sucedido, redirecionar automaticamente para
`/master/dashboard` ou `/app/dashboard` conforme o tipo de usuário
retornado pela API.
4. **Tema dinâmico por tenant (`lib/theme/`)**
- Na tela `/login`, antes de qualquer autenticação, resolver o tema:
se a URL/subdomínio/parâmetro identificar um tenant específico,
chamar o endpoint público `/public/tenants/:slugOuId/tema` e
aplicar `--color-primary` e o logo retornados. Se não houver tenant
identificável (ex: acesso genérico), mostrar o tema padrão da
plataforma.
- Após o login, na área `/app`, resolver o tema a partir do perfil do
tenant autenticado (não precisa mais do endpoint público).
- Implementar a validação de contraste descrita na seção 6 do schema:
se `cor_primaria` do tenant não atingir contraste mínimo (WCAG AA)
contra branco, escurecer automaticamente antes de aplicar como cor
de texto/ícone sobre fundo colorido (manter a cor original para
fundos, ajustar só onde teria problema de legibilidade).
5. **Feedback de erro e loading**
- Todos os formulários desta etapa usam os componentes `FormField`,
`Button` (estado `loading`) e `Toast` já construídos na Etapa 12 —
nenhuma solução de UI paralela aqui.
## Critérios de aceite
- [ ] Login master e login tenant funcionam ponta a ponta com o backend
real
- [ ] Sessão expira corretamente e é renovada automaticamente via
refresh (testar deixando o access token expirar e fazendo uma
ação)
- [ ] Logout limpa a sessão e o backend revoga o refresh token
- [ ] Middleware bloqueia acesso não autenticado a `/master/*` e
`/app/*`, redirecionando para `/login`
- [ ] Usuário master não consegue acessar `/app/*` diretamente pela URL
(e vice-versa) sem ser redirecionado
- [ ] Fluxo completo de esqueci/redefinir senha funciona, incluindo
tratamento de token expirado
- [ ] Tela de login aplica a cor/logo do tenant certo quando o tenant é
identificável, e cai no tema padrão quando não é
- [ ] Cor de tenant com baixo contraste é ajustada automaticamente onde
necessário, sem quebrar a legibilidade
- [ ] Todas as telas desta etapa testadas em 375px, 768px e 1280px
+94
View File
@@ -0,0 +1,94 @@
# Etapa 14 — Layout e Navegação (Master e Tenant)
## Contexto do projeto
Continuação do frontend do **SeConectai Vendas**. Já existem: setup,
componentes base, e autenticação/tema funcionando. Ainda não existe
nenhum layout real das áreas autenticadas — as rotas de `/master` e
`/app` só têm placeholders.
Referência obrigatória: seção 5 (navegação por área) do
`10-frontend-schema-arquitetura.md`.
## Objetivo desta etapa
Construir o "casco" (shell) de navegação das duas áreas autenticadas —
sidebar, topbar, drawer mobile, bottom tab bar — totalmente responsivo,
que vai envolver todas as telas de negócio das próximas etapas.
## Tarefas
1. **Layout master (`app/master/layout.tsx`)**
- Desktop (`lg+`): sidebar fixa à esquerda (~240px) com: logo/marca
do SeConectai Vendas no topo (identidade fixa do master, não
whitelabel), lista de itens de menu com ícone + label, item ativo
destacado, avatar + nome do admin master + botão de logout no
rodapé.
- Itens do menu master: Dashboard, Tenants, Planos, Faturas,
Configuração Evolution API, Alertas (Templates/Logs),
Configurações.
- Topbar: breadcrumb da página atual + ícone de notificações (pode
ficar como placeholder visual nesta etapa, funcionalidade real na
Etapa 18/19) + busca rápida (input, funcionalidade real pode ficar
para depois, mas o componente deve existir).
- Mobile (abaixo de `md`): topbar fixa compacta (logo símbolo +
hambúrguer + notificações) + drawer com o menu completo + bottom
tab bar com os itens mais usados (definir 4 principais: Dashboard,
Tenants, Faturas, Alertas — o resto entra em "Mais").
2. **Layout tenant (`app/app/layout.tsx`)**
- Mesma estrutura de sidebar/topbar/drawer/bottom-nav do master, mas:
- Logo e cor vêm do tema do tenant autenticado (usar o que foi
resolvido na Etapa 13).
- Itens do menu: Dashboard, Clientes, Produtos, Vendas,
Recebimentos, WhatsApp, E-mail, Alertas, Equipe, Perfil.
- Bottom tab bar mobile com 4 principais: Dashboard, Clientes,
Vendas, Recebimentos — resto em "Mais".
- Itens do menu visíveis conforme o papel do usuário logado (ex:
"Equipe" só aparece para `tenant_admin`) — implementar o
controle de visibilidade por `role` aqui, para ser reaproveitado
em todas as telas.
3. **Componentes de layout (`components/layout/`)**
- `Sidebar`: recebe a lista de itens de menu e o slot de rodapé
(usuário) como props/children — não deve ter os itens hardcoded
dentro dela, para poder ser reaproveitada entre master e tenant com
conteúdo diferente.
- `Topbar`: recebe breadcrumb e slots de ações (notificações, busca).
- `Drawer`: menu deslizante mobile, fecha ao navegar ou ao tocar fora.
- `BottomTabBar`: recebe até 4 itens fixos + item "Mais" que abre um
sheet com o restante do menu.
- `Breadcrumb`: gerado a partir da estrutura de rotas (pode ser
manual por página nesta primeira versão, com nota no código sobre
automatizar depois).
4. **Estado de navegação**
- Sidebar colapsável (ícones apenas) no breakpoint `md`, com um botão
para expandir/recolher manualmente também no desktop, se fizer
sentido — decisão livre, mas documentar o comportamento escolhido.
- Persistir a preferência de sidebar colapsada/expandida (ex: em
cookie ou localStorage, não é dado sensível).
5. **Empty shells das páginas de negócio**
- Cada rota de `/master/*` e `/app/*` já criada como placeholder na
Etapa 11 deve agora estar **dentro** do layout correto (sidebar +
topbar aplicados), ainda com conteúdo placeholder — só a moldura
precisa estar pronta e correta aqui.
## Critérios de aceite
- [ ] Layout master renderiza sidebar/topbar no desktop e
drawer+bottom-nav no mobile, com os itens de menu corretos
- [ ] Layout tenant renderiza com a cor/logo do tenant autenticado
aplicada corretamente na sidebar/topbar
- [ ] Item "Equipe" só aparece no menu do tenant para usuários
`tenant_admin` (testar com usuário de outro papel)
- [ ] Navegação entre itens do menu funciona e destaca corretamente o
item ativo, tanto no desktop quanto no drawer/bottom-nav mobile
- [ ] Drawer mobile fecha ao selecionar um item ou tocar fora dele
- [ ] Bottom tab bar mostra os 4 itens definidos + "Mais" com o restante
- [ ] Sidebar colapsável funciona no breakpoint `md` e a preferência do
usuário é lembrada entre sessões
- [ ] Todas as rotas do mapa de rotas abrem dentro do layout correto
(nenhuma "solta" sem sidebar/topbar)
- [ ] Testado em 375px, 768px e 1280px sem quebra de layout
+99
View File
@@ -0,0 +1,99 @@
# Etapa 15 — Área Master: Telas de Negócio
## Contexto do projeto
Continuação do frontend do **SeConectai Vendas**. Já existem: setup,
design system, autenticação/tema, e o layout/navegação da área master
funcionando. Esta etapa implementa as telas reais dentro desse layout,
consumindo os endpoints do painel master já prontos no backend (Etapas
3 e 4 do backend).
## Objetivo desta etapa
Todas as telas funcionais da área `/master`: dashboard, gestão de
tenants, planos, faturas, configuração da Evolution API e templates/logs
de alerta.
## Tarefas
1. **`/master/dashboard`**
- Cards de resumo (usando o componente `Card`/`StatCard`): total de
tenants por status, MRR, total recebido no mês, total em aberto.
- Gráfico simples (Recharts) de evolução de faturamento ou novos
tenants nos últimos meses, se o backend expuser dado suficiente
para isso — caso não exponha, usar só os cards de resumo e anotar a
limitação.
- Layout em grid (2–4 colunas no desktop, coluna única no mobile).
2. **`/master/tenants`**
- Listagem usando o componente `Table` (vira cards no mobile
automaticamente), colunas: nome, status (`StatusPill`), plano,
data de criação. Busca por nome/documento, filtro por status,
paginação.
- Ação rápida por linha: ver detalhe, mudar status.
- Botão "Novo tenant" levando para `/master/tenants/novo`.
3. **`/master/tenants/novo`**
- Formulário de criação: dados cadastrais, seleção de plano
(`Select`), dia de vencimento da mensalidade, dados do primeiro
usuário `tenant_admin` (nome, e-mail, senha provisória — mostrar a
senha gerada/informada em um `Toast`/modal de confirmação após
criar, com aviso claro para copiar/repassar ao cliente).
4. **`/master/tenants/:id`**
- Detalhe completo com `Tabs`: Dados cadastrais (editável), Faturas
(lista de `tenant_faturas` desse tenant, com ação de marcar como
paga), Uso (quantidade de usuários/customers/vendas — dados
agregados do backend).
- Ação de mudar status (`ativo`/`suspenso`/`cancelado`/`inadimplente`)
com confirmação explícita (modal) antes de aplicar, já que afeta o
login dos usuários do tenant.
5. **`/master/planos`**
- CRUD simples em lista + modal de criação/edição (não precisa de
página própria, dado o volume baixo de planos).
- Impedir exclusão de plano em uso (o backend já bloqueia — o
frontend deve mostrar a mensagem de erro retornada de forma clara,
não apenas um erro genérico).
6. **`/master/faturas`**
- Listagem consolidada de todas as `tenant_faturas`, filtros por
status/tenant/período, ação de marcar como paga.
- Botão de ação para disparar a geração de faturas do mês (endpoint
manual do backend nesta fase) com confirmação antes de executar.
7. **`/master/evolution-config`**
- Formulário de cadastro/edição da conexão (nome, URL base, API key —
campo de senha/mascarado, nunca mostrar a key completa depois de
salva).
- Botão "Testar conexão" com feedback claro de sucesso/erro (`Toast`).
8. **`/master/alertas/templates`** e **`/master/alertas/logs`**
- Templates: lista + editor de template (tipo, canal, assunto,
corpo com suporte a inserir os placeholders disponíveis via um
menu de ajuda ao lado do editor).
- Logs: listagem com filtro por canal/status/período, mostrando o
conteúdo enviado e o erro (se houver) ao expandir uma linha.
9. **`/master/configuracoes`**
- Dados do próprio admin master (nome, e-mail), troca de senha.
## Critérios de aceite
- [ ] Todas as telas listadas funcionam ponta a ponta com o backend real
- [ ] Dashboard mostra números corretos comparados com dados de teste
conhecidos
- [ ] Criar tenant exibe claramente a senha provisória gerada, com aviso
de que não será mostrada novamente
- [ ] Mudar status de tenant exige confirmação explícita antes de
aplicar
- [ ] Erros de negócio retornados pela API (ex: exclusão de plano em
uso, limite de instância) aparecem como mensagem clara para o
usuário, não como erro genérico
- [ ] API key da Evolution nunca aparece em texto completo na tela após
salva
- [ ] Editor de template mostra os placeholders disponíveis e insere
corretamente no corpo da mensagem
- [ ] Todas as listagens usam paginação, busca/filtro e estados de
loading/vazio corretamente
- [ ] Todas as telas testadas em 375px, 768px e 1280px
@@ -0,0 +1,86 @@
# Etapa 16 — Área Tenant: Dashboard, Clientes e Produtos
## Contexto do projeto
Continuação do frontend do **SeConectai Vendas**. Já existem: setup,
design system, autenticação/tema, layout/navegação e a área master
completa. Esta etapa inicia as telas de negócio da área `/app` (o
painel que os seus clientes usam), consumindo os endpoints das Etapas 5
e 6 (parte de dashboard) do backend.
## Objetivo desta etapa
Dashboard inicial do tenant, e os cadastros de clientes (customers) e
produtos/serviços — a base de dados que toda venda vai referenciar.
## Tarefas
1. **`/app/dashboard`**
- Cards de resumo: total recebido no mês, total a receber
(pendente+atrasado, com o atrasado destacado visualmente, ex: cor
de alerta), quantidade de vendas no mês.
- Gráfico de resumo semanal/mensal de vendas (Recharts), consumindo
as views de dashboard do backend.
- Lista das próximas parcelas a vencer (top 5–10), com link direto
para `/app/recebimentos`.
- Grid responsivo: 2–3 colunas no desktop, coluna única no mobile,
gráfico sempre com largura total.
2. **`/app/clientes`**
- Listagem (`Table`, vira cards no mobile) com busca por
nome/documento, filtro por ativo/inativo, paginação.
- Colunas/cards mostrando: razão social, documento, telefone/e-mail,
status.
- Botão "Novo cliente" → `/app/clientes/novo`.
3. **`/app/clientes/novo` e `/app/clientes/:id`**
- Formulário completo: dados cadastrais (razão social, nome
fantasia, documento, e-mail, telefone/WhatsApp, endereço, cidade,
UF, observações).
- No detalhe (`:id`), usar `Tabs`: Dados cadastrais (editável),
Contatos (CRUD aninhado — lista de `customer_contacts` com
indicação clara de qual é o principal e ação de marcar
principal), Histórico de vendas (lista somente leitura das vendas
desse cliente, com link para o detalhe de cada venda — mesmo que a
tela de vendas só seja construída na próxima etapa, deixar o link
já apontando para a rota correta).
- Ação de desativar (soft delete) com confirmação, mantendo o
registro visível no histórico.
4. **`/app/produtos`**
- Listagem com filtro por tipo (`produto`/`servico`) e por ativo,
busca por nome/SKU.
- Colunas/cards: nome, tipo (`StatusPill` ou badge simples), valor de
venda, SKU, status.
5. **`/app/produtos/novo` e `/app/produtos/:id`**
- Formulário: tipo (produto/serviço), nome, descrição, SKU (opcional,
validar unicidade — mostrar erro claro se a API retornar conflito),
valor de venda, valor de custo (opcional), unidade, flag
"recorrente".
- Ação de desativar (soft delete) com confirmação.
6. **Validação e limites de plano**
- Ao tentar criar cliente ou produto além do limite do plano do
tenant, a API retorna erro — o frontend deve mostrar essa mensagem
de forma clara (ex: banner ou toast explicando o limite atingido),
não travar silenciosamente o formulário.
## Critérios de aceite
- [ ] Dashboard do tenant mostra números batendo com dados de teste
conhecidos
- [ ] CRUD completo de clientes funciona, incluindo contatos aninhados e
regra de contato principal único
- [ ] Histórico de vendas no detalhe do cliente lista corretamente
(mesmo que a tela de destino ainda não exista até a próxima etapa)
- [ ] CRUD completo de produtos/serviços funciona, incluindo validação
de SKU duplicado
- [ ] Tentar exceder limite do plano (clientes ou produtos) mostra
mensagem clara ao usuário
- [ ] Desativar cliente/produto pede confirmação e reflete corretamente
na listagem (sai da lista de ativos, mas seria visível com filtro
"inativos")
- [ ] Todas as listagens com busca, filtro, paginação e estados de
loading/vazio
- [ ] Todas as telas testadas em 375px, 768px e 1280px
@@ -0,0 +1,97 @@
# Etapa 17 — Área Tenant: Vendas e Financeiro
## Contexto do projeto
Continuação do frontend do **SeConectai Vendas**. Já existem: setup,
design system, autenticação/tema, layout/navegação, área master
completa, e clientes/produtos no painel do tenant. Esta é a etapa mais
crítica do painel do tenant — o fluxo de vendas e o controle financeiro
— consumindo os endpoints da Etapa 6 do backend.
## Objetivo desta etapa
Registro completo de vendas (com itens e parcelas), listagem e ação
sobre recebimentos, e os dashboards financeiros/semana-mês.
## Tarefas
1. **`/app/vendas`**
- Listagem (`Table`) com filtro por status (`orcamento`,
`confirmada`, `concluida`, `cancelada`), por cliente, por período
de `data_venda`, paginação.
- Colunas/cards: número da venda, cliente, valor total, status
(`StatusPill`), data.
- Botão "Nova venda" → `/app/vendas/nova`.
2. **`/app/vendas/nova`** — a tela mais complexa do projeto
- Seleção de cliente (`Select` com busca, puxando de `/app/clientes`)
— se o cliente não existir ainda, oferecer atalho para cadastrar
rápido sem sair do fluxo (modal com o formulário mínimo de
cliente).
- Lista de itens da venda: adicionar produto/serviço (busca por
nome/SKU), quantidade, valor unitário (pré-preenchido do catálogo,
editável para casos de desconto pontual — deixar claro visualmente
quando o valor foi alterado do catálogo). Cálculo automático do
subtotal por item e do total geral, considerando o campo de
desconto geral da venda.
- Definição das parcelas: opção "gerar parcelas automaticamente"
(informar número de parcelas e primeira data de vencimento, o
sistema calcula o resto) ou "definir manualmente" (lista editável
de valor+vencimento por parcela). Validação em tempo real: soma das
parcelas precisa bater com o valor total da venda antes de permitir
salvar (mostrar a diferença, se houver, de forma clara).
- Layout responsivo: no desktop, itens da venda e resumo/parcelas
podem ficar lado a lado (2 colunas); no mobile, tudo empilhado com
o resumo total sempre visível (ex: barra fixa no rodapé mostrando o
total atualizado enquanto o usuário edita os itens).
- Salvar como `orcamento` ou já como `confirmada` (duas ações
distintas, não um único botão "Salvar").
3. **`/app/vendas/:id`**
- Detalhe com `Tabs`: Itens (somente leitura se a venda estiver
`confirmada`/`concluida`, editável se `orcamento`), Parcelas (lista
com status e ação de marcar como paga por parcela), Histórico
(mudanças de status, se o backend expuser isso via `audit_logs`).
- Ações de topo: mudar status (confirmar orçamento, cancelar venda —
com confirmação explícita explicando que isso cancela as parcelas
pendentes associadas).
4. **`/app/recebimentos`**
- Listagem de parcelas (`sale_payments`) via a view/endpoint do
backend, com filtro por status, por cliente, por período de
vencimento. Parcelas atrasadas destacadas visualmente (cor de
alerta) e, se possível, ordenadas por padrão com as mais urgentes
no topo.
- Ação "Marcar como pago" por linha, com seleção de forma de
pagamento — se o backend suportar ação em lote, oferecer seleção
múltipla de linhas com ação "Marcar selecionadas como pagas".
5. **Dashboards financeiros (complementando `/app/dashboard` da Etapa
16, se ainda não coberto)**
- Gráfico de resumo semanal e mensal lado a lado ou alternável (toggle
de período), usando as views `vw_resumo_vendas_semanal` /
`vw_resumo_vendas_mensal` do backend.
- Bloco "Financeiro": total recebido, total a receber, total
atrasado, com cores semânticas consistentes com o `StatusPill`.
## Critérios de aceite
- [ ] Criar venda completa (cliente + itens + parcelas) funciona ponta a
ponta e reflete corretamente no backend
- [ ] Validação de soma das parcelas vs. valor total impede salvar
quando não bate, com mensagem clara da diferença
- [ ] Cadastro rápido de cliente dentro do fluxo de nova venda funciona
sem perder o que já foi preenchido na venda
- [ ] Venda confirmada não permite editar itens/valores na tela de
detalhe (reflete a regra do backend)
- [ ] Cancelar venda pede confirmação e reflete o cancelamento das
parcelas pendentes na tela de recebimentos
- [ ] Marcar parcela como paga atualiza corretamente o dashboard
financeiro sem precisar recarregar a página manualmente
(invalidação de cache do React Query)
- [ ] Parcelas atrasadas visualmente destacadas na listagem de
recebimentos
- [ ] Barra de total fixa no mobile durante a montagem de uma venda
funciona corretamente e não sobrepõe conteúdo importante
- [ ] Todas as telas testadas em 375px, 768px e 1280px, com atenção
especial à tela de nova venda (a mais densa de informação)
@@ -0,0 +1,97 @@
# Etapa 18 — Área Tenant: Integrações, Alertas, Equipe e Perfil
## Contexto do projeto
Continuação do frontend do **SeConectai Vendas**. Já existem: setup,
design system, autenticação/tema, layout/navegação, área master
completa, e clientes/produtos/vendas/recebimentos no painel do tenant.
Esta etapa fecha o painel do tenant com as telas de configuração:
integrações de mensageria, templates/logs de alerta próprios do tenant,
gestão da equipe e personalização whitelabel — consumindo os endpoints
das Etapas 4, 7 e 8 do backend.
## Objetivo desta etapa
Telas de `/app/whatsapp`, `/app/email-config`, `/app/alertas/*`,
`/app/equipe` e `/app/perfil`.
## Tarefas
1. **`/app/whatsapp`**
- Lista de instâncias WhatsApp do tenant (pode ser mais de uma,
conforme limite do plano), com status (`desconectado`,
`conectando`, `conectado`, `erro`) via `StatusPill`.
- Ação "Nova instância" (respeitando limite do plano — mostrar
mensagem clara se atingido).
- Fluxo de conexão: ao criar/selecionar uma instância, mostrar o QR
code retornado pela API (imagem), com polling automático do status
a cada poucos segundos até detectar `conectado` (parar o polling
nesse momento e mostrar sucesso).
- Botão de teste de envio (número + mensagem) com feedback claro.
- Ação de desconectar/remover instância, com confirmação.
2. **`/app/email-config`**
- Toggle "Usar configuração padrão da plataforma" vs "Usar meu
próprio SMTP" — ao desativar o padrão, exibir e exigir os campos de
SMTP próprio.
- Botão de teste de envio (e-mail de destino) com feedback claro.
- Senha do SMTP: campo tipo password, nunca pré-preenchido com o
valor real ao editar (mesma lógica de segurança da API key da
Evolution na área master).
3. **`/app/alertas/templates`**
- Mesma lógica de editor da Etapa 15 (master), mas aqui os templates
são do tenant, com fallback visual indicando quando um tipo/canal
ainda não tem template próprio e está usando o padrão da
plataforma (ex: badge "usando padrão da plataforma" com opção de
"criar meu próprio").
4. **`/app/alertas/logs`**
- Listagem de `alert_logs` filtrada para os alertas enviados pelo
tenant (para os próprios `customers` dele), com os mesmos filtros
da tela master (canal, status, período) e expansão de linha para
ver conteúdo enviado/erro.
5. **`/app/equipe`** (visível só para `tenant_admin`, conforme já
controlado no layout da Etapa 14)
- Listagem dos `tenant_users` do tenant, com papel (`role`) e status
ativo/inativo.
- Convite/criação de novo usuário: nome, e-mail, papel, senha
provisória (mesma UX de exibir e avisar sobre a senha gerada, como
na criação de tenant pelo master).
- Ação de desativar usuário e de alterar papel.
6. **`/app/perfil`**
- Dados da empresa (nome fantasia, razão social — parte editável
dentro do que o backend permitir ao tenant alterar).
- **Upload de logo**: componente de upload com preview imediato,
validação de tipo/tamanho no client antes de enviar (espelhando as
regras do backend: png/jpg/svg, até 2MB), e ação de remover logo
(volta ao padrão da plataforma).
- **Seletor de cor primária**: color picker, com preview ao vivo de
como a cor fica aplicada nos componentes principais (ex: mostrar um
botão de exemplo com a cor escolhida antes de salvar), incluindo o
aviso de contraste automático descrito na Etapa 13.
- Ao salvar, o tema da aplicação inteira (sidebar, botões) deve
atualizar imediatamente, sem precisar recarregar a página.
## Critérios de aceite
- [ ] Fluxo completo de conexão de instância WhatsApp funciona (criar →
escanear QR → status muda para conectado automaticamente via
polling)
- [ ] Teste de envio de WhatsApp e de e-mail funcionam com feedback
claro de sucesso/erro
- [ ] Alternância entre SMTP próprio e padrão da plataforma funciona
corretamente, exigindo campos quando aplicável
- [ ] Templates do tenant mostram claramente quando estão usando
fallback do padrão da plataforma
- [ ] Logs de alerta do tenant mostram apenas os alertas dele
(isolamento correto)
- [ ] CRUD de equipe funciona e respeita a visibilidade só para
`tenant_admin`
- [ ] Upload de logo funciona com validação client-side e preview
imediato
- [ ] Alteração de cor primária reflete instantaneamente em toda a
aplicação após salvar, sem reload manual
- [ ] Todas as telas testadas em 375px, 768px e 1280px
+98
View File
@@ -0,0 +1,98 @@
# Etapa 19 — Polimento, Acessibilidade e Testes E2E
## Contexto do projeto
Etapa final do frontend do **SeConectai Vendas**. Todas as telas de
negócio das áreas master e tenant já estão implementadas (Etapas 11–18).
Esta etapa é uma auditoria completa e o fechamento com testes
automatizados, antes de considerar o frontend pronto para produção.
## Objetivo desta etapa
Revisão sistemática de responsividade e acessibilidade em todas as
telas, ajustes de performance, e uma suíte de testes E2E cobrindo os
fluxos críticos do sistema.
## Tarefas
1. **Auditoria de responsividade**
- Percorrer **todas** as rotas do mapa de rotas (master e tenant) nos
três breakpoints de referência (375px, 768px, 1280px) e registrar/
corrigir qualquer:
- overflow horizontal
- texto cortado ou sobreposto
- touch target menor que 44x44px em mobile
- tabela que não virou card corretamente
- modal que não se adaptou (tela cheia/bottom sheet) no mobile
- Prestar atenção especial às telas mais densas: `/app/vendas/nova`,
`/master/tenants/:id`, dashboards com gráficos.
2. **Auditoria de acessibilidade**
- Rodar um checador automatizado (ex: axe-core) em todas as rotas
principais e corrigir violações críticas e sérias.
- Verificar manualmente: navegação completa por teclado (Tab/Shift+Tab)
em pelo menos um fluxo completo por área (ex: criar um cliente
inteiro só com teclado), foco visível em 100% dos elementos
interativos, contraste de texto mínimo AA em todos os textos
(incluindo sobre a cor primária dinâmica do tenant).
- Confirmar que `prefers-reduced-motion` é respeitado em todas as
transições/animações implementadas ao longo do projeto.
- Textos alternativos em imagens relevantes (logo do tenant, ícones
que carregam significado além do decorativo).
3. **Estados de loading, erro e vazio**
- Conferir que **toda** listagem e todo formulário do sistema trata
corretamente: carregando (skeleton), erro de rede/API (mensagem
clara com ação de tentar novamente quando fizer sentido), e vazio
(EmptyState com ação sugerida). Nenhuma tela pode ficar em branco
silenciosamente em caso de erro.
4. **Performance**
- Rodar Lighthouse (ou equivalente) nas rotas principais, mobile e
desktop, e corrigir problemas de maior impacto (ex: imagens sem
otimização, JS não utilizado sendo carregado antecipadamente,
ausência de `next/image` onde aplicável).
- Garantir `loading="lazy"` em imagens fora da dobra inicial (ex:
logos em listagens longas, se aplicável).
5. **Testes E2E (Playwright ou Cypress — escolher um e justificar)**
- Fluxo 1: login master → criar tenant → confirmar que o
`tenant_admin` criado consegue logar.
- Fluxo 2: login tenant → cadastrar cliente → cadastrar produto →
criar venda com parcelas → marcar uma parcela como paga → conferir
que o dashboard financeiro reflete o pagamento.
- Fluxo 3: alteração de logo/cor no perfil do tenant e confirmação
visual de que o tema mudou na sidebar.
- Fluxo 4: tentativa de acesso não autenticado a rota protegida
(`/app/dashboard` sem login) redireciona corretamente para
`/login`.
- Rodar a suíte pelo menos no viewport mobile e no desktop (Playwright
permite configurar múltiplos projetos/viewports).
6. **Revisão final de conteúdo/copy**
- Passar por todos os textos de botão, mensagem de erro, empty state
e confirmação verificando: voz ativa, consistência de nome de ação
(o botão "Salvar" deve gerar uma confirmação que também fala em
"salvo", não "criado" ou outra variação), e nenhuma mensagem de
erro genérica onde a API já fornece detalhe suficiente para ser
específica.
## Critérios de aceite
- [ ] Nenhuma rota do mapa de rotas tem overflow horizontal em 375px,
768px ou 1280px
- [ ] Checador de acessibilidade automatizado sem violações críticas ou
sérias nas rotas principais
- [ ] Um fluxo completo (ex: cadastro de cliente) realizado do início ao
fim apenas com teclado, com foco sempre visível
- [ ] Contraste de texto validado sobre a cor primária dinâmica em pelo
menos 3 tenants de teste com cores diferentes (incluindo uma cor
clara, para forçar o ajuste automático de contraste)
- [ ] Todas as listagens e formulários tratam loading/erro/vazio
corretamente — testado forçando erro de rede manualmente
- [ ] Lighthouse mobile e desktop nas rotas principais sem alertas de
performance de alto impacto
- [ ] Suíte de testes E2E dos 4 fluxos descritos passa em mobile e
desktop
- [ ] Revisão de copy concluída, sem mensagens de erro genéricas onde a
API fornece detalhe específico
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs', 'dist/**', 'node_modules/**', 'coverage/**'],
ignores: ['eslint.config.mjs', 'dist/**', 'node_modules/**', 'coverage/**', 'frontend/**'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
+3
View File
@@ -0,0 +1,3 @@
# URL base da API do backend (NestJS). Ver Etapas/01-setup-projeto.md —
# porta padrão do backend é 3000.
NEXT_PUBLIC_API_URL=http://localhost:3000
+42
View File
@@ -0,0 +1,42 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (.env.local.example é versionado de propósito)
.env*
!.env*.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+5
View File
@@ -0,0 +1,5 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100
}
+50
View File
@@ -0,0 +1,50 @@
# SeConectai Vendas — Frontend
Aplicação Next.js (App Router) que consome a API do backend NestJS deste
mesmo repositório. Arquitetura de referência:
[`Etapas/V2/10-frontend-schema-arquitetura.md`](../Etapas/V2/10-frontend-schema-arquitetura.md).
Estado de progresso das etapas de frontend:
[`Etapas/V2/20-controle-de-progresso-frontend.md`](../Etapas/V2/20-controle-de-progresso-frontend.md).
## Rodando localmente
1. Backend rodando (via `docker compose up -d` na raiz do repo, ou
localmente) — necessário para `/diagnostico` e para qualquer chamada de
API.
2. `cp .env.local.example .env.local` e ajuste `NEXT_PUBLIC_API_URL` se o
backend não estiver em `http://localhost:3000`.
3. `npm install`
4. `npm run dev` — sobe em `http://localhost:3001` (porta 3000 é do
backend; se estiver livre o Next também aceitaria 3000, mas o
`CORS_ORIGINS` do backend já está configurado para 3001).
## Scripts
- `npm run dev` — servidor de desenvolvimento (Turbopack)
- `npm run build` — build de produção
- `npm run start` — serve o build de produção
- `npm run lint` — ESLint
- `npm run format` / `npm run format:check` — Prettier
## Stack
Next.js 15 (App Router) + React 19 + TypeScript, Tailwind CSS v4
(tokens definidos em [`styles/tokens.css`](./styles/tokens.css) via
`@theme inline`, não em `tailwind.config.ts` — ver decisão registrada no
controle de progresso da Etapa 11).
## Estrutura
```
app/(public)/ rotas públicas (login, recuperação de senha)
app/master/ painel master
app/app/ painel tenant
components/ui/ componentes base (Etapa 12)
components/layout/ sidebar/topbar/drawer (Etapa 14)
components/domain/ componentes específicos de domínio
lib/api/ cliente HTTP
lib/auth/ sessão/autenticação (Etapa 13)
lib/theme/ resolução de tema whitelabel (Etapa 13)
lib/validators/ schemas Zod
styles/tokens.css tokens de design (CSS variables)
```
@@ -0,0 +1,125 @@
'use client';
import * as React from 'react';
import Link from 'next/link';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { apiClient, ApiError } from '@/lib/api/client';
import { forgotPasswordSchema, type ForgotPasswordFormValues } from '@/lib/validators/auth';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { FormField } from '@/components/ui/form-field';
type Area = 'tenant' | 'master';
export default function EsqueciSenhaPage() {
const [area, setArea] = React.useState<Area>('tenant');
const [sent, setSent] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ForgotPasswordFormValues>({ resolver: zodResolver(forgotPasswordSchema) });
const onSubmit = async (values: ForgotPasswordFormValues) => {
setError(null);
try {
// Resposta é sempre genérica (200), mesmo se o e-mail não existir —
// o backend nunca revela isso (Etapa 13: nunca revelar se o e-mail existe).
await apiClient.post('/auth/password/forgot', {
email: values.email,
usuario_tipo: area === 'master' ? 'master' : 'tenant_user',
});
setSent(true);
} catch (err) {
setError(
err instanceof ApiError ? err.message : 'Não foi possível conectar. Tente novamente.',
);
}
};
return (
<main className="flex min-h-screen items-center justify-center bg-neutral-50 p-4">
<div className="flex w-full max-w-[420px] flex-col gap-6 rounded-xl border border-border bg-surface p-6 shadow-sm sm:p-8">
<div className="flex flex-col gap-2 text-center">
<h1 className="text-xl font-semibold text-foreground">Esqueci minha senha</h1>
<p className="text-sm text-muted-foreground">
Informe seu e-mail — se ele existir no sistema, enviaremos instruções de redefinição.
</p>
</div>
{sent ? (
<div className="flex flex-col gap-4">
<p role="status" className="rounded-lg bg-success/10 p-3 text-sm text-success">
Se o e-mail informado existir, as instruções de redefinição foram enviadas.
</p>
<Link
href="/login"
className="text-center text-sm text-primary-on-light underline-offset-4 hover:underline"
>
Voltar para o login
</Link>
</div>
) : (
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)} noValidate>
<div
role="radiogroup"
aria-label="Tipo de acesso"
className="grid grid-cols-2 gap-1 rounded-lg bg-muted p-1"
>
<button
type="button"
role="radio"
aria-checked={area === 'tenant'}
onClick={() => setArea('tenant')}
className={`min-h-9 rounded-md text-sm font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring ${
area === 'tenant'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground'
}`}
>
Sou cliente
</button>
<button
type="button"
role="radio"
aria-checked={area === 'master'}
onClick={() => setArea('master')}
className={`min-h-9 rounded-md text-sm font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring ${
area === 'master'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground'
}`}
>
Sou administrador
</button>
</div>
<FormField label="E-mail" error={errors.email?.message}>
<Input type="email" autoComplete="email" {...register('email')} />
</FormField>
{error && (
<p role="alert" className="rounded-lg bg-danger/10 p-3 text-sm text-danger">
{error}
</p>
)}
<Button type="submit" loading={isSubmitting} className="w-full">
Enviar instruções
</Button>
<Link
href="/login"
className="text-center text-sm text-primary-on-light underline-offset-4 hover:underline"
>
Voltar para o login
</Link>
</form>
)}
</div>
</main>
);
}
+153
View File
@@ -0,0 +1,153 @@
'use client';
import * as React from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import Image from 'next/image';
import { useAuth } from '@/lib/auth/auth-context';
import { useTenantTheme, TenantThemeProvider } from '@/lib/theme/theme-provider';
import { ApiError } from '@/lib/api/client';
import { loginSchema, type LoginFormValues } from '@/lib/validators/auth';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { FormField } from '@/components/ui/form-field';
import Link from 'next/link';
type LoginArea = 'tenant' | 'master';
function GenericInvalidCredentialsMessage(): string {
return 'E-mail ou senha inválidos.';
}
function LoginFormInner() {
const router = useRouter();
const searchParams = useSearchParams();
const { loginMaster, loginTenant } = useAuth();
const { theme } = useTenantTheme();
// Estratégia de detecção de tipo de login (Etapa 13): o backend expõe
// dois endpoints separados (`/auth/master/login` e `/auth/tenant/login`,
// sem detecção automática por e-mail — ver observações da Etapa 13 no
// controle de progresso). Escolhido um toggle explícito em vez de dois
// formulários separados por ser a opção mais simples de implementar e
// mais clara para o usuário; "tenant" é o padrão porque é o público
// majoritário (clientes), master é uso interno raro.
const [area, setArea] = React.useState<LoginArea>('tenant');
const [submitError, setSubmitError] = React.useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormValues>({ resolver: zodResolver(loginSchema) });
const onSubmit = async (values: LoginFormValues) => {
setSubmitError(null);
try {
if (area === 'master') {
await loginMaster(values.email, values.senha);
} else {
await loginTenant(values.email, values.senha);
}
const redirectParam = searchParams.get('redirect');
const fallback = area === 'master' ? '/master/dashboard' : '/app/dashboard';
router.push(redirectParam && redirectParam.startsWith('/') ? redirectParam : fallback);
} catch (err) {
if (err instanceof ApiError && err.statusCode === 401) {
setSubmitError(GenericInvalidCredentialsMessage());
} else if (err instanceof ApiError) {
setSubmitError(err.message);
} else {
setSubmitError('Não foi possível conectar. Tente novamente.');
}
}
};
return (
<div className="flex w-full max-w-[420px] flex-col gap-6 rounded-xl border border-border bg-surface p-6 shadow-sm sm:p-8">
<div className="flex flex-col items-center gap-2 text-center">
{theme?.logoUrl ? (
<Image
src={theme.logoUrl}
alt={theme.nome}
width={48}
height={48}
className="size-12 rounded-lg object-contain"
/>
) : null}
<h1 className="text-xl font-semibold text-foreground">
{theme?.nome ?? 'SeConectai Vendas'}
</h1>
<p className="text-sm text-muted-foreground">Entre com seu e-mail e senha</p>
</div>
<div
role="radiogroup"
aria-label="Tipo de acesso"
className="grid grid-cols-2 gap-1 rounded-lg bg-muted p-1"
>
<button
type="button"
role="radio"
aria-checked={area === 'tenant'}
onClick={() => setArea('tenant')}
className={`min-h-9 rounded-md text-sm font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring ${
area === 'tenant' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground'
}`}
>
Sou cliente
</button>
<button
type="button"
role="radio"
aria-checked={area === 'master'}
onClick={() => setArea('master')}
className={`min-h-9 rounded-md text-sm font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring ${
area === 'master' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground'
}`}
>
Sou administrador
</button>
</div>
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)} noValidate>
<FormField label="E-mail" error={errors.email?.message}>
<Input type="email" autoComplete="email" {...register('email')} />
</FormField>
<FormField label="Senha" error={errors.senha?.message}>
<Input type="password" autoComplete="current-password" {...register('senha')} />
</FormField>
{submitError && (
<p role="alert" className="rounded-lg bg-danger/10 p-3 text-sm text-danger">
{submitError}
</p>
)}
<Button type="submit" loading={isSubmitting} className="w-full">
Entrar
</Button>
<Link
href="/esqueci-senha"
className="text-center text-sm text-primary-on-light underline-offset-4 hover:underline"
>
Esqueci minha senha
</Link>
</form>
</div>
);
}
export function LoginForm() {
const searchParams = useSearchParams();
const tenantId = searchParams.get('tenant');
return (
<TenantThemeProvider tenantId={tenantId}>
<LoginFormInner />
</TenantThemeProvider>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { Suspense } from 'react';
import { LoginForm } from './login-form';
export default function LoginPage() {
return (
<main className="flex min-h-screen items-center justify-center bg-neutral-50 p-4">
<Suspense fallback={null}>
<LoginForm />
</Suspense>
</main>
);
}
@@ -0,0 +1,101 @@
'use client';
import * as React from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { apiClient, ApiError } from '@/lib/api/client';
import { resetPasswordSchema, type ResetPasswordFormValues } from '@/lib/validators/auth';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { FormField } from '@/components/ui/form-field';
export default function RedefinirSenhaPage() {
const { token } = useParams<{ token: string }>();
const [done, setDone] = React.useState(false);
const [tokenInvalido, setTokenInvalido] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ResetPasswordFormValues>({ resolver: zodResolver(resetPasswordSchema) });
const onSubmit = async (values: ResetPasswordFormValues) => {
setError(null);
setTokenInvalido(false);
try {
await apiClient.post('/auth/password/reset', { token, nova_senha: values.novaSenha });
setDone(true);
} catch (err) {
if (err instanceof ApiError && err.statusCode === 401) {
setTokenInvalido(true);
} else if (err instanceof ApiError) {
setError(err.message);
} else {
setError('Não foi possível conectar. Tente novamente.');
}
}
};
return (
<main className="flex min-h-screen items-center justify-center bg-neutral-50 p-4">
<div className="flex w-full max-w-[420px] flex-col gap-6 rounded-xl border border-border bg-surface p-6 shadow-sm sm:p-8">
<div className="flex flex-col gap-2 text-center">
<h1 className="text-xl font-semibold text-foreground">Redefinir senha</h1>
</div>
{done ? (
<div className="flex flex-col gap-4">
<p role="status" className="rounded-lg bg-success/10 p-3 text-sm text-success">
Senha redefinida com sucesso.
</p>
<Link
href="/login"
className="text-center text-sm text-primary-on-light underline-offset-4 hover:underline"
>
Ir para o login
</Link>
</div>
) : tokenInvalido ? (
<div className="flex flex-col gap-4">
<p role="alert" className="rounded-lg bg-danger/10 p-3 text-sm text-danger">
Este link de redefinição é inválido ou já expirou.
</p>
<Link
href="/esqueci-senha"
className="text-center text-sm text-primary-on-light underline-offset-4 hover:underline"
>
Solicitar um novo link
</Link>
</div>
) : (
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)} noValidate>
<FormField
label="Nova senha"
error={errors.novaSenha?.message}
helpText="Mínimo de 10 caracteres."
>
<Input type="password" autoComplete="new-password" {...register('novaSenha')} />
</FormField>
<FormField label="Confirmar nova senha" error={errors.confirmarSenha?.message}>
<Input type="password" autoComplete="new-password" {...register('confirmarSenha')} />
</FormField>
{error && (
<p role="alert" className="rounded-lg bg-danger/10 p-3 text-sm text-danger">
{error}
</p>
)}
<Button type="submit" loading={isSubmitting} className="w-full">
Redefinir senha
</Button>
</form>
)}
</div>
</main>
);
}
+209
View File
@@ -0,0 +1,209 @@
'use client';
import * as React from 'react';
import { useQuery } from '@tanstack/react-query';
import type { DateRange } from 'react-day-picker';
import {
ALERT_LOG_STATUSES,
listTenantAlertLogs,
type AlertLog,
type AlertLogStatus,
} from '@/lib/api/tenant-alert-logs';
import { CANAIS_TEMPLATE, CANAL_TEMPLATE_LABEL, TIPO_TEMPLATE_LABEL } from '@/lib/api/alert-templates';
import type { CanalTemplate } from '@/lib/api/alert-templates';
import { formatDateTime } from '@/lib/format';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { DataTable, type DataTableColumn } from '@/components/ui/data-table';
import { DateRangePicker } from '@/components/ui/date-picker';
import { ResponsiveDialog } from '@/components/ui/responsive-dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
const STATUS_LABEL: Record<AlertLogStatus, string> = {
enviado: 'Enviado',
entregue: 'Entregue',
lido: 'Lido',
falhou: 'Falhou',
};
const STATUS_TONE: Record<AlertLogStatus, 'default' | 'destructive' | 'outline'> = {
enviado: 'outline',
entregue: 'default',
lido: 'default',
falhou: 'destructive',
};
const STATUS_OPTIONS: { value: AlertLogStatus | 'todos'; label: string }[] = [
{ value: 'todos', label: 'Todos os status' },
...ALERT_LOG_STATUSES.map((s) => ({ value: s, label: STATUS_LABEL[s] })),
];
const CANAL_OPTIONS: { value: CanalTemplate | 'todos'; label: string }[] = [
{ value: 'todos', label: 'Todos os canais' },
...CANAIS_TEMPLATE.map((c) => ({ value: c, label: CANAL_TEMPLATE_LABEL[c] })),
];
const LIMIT = 20;
export default function TenantAlertLogsPage() {
const [canal, setCanal] = React.useState<CanalTemplate | 'todos'>('todos');
const [status, setStatus] = React.useState<AlertLogStatus | 'todos'>('todos');
const [periodo, setPeriodo] = React.useState<DateRange | undefined>();
const [page, setPage] = React.useState(1);
const [detail, setDetail] = React.useState<AlertLog | null>(null);
const { data, isLoading } = useQuery({
queryKey: [
'tenant',
'alert-logs',
{ canal, status, de: periodo?.from?.toISOString(), ate: periodo?.to?.toISOString(), page },
],
queryFn: () =>
listTenantAlertLogs({
canal: canal === 'todos' ? undefined : canal,
status: status === 'todos' ? undefined : status,
data_inicio: periodo?.from?.toISOString(),
data_fim: periodo?.to?.toISOString(),
page,
limit: LIMIT,
}),
placeholderData: (prev) => prev,
});
const columns: DataTableColumn<AlertLog>[] = [
{
id: 'tipo',
header: 'Tipo',
primary: true,
cell: (row) => (row.template ? TIPO_TEMPLATE_LABEL[row.template.tipo] : '—'),
},
{ id: 'canal', header: 'Canal', primary: true, cell: (row) => CANAL_TEMPLATE_LABEL[row.canal] },
{
id: 'status',
header: 'Status',
cell: (row) => <Badge variant={STATUS_TONE[row.status]}>{STATUS_LABEL[row.status]}</Badge>,
},
{ id: 'enviado_em', header: 'Enviado em', cell: (row) => formatDateTime(row.enviado_em) },
];
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-xl font-semibold text-foreground">Logs de alerta</h1>
<p className="text-sm text-muted-foreground">Histórico de envios de WhatsApp/e-mail aos seus clientes</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<Select
value={canal}
onValueChange={(value) => {
setCanal(value as CanalTemplate | 'todos');
setPage(1);
}}
>
<SelectTrigger className="w-full sm:w-44" aria-label="Filtrar por canal">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CANAL_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={status}
onValueChange={(value) => {
setStatus(value as AlertLogStatus | 'todos');
setPage(1);
}}
>
<SelectTrigger className="w-full sm:w-44" aria-label="Filtrar por status">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<DateRangePicker
value={periodo}
onChange={(range) => {
setPeriodo(range);
setPage(1);
}}
placeholder="Período"
className="w-full sm:w-64"
/>
</div>
<DataTable
columns={columns}
data={data?.data ?? []}
keyField={(row) => row.id}
loading={isLoading}
emptyTitle="Nenhum log encontrado"
emptyDescription="Ajuste os filtros de período/canal/status."
onRowClick={(row) => setDetail(row)}
rowActions={() => (
<Button variant="ghost" size="sm">
Ver detalhes
</Button>
)}
pagination={
data ? { page: data.meta.page, limit: data.meta.limit, total: data.meta.total, onPageChange: setPage } : undefined
}
/>
<ResponsiveDialog
open={detail !== null}
onOpenChange={(open) => !open && setDetail(null)}
title="Detalhe do envio"
description={detail ? formatDateTime(detail.enviado_em) : undefined}
>
{detail && (
<div className="flex flex-col gap-4 px-4 pb-4 sm:px-0 sm:pb-0">
<div className="flex flex-wrap gap-4 text-sm">
<div>
<p className="text-muted-foreground">Tipo</p>
<p className="font-medium text-foreground">
{detail.template ? TIPO_TEMPLATE_LABEL[detail.template.tipo] : '—'}
</p>
</div>
<div>
<p className="text-muted-foreground">Canal</p>
<p className="font-medium text-foreground">{CANAL_TEMPLATE_LABEL[detail.canal]}</p>
</div>
<div>
<p className="text-muted-foreground">Status</p>
<Badge variant={STATUS_TONE[detail.status]}>{STATUS_LABEL[detail.status]}</Badge>
</div>
</div>
<Separator />
<div>
<p className="mb-1 text-sm font-medium text-foreground">Conteúdo enviado</p>
<p className="rounded-lg border border-border bg-muted/50 p-3 text-sm whitespace-pre-wrap">
{detail.conteudo_enviado || '—'}
</p>
</div>
{detail.erro && (
<div>
<p className="mb-1 text-sm font-medium text-danger">Erro</p>
<p className="rounded-lg border border-danger/30 bg-danger/10 p-3 text-sm whitespace-pre-wrap text-danger">
{detail.erro}
</p>
</div>
)}
</div>
)}
</ResponsiveDialog>
</div>
);
}
+412
View File
@@ -0,0 +1,412 @@
'use client';
import * as React from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Plus } from 'lucide-react';
import {
CANAIS_TEMPLATE,
CANAL_TEMPLATE_LABEL,
PLACEHOLDERS_POR_TIPO,
TIPOS_TEMPLATE_TENANT,
TIPO_TEMPLATE_TENANT_LABEL,
createTenantAlertTemplate,
deleteTenantAlertTemplate,
listTenantAlertTemplates,
updateTenantAlertTemplate,
type AlertTemplate,
type CanalTemplate,
type TipoTemplateTenant,
} from '@/lib/api/tenant-alert-templates';
import {
tenantAlertTemplateSchema,
type TenantAlertTemplateFormValues,
} from '@/lib/validators/tenant-alert-templates';
import { ApiError } from '@/lib/api/client';
import { toast } from '@/lib/toast';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { DataTable, type DataTableColumn } from '@/components/ui/data-table';
import { FormField } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import { ResponsiveDialog } from '@/components/ui/responsive-dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { ConfirmDialog } from '@/components/domain/confirm-dialog';
const TIPO_OPTIONS: { value: TipoTemplateTenant | 'todos'; label: string }[] = [
{ value: 'todos', label: 'Todos os tipos' },
...TIPOS_TEMPLATE_TENANT.map((tipo) => ({ value: tipo, label: TIPO_TEMPLATE_TENANT_LABEL[tipo] })),
];
const CANAL_OPTIONS: { value: CanalTemplate | 'todos'; label: string }[] = [
{ value: 'todos', label: 'Todos os canais' },
...CANAIS_TEMPLATE.map((canal) => ({ value: canal, label: CANAL_TEMPLATE_LABEL[canal] })),
];
const LIMIT = 20;
// Limite alto o bastante para trazer todas as combinações tipo×canal numa
// única chamada — usado só para calcular a matriz de fallback abaixo, não
// para a listagem paginada principal.
const MATRIX_LIMIT = 100;
export default function TenantAlertTemplatesPage() {
const queryClient = useQueryClient();
const [tipo, setTipo] = React.useState<TipoTemplateTenant | 'todos'>('todos');
const [canal, setCanal] = React.useState<CanalTemplate | 'todos'>('todos');
const [page, setPage] = React.useState(1);
const [editing, setEditing] = React.useState<AlertTemplate | null>(null);
const [formOpen, setFormOpen] = React.useState(false);
const [deleting, setDeleting] = React.useState<AlertTemplate | null>(null);
const { data, isLoading } = useQuery({
queryKey: ['tenant', 'alert-templates', { tipo, canal, page }],
queryFn: () =>
listTenantAlertTemplates({
tipo: tipo === 'todos' ? undefined : tipo,
canal: canal === 'todos' ? undefined : canal,
page,
limit: LIMIT,
}),
placeholderData: (prev) => prev,
});
// Matriz de fallback: não existe endpoint que informe se um tipo/canal
// está usando o padrão da plataforma — inferido client-side comparando
// contra a lista completa de templates ativos do tenant.
const { data: allTemplates } = useQuery({
queryKey: ['tenant', 'alert-templates', 'matrix'],
queryFn: () => listTenantAlertTemplates({ ativo: true, limit: MATRIX_LIMIT }),
});
const form = useForm<TenantAlertTemplateFormValues>({ resolver: zodResolver(tenantAlertTemplateSchema) });
const corpoFieldRef = React.useRef<HTMLTextAreaElement | null>(null);
const { ref: corpoRegisterRef, ...corpoRegister } = form.register('corpo');
const watchedTipo = form.watch('tipo');
const watchedCanal = form.watch('canal');
function openCreate(preset?: { tipo: TipoTemplateTenant; canal: CanalTemplate }) {
setEditing(null);
form.reset({
tipo: preset?.tipo ?? 'cobranca_cliente',
canal: preset?.canal ?? 'whatsapp',
assunto: '',
corpo: '',
ativo: true,
});
setFormOpen(true);
}
function openEdit(template: AlertTemplate) {
setEditing(template);
form.reset({
tipo: template.tipo as TipoTemplateTenant,
canal: template.canal,
assunto: template.assunto ?? '',
corpo: template.corpo,
ativo: template.ativo,
});
setFormOpen(true);
}
function insertPlaceholder(chave: string) {
const token = `{{${chave}}}`;
const el = corpoFieldRef.current;
const current = form.getValues('corpo') ?? '';
if (!el) {
form.setValue('corpo', current + token, { shouldValidate: true });
return;
}
const start = el.selectionStart ?? current.length;
const end = el.selectionEnd ?? current.length;
const next = current.slice(0, start) + token + current.slice(end);
form.setValue('corpo', next, { shouldValidate: true });
requestAnimationFrame(() => {
el.focus();
const pos = start + token.length;
el.setSelectionRange(pos, pos);
});
}
function invalidateAll() {
queryClient.invalidateQueries({ queryKey: ['tenant', 'alert-templates'] });
}
const saveMutation = useMutation({
mutationFn: (values: TenantAlertTemplateFormValues) => {
const payload = { ...values, assunto: values.canal === 'email' ? values.assunto : undefined };
return editing ? updateTenantAlertTemplate(editing.id, payload) : createTenantAlertTemplate(payload);
},
onSuccess: () => {
invalidateAll();
toast.success(editing ? 'Template atualizado.' : 'Template criado.');
setFormOpen(false);
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível salvar o template.');
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteTenantAlertTemplate(id),
onSuccess: () => {
invalidateAll();
toast.success('Template desativado.');
setDeleting(null);
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível desativar o template.');
setDeleting(null);
},
});
const columns: DataTableColumn<AlertTemplate>[] = [
{ id: 'tipo', header: 'Tipo', primary: true, cell: (row) => TIPO_TEMPLATE_TENANT_LABEL[row.tipo as TipoTemplateTenant] },
{ id: 'canal', header: 'Canal', primary: true, cell: (row) => CANAL_TEMPLATE_LABEL[row.canal] },
{
id: 'corpo',
header: 'Prévia',
cell: (row) => <span className="line-clamp-1 text-muted-foreground">{row.corpo}</span>,
},
{
id: 'status',
header: 'Status',
cell: (row) => <Badge variant={row.ativo ? 'default' : 'outline'}>{row.ativo ? 'Ativo' : 'Inativo'}</Badge>,
},
];
const placeholders = watchedTipo ? PLACEHOLDERS_POR_TIPO[watchedTipo] : [];
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-foreground">Templates de alerta</h1>
<p className="text-sm text-muted-foreground">Mensagens enviadas aos seus clientes</p>
</div>
<Button onClick={() => openCreate()}>
<Plus /> Novo template
</Button>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{TIPOS_TEMPLATE_TENANT.map((t) =>
CANAIS_TEMPLATE.map((c) => {
const usaProprio = allTemplates?.data.some((tpl) => tpl.tipo === t && tpl.canal === c) ?? false;
return (
<Card key={`${t}-${c}`} className="flex flex-col gap-2 p-4">
<p className="text-sm font-medium text-foreground">
{TIPO_TEMPLATE_TENANT_LABEL[t]} · {CANAL_TEMPLATE_LABEL[c]}
</p>
{usaProprio ? (
<Badge variant="default" className="w-fit">
Template próprio configurado
</Badge>
) : (
<>
<Badge variant="outline" className="w-fit">
Usando padrão da plataforma
</Badge>
<Button
variant="ghost"
size="sm"
className="w-fit"
onClick={() => openCreate({ tipo: t, canal: c })}
>
Criar meu próprio
</Button>
</>
)}
</Card>
);
}),
)}
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Select
value={tipo}
onValueChange={(value) => {
setTipo(value as TipoTemplateTenant | 'todos');
setPage(1);
}}
>
<SelectTrigger className="w-full sm:w-56" aria-label="Filtrar por tipo">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIPO_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={canal}
onValueChange={(value) => {
setCanal(value as CanalTemplate | 'todos');
setPage(1);
}}
>
<SelectTrigger className="w-full sm:w-44" aria-label="Filtrar por canal">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CANAL_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DataTable
columns={columns}
data={data?.data ?? []}
keyField={(row) => row.id}
loading={isLoading}
emptyTitle="Nenhum template próprio cadastrado"
emptyDescription="Sem um template próprio, o padrão da plataforma é usado automaticamente."
emptyAction={
<Button size="sm" onClick={() => openCreate()}>
Novo template
</Button>
}
rowActions={(row) => (
<>
<Button variant="ghost" size="sm" onClick={() => openEdit(row)}>
Editar
</Button>
<Button variant="ghost" size="sm" onClick={() => setDeleting(row)}>
Desativar
</Button>
</>
)}
pagination={
data ? { page: data.meta.page, limit: data.meta.limit, total: data.meta.total, onPageChange: setPage } : undefined
}
/>
<ResponsiveDialog
open={formOpen}
onOpenChange={setFormOpen}
title={editing ? 'Editar template' : 'Novo template'}
className="sm:max-w-2xl"
footer={
<>
<Button variant="outline" onClick={() => setFormOpen(false)}>
Cancelar
</Button>
<Button
loading={saveMutation.isPending}
onClick={form.handleSubmit((values) => saveMutation.mutate(values))}
>
Salvar
</Button>
</>
}
>
<form
className="flex flex-col gap-4 px-4 pb-4 sm:px-0 sm:pb-0"
onSubmit={form.handleSubmit((values) => saveMutation.mutate(values))}
>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Tipo" id="tipo" error={form.formState.errors.tipo?.message} required>
<Select
value={watchedTipo}
onValueChange={(value) =>
form.setValue('tipo', value as TipoTemplateTenant, { shouldValidate: true })
}
>
<SelectTrigger id="tipo" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIPOS_TEMPLATE_TENANT.map((t) => (
<SelectItem key={t} value={t}>
{TIPO_TEMPLATE_TENANT_LABEL[t]}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<FormField label="Canal" id="canal" error={form.formState.errors.canal?.message} required>
<Select
value={watchedCanal}
onValueChange={(value) => form.setValue('canal', value as CanalTemplate, { shouldValidate: true })}
>
<SelectTrigger id="canal" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CANAIS_TEMPLATE.map((c) => (
<SelectItem key={c} value={c}>
{CANAL_TEMPLATE_LABEL[c]}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
</div>
{watchedCanal === 'email' && (
<FormField label="Assunto" error={form.formState.errors.assunto?.message} required>
<Input {...form.register('assunto')} />
</FormField>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[1fr_220px]">
<FormField label="Corpo da mensagem" error={form.formState.errors.corpo?.message} required>
<Textarea
rows={8}
{...corpoRegister}
ref={(el) => {
corpoRegisterRef(el);
corpoFieldRef.current = el;
}}
/>
</FormField>
<div className="flex flex-col gap-2">
<p className="text-xs font-medium text-muted-foreground">Placeholders disponíveis</p>
<div className="flex flex-col gap-1.5 overflow-y-auto rounded-lg border border-border p-2 sm:max-h-56">
{placeholders.map((p) => (
<button
key={p.chave}
type="button"
onClick={() => insertPlaceholder(p.chave)}
title={p.descricao}
className="rounded-md px-2 py-1.5 text-left text-xs hover:bg-muted"
>
<code className="font-mono font-medium text-primary-on-light">{`{{${p.chave}}}`}</code>
<p className="text-muted-foreground">{p.descricao}</p>
</button>
))}
</div>
</div>
</div>
</form>
</ResponsiveDialog>
<ConfirmDialog
open={deleting !== null}
onOpenChange={(open) => !open && setDeleting(null)}
title="Desativar template?"
description="O padrão da plataforma volta a ser usado nos disparos automáticos, mas o template continua no histórico."
confirmLabel="Desativar"
destructive
loading={deleteMutation.isPending}
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
/>
</div>
);
}
+473
View File
@@ -0,0 +1,473 @@
'use client';
import * as React from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Plus, Star } from 'lucide-react';
import {
createCustomerContact,
deactivateCustomer,
deleteCustomerContact,
getCustomer,
updateCustomer,
updateCustomerContact,
type Customer,
type CustomerContact,
} from '@/lib/api/customers';
import { listSales, type SaleListItem } from '@/lib/api/sales';
import {
customerContactSchema,
customerSchema,
type CustomerContactFormValues,
type CustomerFormValues,
} from '@/lib/validators/customers';
import { ApiError } from '@/lib/api/client';
import { BR_STATES } from '@/lib/br-states';
import { formatCurrency, formatDate } from '@/lib/format';
import { toast } from '@/lib/toast';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { DataTable, type DataTableColumn } from '@/components/ui/data-table';
import { FormField } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import { ResponsiveDialog } from '@/components/ui/responsive-dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { StatusPill } from '@/components/ui/status-pill';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import { ConfirmDialog } from '@/components/domain/confirm-dialog';
export default function ClienteDetalhePage() {
const params = useParams<{ id: string }>();
const customerId = params.id;
const queryClient = useQueryClient();
const [deactivateOpen, setDeactivateOpen] = React.useState(false);
const { data: customer, isLoading } = useQuery({
queryKey: ['tenant', 'customers', customerId],
queryFn: () => getCustomer(customerId),
});
const deactivateMutation = useMutation({
mutationFn: () => deactivateCustomer(customerId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tenant', 'customers'] });
toast.success('Cliente desativado.');
setDeactivateOpen(false);
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível desativar o cliente.');
setDeactivateOpen(false);
},
});
if (isLoading || !customer) {
return (
<div className="flex flex-col gap-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-64 w-full" />
</div>
);
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<div className="flex items-center gap-3">
<h1 className="text-xl font-semibold text-foreground">
{customer.nome_fantasia || customer.razao_social}
</h1>
<Badge variant={customer.ativo ? 'default' : 'outline'}>{customer.ativo ? 'Ativo' : 'Inativo'}</Badge>
</div>
<p className="text-sm text-muted-foreground">{customer.documento || customer.email || '—'}</p>
</div>
{customer.ativo && (
<Button variant="outline" onClick={() => setDeactivateOpen(true)}>
Desativar cliente
</Button>
)}
</div>
<Tabs defaultValue="dados">
<TabsList>
<TabsTrigger value="dados">Dados cadastrais</TabsTrigger>
<TabsTrigger value="contatos">Contatos</TabsTrigger>
<TabsTrigger value="historico">Histórico de vendas</TabsTrigger>
</TabsList>
<TabsContent value="dados" className="pt-4">
<DadosCadastraisForm customerId={customerId} customer={customer} />
</TabsContent>
<TabsContent value="contatos" className="pt-4">
<ContatosTab customerId={customerId} contatos={customer.contatos} />
</TabsContent>
<TabsContent value="historico" className="pt-4">
<HistoricoVendasTab customerId={customerId} />
</TabsContent>
</Tabs>
<ConfirmDialog
open={deactivateOpen}
onOpenChange={setDeactivateOpen}
title="Desativar cliente?"
description="O cliente sai das listagens de ativos, mas o cadastro e o histórico de vendas continuam disponíveis."
confirmLabel="Desativar"
destructive
loading={deactivateMutation.isPending}
onConfirm={() => deactivateMutation.mutate()}
/>
</div>
);
}
function DadosCadastraisForm({ customerId, customer }: { customerId: string; customer: Customer }) {
const queryClient = useQueryClient();
const form = useForm<CustomerFormValues>({
resolver: zodResolver(customerSchema),
defaultValues: {
razao_social: customer.razao_social,
nome_fantasia: customer.nome_fantasia ?? '',
documento: customer.documento ?? '',
email: customer.email ?? '',
telefone_whatsapp: customer.telefone_whatsapp ?? '',
endereco: customer.endereco ?? '',
cidade: customer.cidade ?? '',
uf: customer.uf ?? '',
observacoes: customer.observacoes ?? '',
},
});
const mutation = useMutation({
mutationFn: (values: CustomerFormValues) =>
updateCustomer(customerId, {
...values,
nome_fantasia: values.nome_fantasia || undefined,
documento: values.documento || undefined,
email: values.email || undefined,
telefone_whatsapp: values.telefone_whatsapp || undefined,
endereco: values.endereco || undefined,
cidade: values.cidade || undefined,
uf: values.uf || undefined,
observacoes: values.observacoes || undefined,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tenant', 'customers', customerId] });
queryClient.invalidateQueries({ queryKey: ['tenant', 'customers'] });
toast.success('Dados do cliente atualizados.');
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível salvar as alterações.');
},
});
return (
<Card className="max-w-2xl">
<CardHeader>
<CardTitle>Dados cadastrais</CardTitle>
</CardHeader>
<CardContent>
<form
className="flex flex-col gap-4"
onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Razão social" error={form.formState.errors.razao_social?.message} required>
<Input {...form.register('razao_social')} />
</FormField>
<FormField label="Nome fantasia" error={form.formState.errors.nome_fantasia?.message}>
<Input {...form.register('nome_fantasia')} />
</FormField>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Documento (CPF/CNPJ)" error={form.formState.errors.documento?.message}>
<Input {...form.register('documento')} />
</FormField>
<FormField label="Telefone/WhatsApp" error={form.formState.errors.telefone_whatsapp?.message}>
<Input {...form.register('telefone_whatsapp')} />
</FormField>
</div>
<FormField label="E-mail" error={form.formState.errors.email?.message}>
<Input type="email" {...form.register('email')} />
</FormField>
<FormField label="Endereço" error={form.formState.errors.endereco?.message}>
<Input {...form.register('endereco')} />
</FormField>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[1fr_120px]">
<FormField label="Cidade" error={form.formState.errors.cidade?.message}>
<Input {...form.register('cidade')} />
</FormField>
<FormField label="UF" id="uf" error={form.formState.errors.uf?.message}>
<Select
value={form.watch('uf')}
onValueChange={(value) => form.setValue('uf', value, { shouldValidate: true })}
>
<SelectTrigger id="uf" className="w-full">
<SelectValue placeholder="UF" />
</SelectTrigger>
<SelectContent>
{BR_STATES.map((uf) => (
<SelectItem key={uf} value={uf}>
{uf}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
</div>
<FormField label="Observações" error={form.formState.errors.observacoes?.message}>
<Textarea rows={3} {...form.register('observacoes')} />
</FormField>
<div>
<Button type="submit" loading={mutation.isPending}>
Salvar alterações
</Button>
</div>
</form>
</CardContent>
</Card>
);
}
function ContatosTab({ customerId, contatos }: { customerId: string; contatos: CustomerContact[] }) {
const queryClient = useQueryClient();
const [editing, setEditing] = React.useState<CustomerContact | null>(null);
const [formOpen, setFormOpen] = React.useState(false);
const [deleting, setDeleting] = React.useState<CustomerContact | null>(null);
const [settingPrincipalId, setSettingPrincipalId] = React.useState<string | null>(null);
const form = useForm<CustomerContactFormValues>({ resolver: zodResolver(customerContactSchema) });
function invalidate() {
queryClient.invalidateQueries({ queryKey: ['tenant', 'customers', customerId] });
}
function openCreate() {
setEditing(null);
form.reset({ nome: '', cargo: '', email: '', telefone_whatsapp: '', principal: contatos.length === 0 });
setFormOpen(true);
}
function openEdit(contact: CustomerContact) {
setEditing(contact);
form.reset({
nome: contact.nome,
cargo: contact.cargo ?? '',
email: contact.email ?? '',
telefone_whatsapp: contact.telefone_whatsapp ?? '',
principal: contact.principal,
});
setFormOpen(true);
}
const saveMutation = useMutation({
mutationFn: (values: CustomerContactFormValues) => {
const payload = {
nome: values.nome,
cargo: values.cargo || undefined,
email: values.email || undefined,
telefone_whatsapp: values.telefone_whatsapp || undefined,
principal: values.principal,
};
return editing
? updateCustomerContact(customerId, editing.id, payload)
: createCustomerContact(customerId, payload);
},
onSuccess: () => {
invalidate();
toast.success(editing ? 'Contato atualizado.' : 'Contato adicionado.');
setFormOpen(false);
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível salvar o contato.');
},
});
const deleteMutation = useMutation({
mutationFn: (contactId: string) => deleteCustomerContact(customerId, contactId),
onSuccess: () => {
invalidate();
toast.success('Contato removido.');
setDeleting(null);
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível remover o contato.');
setDeleting(null);
},
});
const setPrincipalMutation = useMutation({
mutationFn: (contactId: string) => updateCustomerContact(customerId, contactId, { principal: true }),
onMutate: (contactId) => setSettingPrincipalId(contactId),
onSuccess: () => {
invalidate();
toast.success('Contato principal atualizado.');
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível atualizar o contato principal.');
},
onSettled: () => setSettingPrincipalId(null),
});
const columns: DataTableColumn<CustomerContact>[] = [
{
id: 'nome',
header: 'Nome',
primary: true,
cell: (row) => (
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">{row.nome}</span>
{row.principal && <Badge>Principal</Badge>}
</div>
),
},
{ id: 'cargo', header: 'Cargo', cell: (row) => row.cargo || '—' },
{
id: 'contato',
header: 'Contato',
primary: true,
cell: (row) => (
<div className="flex flex-col">
{row.telefone_whatsapp && <span>{row.telefone_whatsapp}</span>}
{row.email && <span className="text-xs text-muted-foreground">{row.email}</span>}
{!row.telefone_whatsapp && !row.email && '—'}
</div>
),
},
];
return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<Button size="sm" onClick={openCreate}>
<Plus /> Novo contato
</Button>
</div>
<DataTable
columns={columns}
data={contatos}
keyField={(row) => row.id}
emptyTitle="Nenhum contato cadastrado"
emptyAction={
<Button size="sm" onClick={openCreate}>
Novo contato
</Button>
}
rowActions={(row) => (
<>
{!row.principal && (
<Button
variant="ghost"
size="sm"
loading={settingPrincipalId === row.id}
onClick={() => setPrincipalMutation.mutate(row.id)}
>
<Star /> Tornar principal
</Button>
)}
<Button variant="ghost" size="sm" onClick={() => openEdit(row)}>
Editar
</Button>
<Button variant="ghost" size="sm" onClick={() => setDeleting(row)}>
Excluir
</Button>
</>
)}
/>
<ResponsiveDialog
open={formOpen}
onOpenChange={setFormOpen}
title={editing ? 'Editar contato' : 'Novo contato'}
footer={
<>
<Button variant="outline" onClick={() => setFormOpen(false)}>
Cancelar
</Button>
<Button
loading={saveMutation.isPending}
onClick={form.handleSubmit((values) => saveMutation.mutate(values))}
>
Salvar
</Button>
</>
}
>
<form
className="flex flex-col gap-4 px-4 pb-4 sm:px-0 sm:pb-0"
onSubmit={form.handleSubmit((values) => saveMutation.mutate(values))}
>
<FormField label="Nome" error={form.formState.errors.nome?.message} required>
<Input {...form.register('nome')} />
</FormField>
<FormField label="Cargo" error={form.formState.errors.cargo?.message}>
<Input {...form.register('cargo')} />
</FormField>
<FormField label="E-mail" error={form.formState.errors.email?.message}>
<Input type="email" {...form.register('email')} />
</FormField>
<FormField label="Telefone/WhatsApp" error={form.formState.errors.telefone_whatsapp?.message}>
<Input {...form.register('telefone_whatsapp')} />
</FormField>
</form>
</ResponsiveDialog>
<ConfirmDialog
open={deleting !== null}
onOpenChange={(open) => !open && setDeleting(null)}
title={`Remover "${deleting?.nome}"?`}
description="Esta ação não pode ser desfeita."
confirmLabel="Remover"
destructive
loading={deleteMutation.isPending}
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
/>
</div>
);
}
function HistoricoVendasTab({ customerId }: { customerId: string }) {
const [page, setPage] = React.useState(1);
const { data, isLoading } = useQuery({
queryKey: ['tenant', 'vendas', { customerId, page }],
queryFn: () => listSales({ customer_id: customerId, page, limit: 10 }),
placeholderData: (prev) => prev,
});
const columns: DataTableColumn<SaleListItem>[] = [
{ id: 'numero', header: 'Venda', primary: true, cell: (row) => `#${row.numero_venda}` },
{ id: 'status', header: 'Status', primary: true, cell: (row) => <StatusPill status={row.status} /> },
{ id: 'valor', header: 'Valor total', cell: (row) => formatCurrency(row.valor_total) },
{ id: 'data', header: 'Data', cell: (row) => formatDate(row.data_venda) },
];
return (
<DataTable
columns={columns}
data={data?.data ?? []}
keyField={(row) => row.id}
loading={isLoading}
emptyTitle="Nenhuma venda registrada"
emptyDescription="As vendas feitas para este cliente aparecem aqui."
rowActions={(row) => (
<Button variant="ghost" size="sm" asChild>
<Link href={`/app/vendas/${row.id}`}>Ver</Link>
</Button>
)}
pagination={
data ? { page: data.meta.page, limit: data.meta.limit, total: data.meta.total, onPageChange: setPage } : undefined
}
/>
);
}
+140
View File
@@ -0,0 +1,140 @@
'use client';
import { useRouter } from 'next/navigation';
import { useMutation } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { createCustomer } from '@/lib/api/customers';
import { customerSchema, type CustomerFormValues } from '@/lib/validators/customers';
import { ApiError } from '@/lib/api/client';
import { BR_STATES } from '@/lib/br-states';
import { toast } from '@/lib/toast';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { FormField } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
export default function NovoClientePage() {
const router = useRouter();
const form = useForm<CustomerFormValues>({
resolver: zodResolver(customerSchema),
defaultValues: {
razao_social: '',
nome_fantasia: '',
documento: '',
email: '',
telefone_whatsapp: '',
endereco: '',
cidade: '',
uf: '',
observacoes: '',
},
});
const mutation = useMutation({
mutationFn: (values: CustomerFormValues) =>
createCustomer({
razao_social: values.razao_social,
nome_fantasia: values.nome_fantasia || undefined,
documento: values.documento || undefined,
email: values.email || undefined,
telefone_whatsapp: values.telefone_whatsapp || undefined,
endereco: values.endereco || undefined,
cidade: values.cidade || undefined,
uf: values.uf || undefined,
observacoes: values.observacoes || undefined,
}),
onSuccess: (customer) => {
toast.success('Cliente cadastrado.');
router.push(`/app/clientes/${customer.id}`);
},
onError: (err) => {
// Erro de limite do plano (400) vem com mensagem de negócio clara do
// backend — mostrado direto, sem mensagem genérica (Etapa 16, item 6).
toast.error(err instanceof ApiError ? err.message : 'Não foi possível cadastrar o cliente.');
},
});
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-xl font-semibold text-foreground">Novo cliente</h1>
<p className="text-sm text-muted-foreground">Cadastro de cliente</p>
</div>
<form
className="flex max-w-2xl flex-col gap-6"
onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
>
<Card>
<CardHeader>
<CardTitle>Dados cadastrais</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Razão social" error={form.formState.errors.razao_social?.message} required>
<Input {...form.register('razao_social')} />
</FormField>
<FormField label="Nome fantasia" error={form.formState.errors.nome_fantasia?.message}>
<Input {...form.register('nome_fantasia')} />
</FormField>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Documento (CPF/CNPJ)" error={form.formState.errors.documento?.message}>
<Input placeholder="000.000.000-00" {...form.register('documento')} />
</FormField>
<FormField label="Telefone/WhatsApp" error={form.formState.errors.telefone_whatsapp?.message}>
<Input {...form.register('telefone_whatsapp')} />
</FormField>
</div>
<FormField label="E-mail" error={form.formState.errors.email?.message}>
<Input type="email" {...form.register('email')} />
</FormField>
<FormField label="Endereço" error={form.formState.errors.endereco?.message}>
<Input {...form.register('endereco')} />
</FormField>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[1fr_120px]">
<FormField label="Cidade" error={form.formState.errors.cidade?.message}>
<Input {...form.register('cidade')} />
</FormField>
<FormField label="UF" id="uf" error={form.formState.errors.uf?.message}>
<Select
value={form.watch('uf')}
onValueChange={(value) => form.setValue('uf', value, { shouldValidate: true })}
>
<SelectTrigger id="uf" className="w-full">
<SelectValue placeholder="UF" />
</SelectTrigger>
<SelectContent>
{BR_STATES.map((uf) => (
<SelectItem key={uf} value={uf}>
{uf}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
</div>
<FormField label="Observações" error={form.formState.errors.observacoes?.message}>
<Textarea rows={3} {...form.register('observacoes')} />
</FormField>
</CardContent>
</Card>
<div className="flex items-center gap-3">
<Button type="submit" loading={mutation.isPending}>
Cadastrar cliente
</Button>
<Button type="button" variant="outline" onClick={() => router.push('/app/clientes')}>
Cancelar
</Button>
</div>
</form>
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
'use client';
import * as React from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { Plus, Search } from 'lucide-react';
import { listCustomers, type Customer } from '@/lib/api/customers';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { DataTable, type DataTableColumn } from '@/components/ui/data-table';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
const ATIVO_OPTIONS = [
{ value: 'todos', label: 'Ativos e inativos' },
{ value: 'true', label: 'Somente ativos' },
{ value: 'false', label: 'Somente inativos' },
];
const LIMIT = 20;
export default function ClientesPage() {
const router = useRouter();
const [busca, setBusca] = React.useState('');
const [ativo, setAtivo] = React.useState('true');
const [page, setPage] = React.useState(1);
const { data, isLoading } = useQuery({
queryKey: ['tenant', 'customers', { busca, ativo, page }],
queryFn: () =>
listCustomers({
busca: busca || undefined,
ativo: ativo === 'todos' ? undefined : ativo === 'true',
page,
limit: LIMIT,
}),
placeholderData: (prev) => prev,
});
const columns: DataTableColumn<Customer>[] = [
{
id: 'nome',
header: 'Cliente',
primary: true,
cell: (row) => (
<div className="flex flex-col">
<span className="font-medium text-foreground">{row.nome_fantasia || row.razao_social}</span>
{row.documento && <span className="text-xs text-muted-foreground">{row.documento}</span>}
</div>
),
},
{
id: 'contato',
header: 'Contato',
cell: (row) => (
<div className="flex flex-col">
{row.telefone_whatsapp && <span>{row.telefone_whatsapp}</span>}
{row.email && <span className="text-xs text-muted-foreground">{row.email}</span>}
{!row.telefone_whatsapp && !row.email && '—'}
</div>
),
},
{
id: 'status',
header: 'Status',
primary: true,
cell: (row) => <Badge variant={row.ativo ? 'default' : 'outline'}>{row.ativo ? 'Ativo' : 'Inativo'}</Badge>,
},
];
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-foreground">Clientes</h1>
<p className="text-sm text-muted-foreground">Base de clientes do seu negócio</p>
</div>
<Button asChild>
<Link href="/app/clientes/novo">
<Plus /> Novo cliente
</Link>
</Button>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1 sm:max-w-xs">
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-neutral-500" />
<Input
placeholder="Buscar por nome ou documento..."
className="pl-9"
value={busca}
onChange={(e) => {
setBusca(e.target.value);
setPage(1);
}}
/>
</div>
<Select
value={ativo}
onValueChange={(value) => {
setAtivo(value);
setPage(1);
}}
>
<SelectTrigger className="w-full sm:w-52" aria-label="Filtrar por status">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ATIVO_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DataTable
columns={columns}
data={data?.data ?? []}
keyField={(row) => row.id}
loading={isLoading}
emptyTitle="Nenhum cliente encontrado"
emptyDescription="Ajuste os filtros ou cadastre um novo cliente."
emptyAction={
<Button size="sm" asChild>
<Link href="/app/clientes/novo">Novo cliente</Link>
</Button>
}
onRowClick={(row) => router.push(`/app/clientes/${row.id}`)}
rowActions={(row) => (
<Button variant="ghost" size="sm" asChild>
<Link href={`/app/clientes/${row.id}`}>Ver</Link>
</Button>
)}
pagination={
data ? { page: data.meta.page, limit: data.meta.limit, total: data.meta.total, onPageChange: setPage } : undefined
}
/>
</div>
);
}
+181
View File
@@ -0,0 +1,181 @@
'use client';
import * as React from 'react';
import Link from 'next/link';
import { useQuery } from '@tanstack/react-query';
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { ArrowRight, CircleDollarSign, ShoppingCart, TriangleAlert } from 'lucide-react';
import {
getFinanceiro,
getResumoMensal,
getResumoSemanal,
type ResumoMensalRow,
type ResumoSemanalRow,
} from '@/lib/api/tenant-dashboard';
import { formatCurrency, formatDate } from '@/lib/format';
import { StatCard } from '@/components/domain/stat-card';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { EmptyState } from '@/components/ui/empty-state';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
function useChartColor() {
const [color, setColor] = React.useState('#2563eb');
React.useEffect(() => {
const value = getComputedStyle(document.documentElement).getPropertyValue('--color-primary').trim();
if (value) setColor(value);
}, []);
return color;
}
function ResumoChart({ periodo }: { periodo: 'semanal' | 'mensal' }) {
const color = useChartColor();
const { data, isLoading } = useQuery({
queryKey: ['tenant', 'dashboard', 'resumo', periodo],
queryFn: (): Promise<(ResumoSemanalRow | ResumoMensalRow)[]> =>
periodo === 'semanal' ? getResumoSemanal() : getResumoMensal(),
});
const chartData = (data ?? []).map((row) => ({
label: formatDate('semana' in row ? row.semana : row.mes),
total_vendido: row.total_vendido,
qtd_vendas: row.qtd_vendas,
}));
if (isLoading) return <Skeleton className="h-64 w-full" />;
if (chartData.length === 0) {
return <EmptyState title="Sem vendas no período" description="Ainda não há dados suficientes para o gráfico." />;
}
return (
<ResponsiveContainer width="100%" height={260}>
<AreaChart data={chartData} margin={{ left: 8, right: 8, top: 8, bottom: 0 }}>
<defs>
<linearGradient id="resumoVendasFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.35} />
<stop offset="95%" stopColor={color} stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} className="stroke-border" />
<XAxis dataKey="label" tickLine={false} axisLine={false} className="text-xs fill-muted-foreground" />
<YAxis
tickLine={false}
axisLine={false}
width={72}
className="text-xs fill-muted-foreground"
tickFormatter={(value: number) => formatCurrency(value)}
/>
<Tooltip
formatter={(value, name) =>
name === 'total_vendido' ? [formatCurrency(Number(value)), 'Total vendido'] : [String(value), 'Vendas']
}
contentStyle={{ borderRadius: 8, borderColor: 'var(--border)' }}
/>
<Area type="monotone" dataKey="total_vendido" stroke={color} strokeWidth={2} fill="url(#resumoVendasFill)" />
</AreaChart>
</ResponsiveContainer>
);
}
export default function TenantDashboardPage() {
const { data: financeiro, isLoading: loadingFinanceiro } = useQuery({
queryKey: ['tenant', 'dashboard', 'financeiro'],
queryFn: () => getFinanceiro(8),
});
const totalAtrasado = Number(financeiro?.total_atrasado ?? 0);
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-xl font-semibold text-foreground">Dashboard</h1>
<p className="text-sm text-muted-foreground">Visão geral das suas vendas</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatCard
label="Recebido no mês"
value={formatCurrency(financeiro?.total_recebido_mes)}
icon={CircleDollarSign}
tone="success"
loading={loadingFinanceiro}
/>
<StatCard
label="A receber"
value={formatCurrency(financeiro?.total_a_receber)}
icon={ShoppingCart}
tone="warning"
loading={loadingFinanceiro}
/>
<StatCard
label="Atrasado"
value={formatCurrency(financeiro?.total_atrasado)}
icon={TriangleAlert}
tone={totalAtrasado > 0 ? 'danger' : 'default'}
loading={loadingFinanceiro}
/>
</div>
<Card>
<CardHeader>
<CardTitle>Evolução de vendas</CardTitle>
</CardHeader>
<CardContent>
<Tabs defaultValue="semanal">
<TabsList>
<TabsTrigger value="semanal">Semanal</TabsTrigger>
<TabsTrigger value="mensal">Mensal</TabsTrigger>
</TabsList>
<TabsContent value="semanal" className="pt-4">
<ResumoChart periodo="semanal" />
</TabsContent>
<TabsContent value="mensal" className="pt-4">
<ResumoChart periodo="mensal" />
</TabsContent>
</Tabs>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
<CardTitle>Próximas parcelas a vencer</CardTitle>
<Link
href="/app/recebimentos"
className="flex items-center gap-1 text-sm text-primary-on-light hover:underline"
>
Ver todos <ArrowRight className="size-3.5" />
</Link>
</CardHeader>
<CardContent>
{loadingFinanceiro ? (
<div className="flex flex-col gap-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : financeiro && financeiro.proximas_parcelas.length > 0 ? (
<ul className="flex flex-col divide-y divide-border">
{financeiro.proximas_parcelas.map((parcela) => (
<li key={parcela.id} className="flex items-center justify-between gap-4 py-2.5 text-sm">
<div className="min-w-0">
<p className="truncate font-medium text-foreground">{parcela.sale.customer.razao_social}</p>
<p className="text-xs text-muted-foreground">
Venda #{parcela.sale.numero_venda} · Parcela {parcela.numero_parcela}
</p>
</div>
<div className="text-right">
<p className="font-medium tabular-nums text-foreground">{formatCurrency(parcela.valor)}</p>
<p className="text-xs text-muted-foreground">{formatDate(parcela.vencimento)}</p>
</div>
</li>
))}
</ul>
) : (
<EmptyState title="Nenhuma parcela pendente" description="Não há parcelas a vencer no momento." />
)}
</CardContent>
</Card>
</div>
);
}
+215
View File
@@ -0,0 +1,215 @@
'use client';
import * as React from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Send } from 'lucide-react';
import { getTenantEmailConfig, testarEnvioEmail, updateTenantEmailConfig } from '@/lib/api/email-config';
import {
tenantEmailConfigSchema,
testarEnvioEmailSchema,
type TenantEmailConfigFormValues,
type TestarEnvioEmailFormValues,
} from '@/lib/validators/email-config';
import { ApiError } from '@/lib/api/client';
import { toast } from '@/lib/toast';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { FormField } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
const USAR_PADRAO_OPTIONS = [
{ value: 'padrao', label: 'Usar configuração padrão da plataforma' },
{ value: 'proprio', label: 'Usar meu próprio SMTP' },
] as const;
export default function EmailConfigPage() {
const queryClient = useQueryClient();
const { data: config, isLoading } = useQuery({
queryKey: ['tenant', 'email-config'],
queryFn: getTenantEmailConfig,
});
const form = useForm<TenantEmailConfigFormValues>({
resolver: zodResolver(tenantEmailConfigSchema),
values: config
? {
usar_config_padrao: config.usar_config_padrao,
smtp_host: config.smtp_host ?? '',
smtp_porta: config.smtp_porta,
smtp_usuario: config.smtp_usuario ?? '',
smtp_senha: '',
email_remetente: config.email_remetente ?? '',
}
: undefined,
});
const usarPadrao = form.watch('usar_config_padrao');
const saveMutation = useMutation({
mutationFn: (values: TenantEmailConfigFormValues) => updateTenantEmailConfig(values),
onSuccess: (result) => {
queryClient.setQueryData(['tenant', 'email-config'], result);
toast.success('Configuração de e-mail atualizada.');
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível salvar a configuração.');
},
});
const testForm = useForm<TestarEnvioEmailFormValues>({
resolver: zodResolver(testarEnvioEmailSchema),
defaultValues: { destinatario: '' },
});
const testMutation = useMutation({
mutationFn: (values: TestarEnvioEmailFormValues) => testarEnvioEmail(values.destinatario),
onSuccess: (result) => {
// `DispatchResult` sempre responde HTTP 200 — falha de envio vem como
// `{ sucesso: false, erro }`, não como erro HTTP (ver `lib/api/email-config.ts`).
if (result.sucesso) {
toast.success('E-mail de teste enviado com sucesso.');
} else {
toast.error(result.erro ?? 'Falha ao enviar o e-mail de teste.');
}
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Falha ao enviar o e-mail de teste.');
},
});
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-xl font-semibold text-foreground">Configuração de e-mail</h1>
<p className="text-sm text-muted-foreground">SMTP usado para envio de alertas por e-mail</p>
</div>
<Card className="max-w-xl">
<CardHeader>
<CardTitle>Servidor SMTP</CardTitle>
<CardDescription>
Ao desativar o padrão da plataforma, os campos de SMTP próprio ficam obrigatórios.
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex flex-col gap-4">
<Skeleton className="h-11 w-full" />
<Skeleton className="h-11 w-full" />
</div>
) : (
<form
className="flex flex-col gap-4"
onSubmit={form.handleSubmit((values) => saveMutation.mutate(values))}
>
<FormField label="Origem do envio" id="usar_config_padrao">
<Select
value={usarPadrao ? 'padrao' : 'proprio'}
onValueChange={(value) =>
form.setValue('usar_config_padrao', value === 'padrao', { shouldValidate: true })
}
>
<SelectTrigger id="usar_config_padrao" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{USAR_PADRAO_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
{!usarPadrao && (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[1fr_140px]">
<FormField
label="Host SMTP"
error={form.formState.errors.smtp_host?.message}
required
>
<Input placeholder="smtp.exemplo.com" {...form.register('smtp_host')} />
</FormField>
<FormField
label="Porta"
error={form.formState.errors.smtp_porta?.message}
required
>
<Input
type="number"
{...form.register('smtp_porta', {
setValueAs: (v) => (v === '' ? undefined : Number(v)),
})}
/>
</FormField>
</div>
<FormField
label="Usuário SMTP"
error={form.formState.errors.smtp_usuario?.message}
required
>
<Input {...form.register('smtp_usuario')} />
</FormField>
<FormField
label="Senha SMTP"
error={form.formState.errors.smtp_senha?.message}
helpText="Por segurança, o valor atual nunca é pré-preenchido — é preciso reinformar a senha a cada alteração"
required
>
<Input type="password" autoComplete="off" {...form.register('smtp_senha')} />
</FormField>
<FormField
label="E-mail remetente"
error={form.formState.errors.email_remetente?.message}
required
>
<Input type="email" {...form.register('email_remetente')} />
</FormField>
</>
)}
<div>
<Button type="submit" loading={saveMutation.isPending}>
Salvar
</Button>
</div>
</form>
)}
</CardContent>
</Card>
<Card className="max-w-xl">
<CardHeader>
<CardTitle>Testar envio</CardTitle>
<CardDescription>Envia um e-mail de teste usando a configuração atual.</CardDescription>
</CardHeader>
<CardContent>
<form
className="flex flex-col gap-4 sm:flex-row sm:items-end"
onSubmit={testForm.handleSubmit((values) => testMutation.mutate(values))}
>
<FormField
className="flex-1"
label="E-mail de destino"
error={testForm.formState.errors.destinatario?.message}
>
<Input type="email" {...testForm.register('destinatario')} />
</FormField>
<Button type="submit" variant="outline" loading={testMutation.isPending}>
<Send /> Enviar teste
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
+359
View File
@@ -0,0 +1,359 @@
'use client';
import * as React from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Copy, Plus } from 'lucide-react';
import {
EQUIPE_ROLES,
EQUIPE_ROLE_LABEL,
createEquipeUser,
listEquipe,
updateEquipeUser,
type EquipeUser,
type EquipeUserCreated,
} from '@/lib/api/equipe';
import {
createEquipeUserSchema,
updateEquipeRoleSchema,
type CreateEquipeUserFormValues,
type UpdateEquipeRoleFormValues,
} from '@/lib/validators/equipe';
import { ApiError } from '@/lib/api/client';
import { useAuth } from '@/lib/auth/auth-context';
import { formatDateTime } from '@/lib/format';
import { toast } from '@/lib/toast';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { DataTable, type DataTableColumn } from '@/components/ui/data-table';
import { FormField } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import { ResponsiveDialog } from '@/components/ui/responsive-dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { ConfirmDialog } from '@/components/domain/confirm-dialog';
export default function EquipePage() {
const queryClient = useQueryClient();
const { user } = useAuth();
const meuId = user?.sub;
const { data: equipe, isLoading } = useQuery({
queryKey: ['tenant', 'equipe'],
queryFn: listEquipe,
});
const [formOpen, setFormOpen] = React.useState(false);
const [created, setCreated] = React.useState<EquipeUserCreated | null>(null);
const [changingRole, setChangingRole] = React.useState<EquipeUser | null>(null);
const [togglingAtivo, setTogglingAtivo] = React.useState<EquipeUser | null>(null);
const createForm = useForm<CreateEquipeUserFormValues>({
resolver: zodResolver(createEquipeUserSchema),
defaultValues: { nome: '', email: '', role: 'vendedor', senha: '' },
});
function openCreate() {
createForm.reset({ nome: '', email: '', role: 'vendedor', senha: '' });
setFormOpen(true);
}
const createMutation = useMutation({
mutationFn: (values: CreateEquipeUserFormValues) =>
createEquipeUser({
nome: values.nome,
email: values.email,
role: values.role,
senha: values.senha || undefined,
}),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['tenant', 'equipe'] });
setFormOpen(false);
setCreated(result);
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível criar o usuário.');
},
});
async function copySenha() {
if (!created) return;
try {
await navigator.clipboard.writeText(created.senha_provisoria);
toast.success('Senha copiada.');
} catch {
toast.error('Não foi possível copiar automaticamente — selecione e copie manualmente.');
}
}
const roleForm = useForm<UpdateEquipeRoleFormValues>({
resolver: zodResolver(updateEquipeRoleSchema),
});
React.useEffect(() => {
if (changingRole) roleForm.reset({ role: changingRole.role });
}, [changingRole, roleForm]);
const roleMutation = useMutation({
mutationFn: (values: UpdateEquipeRoleFormValues) =>
updateEquipeUser(changingRole!.id, { role: values.role as EquipeUser['role'] }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tenant', 'equipe'] });
toast.success('Papel atualizado.');
setChangingRole(null);
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível alterar o papel.');
},
});
const toggleAtivoMutation = useMutation({
mutationFn: (target: EquipeUser) => updateEquipeUser(target.id, { ativo: !target.ativo }),
onSuccess: (_, target) => {
queryClient.invalidateQueries({ queryKey: ['tenant', 'equipe'] });
toast.success(target.ativo ? 'Usuário desativado.' : 'Usuário reativado.');
setTogglingAtivo(null);
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível atualizar o usuário.');
setTogglingAtivo(null);
},
});
const columns: DataTableColumn<EquipeUser>[] = [
{ id: 'nome', header: 'Nome', primary: true, cell: (row) => row.nome },
{ id: 'email', header: 'E-mail', primary: true, cell: (row) => row.email },
{ id: 'role', header: 'Papel', cell: (row) => EQUIPE_ROLE_LABEL[row.role] },
{
id: 'ativo',
header: 'Status',
cell: (row) => (
<Badge variant={row.ativo ? 'default' : 'outline'}>{row.ativo ? 'Ativo' : 'Inativo'}</Badge>
),
},
{
id: 'criado_em',
header: 'Criado em',
hideOnMobileCard: true,
cell: (row) => formatDateTime(row.criado_em),
},
];
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-foreground">Equipe</h1>
<p className="text-sm text-muted-foreground">Usuários com acesso ao painel deste tenant</p>
</div>
<Button onClick={openCreate}>
<Plus /> Nova pessoa
</Button>
</div>
<DataTable
columns={columns}
data={equipe ?? []}
keyField={(row) => row.id}
loading={isLoading}
emptyTitle="Nenhum usuário cadastrado"
emptyAction={
<Button size="sm" onClick={openCreate}>
Nova pessoa
</Button>
}
rowActions={(row) => {
const isSelf = row.id === meuId;
return (
<>
<Button
variant="ghost"
size="sm"
disabled={isSelf}
title={isSelf ? 'Não é possível alterar o próprio papel' : undefined}
onClick={() => setChangingRole(row)}
>
Alterar papel
</Button>
<Button
variant="ghost"
size="sm"
disabled={isSelf}
title={isSelf ? 'Não é possível desativar o próprio usuário' : undefined}
onClick={() => setTogglingAtivo(row)}
>
{row.ativo ? 'Desativar' : 'Reativar'}
</Button>
</>
);
}}
/>
<ResponsiveDialog
open={formOpen}
onOpenChange={setFormOpen}
title="Nova pessoa"
description="Cria um novo acesso ao painel deste tenant."
footer={
<>
<Button variant="outline" onClick={() => setFormOpen(false)}>
Cancelar
</Button>
<Button
loading={createMutation.isPending}
onClick={createForm.handleSubmit((values) => createMutation.mutate(values))}
>
Criar
</Button>
</>
}
>
<form
className="flex flex-col gap-4 px-4 pb-4 sm:px-0 sm:pb-0"
onSubmit={createForm.handleSubmit((values) => createMutation.mutate(values))}
>
<FormField label="Nome" error={createForm.formState.errors.nome?.message} required>
<Input {...createForm.register('nome')} />
</FormField>
<FormField label="E-mail" error={createForm.formState.errors.email?.message} required>
<Input type="email" {...createForm.register('email')} />
</FormField>
<FormField label="Papel" id="role" error={createForm.formState.errors.role?.message} required>
<Select
value={createForm.watch('role')}
onValueChange={(value) =>
createForm.setValue('role', value as CreateEquipeUserFormValues['role'], {
shouldValidate: true,
})
}
>
<SelectTrigger id="role" className="w-full">
<SelectValue placeholder="Selecione um papel" />
</SelectTrigger>
<SelectContent>
{EQUIPE_ROLES.map((role) => (
<SelectItem key={role} value={role}>
{EQUIPE_ROLE_LABEL[role]}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<FormField
label="Senha provisória"
helpText="Deixe em branco para o sistema gerar uma senha automaticamente"
error={createForm.formState.errors.senha?.message}
>
<Input type="text" autoComplete="off" {...createForm.register('senha')} />
</FormField>
</form>
</ResponsiveDialog>
<ResponsiveDialog
open={created !== null}
onOpenChange={(open) => !open && setCreated(null)}
title="Usuário criado com sucesso"
description="Copie e repasse a senha provisória à pessoa agora — ela não será mostrada novamente."
footer={
<Button className="w-full" onClick={() => setCreated(null)}>
Concluir
</Button>
}
>
{created && (
<div className="flex flex-col gap-4 px-4 pb-4 sm:px-0 sm:pb-0">
<div>
<p className="text-sm text-muted-foreground">Usuário</p>
<p className="font-medium text-foreground">
{created.nome} · {created.email} · {EQUIPE_ROLE_LABEL[created.role]}
</p>
</div>
<Separator />
<div className="flex flex-col gap-2">
<p className="text-sm font-medium text-foreground">Senha provisória</p>
<div className="flex items-center gap-2 rounded-lg border border-warning/30 bg-warning/10 p-3">
<code className="flex-1 font-mono text-base font-semibold text-foreground select-all">
{created.senha_provisoria}
</code>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={copySenha}
aria-label="Copiar senha"
>
<Copy className="size-4" />
</Button>
</div>
<p className="text-xs text-warning">
Atenção: esta senha não fica salva em texto puro em nenhum lugar e não será exibida
novamente. Copie e repasse à pessoa agora.
</p>
</div>
</div>
)}
</ResponsiveDialog>
<ResponsiveDialog
open={changingRole !== null}
onOpenChange={(open) => !open && setChangingRole(null)}
title={`Alterar papel — ${changingRole?.nome ?? ''}`}
footer={
<>
<Button variant="outline" onClick={() => setChangingRole(null)}>
Cancelar
</Button>
<Button
loading={roleMutation.isPending}
onClick={roleForm.handleSubmit((values) => roleMutation.mutate(values))}
>
Salvar
</Button>
</>
}
>
<div className="flex flex-col gap-4 px-4 pb-4 sm:px-0 sm:pb-0">
<FormField label="Papel" id="changing-role">
<Select
value={roleForm.watch('role')}
onValueChange={(value) =>
roleForm.setValue('role', value as UpdateEquipeRoleFormValues['role'], {
shouldValidate: true,
})
}
>
<SelectTrigger id="changing-role" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{EQUIPE_ROLES.map((role) => (
<SelectItem key={role} value={role}>
{EQUIPE_ROLE_LABEL[role]}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
</div>
</ResponsiveDialog>
<ConfirmDialog
open={togglingAtivo !== null}
onOpenChange={(open) => !open && setTogglingAtivo(null)}
title={togglingAtivo?.ativo ? `Desativar ${togglingAtivo.nome}?` : `Reativar ${togglingAtivo?.nome}?`}
description={
togglingAtivo?.ativo
? 'A pessoa perde acesso imediatamente — as sessões abertas são revogadas.'
: 'A pessoa volta a ter acesso ao painel com as mesmas credenciais.'
}
confirmLabel={togglingAtivo?.ativo ? 'Desativar' : 'Reativar'}
destructive={togglingAtivo?.ativo}
loading={toggleAtivoMutation.isPending}
onConfirm={() => togglingAtivo && toggleAtivoMutation.mutate(togglingAtivo)}
/>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { cookies } from 'next/headers';
import { TenantShell } from '@/components/layout/tenant-shell';
import { SIDEBAR_COOKIE, readSidebarCollapsedCookie } from '@/lib/layout/sidebar-cookie';
export default async function TenantLayout({ children }: { children: React.ReactNode }) {
const cookieStore = await cookies();
const collapsed = readSidebarCollapsedCookie(cookieStore.get(SIDEBAR_COOKIE)?.value);
return <TenantShell initialCollapsed={collapsed}>{children}</TenantShell>;
}
+332
View File
@@ -0,0 +1,332 @@
'use client';
import * as React from 'react';
import Image from 'next/image';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trash2, Upload } from 'lucide-react';
import {
getTenantProfile,
removeTenantLogo,
updateTenantCor,
updateTenantProfile,
uploadTenantLogo,
} from '@/lib/api/tenant-profile';
import {
updateTenantCorSchema,
updateTenantProfileSchema,
type UpdateTenantCorFormValues,
type UpdateTenantProfileFormValues,
} from '@/lib/validators/tenant-profile';
import { ApiError } from '@/lib/api/client';
import { resolveAssetUrl } from '@/lib/api/asset-url';
import { ensureAccessibleTextColor } from '@/lib/theme/contrast';
import { useTenantTheme } from '@/lib/theme/theme-provider';
import { toast } from '@/lib/toast';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { ConfirmDialog } from '@/components/domain/confirm-dialog';
import { FormField } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
const TAMANHO_MAXIMO_LOGO_BYTES = 2 * 1024 * 1024;
const TIPOS_LOGO_ACEITOS = ['image/png', 'image/jpeg', 'image/svg+xml'];
export default function PerfilPage() {
const queryClient = useQueryClient();
const { applyThemeUpdate } = useTenantTheme();
const { data: perfil, isLoading } = useQuery({
queryKey: ['tenant', 'perfil'],
queryFn: getTenantProfile,
});
const dadosForm = useForm<UpdateTenantProfileFormValues>({
resolver: zodResolver(updateTenantProfileSchema),
values: perfil
? { nome_fantasia: perfil.nome_fantasia ?? '', razao_social: perfil.razao_social ?? '' }
: undefined,
});
const dadosMutation = useMutation({
mutationFn: (values: UpdateTenantProfileFormValues) => updateTenantProfile(values),
onSuccess: (result) => {
queryClient.setQueryData(['tenant', 'perfil'], result);
applyThemeUpdate({ nome: result.nome_fantasia ?? result.razao_social ?? undefined });
toast.success('Dados da empresa atualizados.');
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível salvar os dados.');
},
});
// --- Logo ---
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null);
const [removingLogo, setRemovingLogo] = React.useState(false);
const uploadMutation = useMutation({
mutationFn: (file: File) => uploadTenantLogo(file),
onSuccess: (result) => {
queryClient.setQueryData(['tenant', 'perfil'], (prev: typeof perfil) =>
prev ? { ...prev, logo_url: result.logo_url } : prev,
);
applyThemeUpdate({ logoUrl: resolveAssetUrl(result.logo_url) });
toast.success('Logo atualizada.');
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível enviar a logo.');
},
onSettled: () => {
setPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
},
});
const removeLogoMutation = useMutation({
mutationFn: removeTenantLogo,
onSuccess: (result) => {
queryClient.setQueryData(['tenant', 'perfil'], (prev: typeof perfil) =>
prev ? { ...prev, logo_url: result.logo_url } : prev,
);
applyThemeUpdate({ logoUrl: null });
toast.success('Logo removida — voltou ao padrão da plataforma.');
setRemovingLogo(false);
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível remover a logo.');
setRemovingLogo(false);
},
});
function onSelectLogoFile(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
if (!TIPOS_LOGO_ACEITOS.includes(file.type)) {
toast.error('Formato não suportado — envie um arquivo PNG, JPG ou SVG.');
return;
}
if (file.size > TAMANHO_MAXIMO_LOGO_BYTES) {
toast.error('Arquivo maior que 2MB — escolha uma imagem menor.');
return;
}
setPreviewUrl(URL.createObjectURL(file));
uploadMutation.mutate(file);
}
const logoSrc = previewUrl ?? resolveAssetUrl(perfil?.logo_url);
// --- Cor primária ---
const corForm = useForm<UpdateTenantCorFormValues>({
resolver: zodResolver(updateTenantCorSchema),
values: perfil ? { cor_primaria: perfil.cor_primaria ?? '#2563EB' } : undefined,
});
const corAtual = corForm.watch('cor_primaria') || '#2563EB';
const corValida = /^#[0-9A-Fa-f]{6}$/.test(corAtual);
const corMutation = useMutation({
mutationFn: (values: UpdateTenantCorFormValues) => updateTenantCor(values.cor_primaria),
onSuccess: (result) => {
queryClient.setQueryData(['tenant', 'perfil'], (prev: typeof perfil) =>
prev ? { ...prev, cor_primaria: result.cor_primaria } : prev,
);
applyThemeUpdate({ corPrimaria: result.cor_primaria });
toast.success('Cor primária atualizada.');
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível salvar a cor.');
},
});
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-xl font-semibold text-foreground">Perfil da empresa</h1>
<p className="text-sm text-muted-foreground">
Dados cadastrais e identidade visual usados em toda a aplicação
</p>
</div>
<Card className="max-w-xl">
<CardHeader>
<CardTitle>Dados da empresa</CardTitle>
<CardDescription>Nome fantasia e razão social exibidos no sistema.</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex flex-col gap-4">
<Skeleton className="h-11 w-full" />
<Skeleton className="h-11 w-full" />
</div>
) : (
<form
className="flex flex-col gap-4"
onSubmit={dadosForm.handleSubmit((values) => dadosMutation.mutate(values))}
>
<FormField
label="Nome fantasia"
error={dadosForm.formState.errors.nome_fantasia?.message}
required
>
<Input {...dadosForm.register('nome_fantasia')} />
</FormField>
<FormField
label="Razão social"
error={dadosForm.formState.errors.razao_social?.message}
required
>
<Input {...dadosForm.register('razao_social')} />
</FormField>
<div>
<Button type="submit" loading={dadosMutation.isPending}>
Salvar dados
</Button>
</div>
</form>
)}
</CardContent>
</Card>
<Card className="max-w-xl">
<CardHeader>
<CardTitle>Logo</CardTitle>
<CardDescription>
PNG, JPG ou SVG, até 2MB. Sem logo própria, a plataforma usa um ícone padrão.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex items-center gap-4">
<div className="flex size-20 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border bg-muted">
{logoSrc ? (
<Image
src={logoSrc}
alt="Logo do tenant"
width={80}
height={80}
unoptimized={previewUrl !== null}
className="size-20 object-contain"
/>
) : (
<span className="text-xs text-muted-foreground">Sem logo</span>
)}
</div>
<div className="flex flex-col gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/svg+xml"
className="hidden"
onChange={onSelectLogoFile}
/>
<Button
type="button"
variant="outline"
loading={uploadMutation.isPending}
onClick={() => fileInputRef.current?.click()}
>
<Upload /> Enviar logo
</Button>
{perfil?.logo_url && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setRemovingLogo(true)}
>
<Trash2 /> Remover logo
</Button>
)}
</div>
</div>
</CardContent>
</Card>
<Card className="max-w-xl">
<CardHeader>
<CardTitle>Cor primária</CardTitle>
<CardDescription>
Aplicada na sidebar, botões e destaques em toda a aplicação, com ajuste automático de
contraste quando necessário.
</CardDescription>
</CardHeader>
<CardContent>
<form
className="flex flex-col gap-4"
onSubmit={corForm.handleSubmit((values) => corMutation.mutate(values))}
>
<FormField
label="Cor"
error={corForm.formState.errors.cor_primaria?.message}
required
>
<div className="flex items-center gap-2">
<input
type="color"
aria-label="Selecionar cor primária"
className="size-11 shrink-0 cursor-pointer rounded-lg border border-input bg-transparent p-1"
value={corValida ? corAtual : '#2563EB'}
onChange={(e) =>
corForm.setValue('cor_primaria', e.target.value.toUpperCase(), {
shouldValidate: true,
})
}
/>
<Input
className="max-w-40 font-mono uppercase"
placeholder="#2563EB"
{...corForm.register('cor_primaria')}
/>
</div>
</FormField>
{corValida && (
<div className="flex flex-col gap-2 rounded-lg border border-border p-4">
<p className="text-xs font-medium text-muted-foreground">Pré-visualização</p>
<div className="flex flex-wrap items-center gap-3">
<span
className="inline-flex h-11 items-center rounded-lg px-4 text-sm font-medium text-white"
style={{ backgroundColor: corAtual }}
>
Botão de exemplo
</span>
<span
className="text-sm font-medium"
style={{ color: ensureAccessibleTextColor(corAtual) }}
>
Texto/ícone sobre fundo claro
</span>
</div>
</div>
)}
<div>
<Button type="submit" loading={corMutation.isPending} disabled={!corValida}>
Salvar cor
</Button>
</div>
</form>
</CardContent>
</Card>
<ConfirmDialog
open={removingLogo}
onOpenChange={setRemovingLogo}
title="Remover logo?"
description="A identidade visual volta a usar o ícone padrão da plataforma."
confirmLabel="Remover"
destructive
loading={removeLogoMutation.isPending}
onConfirm={() => removeLogoMutation.mutate()}
/>
</div>
);
}
+114
View File
@@ -0,0 +1,114 @@
'use client';
import * as React from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { deactivateProduct, getProduct, updateProduct } from '@/lib/api/products';
import type { ProductFormValues } from '@/lib/validators/products';
import { ApiError } from '@/lib/api/client';
import { toast } from '@/lib/toast';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { ProductForm } from '@/components/domain/product-form';
import { ConfirmDialog } from '@/components/domain/confirm-dialog';
export default function ProdutoDetalhePage() {
const params = useParams<{ id: string }>();
const productId = params.id;
const router = useRouter();
const queryClient = useQueryClient();
const [deactivateOpen, setDeactivateOpen] = React.useState(false);
const { data: product, isLoading } = useQuery({
queryKey: ['tenant', 'produtos', productId],
queryFn: () => getProduct(productId),
});
const saveMutation = useMutation({
mutationFn: (values: ProductFormValues) =>
updateProduct(productId, {
...values,
sku: values.sku || undefined,
descricao: values.descricao || undefined,
unidade: values.unidade || undefined,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tenant', 'produtos', productId] });
queryClient.invalidateQueries({ queryKey: ['tenant', 'produtos'] });
toast.success('Produto atualizado.');
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível salvar as alterações.');
},
});
const deactivateMutation = useMutation({
mutationFn: () => deactivateProduct(productId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tenant', 'produtos', productId] });
queryClient.invalidateQueries({ queryKey: ['tenant', 'produtos'] });
toast.success('Produto desativado.');
setDeactivateOpen(false);
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível desativar o produto.');
setDeactivateOpen(false);
},
});
if (isLoading || !product) {
return (
<div className="flex flex-col gap-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-64 w-full" />
</div>
);
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-3">
<h1 className="text-xl font-semibold text-foreground">{product.nome}</h1>
<Badge variant={product.ativo ? 'default' : 'outline'}>{product.ativo ? 'Ativo' : 'Inativo'}</Badge>
</div>
{product.ativo && (
<Button variant="outline" onClick={() => setDeactivateOpen(true)}>
Desativar
</Button>
)}
</div>
<ProductForm
defaultValues={{
tipo: product.tipo,
nome: product.nome,
descricao: product.descricao ?? '',
sku: product.sku ?? '',
valor_venda: Number(product.valor_venda),
valor_custo: product.valor_custo ? Number(product.valor_custo) : undefined,
unidade: product.unidade,
recorrente: product.recorrente,
}}
onSubmit={(values) => saveMutation.mutate(values)}
onCancel={() => router.push('/app/produtos')}
submitting={saveMutation.isPending}
submitLabel="Salvar alterações"
/>
<ConfirmDialog
open={deactivateOpen}
onOpenChange={setDeactivateOpen}
title="Desativar produto?"
description="O item sai das listagens de ativos, mas o histórico de vendas continua íntegro."
confirmLabel="Desativar"
destructive
loading={deactivateMutation.isPending}
onConfirm={() => deactivateMutation.mutate()}
/>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
'use client';
import { useRouter } from 'next/navigation';
import { useMutation } from '@tanstack/react-query';
import { createProduct } from '@/lib/api/products';
import type { ProductFormValues } from '@/lib/validators/products';
import { ApiError } from '@/lib/api/client';
import { toast } from '@/lib/toast';
import { ProductForm } from '@/components/domain/product-form';
export default function NovoProdutoPage() {
const router = useRouter();
const mutation = useMutation({
mutationFn: (values: ProductFormValues) =>
createProduct({
...values,
sku: values.sku || undefined,
descricao: values.descricao || undefined,
unidade: values.unidade || undefined,
}),
onSuccess: (product) => {
toast.success('Produto cadastrado.');
router.push(`/app/produtos/${product.id}`);
},
onError: (err) => {
// SKU duplicado (409) e limite de plano vêm com mensagem de negócio
// clara do backend — mostrada direto (Etapa 16, item 6).
toast.error(err instanceof ApiError ? err.message : 'Não foi possível cadastrar o produto.');
},
});
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-xl font-semibold text-foreground">Novo produto</h1>
<p className="text-sm text-muted-foreground">Cadastro de produto ou serviço</p>
</div>
<ProductForm
defaultValues={{
tipo: 'produto',
nome: '',
descricao: '',
sku: '',
valor_venda: 0,
valor_custo: undefined,
unidade: '',
recorrente: false,
}}
onSubmit={(values) => mutation.mutate(values)}
onCancel={() => router.push('/app/produtos')}
submitting={mutation.isPending}
submitLabel="Cadastrar produto"
/>
</div>
);
}
+167
View File
@@ -0,0 +1,167 @@
'use client';
import * as React from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { Plus, Search } from 'lucide-react';
import { listProducts, type ItemTipo, type ProductService } from '@/lib/api/products';
import { formatCurrency } from '@/lib/format';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { DataTable, type DataTableColumn } from '@/components/ui/data-table';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
const TIPO_OPTIONS: { value: ItemTipo | 'todos'; label: string }[] = [
{ value: 'todos', label: 'Produtos e serviços' },
{ value: 'produto', label: 'Somente produtos' },
{ value: 'servico', label: 'Somente serviços' },
];
const ATIVO_OPTIONS = [
{ value: 'todos', label: 'Ativos e inativos' },
{ value: 'true', label: 'Somente ativos' },
{ value: 'false', label: 'Somente inativos' },
];
const LIMIT = 20;
export default function ProdutosPage() {
const router = useRouter();
const [busca, setBusca] = React.useState('');
const [tipo, setTipo] = React.useState<ItemTipo | 'todos'>('todos');
const [ativo, setAtivo] = React.useState('true');
const [page, setPage] = React.useState(1);
const { data, isLoading } = useQuery({
queryKey: ['tenant', 'produtos', { busca, tipo, ativo, page }],
queryFn: () =>
listProducts({
busca: busca || undefined,
tipo: tipo === 'todos' ? undefined : tipo,
ativo: ativo === 'todos' ? undefined : ativo === 'true',
page,
limit: LIMIT,
}),
placeholderData: (prev) => prev,
});
const columns: DataTableColumn<ProductService>[] = [
{
id: 'nome',
header: 'Nome',
primary: true,
cell: (row) => (
<div className="flex flex-col">
<span className="font-medium text-foreground">{row.nome}</span>
{row.sku && <span className="text-xs text-muted-foreground">SKU {row.sku}</span>}
</div>
),
},
{
id: 'tipo',
header: 'Tipo',
primary: true,
cell: (row) => <Badge variant="outline">{row.tipo === 'produto' ? 'Produto' : 'Serviço'}</Badge>,
},
{ id: 'valor', header: 'Valor de venda', cell: (row) => formatCurrency(row.valor_venda) },
{
id: 'status',
header: 'Status',
cell: (row) => <Badge variant={row.ativo ? 'default' : 'outline'}>{row.ativo ? 'Ativo' : 'Inativo'}</Badge>,
},
];
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-foreground">Produtos e serviços</h1>
<p className="text-sm text-muted-foreground">Itens que você vende</p>
</div>
<Button asChild>
<Link href="/app/produtos/novo">
<Plus /> Novo produto
</Link>
</Button>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1 sm:max-w-xs">
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-neutral-500" />
<Input
placeholder="Buscar por nome ou SKU..."
className="pl-9"
value={busca}
onChange={(e) => {
setBusca(e.target.value);
setPage(1);
}}
/>
</div>
<Select
value={tipo}
onValueChange={(value) => {
setTipo(value as ItemTipo | 'todos');
setPage(1);
}}
>
<SelectTrigger className="w-full sm:w-52" aria-label="Filtrar por tipo">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIPO_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={ativo}
onValueChange={(value) => {
setAtivo(value);
setPage(1);
}}
>
<SelectTrigger className="w-full sm:w-52" aria-label="Filtrar por status">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ATIVO_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DataTable
columns={columns}
data={data?.data ?? []}
keyField={(row) => row.id}
loading={isLoading}
emptyTitle="Nenhum produto encontrado"
emptyDescription="Ajuste os filtros ou cadastre um novo produto."
emptyAction={
<Button size="sm" asChild>
<Link href="/app/produtos/novo">Novo produto</Link>
</Button>
}
onRowClick={(row) => router.push(`/app/produtos/${row.id}`)}
rowActions={(row) => (
<Button variant="ghost" size="sm" asChild>
<Link href={`/app/produtos/${row.id}`}>Ver</Link>
</Button>
)}
pagination={
data ? { page: data.meta.page, limit: data.meta.limit, total: data.meta.total, onPageChange: setPage } : undefined
}
/>
</div>
);
}
+299
View File
@@ -0,0 +1,299 @@
'use client';
import * as React from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { RefreshCw } from 'lucide-react';
import type { DateRange } from 'react-day-picker';
import {
atualizarRecebimentosAtrasados,
listRecebimentos,
marcarRecebimentoPago,
type FormaPagamento,
type Recebimento,
} from '@/lib/api/sale-payments';
import { listCustomers } from '@/lib/api/customers';
import type { ParcelaStatus } from '@/lib/api/sales';
import { ApiError } from '@/lib/api/client';
import { formatCurrency, formatDate } from '@/lib/format';
import { toast } from '@/lib/toast';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import { DataTable, type DataTableColumn } from '@/components/ui/data-table';
import { DateRangePicker } from '@/components/ui/date-picker';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { StatusPill } from '@/components/ui/status-pill';
import { ConfirmDialog } from '@/components/domain/confirm-dialog';
import { MarcarPagoDialog } from '@/components/domain/marcar-pago-dialog';
const STATUS_OPTIONS: { value: ParcelaStatus | 'todos'; label: string }[] = [
{ value: 'todos', label: 'Todos os status' },
{ value: 'pendente', label: 'Pendente' },
{ value: 'atrasado', label: 'Atrasado' },
{ value: 'pago', label: 'Pago' },
{ value: 'cancelado', label: 'Cancelado' },
];
const FORMAS: { value: FormaPagamento; label: string }[] = [
{ value: 'pix', label: 'Pix' },
{ value: 'boleto', label: 'Boleto' },
{ value: 'cartao', label: 'Cartão' },
{ value: 'dinheiro', label: 'Dinheiro' },
];
const LIMIT = 20;
function invalidateAll(queryClient: ReturnType<typeof useQueryClient>) {
queryClient.invalidateQueries({ queryKey: ['tenant', 'recebimentos'] });
queryClient.invalidateQueries({ queryKey: ['tenant', 'vendas'] });
queryClient.invalidateQueries({ queryKey: ['tenant', 'dashboard'] });
}
export default function RecebimentosPage() {
const queryClient = useQueryClient();
const [status, setStatus] = React.useState<ParcelaStatus | 'todos'>('todos');
const [customerId, setCustomerId] = React.useState('');
const [periodo, setPeriodo] = React.useState<DateRange | undefined>();
const [page, setPage] = React.useState(1);
const [payingParcela, setPayingParcela] = React.useState<{ id: string; valor: string } | null>(null);
const [selected, setSelected] = React.useState<Set<string>>(new Set());
const [bulkForma, setBulkForma] = React.useState<FormaPagamento | ''>('');
const [bulkOpen, setBulkOpen] = React.useState(false);
const [bulkLoading, setBulkLoading] = React.useState(false);
const [atualizarOpen, setAtualizarOpen] = React.useState(false);
const { data: customersData } = useQuery({
queryKey: ['tenant', 'customers', 'combobox'],
queryFn: () => listCustomers({ limit: 100 }),
});
const { data, isLoading } = useQuery({
queryKey: [
'tenant',
'recebimentos',
{ status, customerId, de: periodo?.from?.toISOString(), ate: periodo?.to?.toISOString(), page },
],
queryFn: () =>
listRecebimentos({
status: status === 'todos' ? undefined : status,
customer_id: customerId || undefined,
vencimento_inicio: periodo?.from?.toISOString().slice(0, 10),
vencimento_fim: periodo?.to?.toISOString().slice(0, 10),
page,
limit: LIMIT,
}),
placeholderData: (prev) => prev,
});
const atualizarMutation = useMutation({
mutationFn: () => atualizarRecebimentosAtrasados(),
onSuccess: (result) => {
invalidateAll(queryClient);
toast.success(`${result.atualizadas} parcela(s) marcadas como atrasadas.`);
setAtualizarOpen(false);
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível atualizar as parcelas atrasadas.');
setAtualizarOpen(false);
},
});
function toggleSelected(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
async function marcarSelecionadasPagas() {
if (!bulkForma) return;
setBulkLoading(true);
const ids = Array.from(selected);
const results = await Promise.allSettled(ids.map((id) => marcarRecebimentoPago(id, bulkForma)));
const falhas = results.filter((r) => r.status === 'rejected').length;
setBulkLoading(false);
setBulkOpen(false);
setSelected(new Set());
setBulkForma('');
invalidateAll(queryClient);
if (falhas === 0) {
toast.success(`${ids.length} parcela(s) marcadas como pagas.`);
} else {
toast.error(`${ids.length - falhas} de ${ids.length} marcadas como pagas — ${falhas} falharam.`);
}
}
const customerOptions = [
{ value: '', label: 'Todos os clientes' },
...(customersData?.data ?? []).map((c) => ({ value: c.id, label: c.nome_fantasia || c.razao_social })),
];
const columns: DataTableColumn<Recebimento>[] = [
{
id: 'select',
header: '',
hideOnMobileCard: true,
cell: (row) =>
row.status === 'pendente' || row.status === 'atrasado' ? (
<input
type="checkbox"
className="size-4 accent-primary"
checked={selected.has(row.id)}
onChange={() => toggleSelected(row.id)}
aria-label={`Selecionar parcela de ${row.sale.customer.razao_social}`}
/>
) : null,
className: 'w-px',
},
{ id: 'cliente', header: 'Cliente', primary: true, cell: (row) => row.sale.customer.razao_social },
{
id: 'venda',
header: 'Venda',
cell: (row) => `#${row.sale.numero_venda} · parcela ${row.numero_parcela}`,
},
{ id: 'valor', header: 'Valor', primary: true, cell: (row) => formatCurrency(row.valor) },
{
id: 'vencimento',
header: 'Vencimento',
cell: (row) => (
<span className={cn(row.status === 'atrasado' && 'font-medium text-danger')}>{formatDate(row.vencimento)}</span>
),
},
{ id: 'status', header: 'Status', cell: (row) => <StatusPill status={row.status} /> },
];
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-foreground">Recebimentos</h1>
<p className="text-sm text-muted-foreground">Parcelas de vendas a receber</p>
</div>
<Button variant="outline" onClick={() => setAtualizarOpen(true)}>
<RefreshCw /> Atualizar atrasadas
</Button>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<Select
value={status}
onValueChange={(value) => {
setStatus(value as ParcelaStatus | 'todos');
setPage(1);
}}
>
<SelectTrigger className="w-full sm:w-48" aria-label="Filtrar por status">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Combobox
options={customerOptions}
value={customerId}
onChange={(value) => {
setCustomerId(value);
setPage(1);
}}
placeholder="Todos os clientes"
searchPlaceholder="Buscar cliente..."
className="w-full sm:w-56"
/>
<DateRangePicker
value={periodo}
onChange={(range) => {
setPeriodo(range);
setPage(1);
}}
placeholder="Período de vencimento"
className="w-full sm:w-64"
/>
</div>
{selected.size > 0 && (
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-primary/30 bg-primary/5 p-3">
<span className="text-sm font-medium text-foreground">{selected.size} selecionada(s)</span>
<Button size="sm" onClick={() => setBulkOpen(true)}>
Marcar selecionadas como pagas
</Button>
<Button size="sm" variant="ghost" onClick={() => setSelected(new Set())}>
Limpar seleção
</Button>
</div>
)}
<DataTable
columns={columns}
data={data?.data ?? []}
keyField={(row) => row.id}
loading={isLoading}
emptyTitle="Nenhum recebimento encontrado"
emptyDescription="Ajuste os filtros de status/cliente/período."
rowActions={(row) =>
row.status === 'pendente' || row.status === 'atrasado' ? (
<Button variant="ghost" size="sm" onClick={() => setPayingParcela(row)}>
Marcar como paga
</Button>
) : null
}
pagination={
data ? { page: data.meta.page, limit: data.meta.limit, total: data.meta.total, onPageChange: setPage } : undefined
}
/>
<MarcarPagoDialog
parcela={payingParcela}
onOpenChange={(open) => !open && setPayingParcela(null)}
onSuccess={() => invalidateAll(queryClient)}
/>
<ConfirmDialog
open={atualizarOpen}
onOpenChange={setAtualizarOpen}
title="Atualizar parcelas atrasadas?"
description="Marca como atrasada toda parcela pendente já vencida."
confirmLabel="Atualizar"
loading={atualizarMutation.isPending}
onConfirm={() => atualizarMutation.mutate()}
/>
<ConfirmDialog
open={bulkOpen}
onOpenChange={(open) => {
setBulkOpen(open);
if (!open) setBulkForma('');
}}
title={`Marcar ${selected.size} parcela(s) como pagas?`}
description="Selecione a forma de pagamento usada em todas as parcelas selecionadas."
confirmLabel="Confirmar"
confirmDisabled={!bulkForma}
loading={bulkLoading}
onConfirm={marcarSelecionadasPagas}
>
<div className="px-4 pb-4 sm:px-0 sm:pb-0">
<Select value={bulkForma} onValueChange={(value) => setBulkForma(value as FormaPagamento)}>
<SelectTrigger className="w-full" aria-label="Forma de pagamento">
<SelectValue placeholder="Selecione a forma de pagamento" />
</SelectTrigger>
<SelectContent>
{FORMAS.map((f) => (
<SelectItem key={f.value} value={f.value}>
{f.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</ConfirmDialog>
</div>
);
}
+301
View File
@@ -0,0 +1,301 @@
'use client';
import * as React from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
changeSaleStatus,
getSale,
updateSale,
TRANSICOES_VALIDAS,
type SaleInput,
type SaleListItem,
type VendaStatus,
} from '@/lib/api/sales';
import type { SaleFormValues } from '@/lib/validators/sales';
import { ApiError } from '@/lib/api/client';
import { formatCurrency, formatDate, formatDateTime } from '@/lib/format';
import { toast } from '@/lib/toast';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { EmptyState } from '@/components/ui/empty-state';
import { Skeleton } from '@/components/ui/skeleton';
import { StatusPill } from '@/components/ui/status-pill';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ConfirmDialog } from '@/components/domain/confirm-dialog';
import { MarcarPagoDialog } from '@/components/domain/marcar-pago-dialog';
import { VendaForm } from '@/components/domain/venda-form';
function saleToFormValues(sale: SaleListItem): SaleFormValues {
return {
customer_id: sale.customer_id,
data_venda: sale.data_venda.slice(0, 10),
desconto: Number(sale.desconto),
observacoes: sale.observacoes ?? '',
itens: sale.itens.map((item) => ({
product_service_id: item.product_service_id,
nome: item.produto.nome,
quantidade: Number(item.quantidade),
valor_unitario: Number(item.valor_unitario),
valor_catalogo: Number(item.produto.valor_venda),
})),
modoParcelas: 'manual',
numeroParcelas: sale.parcelas.length || 1,
parcelasManuais: sale.parcelas.map((p) => ({ valor: Number(p.valor), vencimento: p.vencimento.slice(0, 10) })),
};
}
function invalidateSaleQueries(
queryClient: ReturnType<typeof useQueryClient>,
saleId: string,
) {
queryClient.invalidateQueries({ queryKey: ['tenant', 'vendas', saleId] });
queryClient.invalidateQueries({ queryKey: ['tenant', 'vendas'] });
queryClient.invalidateQueries({ queryKey: ['tenant', 'recebimentos'] });
queryClient.invalidateQueries({ queryKey: ['tenant', 'dashboard'] });
}
export default function VendaDetalhePage() {
const params = useParams<{ id: string }>();
const saleId = params.id;
const router = useRouter();
const queryClient = useQueryClient();
const [cancelOpen, setCancelOpen] = React.useState(false);
const [concludeOpen, setConcludeOpen] = React.useState(false);
const [payingParcela, setPayingParcela] = React.useState<{ id: string; valor: string } | null>(null);
const { data: sale, isLoading } = useQuery({
queryKey: ['tenant', 'vendas', saleId],
queryFn: () => getSale(saleId),
});
const statusMutation = useMutation({
mutationFn: (status: Exclude<VendaStatus, 'orcamento'>) => changeSaleStatus(saleId, status),
onSuccess: () => {
invalidateSaleQueries(queryClient, saleId);
toast.success('Status da venda atualizado.');
setCancelOpen(false);
setConcludeOpen(false);
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível atualizar o status.');
setCancelOpen(false);
setConcludeOpen(false);
},
});
const updateMutation = useMutation({
mutationFn: (input: SaleInput) => updateSale(saleId, input),
onSuccess: () => {
invalidateSaleQueries(queryClient, saleId);
toast.success('Venda atualizada.');
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível salvar as alterações.');
},
});
if (isLoading || !sale) {
return (
<div className="flex flex-col gap-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-64 w-full" />
</div>
);
}
const transicoesDisponiveis = TRANSICOES_VALIDAS[sale.status];
const podeConfirmar = transicoesDisponiveis.includes('confirmada');
const podeConcluir = transicoesDisponiveis.includes('concluida');
const podeCancelar = transicoesDisponiveis.includes('cancelada');
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<div className="flex items-center gap-3">
<h1 className="text-xl font-semibold text-foreground">Venda #{sale.numero_venda}</h1>
<StatusPill status={sale.status} />
</div>
<p className="text-sm text-muted-foreground">
{sale.customer.razao_social} · {formatDate(sale.data_venda)}
</p>
</div>
<div className="flex flex-wrap gap-2">
{podeConfirmar && (
<Button onClick={() => statusMutation.mutate('confirmada')} loading={statusMutation.isPending}>
Confirmar venda
</Button>
)}
{podeConcluir && (
<Button variant="outline" onClick={() => setConcludeOpen(true)}>
Concluir venda
</Button>
)}
{podeCancelar && (
<Button variant="outline" onClick={() => setCancelOpen(true)}>
Cancelar venda
</Button>
)}
</div>
</div>
<Tabs defaultValue="itens">
<TabsList>
<TabsTrigger value="itens">Itens</TabsTrigger>
<TabsTrigger value="parcelas">Parcelas</TabsTrigger>
<TabsTrigger value="historico">Histórico</TabsTrigger>
</TabsList>
<TabsContent value="itens" className="pt-4">
{sale.status === 'orcamento' ? (
<VendaForm
mode="edit"
defaultValues={saleToFormValues(sale)}
submittingConfirmOrSave={updateMutation.isPending}
onConfirmOrSave={(input) => updateMutation.mutate(input)}
onCancel={() => router.push('/app/vendas')}
/>
) : (
<ItensReadOnly sale={sale} />
)}
</TabsContent>
<TabsContent value="parcelas" className="pt-4">
<ParcelasTab sale={sale} onMarcarPago={setPayingParcela} />
</TabsContent>
<TabsContent value="historico" className="pt-4">
<EmptyState
title="Histórico não disponível nesta versão"
description="O backend ainda não expõe um endpoint de auditoria (audit_logs) para consulta pelo tenant — só registra internamente."
/>
</TabsContent>
</Tabs>
<ConfirmDialog
open={cancelOpen}
onOpenChange={setCancelOpen}
title="Cancelar venda?"
description="Isso cancela todas as parcelas pendentes/atrasadas desta venda. Parcelas já pagas não são estornadas."
confirmLabel="Cancelar venda"
destructive
loading={statusMutation.isPending}
onConfirm={() => statusMutation.mutate('cancelada')}
/>
<ConfirmDialog
open={concludeOpen}
onOpenChange={setConcludeOpen}
title="Concluir venda?"
description="Marca a venda como concluída — use quando a entrega/prestação do serviço tiver sido finalizada."
confirmLabel="Concluir"
loading={statusMutation.isPending}
onConfirm={() => statusMutation.mutate('concluida')}
/>
<MarcarPagoDialog
parcela={payingParcela}
onOpenChange={(open) => !open && setPayingParcela(null)}
onSuccess={() => invalidateSaleQueries(queryClient, saleId)}
/>
</div>
);
}
function ItensReadOnly({ sale }: { sale: SaleListItem }) {
return (
<div className="flex flex-col gap-4">
<div className="overflow-x-auto rounded-lg border border-border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>Qtd.</TableHead>
<TableHead>Valor unit.</TableHead>
<TableHead>Subtotal</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sale.itens.map((item) => (
<TableRow key={item.id}>
<TableCell>{item.produto.nome}</TableCell>
<TableCell className="tabular-nums">{item.quantidade}</TableCell>
<TableCell className="tabular-nums">{formatCurrency(item.valor_unitario)}</TableCell>
<TableCell className="tabular-nums">{formatCurrency(item.valor_total)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex flex-col items-end gap-1 text-sm">
<div className="flex w-48 justify-between text-muted-foreground">
<span>Desconto</span>
<span className="tabular-nums">-{formatCurrency(sale.desconto)}</span>
</div>
<div className="flex w-48 justify-between text-base font-semibold text-foreground">
<span>Total</span>
<span className="tabular-nums">{formatCurrency(sale.valor_total)}</span>
</div>
</div>
{sale.observacoes && (
<p className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">Observações: </span>
{sale.observacoes}
</p>
)}
</div>
);
}
function ParcelasTab({
sale,
onMarcarPago,
}: {
sale: SaleListItem;
onMarcarPago: (parcela: { id: string; valor: string }) => void;
}) {
return (
<div className="overflow-x-auto rounded-lg border border-border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Parcela</TableHead>
<TableHead>Valor</TableHead>
<TableHead>Vencimento</TableHead>
<TableHead>Status</TableHead>
<TableHead>Pago em</TableHead>
<TableHead className="w-px">
<span className="sr-only">Ações</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sale.parcelas.map((parcela) => (
<TableRow key={parcela.id}>
<TableCell>{parcela.numero_parcela}</TableCell>
<TableCell className="tabular-nums">{formatCurrency(parcela.valor)}</TableCell>
<TableCell className="tabular-nums">{formatDate(parcela.vencimento)}</TableCell>
<TableCell>
<StatusPill status={parcela.status} />
</TableCell>
<TableCell className="tabular-nums">{formatDateTime(parcela.pago_em)}</TableCell>
<TableCell>
{parcela.status === 'pendente' || parcela.status === 'atrasado' ? (
<Button variant="ghost" size="sm" onClick={() => onMarcarPago(parcela)}>
Marcar como paga
</Button>
) : parcela.status === 'pago' ? (
<Badge variant="outline">{parcela.forma_pagamento}</Badge>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
'use client';
import { useRouter } from 'next/navigation';
import { useMutation } from '@tanstack/react-query';
import { createSale, type SaleInput } from '@/lib/api/sales';
import type { SaleFormValues } from '@/lib/validators/sales';
import { ApiError } from '@/lib/api/client';
import { toast } from '@/lib/toast';
import { VendaForm } from '@/components/domain/venda-form';
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
const DEFAULT_VALUES: SaleFormValues = {
customer_id: '',
data_venda: todayIso(),
desconto: 0,
observacoes: '',
itens: [],
modoParcelas: 'automatico',
numeroParcelas: 1,
parcelasManuais: [],
};
export default function NovaVendaPage() {
const router = useRouter();
const draftMutation = useMutation({
mutationFn: (input: SaleInput) => createSale({ ...input, status: 'orcamento' }),
onSuccess: (sale) => {
toast.success('Orçamento salvo.');
router.push(`/app/vendas/${sale.id}`);
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível salvar o orçamento.');
},
});
const confirmMutation = useMutation({
mutationFn: (input: SaleInput) => createSale({ ...input, status: 'confirmada' }),
onSuccess: (sale) => {
toast.success('Venda confirmada.');
router.push(`/app/vendas/${sale.id}`);
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível confirmar a venda.');
},
});
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-xl font-semibold text-foreground">Nova venda</h1>
<p className="text-sm text-muted-foreground">Registre uma venda com itens e parcelas</p>
</div>
<VendaForm
mode="create"
defaultValues={DEFAULT_VALUES}
submittingDraft={draftMutation.isPending}
submittingConfirmOrSave={confirmMutation.isPending}
onSaveDraft={(input) => draftMutation.mutate(input)}
onConfirmOrSave={(input) => confirmMutation.mutate(input)}
onCancel={() => router.push('/app/vendas')}
/>
</div>
);
}
+153
View File
@@ -0,0 +1,153 @@
'use client';
import * as React from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { Plus } from 'lucide-react';
import type { DateRange } from 'react-day-picker';
import { listSales, type SaleListItem, type VendaStatus } from '@/lib/api/sales';
import { listCustomers } from '@/lib/api/customers';
import { formatCurrency, formatDate } from '@/lib/format';
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import { DataTable, type DataTableColumn } from '@/components/ui/data-table';
import { DateRangePicker } from '@/components/ui/date-picker';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { StatusPill } from '@/components/ui/status-pill';
const STATUS_OPTIONS: { value: VendaStatus | 'todos'; label: string }[] = [
{ value: 'todos', label: 'Todos os status' },
{ value: 'orcamento', label: 'Orçamento' },
{ value: 'confirmada', label: 'Confirmada' },
{ value: 'concluida', label: 'Concluída' },
{ value: 'cancelada', label: 'Cancelada' },
];
const LIMIT = 20;
export default function VendasPage() {
const router = useRouter();
const [status, setStatus] = React.useState<VendaStatus | 'todos'>('todos');
const [customerId, setCustomerId] = React.useState('');
const [periodo, setPeriodo] = React.useState<DateRange | undefined>();
const [page, setPage] = React.useState(1);
const { data: customersData } = useQuery({
queryKey: ['tenant', 'customers', 'combobox'],
queryFn: () => listCustomers({ limit: 100 }),
});
const { data, isLoading } = useQuery({
queryKey: [
'tenant',
'vendas',
{ status, customerId, de: periodo?.from?.toISOString(), ate: periodo?.to?.toISOString(), page },
],
queryFn: () =>
listSales({
status: status === 'todos' ? undefined : status,
customer_id: customerId || undefined,
data_inicio: periodo?.from?.toISOString().slice(0, 10),
data_fim: periodo?.to?.toISOString().slice(0, 10),
page,
limit: LIMIT,
}),
placeholderData: (prev) => prev,
});
const customerOptions = [
{ value: '', label: 'Todos os clientes' },
...(customersData?.data ?? []).map((c) => ({ value: c.id, label: c.nome_fantasia || c.razao_social })),
];
const columns: DataTableColumn<SaleListItem>[] = [
{ id: 'numero', header: 'Venda', primary: true, cell: (row) => `#${row.numero_venda}` },
{ id: 'cliente', header: 'Cliente', primary: true, cell: (row) => row.customer.razao_social },
{ id: 'valor', header: 'Valor total', cell: (row) => formatCurrency(row.valor_total) },
{ id: 'status', header: 'Status', cell: (row) => <StatusPill status={row.status} /> },
{ id: 'data', header: 'Data', cell: (row) => formatDate(row.data_venda) },
];
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-foreground">Vendas</h1>
<p className="text-sm text-muted-foreground">Orçamentos e vendas confirmadas</p>
</div>
<Button asChild>
<Link href="/app/vendas/nova">
<Plus /> Nova venda
</Link>
</Button>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<Select
value={status}
onValueChange={(value) => {
setStatus(value as VendaStatus | 'todos');
setPage(1);
}}
>
<SelectTrigger className="w-full sm:w-48" aria-label="Filtrar por status">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Combobox
options={customerOptions}
value={customerId}
onChange={(value) => {
setCustomerId(value);
setPage(1);
}}
placeholder="Todos os clientes"
searchPlaceholder="Buscar cliente..."
className="w-full sm:w-56"
/>
<DateRangePicker
value={periodo}
onChange={(range) => {
setPeriodo(range);
setPage(1);
}}
placeholder="Período da venda"
className="w-full sm:w-64"
/>
</div>
<DataTable
columns={columns}
data={data?.data ?? []}
keyField={(row) => row.id}
loading={isLoading}
emptyTitle="Nenhuma venda encontrada"
emptyDescription="Ajuste os filtros ou registre uma nova venda."
emptyAction={
<Button size="sm" asChild>
<Link href="/app/vendas/nova">Nova venda</Link>
</Button>
}
onRowClick={(row) => router.push(`/app/vendas/${row.id}`)}
rowActions={(row) => (
<Button variant="ghost" size="sm" asChild>
<Link href={`/app/vendas/${row.id}`}>Ver</Link>
</Button>
)}
pagination={
data ? { page: data.meta.page, limit: data.meta.limit, total: data.meta.total, onPageChange: setPage } : undefined
}
/>
</div>
);
}
+338
View File
@@ -0,0 +1,338 @@
'use client';
import * as React from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { CheckCircle2, Plus, Send } from 'lucide-react';
import {
consultarWhatsappStatus,
criarWhatsappInstance,
listWhatsappInstances,
obterWhatsappQrCode,
removerWhatsappInstance,
testarEnvioWhatsapp,
type WhatsappInstance,
type WhatsappInstanceStatus,
} from '@/lib/api/whatsapp';
import {
testarEnvioWhatsappSchema,
type TestarEnvioWhatsappFormValues,
} from '@/lib/validators/whatsapp';
import { ApiError } from '@/lib/api/client';
import { formatDateTime } from '@/lib/format';
import { toast } from '@/lib/toast';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { DataTable, type DataTableColumn } from '@/components/ui/data-table';
import { FormField } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import { ResponsiveDialog } from '@/components/ui/responsive-dialog';
import { Skeleton } from '@/components/ui/skeleton';
import { Textarea } from '@/components/ui/textarea';
import { ConfirmDialog } from '@/components/domain/confirm-dialog';
const STATUS_LABEL: Record<WhatsappInstanceStatus, string> = {
desconectado: 'Desconectado',
conectando: 'Conectando',
conectado: 'Conectado',
erro: 'Erro',
};
const STATUS_TONE: Record<WhatsappInstanceStatus, 'default' | 'destructive' | 'outline' | 'secondary'> = {
desconectado: 'outline',
conectando: 'secondary',
conectado: 'default',
erro: 'destructive',
};
const POLL_INTERVAL_MS = 4000;
/** `EvolutionQrCode.base64` não tem prefixo garantido pelo backend — renderiza defensivamente. */
function qrImageSrc(base64?: string): string | null {
if (!base64) return null;
return base64.startsWith('data:') ? base64 : `data:image/png;base64,${base64}`;
}
function ConnectInstanceDialog({
instance,
onOpenChange,
}: {
instance: WhatsappInstance | null;
onOpenChange: (open: boolean) => void;
}) {
const open = instance !== null;
const queryClient = useQueryClient();
const [status, setStatus] = React.useState<WhatsappInstanceStatus | null>(null);
React.useEffect(() => {
setStatus(instance?.status ?? null);
}, [instance?.id, instance?.status]);
const {
data: qrcode,
isLoading: qrLoading,
error: qrError,
} = useQuery({
queryKey: ['tenant', 'whatsapp', 'qrcode', instance?.id],
queryFn: () => obterWhatsappQrCode(instance!.id),
enabled: open && status !== 'conectado',
retry: false,
});
// Polling automático do status a cada poucos segundos, parando sozinho ao
// detectar "conectado" ou quando o diálogo fecha/desmonta — o efeito
// depende de `status`, então uma vez "conectado" a condição de guarda
// abaixo impede reagendar um novo `setInterval` (sem vazamento).
React.useEffect(() => {
if (!open || !instance || status === 'conectado') return;
const interval = setInterval(() => {
consultarWhatsappStatus(instance.id)
.then((updated) => {
setStatus(updated.status);
if (updated.status === 'conectado') {
queryClient.invalidateQueries({ queryKey: ['tenant', 'whatsapp'] });
}
})
.catch(() => {
// falha pontual de rede/consulta — a próxima iteração tenta de novo
});
}, POLL_INTERVAL_MS);
return () => clearInterval(interval);
}, [open, instance, status, queryClient]);
const qrSrc = qrImageSrc(qrcode?.base64);
return (
<ResponsiveDialog
open={open}
onOpenChange={onOpenChange}
title={`Conectar — ${instance?.instance_name ?? ''}`}
description="Escaneie o QR code no WhatsApp do número que vai enviar as mensagens."
footer={
<Button className="w-full" variant="outline" onClick={() => onOpenChange(false)}>
Fechar
</Button>
}
>
<div className="flex flex-col items-center gap-4 px-4 pb-4 sm:px-0 sm:pb-0">
{status === 'conectado' ? (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<CheckCircle2 className="size-10 text-success" aria-hidden />
<p className="font-medium text-foreground">Conectado com sucesso!</p>
<p className="text-sm text-muted-foreground">Esta instância já pode enviar mensagens.</p>
</div>
) : qrLoading ? (
<Skeleton className="size-56" />
) : qrError ? (
<p className="py-8 text-center text-sm text-destructive">
{qrError instanceof ApiError ? qrError.message : 'Não foi possível obter o QR code.'}
</p>
) : qrSrc ? (
<>
{/* Base64 de origem dinâmica (Evolution API) — `<img>` puro, `next/image` não otimiza data URIs. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={qrSrc}
alt="QR code para conectar o WhatsApp"
className="size-56 rounded-lg border border-border object-contain"
/>
{qrcode?.pairing_code && (
<p className="text-sm text-muted-foreground">
Código de pareamento: <span className="font-mono font-medium">{qrcode.pairing_code}</span>
</p>
)}
<p className="text-xs text-muted-foreground">
Aguardando leitura — o status atualiza automaticamente assim que o WhatsApp conectar.
</p>
</>
) : (
<p className="py-8 text-center text-sm text-muted-foreground">QR code indisponível.</p>
)}
</div>
</ResponsiveDialog>
);
}
function TestSendDialog({
instance,
onOpenChange,
}: {
instance: WhatsappInstance | null;
onOpenChange: (open: boolean) => void;
}) {
const form = useForm<TestarEnvioWhatsappFormValues>({
resolver: zodResolver(testarEnvioWhatsappSchema),
defaultValues: { numero: '', mensagem: '' },
});
React.useEffect(() => {
if (instance) form.reset({ numero: '', mensagem: '' });
}, [instance, form]);
const mutation = useMutation({
mutationFn: (values: TestarEnvioWhatsappFormValues) =>
testarEnvioWhatsapp(instance!.id, { numero: values.numero, mensagem: values.mensagem || undefined }),
onSuccess: (result) => {
// `DispatchResult` sempre responde HTTP 200 — falha de envio vem como
// `{ sucesso: false, erro }`, não como erro HTTP (ver `lib/api/whatsapp.ts`).
if (result.sucesso) {
toast.success('Mensagem de teste enviada.');
onOpenChange(false);
} else {
toast.error(result.erro ?? 'Falha ao enviar a mensagem de teste.');
}
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Falha ao enviar a mensagem de teste.');
},
});
return (
<ResponsiveDialog
open={instance !== null}
onOpenChange={onOpenChange}
title={`Testar envio — ${instance?.instance_name ?? ''}`}
footer={
<>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancelar
</Button>
<Button loading={mutation.isPending} onClick={form.handleSubmit((v) => mutation.mutate(v))}>
<Send /> Enviar
</Button>
</>
}
>
<form
className="flex flex-col gap-4 px-4 pb-4 sm:px-0 sm:pb-0"
onSubmit={form.handleSubmit((v) => mutation.mutate(v))}
>
<FormField
label="Número (DDI+DDD+número)"
error={form.formState.errors.numero?.message}
required
>
<Input placeholder="5511999998888" {...form.register('numero')} />
</FormField>
<FormField label="Mensagem" error={form.formState.errors.mensagem?.message}>
<Textarea placeholder="Mensagem de teste do SeConectai Vendas." {...form.register('mensagem')} />
</FormField>
</form>
</ResponsiveDialog>
);
}
export default function WhatsappPage() {
const queryClient = useQueryClient();
const { data: instances, isLoading } = useQuery({
queryKey: ['tenant', 'whatsapp'],
queryFn: listWhatsappInstances,
});
const [connecting, setConnecting] = React.useState<WhatsappInstance | null>(null);
const [testing, setTesting] = React.useState<WhatsappInstance | null>(null);
const [removing, setRemoving] = React.useState<WhatsappInstance | null>(null);
const createMutation = useMutation({
mutationFn: criarWhatsappInstance,
onSuccess: (instance) => {
queryClient.invalidateQueries({ queryKey: ['tenant', 'whatsapp'] });
setConnecting(instance);
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível criar a instância.');
},
});
const removeMutation = useMutation({
mutationFn: (id: string) => removerWhatsappInstance(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tenant', 'whatsapp'] });
toast.success('Instância removida.');
setRemoving(null);
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível remover a instância.');
setRemoving(null);
},
});
const columns: DataTableColumn<WhatsappInstance>[] = [
{ id: 'instance_name', header: 'Instância', primary: true, cell: (row) => row.instance_name },
{
id: 'status',
header: 'Status',
primary: true,
cell: (row) => <Badge variant={STATUS_TONE[row.status]}>{STATUS_LABEL[row.status]}</Badge>,
},
{
id: 'numero_conectado',
header: 'Número conectado',
cell: (row) => row.numero_conectado ?? '—',
},
{ id: 'criado_em', header: 'Criada em', hideOnMobileCard: true, cell: (row) => formatDateTime(row.criado_em) },
];
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-foreground">WhatsApp</h1>
<p className="text-sm text-muted-foreground">Instâncias de envio de mensagens via Evolution API</p>
</div>
<Button loading={createMutation.isPending} onClick={() => createMutation.mutate()}>
<Plus /> Nova instância
</Button>
</div>
<DataTable
columns={columns}
data={instances ?? []}
keyField={(row) => row.id}
loading={isLoading}
emptyTitle="Nenhuma instância cadastrada"
emptyDescription="Crie uma instância para conectar um número de WhatsApp."
emptyAction={
<Button size="sm" loading={createMutation.isPending} onClick={() => createMutation.mutate()}>
Nova instância
</Button>
}
rowActions={(row) => (
<>
{row.status !== 'conectado' && (
<Button variant="ghost" size="sm" onClick={() => setConnecting(row)}>
Conectar
</Button>
)}
<Button variant="ghost" size="sm" onClick={() => setTesting(row)}>
Testar envio
</Button>
<Button variant="ghost" size="sm" onClick={() => setRemoving(row)}>
Remover
</Button>
</>
)}
/>
<ConnectInstanceDialog instance={connecting} onOpenChange={(open) => !open && setConnecting(null)} />
<TestSendDialog instance={testing} onOpenChange={(open) => !open && setTesting(null)} />
<ConfirmDialog
open={removing !== null}
onOpenChange={(open) => !open && setRemoving(null)}
title={`Remover instância "${removing?.instance_name}"?`}
description="Desconecta o número e apaga a instância na Evolution API — não é possível desfazer."
confirmLabel="Remover"
destructive
loading={removeMutation.isPending}
onConfirm={() => removing && removeMutation.mutate(removing.id)}
/>
</div>
);
}
+11
View File
@@ -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>
);
}
+65
View File
@@ -0,0 +1,65 @@
'use client';
import { useEffect, useState } from 'react';
import { apiClient, ApiError } from '@/lib/api/client';
interface HealthStatus {
status: 'ok' | 'error';
timestamp: string;
database: 'up' | 'down';
}
type DiagnosticoState =
| { phase: 'loading' }
| { phase: 'success'; data: HealthStatus }
| { phase: 'error'; message: string };
/**
* Rota temporária para validar a integração frontend↔backend de ponta a
* ponta (Etapa 11). Pode ser removida quando não fizer mais falta.
*/
export default function DiagnosticoPage() {
const [state, setState] = useState<DiagnosticoState>({ phase: 'loading' });
useEffect(() => {
apiClient
.get<HealthStatus>('/health')
.then((data) => setState({ phase: 'success', data }))
.catch((err: unknown) => {
const message = err instanceof ApiError ? err.message : 'Falha ao conectar com a API.';
setState({ phase: 'error', message });
});
}, []);
return (
<main className="flex min-h-screen flex-col items-center justify-center gap-4 p-8">
<h1 className="text-xl font-semibold text-neutral-950">Diagnóstico de conexão com a API</h1>
{state.phase === 'loading' && <p className="text-neutral-500">Consultando GET /health…</p>}
{state.phase === 'success' && (
<div className="tabular-nums rounded-lg border border-neutral-200 bg-surface p-4 text-sm">
<p>
Status:{' '}
<span className={state.data.status === 'ok' ? 'text-success' : 'text-danger'}>
{state.data.status}
</span>
</p>
<p>
Banco de dados:{' '}
<span className={state.data.database === 'up' ? 'text-success' : 'text-danger'}>
{state.data.database}
</span>
</p>
<p>Timestamp: {state.data.timestamp}</p>
</div>
)}
{state.phase === 'error' && (
<p className="rounded-lg border border-danger bg-surface p-4 text-sm text-danger">
{state.message}
</p>
)}
</main>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+129
View File
@@ -0,0 +1,129 @@
@import 'tailwindcss';
@import '../styles/tokens.css';
@import 'tw-animate-css';
@import 'shadcn/tailwind.css';
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive-foreground: var(--destructive-foreground);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
/*
* Ponte shadcn/ui → tokens do projeto (styles/tokens.css). Em vez da
* paleta oklch neutra que o `shadcn init` gera por padrão, cada variável
* que os componentes shadcn consomem aponta para os tokens definidos na
* Etapa 11 — assim customizar a cor de marca (--color-primary, dinâmica
* por tenant a partir da Etapa 13) muda os componentes automaticamente,
* sem precisar editar cada um.
*/
:root {
--background: var(--neutral-50);
--foreground: var(--neutral-950);
--card: var(--surface);
--card-foreground: var(--neutral-950);
--popover: var(--surface);
--popover-foreground: var(--neutral-950);
--primary: var(--color-primary);
--primary-foreground: #ffffff;
--secondary: var(--neutral-50);
--secondary-foreground: var(--neutral-950);
--muted: var(--neutral-50);
--muted-foreground: var(--neutral-500);
--accent: var(--neutral-50);
--accent-foreground: var(--neutral-950);
--destructive: var(--danger);
--destructive-foreground: #ffffff;
--border: var(--neutral-200);
--input: var(--neutral-200);
--ring: var(--color-primary);
--radius: 0.625rem;
--chart-1: var(--color-primary);
--chart-2: var(--success);
--chart-3: var(--warning);
--chart-4: var(--danger);
--chart-5: var(--neutral-500);
--sidebar: var(--surface);
--sidebar-foreground: var(--neutral-950);
--sidebar-primary: var(--color-primary);
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: var(--neutral-50);
--sidebar-accent-foreground: var(--neutral-950);
--sidebar-border: var(--neutral-200);
--sidebar-ring: var(--color-primary);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
/* Fallback de foco para elementos nativos que não passam por um
componente shadcn (esses já têm seu próprio focus-visible:ring). */
a:focus-visible,
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
[tabindex]:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
}
/* `prefers-reduced-motion` (Etapa 12, obrigatório) — aplicado globalmente
para cobrir qualquer animação/transição, independente da biblioteca. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
+33
View File
@@ -0,0 +1,33 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { Toaster } from '@/components/ui/sonner';
import { AuthProvider } from '@/lib/auth/auth-context';
import { QueryProvider } from '@/lib/query/query-provider';
const inter = Inter({
variable: '--font-inter',
subsets: ['latin'],
});
export const metadata: Metadata = {
title: 'SeConectai Vendas',
description: 'CRM/vendas multi-tenant SeConectai',
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="pt-BR">
<body className={`${inter.variable} font-sans antialiased`}>
<QueryProvider>
<AuthProvider>{children}</AuthProvider>
<Toaster position="top-center" />
</QueryProvider>
</body>
</html>
);
}
+209
View File
@@ -0,0 +1,209 @@
'use client';
import * as React from 'react';
import { useQuery } from '@tanstack/react-query';
import type { DateRange } from 'react-day-picker';
import {
ALERT_LOG_STATUSES,
listAlertLogs,
type AlertLog,
type AlertLogStatus,
} from '@/lib/api/alert-logs';
import { CANAIS_TEMPLATE, CANAL_TEMPLATE_LABEL, TIPO_TEMPLATE_LABEL } from '@/lib/api/alert-templates';
import type { CanalTemplate } from '@/lib/api/alert-templates';
import { formatDateTime } from '@/lib/format';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { DataTable, type DataTableColumn } from '@/components/ui/data-table';
import { DateRangePicker } from '@/components/ui/date-picker';
import { ResponsiveDialog } from '@/components/ui/responsive-dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
const STATUS_LABEL: Record<AlertLogStatus, string> = {
enviado: 'Enviado',
entregue: 'Entregue',
lido: 'Lido',
falhou: 'Falhou',
};
const STATUS_TONE: Record<AlertLogStatus, 'default' | 'destructive' | 'outline'> = {
enviado: 'outline',
entregue: 'default',
lido: 'default',
falhou: 'destructive',
};
const STATUS_OPTIONS: { value: AlertLogStatus | 'todos'; label: string }[] = [
{ value: 'todos', label: 'Todos os status' },
...ALERT_LOG_STATUSES.map((s) => ({ value: s, label: STATUS_LABEL[s] })),
];
const CANAL_OPTIONS: { value: CanalTemplate | 'todos'; label: string }[] = [
{ value: 'todos', label: 'Todos os canais' },
...CANAIS_TEMPLATE.map((c) => ({ value: c, label: CANAL_TEMPLATE_LABEL[c] })),
];
const LIMIT = 20;
export default function AlertLogsPage() {
const [canal, setCanal] = React.useState<CanalTemplate | 'todos'>('todos');
const [status, setStatus] = React.useState<AlertLogStatus | 'todos'>('todos');
const [periodo, setPeriodo] = React.useState<DateRange | undefined>();
const [page, setPage] = React.useState(1);
const [detail, setDetail] = React.useState<AlertLog | null>(null);
const { data, isLoading } = useQuery({
queryKey: [
'master',
'alert-logs',
{ canal, status, de: periodo?.from?.toISOString(), ate: periodo?.to?.toISOString(), page },
],
queryFn: () =>
listAlertLogs({
canal: canal === 'todos' ? undefined : canal,
status: status === 'todos' ? undefined : status,
data_inicio: periodo?.from?.toISOString(),
data_fim: periodo?.to?.toISOString(),
page,
limit: LIMIT,
}),
placeholderData: (prev) => prev,
});
const columns: DataTableColumn<AlertLog>[] = [
{
id: 'tipo',
header: 'Tipo',
primary: true,
cell: (row) => (row.template ? TIPO_TEMPLATE_LABEL[row.template.tipo] : '—'),
},
{ id: 'canal', header: 'Canal', primary: true, cell: (row) => CANAL_TEMPLATE_LABEL[row.canal] },
{
id: 'status',
header: 'Status',
cell: (row) => <Badge variant={STATUS_TONE[row.status]}>{STATUS_LABEL[row.status]}</Badge>,
},
{ id: 'enviado_em', header: 'Enviado em', cell: (row) => formatDateTime(row.enviado_em) },
];
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-xl font-semibold text-foreground">Logs de alerta</h1>
<p className="text-sm text-muted-foreground">Histórico de envios de WhatsApp/e-mail</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<Select
value={canal}
onValueChange={(value) => {
setCanal(value as CanalTemplate | 'todos');
setPage(1);
}}
>
<SelectTrigger className="w-full sm:w-44" aria-label="Filtrar por canal">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CANAL_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={status}
onValueChange={(value) => {
setStatus(value as AlertLogStatus | 'todos');
setPage(1);
}}
>
<SelectTrigger className="w-full sm:w-44" aria-label="Filtrar por status">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<DateRangePicker
value={periodo}
onChange={(range) => {
setPeriodo(range);
setPage(1);
}}
placeholder="Período"
className="w-full sm:w-64"
/>
</div>
<DataTable
columns={columns}
data={data?.data ?? []}
keyField={(row) => row.id}
loading={isLoading}
emptyTitle="Nenhum log encontrado"
emptyDescription="Ajuste os filtros de período/canal/status."
onRowClick={(row) => setDetail(row)}
rowActions={() => (
<Button variant="ghost" size="sm">
Ver detalhes
</Button>
)}
pagination={
data ? { page: data.meta.page, limit: data.meta.limit, total: data.meta.total, onPageChange: setPage } : undefined
}
/>
<ResponsiveDialog
open={detail !== null}
onOpenChange={(open) => !open && setDetail(null)}
title="Detalhe do envio"
description={detail ? formatDateTime(detail.enviado_em) : undefined}
>
{detail && (
<div className="flex flex-col gap-4 px-4 pb-4 sm:px-0 sm:pb-0">
<div className="flex flex-wrap gap-4 text-sm">
<div>
<p className="text-muted-foreground">Tipo</p>
<p className="font-medium text-foreground">
{detail.template ? TIPO_TEMPLATE_LABEL[detail.template.tipo] : '—'}
</p>
</div>
<div>
<p className="text-muted-foreground">Canal</p>
<p className="font-medium text-foreground">{CANAL_TEMPLATE_LABEL[detail.canal]}</p>
</div>
<div>
<p className="text-muted-foreground">Status</p>
<Badge variant={STATUS_TONE[detail.status]}>{STATUS_LABEL[detail.status]}</Badge>
</div>
</div>
<Separator />
<div>
<p className="mb-1 text-sm font-medium text-foreground">Conteúdo enviado</p>
<p className="rounded-lg border border-border bg-muted/50 p-3 text-sm whitespace-pre-wrap">
{detail.conteudo_enviado || '—'}
</p>
</div>
{detail.erro && (
<div>
<p className="mb-1 text-sm font-medium text-danger">Erro</p>
<p className="rounded-lg border border-danger/30 bg-danger/10 p-3 text-sm whitespace-pre-wrap text-danger">
{detail.erro}
</p>
</div>
)}
</div>
)}
</ResponsiveDialog>
</div>
);
}
@@ -0,0 +1,349 @@
'use client';
import * as React from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Plus } from 'lucide-react';
import {
CANAL_TEMPLATE_LABEL,
CANAIS_TEMPLATE,
PLACEHOLDERS_POR_TIPO,
TIPO_TEMPLATE_LABEL,
TIPOS_TEMPLATE,
createAlertTemplate,
deleteAlertTemplate,
listAlertTemplates,
updateAlertTemplate,
type AlertTemplate,
type CanalTemplate,
type TipoTemplate,
} from '@/lib/api/alert-templates';
import { alertTemplateSchema, type AlertTemplateFormValues } from '@/lib/validators/alert-templates';
import { ApiError } from '@/lib/api/client';
import { toast } from '@/lib/toast';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { DataTable, type DataTableColumn } from '@/components/ui/data-table';
import { FormField } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import { ResponsiveDialog } from '@/components/ui/responsive-dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { ConfirmDialog } from '@/components/domain/confirm-dialog';
const TIPO_OPTIONS: { value: TipoTemplate | 'todos'; label: string }[] = [
{ value: 'todos', label: 'Todos os tipos' },
...TIPOS_TEMPLATE.map((tipo) => ({ value: tipo, label: TIPO_TEMPLATE_LABEL[tipo] })),
];
const CANAL_OPTIONS: { value: CanalTemplate | 'todos'; label: string }[] = [
{ value: 'todos', label: 'Todos os canais' },
...CANAIS_TEMPLATE.map((canal) => ({ value: canal, label: CANAL_TEMPLATE_LABEL[canal] })),
];
const LIMIT = 20;
export default function AlertTemplatesPage() {
const queryClient = useQueryClient();
const [tipo, setTipo] = React.useState<TipoTemplate | 'todos'>('todos');
const [canal, setCanal] = React.useState<CanalTemplate | 'todos'>('todos');
const [page, setPage] = React.useState(1);
const [editing, setEditing] = React.useState<AlertTemplate | null>(null);
const [formOpen, setFormOpen] = React.useState(false);
const [deleting, setDeleting] = React.useState<AlertTemplate | null>(null);
const { data, isLoading } = useQuery({
queryKey: ['master', 'alert-templates', { tipo, canal, page }],
queryFn: () =>
listAlertTemplates({
tipo: tipo === 'todos' ? undefined : tipo,
canal: canal === 'todos' ? undefined : canal,
page,
limit: LIMIT,
}),
placeholderData: (prev) => prev,
});
const form = useForm<AlertTemplateFormValues>({ resolver: zodResolver(alertTemplateSchema) });
const corpoFieldRef = React.useRef<HTMLTextAreaElement | null>(null);
const { ref: corpoRegisterRef, ...corpoRegister } = form.register('corpo');
const watchedTipo = form.watch('tipo');
const watchedCanal = form.watch('canal');
function openCreate() {
setEditing(null);
form.reset({ tipo: 'mensalidade_tenant', canal: 'whatsapp', assunto: '', corpo: '', ativo: true });
setFormOpen(true);
}
function openEdit(template: AlertTemplate) {
setEditing(template);
form.reset({
tipo: template.tipo,
canal: template.canal,
assunto: template.assunto ?? '',
corpo: template.corpo,
ativo: template.ativo,
});
setFormOpen(true);
}
function insertPlaceholder(chave: string) {
const token = `{{${chave}}}`;
const el = corpoFieldRef.current;
const current = form.getValues('corpo') ?? '';
if (!el) {
form.setValue('corpo', current + token, { shouldValidate: true });
return;
}
const start = el.selectionStart ?? current.length;
const end = el.selectionEnd ?? current.length;
const next = current.slice(0, start) + token + current.slice(end);
form.setValue('corpo', next, { shouldValidate: true });
requestAnimationFrame(() => {
el.focus();
const pos = start + token.length;
el.setSelectionRange(pos, pos);
});
}
const saveMutation = useMutation({
mutationFn: (values: AlertTemplateFormValues) => {
const payload = { ...values, assunto: values.canal === 'email' ? values.assunto : undefined };
return editing ? updateAlertTemplate(editing.id, payload) : createAlertTemplate(payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['master', 'alert-templates'] });
toast.success(editing ? 'Template atualizado.' : 'Template criado.');
setFormOpen(false);
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível salvar o template.');
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteAlertTemplate(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['master', 'alert-templates'] });
toast.success('Template desativado.');
setDeleting(null);
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível desativar o template.');
setDeleting(null);
},
});
const columns: DataTableColumn<AlertTemplate>[] = [
{ id: 'tipo', header: 'Tipo', primary: true, cell: (row) => TIPO_TEMPLATE_LABEL[row.tipo] },
{ id: 'canal', header: 'Canal', primary: true, cell: (row) => CANAL_TEMPLATE_LABEL[row.canal] },
{
id: 'corpo',
header: 'Prévia',
cell: (row) => <span className="line-clamp-1 text-muted-foreground">{row.corpo}</span>,
},
{
id: 'status',
header: 'Status',
cell: (row) => <Badge variant={row.ativo ? 'default' : 'outline'}>{row.ativo ? 'Ativo' : 'Inativo'}</Badge>,
},
];
const placeholders = watchedTipo ? PLACEHOLDERS_POR_TIPO[watchedTipo] : [];
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-foreground">Templates de alerta</h1>
<p className="text-sm text-muted-foreground">Padrões de mensagem da plataforma</p>
</div>
<Button onClick={openCreate}>
<Plus /> Novo template
</Button>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Select
value={tipo}
onValueChange={(value) => {
setTipo(value as TipoTemplate | 'todos');
setPage(1);
}}
>
<SelectTrigger className="w-full sm:w-56" aria-label="Filtrar por tipo">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIPO_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={canal}
onValueChange={(value) => {
setCanal(value as CanalTemplate | 'todos');
setPage(1);
}}
>
<SelectTrigger className="w-full sm:w-44" aria-label="Filtrar por canal">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CANAL_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DataTable
columns={columns}
data={data?.data ?? []}
keyField={(row) => row.id}
loading={isLoading}
emptyTitle="Nenhum template cadastrado"
emptyAction={
<Button size="sm" onClick={openCreate}>
Novo template
</Button>
}
rowActions={(row) => (
<>
<Button variant="ghost" size="sm" onClick={() => openEdit(row)}>
Editar
</Button>
<Button variant="ghost" size="sm" onClick={() => setDeleting(row)}>
Desativar
</Button>
</>
)}
pagination={
data ? { page: data.meta.page, limit: data.meta.limit, total: data.meta.total, onPageChange: setPage } : undefined
}
/>
<ResponsiveDialog
open={formOpen}
onOpenChange={setFormOpen}
title={editing ? 'Editar template' : 'Novo template'}
className="sm:max-w-2xl"
footer={
<>
<Button variant="outline" onClick={() => setFormOpen(false)}>
Cancelar
</Button>
<Button
loading={saveMutation.isPending}
onClick={form.handleSubmit((values) => saveMutation.mutate(values))}
>
Salvar
</Button>
</>
}
>
<form
className="flex flex-col gap-4 px-4 pb-4 sm:px-0 sm:pb-0"
onSubmit={form.handleSubmit((values) => saveMutation.mutate(values))}
>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Tipo" id="tipo" error={form.formState.errors.tipo?.message} required>
<Select
value={watchedTipo}
onValueChange={(value) => form.setValue('tipo', value as TipoTemplate, { shouldValidate: true })}
>
<SelectTrigger id="tipo" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIPOS_TEMPLATE.map((t) => (
<SelectItem key={t} value={t}>
{TIPO_TEMPLATE_LABEL[t]}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<FormField label="Canal" id="canal" error={form.formState.errors.canal?.message} required>
<Select
value={watchedCanal}
onValueChange={(value) => form.setValue('canal', value as CanalTemplate, { shouldValidate: true })}
>
<SelectTrigger id="canal" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CANAIS_TEMPLATE.map((c) => (
<SelectItem key={c} value={c}>
{CANAL_TEMPLATE_LABEL[c]}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
</div>
{watchedCanal === 'email' && (
<FormField label="Assunto" error={form.formState.errors.assunto?.message} required>
<Input {...form.register('assunto')} />
</FormField>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[1fr_220px]">
<FormField label="Corpo da mensagem" error={form.formState.errors.corpo?.message} required>
<Textarea
rows={8}
{...corpoRegister}
ref={(el) => {
corpoRegisterRef(el);
corpoFieldRef.current = el;
}}
/>
</FormField>
<div className="flex flex-col gap-2">
<p className="text-xs font-medium text-muted-foreground">Placeholders disponíveis</p>
<div className="flex flex-col gap-1.5 overflow-y-auto rounded-lg border border-border p-2 sm:max-h-56">
{placeholders.map((p) => (
<button
key={p.chave}
type="button"
onClick={() => insertPlaceholder(p.chave)}
title={p.descricao}
className="rounded-md px-2 py-1.5 text-left text-xs hover:bg-muted"
>
<code className="font-mono font-medium text-primary-on-light">{`{{${p.chave}}}`}</code>
<p className="text-muted-foreground">{p.descricao}</p>
</button>
))}
</div>
</div>
</div>
</form>
</ResponsiveDialog>
<ConfirmDialog
open={deleting !== null}
onOpenChange={(open) => !open && setDeleting(null)}
title="Desativar template?"
description="O template deixa de ser usado nos disparos automáticos, mas continua no histórico."
confirmLabel="Desativar"
destructive
loading={deleteMutation.isPending}
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
/>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
'use client';
import * as React from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { KeyRound } from 'lucide-react';
import { listMasterAdmins } from '@/lib/api/master-admins';
import { apiClient, ApiError } from '@/lib/api/client';
import { useAuth } from '@/lib/auth/auth-context';
import { formatDateTime } from '@/lib/format';
import { toast } from '@/lib/toast';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
/**
* Não existe endpoint de perfil/senha dedicado para o master admin no
* backend (só `GET /auth/master/admins`, somente leitura, e o fluxo
* genérico `forgot`/`reset` por e-mail — ver observações da Etapa 15 no
* controle de progresso). Nome/e-mail aqui são só exibidos (resolvidos
* filtrando a lista completa pelo `sub` do JWT), e a troca de senha
* reaproveita `POST /auth/password/forgot`, o mesmo fluxo já usado em
* `/esqueci-senha` — não uma troca "senha atual → nova senha" direta na
* tela, porque essa rota não existe.
*/
export default function MasterConfiguracoesPage() {
const { user } = useAuth();
const { data: admins, isLoading } = useQuery({
queryKey: ['master', 'admins'],
queryFn: listMasterAdmins,
});
const me = admins?.find((admin) => admin.id === user?.sub);
const resetMutation = useMutation({
mutationFn: () =>
apiClient.post('/auth/password/forgot', { email: me!.email, usuario_tipo: 'master' }),
onSuccess: () => {
toast.success('Se o e-mail existir, as instruções de redefinição foram enviadas.');
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível enviar o e-mail.');
},
});
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-xl font-semibold text-foreground">Configurações</h1>
<p className="text-sm text-muted-foreground">Dados da sua conta de administrador</p>
</div>
<Card className="max-w-xl">
<CardHeader>
<CardTitle>Meus dados</CardTitle>
<CardDescription>Somente leitura nesta versão.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{isLoading ? (
<div className="flex flex-col gap-2">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-5 w-64" />
</div>
) : me ? (
<dl className="flex flex-col gap-3 text-sm">
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">Nome</dt>
<dd className="font-medium text-foreground">{me.nome}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">E-mail</dt>
<dd className="font-medium text-foreground">{me.email}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">Último login</dt>
<dd className="text-foreground">{formatDateTime(me.ultimo_login)}</dd>
</div>
</dl>
) : (
<p className="text-sm text-muted-foreground">Não foi possível identificar sua conta.</p>
)}
</CardContent>
</Card>
<Card className="max-w-xl">
<CardHeader>
<CardTitle>Senha</CardTitle>
<CardDescription>
Enviaremos um e-mail com um link para você definir uma nova senha.
</CardDescription>
</CardHeader>
<CardContent>
<Button
variant="outline"
loading={resetMutation.isPending}
disabled={!me}
onClick={() => resetMutation.mutate()}
>
<KeyRound /> Enviar e-mail de redefinição de senha
</Button>
</CardContent>
</Card>
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { Building2, CheckCircle2, CircleDollarSign, Clock, TrendingUp, XCircle } from 'lucide-react';
import { getMasterDashboard } from '@/lib/api/dashboard';
import { formatCurrency } from '@/lib/format';
import { StatCard } from '@/components/domain/stat-card';
/**
* Sem gráfico de evolução (tarefa 1 da Etapa 15, "se o backend expuser dado
* suficiente"): `GET /master/dashboard` (`dashboard.service.ts#resumo`) só
* devolve o snapshot atual — nenhuma série histórica de faturamento/novos
* tenants. Ficou só com os cards de resumo, como a própria tarefa já
* previa para esse cenário.
*/
export default function MasterDashboardPage() {
const { data, isLoading } = useQuery({
queryKey: ['master', 'dashboard'],
queryFn: getMasterDashboard,
});
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-xl font-semibold text-foreground">Dashboard</h1>
<p className="text-sm text-muted-foreground">Visão geral da plataforma</p>
</div>
<div>
<h2 className="mb-3 text-sm font-medium text-muted-foreground">Tenants por status</h2>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
<StatCard label="Trial" value={String(data?.tenants_por_status.trial ?? 0)} icon={Clock} loading={isLoading} />
<StatCard
label="Ativos"
value={String(data?.tenants_por_status.ativo ?? 0)}
icon={CheckCircle2}
tone="success"
loading={isLoading}
/>
<StatCard
label="Inadimplentes"
value={String(data?.tenants_por_status.inadimplente ?? 0)}
icon={XCircle}
tone="danger"
loading={isLoading}
/>
<StatCard
label="Suspensos"
value={String(data?.tenants_por_status.suspenso ?? 0)}
icon={XCircle}
tone="warning"
loading={isLoading}
/>
<StatCard
label="Cancelados"
value={String(data?.tenants_por_status.cancelado ?? 0)}
icon={Building2}
loading={isLoading}
/>
</div>
</div>
<div>
<h2 className="mb-3 text-sm font-medium text-muted-foreground">Financeiro</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatCard label="MRR" value={formatCurrency(data?.mrr)} icon={TrendingUp} loading={isLoading} />
<StatCard
label="Recebido no mês"
value={formatCurrency(data?.total_recebido_mes)}
icon={CircleDollarSign}
tone="success"
loading={isLoading}
/>
<StatCard
label="Em aberto"
value={formatCurrency(data?.total_em_aberto)}
icon={CircleDollarSign}
tone="warning"
loading={isLoading}
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,223 @@
'use client';
import * as React from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Plug, Plus } from 'lucide-react';
import {
createEvolutionConfig,
deleteEvolutionConfig,
listEvolutionConfigs,
testarConexaoEvolution,
updateEvolutionConfig,
type EvolutionConfig,
} from '@/lib/api/evolution-config';
import {
evolutionConfigSchema,
type EvolutionConfigFormValues,
} from '@/lib/validators/evolution-config';
import { ApiError } from '@/lib/api/client';
import { formatDate } from '@/lib/format';
import { toast } from '@/lib/toast';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { DataTable, type DataTableColumn } from '@/components/ui/data-table';
import { FormField } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import { ResponsiveDialog } from '@/components/ui/responsive-dialog';
import { ConfirmDialog } from '@/components/domain/confirm-dialog';
export default function EvolutionConfigPage() {
const queryClient = useQueryClient();
const { data: configs, isLoading } = useQuery({
queryKey: ['master', 'evolution-config'],
queryFn: listEvolutionConfigs,
});
const [editing, setEditing] = React.useState<EvolutionConfig | null>(null);
const [formOpen, setFormOpen] = React.useState(false);
const [deleting, setDeleting] = React.useState<EvolutionConfig | null>(null);
const [testingId, setTestingId] = React.useState<string | null>(null);
const form = useForm<EvolutionConfigFormValues>({ resolver: zodResolver(evolutionConfigSchema) });
function openCreate() {
setEditing(null);
form.reset({ nome: '', base_url: '', api_key: '', versao: '' });
setFormOpen(true);
}
function openEdit(config: EvolutionConfig) {
setEditing(config);
form.reset({ nome: config.nome, base_url: config.base_url, api_key: '', versao: config.versao });
setFormOpen(true);
}
const saveMutation = useMutation({
mutationFn: (values: EvolutionConfigFormValues) => {
if (editing) {
return updateEvolutionConfig(editing.id, {
nome: values.nome,
base_url: values.base_url,
versao: values.versao || undefined,
...(values.api_key ? { api_key: values.api_key } : {}),
});
}
return createEvolutionConfig({
nome: values.nome,
base_url: values.base_url,
api_key: values.api_key,
versao: values.versao || undefined,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['master', 'evolution-config'] });
toast.success(editing ? 'Configuração atualizada.' : 'Configuração criada.');
setFormOpen(false);
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível salvar a configuração.');
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteEvolutionConfig(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['master', 'evolution-config'] });
toast.success('Configuração excluída.');
setDeleting(null);
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível excluir a configuração.');
setDeleting(null);
},
});
const testMutation = useMutation({
mutationFn: (id: string) => testarConexaoEvolution(id),
onMutate: (id) => setTestingId(id),
onSuccess: (result) => {
toast.success(`Conexão OK (status ${result.status_http}${result.versao ? `, versão ${result.versao}` : ''}).`);
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Falha ao testar a conexão.');
},
onSettled: () => setTestingId(null),
});
const columns: DataTableColumn<EvolutionConfig>[] = [
{ id: 'nome', header: 'Nome', primary: true, cell: (row) => row.nome },
{ id: 'base_url', header: 'URL base', cell: (row) => row.base_url },
{ id: 'api_key', header: 'API key', cell: (row) => <code className="text-xs">{row.api_key}</code> },
{ id: 'versao', header: 'Versão', cell: (row) => row.versao },
{
id: 'status',
header: 'Status',
primary: true,
cell: (row) => <Badge variant={row.ativo ? 'default' : 'outline'}>{row.ativo ? 'Ativo' : 'Inativo'}</Badge>,
},
{ id: 'criado_em', header: 'Criado em', hideOnMobileCard: true, cell: (row) => formatDate(row.criado_em) },
];
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-foreground">Evolution API</h1>
<p className="text-sm text-muted-foreground">Conexão usada para disparar mensagens de WhatsApp</p>
</div>
<Button onClick={openCreate}>
<Plus /> Nova configuração
</Button>
</div>
<DataTable
columns={columns}
data={configs ?? []}
keyField={(row) => row.id}
loading={isLoading}
emptyTitle="Nenhuma configuração cadastrada"
emptyDescription="Cadastre a conexão com a Evolution API para habilitar o envio de WhatsApp."
emptyAction={
<Button size="sm" onClick={openCreate}>
Nova configuração
</Button>
}
rowActions={(row) => (
<>
<Button
variant="ghost"
size="sm"
loading={testingId === row.id}
onClick={() => testMutation.mutate(row.id)}
>
<Plug /> Testar conexão
</Button>
<Button variant="ghost" size="sm" onClick={() => openEdit(row)}>
Editar
</Button>
<Button variant="ghost" size="sm" onClick={() => setDeleting(row)}>
Excluir
</Button>
</>
)}
/>
<ResponsiveDialog
open={formOpen}
onOpenChange={setFormOpen}
title={editing ? 'Editar configuração' : 'Nova configuração'}
footer={
<>
<Button variant="outline" onClick={() => setFormOpen(false)}>
Cancelar
</Button>
<Button
loading={saveMutation.isPending}
onClick={form.handleSubmit((values) => saveMutation.mutate(values))}
>
Salvar
</Button>
</>
}
>
<form
className="flex flex-col gap-4 px-4 pb-4 sm:px-0 sm:pb-0"
onSubmit={form.handleSubmit((values) => saveMutation.mutate(values))}
>
<FormField label="Nome" error={form.formState.errors.nome?.message} required>
<Input {...form.register('nome')} />
</FormField>
<FormField label="URL base" error={form.formState.errors.base_url?.message} required>
<Input placeholder="https://evolution.exemplo.com" {...form.register('base_url')} />
</FormField>
<FormField
label="API key"
error={form.formState.errors.api_key?.message}
helpText={editing ? 'Deixe em branco para manter a chave atual' : undefined}
required={!editing}
>
<Input type="password" autoComplete="off" {...form.register('api_key')} />
</FormField>
<FormField label="Versão" error={form.formState.errors.versao?.message}>
<Input placeholder="2.3" {...form.register('versao')} />
</FormField>
</form>
</ResponsiveDialog>
<ConfirmDialog
open={deleting !== null}
onOpenChange={(open) => !open && setDeleting(null)}
title={`Excluir "${deleting?.nome}"?`}
description="Só é possível excluir se nenhuma instância de WhatsApp estiver usando esta configuração."
confirmLabel="Excluir"
destructive
loading={deleteMutation.isPending}
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
/>
</div>
);
}
+218
View File
@@ -0,0 +1,218 @@
'use client';
import * as React from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { RefreshCw, Zap } from 'lucide-react';
import {
atualizarFaturasAtrasadas,
gerarFaturasMes,
listFaturas,
marcarFaturaPaga,
type FaturaListItem,
type FaturaStatus,
} from '@/lib/api/faturas';
import { listTenants } from '@/lib/api/tenants';
import { ApiError } from '@/lib/api/client';
import { formatCurrency, formatDate } from '@/lib/format';
import { toast } from '@/lib/toast';
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import { DataTable, type DataTableColumn } from '@/components/ui/data-table';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { StatusPill } from '@/components/ui/status-pill';
import { ConfirmDialog } from '@/components/domain/confirm-dialog';
const STATUS_OPTIONS: { value: FaturaStatus | 'todos'; label: string }[] = [
{ value: 'todos', label: 'Todos os status' },
{ value: 'pendente', label: 'Pendente' },
{ value: 'pago', label: 'Pago' },
{ value: 'atrasado', label: 'Atrasado' },
{ value: 'cancelado', label: 'Cancelado' },
];
const LIMIT = 20;
export default function FaturasPage() {
const queryClient = useQueryClient();
const [status, setStatus] = React.useState<FaturaStatus | 'todos'>('todos');
const [tenantId, setTenantId] = React.useState<string>('');
const [referencia, setReferencia] = React.useState('');
const [page, setPage] = React.useState(1);
const [gerarOpen, setGerarOpen] = React.useState(false);
const [atualizarOpen, setAtualizarOpen] = React.useState(false);
const { data: tenantsOptions } = useQuery({
queryKey: ['master', 'tenants', 'combobox'],
queryFn: () => listTenants({ limit: 100 }),
});
const { data, isLoading } = useQuery({
queryKey: ['master', 'faturas', { status, tenantId, referencia, page }],
queryFn: () =>
listFaturas({
status: status === 'todos' ? undefined : status,
tenant_id: tenantId || undefined,
referencia: referencia || undefined,
page,
limit: LIMIT,
}),
placeholderData: (prev) => prev,
});
const marcarPagoMutation = useMutation({
mutationFn: (id: string) => marcarFaturaPaga(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['master', 'faturas'] });
queryClient.invalidateQueries({ queryKey: ['master', 'dashboard'] });
toast.success('Fatura marcada como paga.');
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível marcar a fatura como paga.');
},
});
const gerarMutation = useMutation({
mutationFn: () => gerarFaturasMes(),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['master', 'faturas'] });
toast.success(`${result.criadas} fatura(s) geradas para ${result.referencia} (${result.ignoradas} já existiam).`);
setGerarOpen(false);
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível gerar as faturas do mês.');
},
});
const atualizarMutation = useMutation({
mutationFn: () => atualizarFaturasAtrasadas(),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['master', 'faturas'] });
queryClient.invalidateQueries({ queryKey: ['master', 'tenants'] });
toast.success(`${result.atualizadas} fatura(s) marcadas como atrasadas (${result.tenants_afetados} tenant(s) afetados).`);
setAtualizarOpen(false);
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível atualizar as faturas atrasadas.');
},
});
const columns: DataTableColumn<FaturaListItem>[] = [
{ id: 'tenant', header: 'Tenant', primary: true, cell: (row) => row.tenant.razao_social },
{ id: 'referencia', header: 'Referência', cell: (row) => row.referencia },
{ id: 'valor', header: 'Valor', cell: (row) => formatCurrency(row.valor) },
{ id: 'vencimento', header: 'Vencimento', cell: (row) => formatDate(row.vencimento) },
{ id: 'status', header: 'Status', primary: true, cell: (row) => <StatusPill status={row.status} /> },
];
const tenantComboboxOptions = (tenantsOptions?.data ?? []).map((t) => ({
value: t.id,
label: t.nome_fantasia || t.razao_social,
}));
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-foreground">Faturas</h1>
<p className="text-sm text-muted-foreground">Mensalidades cobradas dos tenants</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setAtualizarOpen(true)}>
<RefreshCw /> Atualizar atrasadas
</Button>
<Button onClick={() => setGerarOpen(true)}>
<Zap /> Gerar faturas do mês
</Button>
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<Select
value={status}
onValueChange={(value) => {
setStatus(value as FaturaStatus | 'todos');
setPage(1);
}}
>
<SelectTrigger className="w-full sm:w-48" aria-label="Filtrar por status">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Combobox
options={[{ value: '', label: 'Todos os tenants' }, ...tenantComboboxOptions]}
value={tenantId}
onChange={(value) => {
setTenantId(value);
setPage(1);
}}
placeholder="Todos os tenants"
searchPlaceholder="Buscar tenant..."
className="w-full sm:w-56"
/>
<Input
placeholder="Referência (aaaa-mm)"
value={referencia}
onChange={(e) => {
setReferencia(e.target.value);
setPage(1);
}}
className="w-full sm:w-40"
/>
</div>
<DataTable
columns={columns}
data={data?.data ?? []}
keyField={(row) => row.id}
loading={isLoading}
emptyTitle="Nenhuma fatura encontrada"
emptyDescription="Ajuste os filtros ou gere as faturas do mês."
rowActions={(row) =>
row.status !== 'pago' && row.status !== 'cancelado' ? (
<Button
variant="ghost"
size="sm"
loading={marcarPagoMutation.isPending && marcarPagoMutation.variables === row.id}
onClick={() => marcarPagoMutation.mutate(row.id)}
>
Marcar como paga
</Button>
) : null
}
pagination={
data ? { page: data.meta.page, limit: data.meta.limit, total: data.meta.total, onPageChange: setPage } : undefined
}
/>
<ConfirmDialog
open={gerarOpen}
onOpenChange={setGerarOpen}
title="Gerar faturas do mês"
description="Cria uma fatura pendente para cada tenant que ainda não tem uma fatura no mês corrente. Tenants que já têm fatura para o mês não são duplicados."
confirmLabel="Gerar faturas"
loading={gerarMutation.isPending}
onConfirm={() => gerarMutation.mutate()}
/>
<ConfirmDialog
open={atualizarOpen}
onOpenChange={setAtualizarOpen}
title="Atualizar faturas atrasadas"
description="Marca como atrasada toda fatura pendente vencida, e move para inadimplente os tenants ativos afetados."
confirmLabel="Atualizar"
loading={atualizarMutation.isPending}
onConfirm={() => atualizarMutation.mutate()}
/>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { cookies } from 'next/headers';
import { MasterShell } from '@/components/layout/master-shell';
import { SIDEBAR_COOKIE, readSidebarCollapsedCookie } from '@/lib/layout/sidebar-cookie';
export default async function MasterLayout({ children }: { children: React.ReactNode }) {
const cookieStore = await cookies();
const collapsed = readSidebarCollapsedCookie(cookieStore.get(SIDEBAR_COOKIE)?.value);
return <MasterShell initialCollapsed={collapsed}>{children}</MasterShell>;
}
+248
View File
@@ -0,0 +1,248 @@
'use client';
import * as React from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Plus } from 'lucide-react';
import { createPlano, deletePlano, listPlanos, updatePlano, type Plano } from '@/lib/api/planos';
import { planoSchema, type PlanoFormValues } from '@/lib/validators/planos';
import { ApiError } from '@/lib/api/client';
import { formatCurrency } from '@/lib/format';
import { toast } from '@/lib/toast';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { DataTable, type DataTableColumn } from '@/components/ui/data-table';
import { FormField } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import { ResponsiveDialog } from '@/components/ui/responsive-dialog';
import { Textarea } from '@/components/ui/textarea';
import { ConfirmDialog } from '@/components/domain/confirm-dialog';
/** `<input type="number">` vazio deve virar `undefined` (ilimitado), não `NaN`/`0`. */
function toOptionalInt(value: string): number | undefined {
return value === '' ? undefined : Number(value);
}
export default function PlanosPage() {
const queryClient = useQueryClient();
const { data: planos, isLoading } = useQuery({
queryKey: ['master', 'planos'],
queryFn: () => listPlanos(),
});
const [editing, setEditing] = React.useState<Plano | null>(null);
const [formOpen, setFormOpen] = React.useState(false);
const [deleting, setDeleting] = React.useState<Plano | null>(null);
const form = useForm<PlanoFormValues>({ resolver: zodResolver(planoSchema) });
function openCreate() {
setEditing(null);
form.reset({
nome: '',
descricao: '',
valor_mensal: 0,
limite_customers: undefined,
limite_usuarios: undefined,
limite_instancias_whatsapp: undefined,
});
setFormOpen(true);
}
function openEdit(plano: Plano) {
setEditing(plano);
form.reset({
nome: plano.nome,
descricao: plano.descricao ?? '',
valor_mensal: Number(plano.valor_mensal),
limite_customers: plano.limite_customers ?? undefined,
limite_usuarios: plano.limite_usuarios ?? undefined,
limite_instancias_whatsapp: plano.limite_instancias_whatsapp,
});
setFormOpen(true);
}
const saveMutation = useMutation({
mutationFn: (values: PlanoFormValues) =>
editing ? updatePlano(editing.id, values) : createPlano(values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['master', 'planos'] });
toast.success(editing ? 'Plano atualizado.' : 'Plano criado.');
setFormOpen(false);
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível salvar o plano.');
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deletePlano(id),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['master', 'planos'] });
toast.success(
result.deletado ? 'Plano excluído.' : 'Plano em uso por tenant(s) — desativado em vez de excluído.',
);
setDeleting(null);
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível excluir o plano.');
setDeleting(null);
},
});
const columns: DataTableColumn<Plano>[] = [
{ id: 'nome', header: 'Plano', primary: true, cell: (row) => row.nome },
{
id: 'valor',
header: 'Valor mensal',
cell: (row) => formatCurrency(row.valor_mensal),
},
{
id: 'limites',
header: 'Limites',
cell: (row) => (
<span className="text-muted-foreground">
{row.limite_customers ?? '∞'} clientes · {row.limite_usuarios ?? '∞'} usuários ·{' '}
{row.limite_instancias_whatsapp} WhatsApp
</span>
),
},
{
id: 'status',
header: 'Status',
primary: true,
cell: (row) => (
<Badge variant={row.ativo ? 'default' : 'outline'}>{row.ativo ? 'Ativo' : 'Inativo'}</Badge>
),
},
];
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-foreground">Planos</h1>
<p className="text-sm text-muted-foreground">Planos de assinatura oferecidos aos tenants</p>
</div>
<Button onClick={openCreate}>
<Plus /> Novo plano
</Button>
</div>
<DataTable
columns={columns}
data={planos ?? []}
keyField={(row) => row.id}
loading={isLoading}
emptyTitle="Nenhum plano cadastrado"
emptyDescription="Crie o primeiro plano para poder cadastrar tenants."
emptyAction={
<Button size="sm" onClick={openCreate}>
Novo plano
</Button>
}
rowActions={(row) => (
<>
<Button variant="ghost" size="sm" onClick={() => openEdit(row)}>
Editar
</Button>
<Button variant="ghost" size="sm" onClick={() => setDeleting(row)}>
Excluir
</Button>
</>
)}
/>
<ResponsiveDialog
open={formOpen}
onOpenChange={setFormOpen}
title={editing ? 'Editar plano' : 'Novo plano'}
footer={
<>
<Button variant="outline" onClick={() => setFormOpen(false)}>
Cancelar
</Button>
<Button
loading={saveMutation.isPending}
onClick={form.handleSubmit((values) => saveMutation.mutate(values))}
>
Salvar
</Button>
</>
}
>
<form
className="flex flex-col gap-4 px-4 pb-4 sm:px-0 sm:pb-0"
onSubmit={form.handleSubmit((values) => saveMutation.mutate(values))}
>
<FormField label="Nome" error={form.formState.errors.nome?.message} required>
<Input {...form.register('nome')} />
</FormField>
<FormField label="Descrição" error={form.formState.errors.descricao?.message}>
<Textarea rows={2} {...form.register('descricao')} />
</FormField>
<FormField
label="Valor mensal (R$)"
error={form.formState.errors.valor_mensal?.message}
required
>
<Input
type="number"
step="0.01"
min="0"
{...form.register('valor_mensal', { valueAsNumber: true })}
/>
</FormField>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField
label="Limite de clientes"
helpText="Vazio = ilimitado"
error={form.formState.errors.limite_customers?.message}
>
<Input
type="number"
min="1"
{...form.register('limite_customers', { setValueAs: toOptionalInt })}
/>
</FormField>
<FormField
label="Limite de usuários"
helpText="Vazio = ilimitado"
error={form.formState.errors.limite_usuarios?.message}
>
<Input
type="number"
min="1"
{...form.register('limite_usuarios', { setValueAs: toOptionalInt })}
/>
</FormField>
<FormField
label="Instâncias WhatsApp"
error={form.formState.errors.limite_instancias_whatsapp?.message}
>
<Input
type="number"
min="1"
{...form.register('limite_instancias_whatsapp', { setValueAs: toOptionalInt })}
/>
</FormField>
</div>
</form>
</ResponsiveDialog>
<ConfirmDialog
open={deleting !== null}
onOpenChange={(open) => !open && setDeleting(null)}
title={`Excluir "${deleting?.nome}"?`}
description="Se este plano estiver em uso por algum tenant não cancelado, ele será desativado em vez de excluído."
confirmLabel="Excluir"
destructive
loading={deleteMutation.isPending}
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
/>
</div>
);
}
+271
View File
@@ -0,0 +1,271 @@
'use client';
import * as React from 'react';
import { useParams } from 'next/navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Package, ShoppingCart, Users } from 'lucide-react';
import { getTenant, updateTenant, type TenantDetail } from '@/lib/api/tenants';
import { listPlanos } from '@/lib/api/planos';
import { marcarFaturaPaga } from '@/lib/api/faturas';
import type { Fatura } from '@/lib/api/faturas';
import { updateTenantSchema, type UpdateTenantFormValues } from '@/lib/validators/tenants';
import { ApiError } from '@/lib/api/client';
import { formatCurrency, formatDate } from '@/lib/format';
import { toast } from '@/lib/toast';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { DataTable, type DataTableColumn } from '@/components/ui/data-table';
import { FormField } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { StatusPill } from '@/components/ui/status-pill';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { StatCard } from '@/components/domain/stat-card';
import { TenantStatusDialog } from '@/components/domain/tenant-status-dialog';
export default function TenantDetailPage() {
const params = useParams<{ id: string }>();
const tenantId = params.id;
const queryClient = useQueryClient();
const [statusDialogOpen, setStatusDialogOpen] = React.useState(false);
const { data: tenant, isLoading } = useQuery({
queryKey: ['master', 'tenants', tenantId],
queryFn: () => getTenant(tenantId),
});
if (isLoading || !tenant) {
return (
<div className="flex flex-col gap-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-64 w-full" />
</div>
);
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<div className="flex items-center gap-3">
<h1 className="text-xl font-semibold text-foreground">
{tenant.nome_fantasia || tenant.razao_social}
</h1>
<StatusPill status={tenant.status} />
</div>
<p className="text-sm text-muted-foreground">{tenant.email_contato}</p>
</div>
<Button variant="outline" onClick={() => setStatusDialogOpen(true)}>
Mudar status
</Button>
</div>
<Tabs defaultValue="dados">
<TabsList>
<TabsTrigger value="dados">Dados cadastrais</TabsTrigger>
<TabsTrigger value="faturas">Faturas</TabsTrigger>
<TabsTrigger value="uso">Uso</TabsTrigger>
</TabsList>
<TabsContent value="dados" className="pt-4">
<DadosCadastraisForm tenantId={tenantId} tenant={tenant} />
</TabsContent>
<TabsContent value="faturas" className="pt-4">
<FaturasTab faturas={tenant.faturas} tenantId={tenantId} />
</TabsContent>
<TabsContent value="uso" className="pt-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatCard label="Usuários" value={String(tenant._count.usuarios)} icon={Users} />
<StatCard
label="Clientes"
value={`${tenant._count.customers}${tenant.plano.limite_customers ? ` / ${tenant.plano.limite_customers}` : ''}`}
icon={Package}
/>
<StatCard label="Vendas" value={String(tenant._count.vendas)} icon={ShoppingCart} />
</div>
</TabsContent>
</Tabs>
<TenantStatusDialog
tenant={statusDialogOpen ? { id: tenant.id, razao_social: tenant.razao_social, status: tenant.status } : null}
onOpenChange={setStatusDialogOpen}
onSuccess={() => queryClient.invalidateQueries({ queryKey: ['master', 'tenants', tenantId] })}
/>
</div>
);
}
function DadosCadastraisForm({ tenantId, tenant }: { tenantId: string; tenant: TenantDetail }) {
const queryClient = useQueryClient();
const { data: planos } = useQuery({
queryKey: ['master', 'planos', { ativo: true }],
queryFn: () => listPlanos({ ativo: true }),
});
const form = useForm<UpdateTenantFormValues>({
resolver: zodResolver(updateTenantSchema),
defaultValues: {
razao_social: tenant.razao_social,
nome_fantasia: tenant.nome_fantasia ?? '',
documento: tenant.documento ?? '',
email_contato: tenant.email_contato,
telefone: tenant.telefone ?? '',
plano_id: tenant.plano_id,
dia_vencimento_mensalidade: tenant.dia_vencimento_mensalidade,
},
});
const mutation = useMutation({
mutationFn: (values: UpdateTenantFormValues) =>
updateTenant(tenantId, {
...values,
nome_fantasia: values.nome_fantasia || undefined,
documento: values.documento || undefined,
telefone: values.telefone || undefined,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['master', 'tenants', tenantId] });
queryClient.invalidateQueries({ queryKey: ['master', 'tenants'] });
toast.success('Dados do tenant atualizados.');
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível salvar as alterações.');
},
});
return (
<Card className="max-w-2xl">
<CardHeader>
<CardTitle>Dados cadastrais</CardTitle>
</CardHeader>
<CardContent>
<form
className="flex flex-col gap-4"
onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Razão social" error={form.formState.errors.razao_social?.message} required>
<Input {...form.register('razao_social')} />
</FormField>
<FormField label="Nome fantasia" error={form.formState.errors.nome_fantasia?.message}>
<Input {...form.register('nome_fantasia')} />
</FormField>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Documento" error={form.formState.errors.documento?.message}>
<Input {...form.register('documento')} />
</FormField>
<FormField label="Telefone" error={form.formState.errors.telefone?.message}>
<Input {...form.register('telefone')} />
</FormField>
</div>
<FormField label="E-mail de contato" error={form.formState.errors.email_contato?.message} required>
<Input type="email" {...form.register('email_contato')} />
</FormField>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
label="Plano"
id="plano_id"
error={form.formState.errors.plano_id?.message}
required
>
<Select
value={form.watch('plano_id')}
onValueChange={(value) => form.setValue('plano_id', value, { shouldValidate: true })}
>
<SelectTrigger id="plano_id" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(planos ?? []).map((plano) => (
<SelectItem key={plano.id} value={plano.id}>
{plano.nome}
</SelectItem>
))}
{!planos?.some((p) => p.id === tenant.plano_id) && (
<SelectItem value={tenant.plano_id}>{tenant.plano.nome} (inativo)</SelectItem>
)}
</SelectContent>
</Select>
</FormField>
<FormField
label="Dia de vencimento da mensalidade"
error={form.formState.errors.dia_vencimento_mensalidade?.message}
>
<Input
type="number"
min="1"
max="28"
{...form.register('dia_vencimento_mensalidade', {
setValueAs: (v) => (v === '' ? undefined : Number(v)),
})}
/>
</FormField>
</div>
<div>
<Button type="submit" loading={mutation.isPending}>
Salvar alterações
</Button>
</div>
</form>
</CardContent>
</Card>
);
}
function FaturasTab({ faturas, tenantId }: { faturas: Fatura[]; tenantId: string }) {
const queryClient = useQueryClient();
const marcarPagoMutation = useMutation({
mutationFn: (id: string) => marcarFaturaPaga(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['master', 'tenants', tenantId] });
toast.success('Fatura marcada como paga.');
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível marcar a fatura como paga.');
},
});
const columns: DataTableColumn<Fatura>[] = [
{ id: 'referencia', header: 'Referência', primary: true, cell: (row) => row.referencia },
{ id: 'valor', header: 'Valor', cell: (row) => formatCurrency(row.valor) },
{ id: 'vencimento', header: 'Vencimento', cell: (row) => formatDate(row.vencimento) },
{ id: 'status', header: 'Status', primary: true, cell: (row) => <StatusPill status={row.status} /> },
{ id: 'pago_em', header: 'Pago em', cell: (row) => formatDate(row.pago_em) },
];
return (
<div className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">Últimas 5 faturas deste tenant.</p>
<DataTable
columns={columns}
data={faturas}
keyField={(row) => row.id}
emptyTitle="Nenhuma fatura gerada ainda"
rowActions={(row) =>
row.status !== 'pago' ? (
<Button
variant="ghost"
size="sm"
loading={marcarPagoMutation.isPending && marcarPagoMutation.variables === row.id}
onClick={() => marcarPagoMutation.mutate(row.id)}
>
Marcar como paga
</Button>
) : (
<Badge variant="outline">Paga</Badge>
)
}
/>
</div>
);
}
+238
View File
@@ -0,0 +1,238 @@
'use client';
import * as React from 'react';
import { useRouter } from 'next/navigation';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Copy } from 'lucide-react';
import { createTenant, type CreateTenantResult } from '@/lib/api/tenants';
import { listPlanos } from '@/lib/api/planos';
import { createTenantSchema, type CreateTenantFormValues } from '@/lib/validators/tenants';
import { ApiError } from '@/lib/api/client';
import { toast } from '@/lib/toast';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { FormField } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import { ResponsiveDialog } from '@/components/ui/responsive-dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
export default function NovoTenantPage() {
const router = useRouter();
const [created, setCreated] = React.useState<CreateTenantResult | null>(null);
const { data: planos } = useQuery({
queryKey: ['master', 'planos', { ativo: true }],
queryFn: () => listPlanos({ ativo: true }),
});
const form = useForm<CreateTenantFormValues>({
resolver: zodResolver(createTenantSchema),
defaultValues: {
razao_social: '',
nome_fantasia: '',
documento: '',
email_contato: '',
telefone: '',
plano_id: '',
admin_nome: '',
admin_email: '',
admin_senha: '',
},
});
const mutation = useMutation({
mutationFn: (values: CreateTenantFormValues) =>
createTenant({
razao_social: values.razao_social,
nome_fantasia: values.nome_fantasia || undefined,
documento: values.documento || undefined,
email_contato: values.email_contato,
telefone: values.telefone || undefined,
plano_id: values.plano_id,
dia_vencimento_mensalidade: values.dia_vencimento_mensalidade,
admin: {
nome: values.admin_nome,
email: values.admin_email,
senha: values.admin_senha || undefined,
},
}),
onSuccess: (result) => setCreated(result),
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível criar o tenant.');
},
});
async function copySenha() {
if (!created) return;
try {
await navigator.clipboard.writeText(created.admin.senha_provisoria);
toast.success('Senha copiada.');
} catch {
toast.error('Não foi possível copiar automaticamente — selecione e copie manualmente.');
}
}
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-xl font-semibold text-foreground">Novo tenant</h1>
<p className="text-sm text-muted-foreground">Cadastro de um novo cliente da plataforma</p>
</div>
<form
className="flex max-w-2xl flex-col gap-6"
onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
>
<Card>
<CardHeader>
<CardTitle>Dados cadastrais</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Razão social" error={form.formState.errors.razao_social?.message} required>
<Input {...form.register('razao_social')} />
</FormField>
<FormField label="Nome fantasia" error={form.formState.errors.nome_fantasia?.message}>
<Input {...form.register('nome_fantasia')} />
</FormField>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Documento (CNPJ/CPF)" error={form.formState.errors.documento?.message}>
<Input {...form.register('documento')} />
</FormField>
<FormField label="Telefone" error={form.formState.errors.telefone?.message}>
<Input {...form.register('telefone')} />
</FormField>
</div>
<FormField
label="E-mail de contato"
error={form.formState.errors.email_contato?.message}
required
>
<Input type="email" {...form.register('email_contato')} />
</FormField>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
label="Plano"
id="plano_id"
error={form.formState.errors.plano_id?.message}
required
>
<Select
value={form.watch('plano_id')}
onValueChange={(value) => form.setValue('plano_id', value, { shouldValidate: true })}
>
<SelectTrigger id="plano_id" className="w-full">
<SelectValue placeholder="Selecione um plano" />
</SelectTrigger>
<SelectContent>
{(planos ?? []).map((plano) => (
<SelectItem key={plano.id} value={plano.id}>
{plano.nome}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<FormField
label="Dia de vencimento da mensalidade"
helpText="1 a 28 — vazio usa o padrão do sistema"
error={form.formState.errors.dia_vencimento_mensalidade?.message}
>
<Input
type="number"
min="1"
max="28"
{...form.register('dia_vencimento_mensalidade', {
setValueAs: (v) => (v === '' ? undefined : Number(v)),
})}
/>
</FormField>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Primeiro usuário (administrador do tenant)</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Nome" error={form.formState.errors.admin_nome?.message} required>
<Input {...form.register('admin_nome')} />
</FormField>
<FormField label="E-mail" error={form.formState.errors.admin_email?.message} required>
<Input type="email" {...form.register('admin_email')} />
</FormField>
</div>
<FormField
label="Senha provisória"
helpText="Deixe em branco para o sistema gerar uma senha automaticamente"
error={form.formState.errors.admin_senha?.message}
>
<Input type="text" autoComplete="off" {...form.register('admin_senha')} />
</FormField>
</CardContent>
</Card>
<div className="flex items-center gap-3">
<Button type="submit" loading={mutation.isPending}>
Criar tenant
</Button>
<Button type="button" variant="outline" onClick={() => router.push('/master/tenants')}>
Cancelar
</Button>
</div>
</form>
<ResponsiveDialog
open={created !== null}
onOpenChange={(open) => {
if (!open && created) router.push(`/master/tenants/${created.tenant.id}`);
}}
title="Tenant criado com sucesso"
description="Copie e repasse a senha provisória ao cliente agora — ela não será mostrada novamente."
footer={
<Button
className="w-full"
onClick={() => created && router.push(`/master/tenants/${created.tenant.id}`)}
>
Concluir
</Button>
}
>
{created && (
<div className="flex flex-col gap-4 px-4 pb-4 sm:px-0 sm:pb-0">
<div>
<p className="text-sm text-muted-foreground">Administrador</p>
<p className="font-medium text-foreground">
{created.admin.nome} · {created.admin.email}
</p>
</div>
<Separator />
<div className="flex flex-col gap-2">
<p className="text-sm font-medium text-foreground">Senha provisória</p>
<div className="flex items-center gap-2 rounded-lg border border-warning/30 bg-warning/10 p-3">
<code className="flex-1 font-mono text-base font-semibold text-foreground select-all">
{created.admin.senha_provisoria}
</code>
<Button type="button" variant="ghost" size="icon-sm" onClick={copySenha} aria-label="Copiar senha">
<Copy className="size-4" />
</Button>
</div>
<p className="text-xs text-warning">
Atenção: esta senha não fica salva em texto puro em nenhum lugar e não será exibida
novamente. Copie e repasse ao cliente agora.
</p>
</div>
</div>
)}
</ResponsiveDialog>
</div>
);
}
+152
View File
@@ -0,0 +1,152 @@
'use client';
import * as React from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { Plus, Search } from 'lucide-react';
import { listTenants, type TenantListItem, type TenantStatus } from '@/lib/api/tenants';
import { formatDate } from '@/lib/format';
import { Button } from '@/components/ui/button';
import { DataTable, type DataTableColumn } from '@/components/ui/data-table';
import { Input } from '@/components/ui/input';
import { StatusPill } from '@/components/ui/status-pill';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { TenantStatusDialog } from '@/components/domain/tenant-status-dialog';
const STATUS_OPTIONS: { value: TenantStatus | 'todos'; label: string }[] = [
{ value: 'todos', label: 'Todos os status' },
{ value: 'trial', label: 'Trial' },
{ value: 'ativo', label: 'Ativo' },
{ value: 'inadimplente', label: 'Inadimplente' },
{ value: 'suspenso', label: 'Suspenso' },
{ value: 'cancelado', label: 'Cancelado' },
];
const LIMIT = 20;
export default function TenantsPage() {
const router = useRouter();
const [busca, setBusca] = React.useState('');
const [status, setStatus] = React.useState<TenantStatus | 'todos'>('todos');
const [page, setPage] = React.useState(1);
const [statusTarget, setStatusTarget] = React.useState<TenantListItem | null>(null);
const { data, isLoading } = useQuery({
queryKey: ['master', 'tenants', { busca, status, page }],
queryFn: () =>
listTenants({
busca: busca || undefined,
status: status === 'todos' ? undefined : status,
page,
limit: LIMIT,
}),
placeholderData: (prev) => prev,
});
const columns: DataTableColumn<TenantListItem>[] = [
{
id: 'nome',
header: 'Tenant',
primary: true,
cell: (row) => (
<div className="flex flex-col">
<span className="font-medium text-foreground">{row.nome_fantasia || row.razao_social}</span>
{row.documento && <span className="text-xs text-muted-foreground">{row.documento}</span>}
</div>
),
},
{
id: 'status',
header: 'Status',
primary: true,
cell: (row) => <StatusPill status={row.status} />,
},
{ id: 'plano', header: 'Plano', cell: (row) => row.plano?.nome ?? '—' },
{ id: 'criado_em', header: 'Criado em', cell: (row) => formatDate(row.criado_em) },
];
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-foreground">Tenants</h1>
<p className="text-sm text-muted-foreground">Clientes da plataforma SeConectai Vendas</p>
</div>
<Button asChild>
<Link href="/master/tenants/novo">
<Plus /> Novo tenant
</Link>
</Button>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1 sm:max-w-xs">
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-neutral-500" />
<Input
placeholder="Buscar por nome ou documento..."
className="pl-9"
value={busca}
onChange={(e) => {
setBusca(e.target.value);
setPage(1);
}}
/>
</div>
<Select
value={status}
onValueChange={(value) => {
setStatus(value as TenantStatus | 'todos');
setPage(1);
}}
>
<SelectTrigger className="w-full sm:w-48" aria-label="Filtrar por status">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DataTable
columns={columns}
data={data?.data ?? []}
keyField={(row) => row.id}
loading={isLoading}
emptyTitle="Nenhum tenant encontrado"
emptyDescription="Ajuste os filtros ou cadastre um novo tenant."
emptyAction={
<Button size="sm" asChild>
<Link href="/master/tenants/novo">Novo tenant</Link>
</Button>
}
onRowClick={(row) => router.push(`/master/tenants/${row.id}`)}
rowActions={(row) => (
<>
<Button variant="ghost" size="sm" asChild>
<Link href={`/master/tenants/${row.id}`}>Ver</Link>
</Button>
<Button variant="ghost" size="sm" onClick={() => setStatusTarget(row)}>
Mudar status
</Button>
</>
)}
pagination={
data ? { page: data.meta.page, limit: data.meta.limit, total: data.meta.total, onPageChange: setPage } : undefined
}
/>
<TenantStatusDialog
tenant={statusTarget}
onOpenChange={(open) => !open && setStatusTarget(null)}
/>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function RootPage() {
redirect('/login');
}
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
@@ -0,0 +1,66 @@
import type { ReactNode } from 'react';
import { Button } from '@/components/ui/button';
import { ResponsiveDialog } from '@/components/ui/responsive-dialog';
interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: string;
confirmLabel?: string;
cancelLabel?: string;
destructive?: boolean;
loading?: boolean;
/** Desabilita o botão de confirmar (ex: falta escolher uma opção obrigatória em `children`). */
confirmDisabled?: boolean;
onConfirm: () => void;
/** Conteúdo extra entre a descrição e os botões (ex: um `Select` obrigatório) — opcional. */
children?: ReactNode;
}
/**
* Confirmação explícita antes de ações sensíveis (mudar status de tenant,
* excluir plano, disparar geração de faturas etc) — Etapa 15. `children`
* é opcional (Etapa 17: usado para pedir a forma de pagamento antes de
* marcar parcelas selecionadas como pagas em lote).
*/
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel = 'Confirmar',
cancelLabel = 'Cancelar',
destructive = false,
loading = false,
confirmDisabled = false,
onConfirm,
children,
}: ConfirmDialogProps) {
return (
<ResponsiveDialog
open={open}
onOpenChange={onOpenChange}
title={title}
description={description}
footer={
<>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
{cancelLabel}
</Button>
<Button
variant={destructive ? 'destructive' : 'primary'}
loading={loading}
disabled={confirmDisabled}
onClick={onConfirm}
>
{confirmLabel}
</Button>
</>
}
>
{children ?? <div className="sr-only" />}
</ResponsiveDialog>
);
}
@@ -0,0 +1,92 @@
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createCustomer, type Customer } from '@/lib/api/customers';
import { customerSchema, type CustomerFormValues } from '@/lib/validators/customers';
import { ApiError } from '@/lib/api/client';
import { toast } from '@/lib/toast';
import { Button } from '@/components/ui/button';
import { FormField } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import { ResponsiveDialog } from '@/components/ui/responsive-dialog';
interface CustomerQuickCreateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated: (customer: Customer) => void;
}
/**
* Cadastro rápido de cliente sem sair do fluxo de nova venda (Etapa 17,
* critério de aceite "não perder o que já foi preenchido na venda") — só
* os campos mínimos; o resto pode ser completado depois em
* `/app/clientes/:id`.
*/
export function CustomerQuickCreateDialog({ open, onOpenChange, onCreated }: CustomerQuickCreateDialogProps) {
const queryClient = useQueryClient();
const form = useForm<CustomerFormValues>({
resolver: zodResolver(customerSchema),
defaultValues: { razao_social: '', documento: '', email: '', telefone_whatsapp: '' },
});
const mutation = useMutation({
mutationFn: (values: CustomerFormValues) =>
createCustomer({
razao_social: values.razao_social,
documento: values.documento || undefined,
email: values.email || undefined,
telefone_whatsapp: values.telefone_whatsapp || undefined,
}),
onSuccess: (customer) => {
queryClient.invalidateQueries({ queryKey: ['tenant', 'customers'] });
toast.success('Cliente cadastrado.');
form.reset({ razao_social: '', documento: '', email: '', telefone_whatsapp: '' });
onCreated(customer);
onOpenChange(false);
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível cadastrar o cliente.');
},
});
return (
<ResponsiveDialog
open={open}
onOpenChange={onOpenChange}
title="Cadastro rápido de cliente"
description="Só o essencial — complete o resto depois no cadastro do cliente."
footer={
<>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancelar
</Button>
<Button loading={mutation.isPending} onClick={form.handleSubmit((values) => mutation.mutate(values))}>
Cadastrar e usar
</Button>
</>
}
>
<form
className="flex flex-col gap-4 px-4 pb-4 sm:px-0 sm:pb-0"
onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
>
<FormField label="Razão social" error={form.formState.errors.razao_social?.message} required>
<Input {...form.register('razao_social')} />
</FormField>
<FormField label="Documento (CPF/CNPJ)" error={form.formState.errors.documento?.message}>
<Input {...form.register('documento')} />
</FormField>
<FormField label="Telefone/WhatsApp" error={form.formState.errors.telefone_whatsapp?.message}>
<Input {...form.register('telefone_whatsapp')} />
</FormField>
<FormField label="E-mail" error={form.formState.errors.email?.message}>
<Input type="email" {...form.register('email')} />
</FormField>
</form>
</ResponsiveDialog>
);
}
@@ -0,0 +1,81 @@
'use client';
import * as React from 'react';
import { useMutation } from '@tanstack/react-query';
import { marcarRecebimentoPago, type FormaPagamento } from '@/lib/api/sale-payments';
import { ApiError } from '@/lib/api/client';
import { formatCurrency } from '@/lib/format';
import { toast } from '@/lib/toast';
import { Button } from '@/components/ui/button';
import { ResponsiveDialog } from '@/components/ui/responsive-dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
const FORMAS: { value: FormaPagamento; label: string }[] = [
{ value: 'pix', label: 'Pix' },
{ value: 'boleto', label: 'Boleto' },
{ value: 'cartao', label: 'Cartão' },
{ value: 'dinheiro', label: 'Dinheiro' },
];
interface MarcarPagoDialogProps {
parcela: { id: string; valor: string } | null;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
/** Reaproveitado no detalhe da venda (aba Parcelas) e em `/app/recebimentos`. */
export function MarcarPagoDialog({ parcela, onOpenChange, onSuccess }: MarcarPagoDialogProps) {
const [forma, setForma] = React.useState<FormaPagamento | ''>('');
React.useEffect(() => {
setForma('');
}, [parcela?.id]);
const mutation = useMutation({
mutationFn: () => marcarRecebimentoPago(parcela!.id, forma as FormaPagamento),
onSuccess: () => {
toast.success('Parcela marcada como paga.');
onOpenChange(false);
onSuccess();
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível marcar a parcela como paga.');
},
});
return (
<ResponsiveDialog
open={parcela !== null}
onOpenChange={onOpenChange}
title="Marcar parcela como paga"
description={parcela ? `Valor: ${formatCurrency(parcela.valor)}` : undefined}
footer={
<>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={mutation.isPending}>
Cancelar
</Button>
<Button disabled={!forma} loading={mutation.isPending} onClick={() => mutation.mutate()}>
Confirmar pagamento
</Button>
</>
}
>
<div className="flex flex-col gap-4 px-4 pb-4 sm:px-0 sm:pb-0">
<Select value={forma} onValueChange={(value) => setForma(value as FormaPagamento)}>
<SelectTrigger className="w-full" aria-label="Forma de pagamento">
<SelectValue placeholder="Selecione a forma de pagamento" />
</SelectTrigger>
<SelectContent>
{FORMAS.map((f) => (
<SelectItem key={f.value} value={f.value}>
{f.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</ResponsiveDialog>
);
}
+116
View File
@@ -0,0 +1,116 @@
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { productSchema, type ProductFormValues } from '@/lib/validators/products';
import type { ItemTipo } from '@/lib/api/products';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { FormField } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
interface ProductFormProps {
defaultValues: ProductFormValues;
onSubmit: (values: ProductFormValues) => void;
onCancel: () => void;
submitting: boolean;
submitLabel: string;
}
/** Campos de produto/serviço, compartilhado entre `/app/produtos/novo` e `/app/produtos/:id` (Etapa 16). */
export function ProductForm({ defaultValues, onSubmit, onCancel, submitting, submitLabel }: ProductFormProps) {
const form = useForm<ProductFormValues>({ resolver: zodResolver(productSchema), defaultValues });
return (
<form className="flex max-w-2xl flex-col gap-6" onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardHeader>
<CardTitle>Dados do item</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[160px_1fr]">
<FormField label="Tipo" id="tipo" error={form.formState.errors.tipo?.message} required>
<Select
value={form.watch('tipo')}
onValueChange={(value) => form.setValue('tipo', value as ItemTipo, { shouldValidate: true })}
>
<SelectTrigger id="tipo" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="produto">Produto</SelectItem>
<SelectItem value="servico">Serviço</SelectItem>
</SelectContent>
</Select>
</FormField>
<FormField label="Nome" error={form.formState.errors.nome?.message} required>
<Input {...form.register('nome')} />
</FormField>
</div>
<FormField label="Descrição" error={form.formState.errors.descricao?.message}>
<Textarea rows={2} {...form.register('descricao')} />
</FormField>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="SKU" helpText="Opcional — precisa ser único no seu catálogo" error={form.formState.errors.sku?.message}>
<Input {...form.register('sku')} />
</FormField>
<FormField label="Unidade" helpText="Ex: un, kg, hora" error={form.formState.errors.unidade?.message}>
<Input {...form.register('unidade')} />
</FormField>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Valor de venda (R$)" error={form.formState.errors.valor_venda?.message} required>
<Input
type="number"
step="0.01"
min="0"
{...form.register('valor_venda', { valueAsNumber: true })}
/>
</FormField>
<FormField label="Valor de custo (R$)" helpText="Opcional" error={form.formState.errors.valor_custo?.message}>
<Input
type="number"
step="0.01"
min="0"
{...form.register('valor_custo', {
setValueAs: (v) => (v === '' ? undefined : Number(v)),
})}
/>
</FormField>
</div>
<FormField label="Recorrente" id="recorrente" error={form.formState.errors.recorrente?.message}>
<Select
value={form.watch('recorrente') ? 'true' : 'false'}
onValueChange={(value) => form.setValue('recorrente', value === 'true', { shouldValidate: true })}
>
<SelectTrigger id="recorrente" className="w-full sm:w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="false">Não</SelectItem>
<SelectItem value="true">Sim — cobrança recorrente</SelectItem>
</SelectContent>
</Select>
</FormField>
</CardContent>
</Card>
<div className="flex items-center gap-3">
<Button type="submit" loading={submitting}>
{submitLabel}
</Button>
<Button type="button" variant="outline" onClick={onCancel}>
Cancelar
</Button>
</div>
</form>
);
}
+40
View File
@@ -0,0 +1,40 @@
import type { LucideIcon } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
interface StatCardProps {
label: string;
value: string;
icon?: LucideIcon;
loading?: boolean;
tone?: 'default' | 'success' | 'warning' | 'danger';
className?: string;
}
const TONE_CLASSNAMES: Record<NonNullable<StatCardProps['tone']>, string> = {
default: 'text-foreground',
success: 'text-success',
warning: 'text-warning',
danger: 'text-danger',
};
/** Card de resumo numérico (dashboards) — Etapa 15. */
export function StatCard({ label, value, icon: Icon, loading, tone = 'default', className }: StatCardProps) {
return (
<Card className={className}>
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
{Icon && <Icon className="size-4 text-muted-foreground" aria-hidden />}
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-8 w-24" />
) : (
<p className={cn('text-2xl font-semibold tabular-nums', TONE_CLASSNAMES[tone])}>{value}</p>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,90 @@
'use client';
import * as React from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { updateTenantStatus, type TenantStatusTransition } from '@/lib/api/tenants';
import { ApiError } from '@/lib/api/client';
import { toast } from '@/lib/toast';
import { STATUS_PILL_MAP } from '@/components/ui/status-pill';
import { Button } from '@/components/ui/button';
import { ResponsiveDialog } from '@/components/ui/responsive-dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
const TRANSITIONS: TenantStatusTransition[] = ['ativo', 'inadimplente', 'suspenso', 'cancelado'];
interface TenantStatusDialogProps {
tenant: { id: string; razao_social: string; status: string } | null;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
}
/**
* Mudança de status de tenant com confirmação explícita (critério de
* aceite da Etapa 15) — reaproveitado pela listagem e pelo detalhe de
* tenant, já que a mesma ação aparece nos dois lugares.
*/
export function TenantStatusDialog({ tenant, onOpenChange, onSuccess }: TenantStatusDialogProps) {
const queryClient = useQueryClient();
const [target, setTarget] = React.useState<TenantStatusTransition | ''>('');
React.useEffect(() => {
setTarget('');
}, [tenant?.id]);
const mutation = useMutation({
mutationFn: (status: TenantStatusTransition) => updateTenantStatus(tenant!.id, status),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['master', 'tenants'] });
toast.success('Status do tenant atualizado.');
onOpenChange(false);
onSuccess?.();
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível atualizar o status.');
},
});
return (
<ResponsiveDialog
open={tenant !== null}
onOpenChange={onOpenChange}
title={`Mudar status — ${tenant?.razao_social ?? ''}`}
description="Suspender ou cancelar bloqueia imediatamente o login de todos os usuários deste tenant, mesmo com sessões já abertas."
footer={
<>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={mutation.isPending}>
Cancelar
</Button>
<Button
variant={target === 'suspenso' || target === 'cancelado' ? 'destructive' : 'primary'}
disabled={!target}
loading={mutation.isPending}
onClick={() => target && mutation.mutate(target)}
>
Confirmar
</Button>
</>
}
>
<div className="flex flex-col gap-4 px-4 pb-4 sm:px-0 sm:pb-0">
<p className="text-sm text-muted-foreground">
Status atual: <span className="font-medium text-foreground">{STATUS_PILL_MAP[tenant?.status as keyof typeof STATUS_PILL_MAP]?.label ?? tenant?.status}</span>
</p>
<Select value={target} onValueChange={(v) => setTarget(v as TenantStatusTransition)}>
<SelectTrigger className="w-full" aria-label="Novo status">
<SelectValue placeholder="Selecione o novo status" />
</SelectTrigger>
<SelectContent>
{TRANSITIONS.filter((status) => status !== tenant?.status).map((status) => (
<SelectItem key={status} value={status}>
{STATUS_PILL_MAP[status].label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</ResponsiveDialog>
);
}
+497
View File
@@ -0,0 +1,497 @@
'use client';
import * as React from 'react';
import { useFieldArray, useForm, type UseFormRegisterReturn } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { Plus, Trash2 } from 'lucide-react';
import { listCustomers } from '@/lib/api/customers';
import { listProducts } from '@/lib/api/products';
import type { SaleInput } from '@/lib/api/sales';
import { saleFormSchema, type SaleFormValues } from '@/lib/validators/sales';
import { formatCurrency, formatDate } from '@/lib/format';
import { CustomerQuickCreateDialog } from '@/components/domain/customer-quick-create-dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Combobox } from '@/components/ui/combobox';
import { FormField } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
function toCents(value: number): number {
return Math.round(value * 100);
}
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
/** Calcula os vencimentos (30 em 30 dias) só para pré-visualização — o backend recalcula de verdade. */
function previewVencimentosAutomaticos(dataVenda: string, numeroParcelas: number): Date[] {
const base = dataVenda ? new Date(`${dataVenda}T00:00:00Z`) : new Date();
return Array.from({ length: numeroParcelas }, (_, i) => {
const d = new Date(base);
d.setUTCDate(d.getUTCDate() + 30 * (i + 1));
return d;
});
}
export interface VendaFormSubmitHandlers {
/** Só no modo `create` — salva como orçamento. */
onSaveDraft?: (input: SaleInput) => void;
/** No `create`, confirma a venda; no `edit`, salva as alterações (venda continua em orçamento). */
onConfirmOrSave: (input: SaleInput) => void;
}
interface VendaFormProps extends VendaFormSubmitHandlers {
mode: 'create' | 'edit';
defaultValues: SaleFormValues;
submittingDraft?: boolean;
submittingConfirmOrSave?: boolean;
onCancel: () => void;
}
export function VendaForm({
mode,
defaultValues,
submittingDraft,
submittingConfirmOrSave,
onSaveDraft,
onConfirmOrSave,
onCancel,
}: VendaFormProps) {
const [quickCreateOpen, setQuickCreateOpen] = React.useState(false);
const form = useForm<SaleFormValues>({ resolver: zodResolver(saleFormSchema), defaultValues });
const itensArray = useFieldArray({ control: form.control, name: 'itens' });
const parcelasArray = useFieldArray({ control: form.control, name: 'parcelasManuais' });
const customerId = form.watch('customer_id');
const itens = form.watch('itens');
const desconto = form.watch('desconto') || 0;
const dataVenda = form.watch('data_venda');
const modoParcelas = form.watch('modoParcelas');
const numeroParcelas = form.watch('numeroParcelas');
const parcelasManuais = form.watch('parcelasManuais') ?? [];
const { data: customersData } = useQuery({
queryKey: ['tenant', 'customers', 'combobox'],
queryFn: () => listCustomers({ ativo: true, limit: 100 }),
});
const { data: productsData } = useQuery({
queryKey: ['tenant', 'produtos', 'combobox'],
queryFn: () => listProducts({ ativo: true, limit: 100 }),
});
const customerOptions = (customersData?.data ?? []).map((c) => ({
value: c.id,
label: c.nome_fantasia || c.razao_social,
}));
const productOptions = (productsData?.data ?? []).map((p) => ({
value: p.id,
label: p.sku ? `${p.nome} (${p.sku})` : p.nome,
}));
const subtotalItens = itens.reduce((acc, item) => acc + item.quantidade * item.valor_unitario, 0);
const total = Math.max(0, subtotalItens - desconto);
const descontoExcedeItens = desconto > subtotalItens;
const somaParcelasManuais = parcelasManuais.reduce((acc, p) => acc + (p.valor || 0), 0);
const diffParcelas = toCents(total) - toCents(somaParcelasManuais);
const parcelasBatem = modoParcelas === 'automatico' || diffParcelas === 0;
function addItem(productId: string) {
const produto = productsData?.data.find((p) => p.id === productId);
if (!produto) return;
if (itensArray.fields.some((f) => f.product_service_id === productId)) return;
itensArray.append({
product_service_id: produto.id,
nome: produto.nome,
quantidade: 1,
valor_unitario: Number(produto.valor_venda),
valor_catalogo: Number(produto.valor_venda),
});
}
function buildInput(values: SaleFormValues): SaleInput | null {
if (values.modoParcelas === 'manual') {
if (!values.parcelasManuais || values.parcelasManuais.length === 0) {
form.setError('parcelasManuais', { message: 'Adicione pelo menos uma parcela' });
return null;
}
if (!parcelasBatem) {
form.setError('parcelasManuais', {
message: `A soma das parcelas não bate com o total da venda (diferença de ${formatCurrency(Math.abs(diffParcelas) / 100)})`,
});
return null;
}
} else if (!values.numeroParcelas || values.numeroParcelas < 1) {
form.setError('numeroParcelas', { message: 'Informe o número de parcelas' });
return null;
}
return {
customer_id: values.customer_id,
data_venda: values.data_venda,
desconto: values.desconto || undefined,
observacoes: values.observacoes || undefined,
itens: values.itens.map((item) => ({
product_service_id: item.product_service_id,
quantidade: item.quantidade,
valor_unitario: item.valor_unitario,
})),
...(values.modoParcelas === 'manual'
? { parcelas: values.parcelasManuais!.map((p) => ({ valor: p.valor, vencimento: p.vencimento })) }
: { numero_parcelas: values.numeroParcelas }),
};
}
const vencimentosPreview =
modoParcelas === 'automatico' && numeroParcelas
? previewVencimentosAutomaticos(dataVenda, numeroParcelas)
: [];
return (
<div className="flex flex-col gap-6 pb-24 lg:pb-0">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_380px]">
<div className="flex flex-col gap-6">
<Card>
<CardHeader>
<CardTitle>Cliente e data</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex items-end gap-2">
<FormField
label="Cliente"
id="customer_id"
className="flex-1"
error={form.formState.errors.customer_id?.message}
required
>
<Combobox
id="customer_id"
options={customerOptions}
value={customerId}
onChange={(value) => form.setValue('customer_id', value, { shouldValidate: true })}
placeholder="Selecionar cliente..."
searchPlaceholder="Buscar cliente..."
/>
</FormField>
<Button type="button" variant="outline" onClick={() => setQuickCreateOpen(true)}>
<Plus /> Novo
</Button>
</div>
<FormField label="Data da venda" error={form.formState.errors.data_venda?.message} required>
<Input type="date" {...form.register('data_venda')} />
</FormField>
<FormField label="Observações" error={form.formState.errors.observacoes?.message}>
<Textarea rows={2} {...form.register('observacoes')} />
</FormField>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
<CardTitle>Itens da venda</CardTitle>
<div className="w-64">
<Combobox
options={productOptions}
value=""
onChange={addItem}
placeholder="Adicionar item..."
searchPlaceholder="Buscar produto/serviço..."
/>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{form.formState.errors.itens?.message && (
<p className="text-sm text-destructive">{form.formState.errors.itens.message}</p>
)}
{itensArray.fields.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
Nenhum item adicionado ainda — busque um produto ou serviço acima.
</p>
) : (
<div className="flex flex-col divide-y divide-border">
{itensArray.fields.map((field, index) => {
const item = itens[index];
const alterado = item && item.valor_unitario !== item.valor_catalogo;
return (
<div key={field.id} className="flex flex-wrap items-end gap-3 py-3">
<div className="min-w-40 flex-1">
<p className="text-sm font-medium text-foreground">{field.nome}</p>
{alterado && (
<Badge variant="outline" className="mt-1">
Valor alterado do catálogo ({formatCurrency(field.valor_catalogo)})
</Badge>
)}
</div>
<FormField label="Qtd." className="w-24">
<Input
type="number"
step="0.01"
min="0.01"
{...form.register(`itens.${index}.quantidade`, { valueAsNumber: true })}
/>
</FormField>
<FormField label="Valor unit. (R$)" className="w-32">
<Input
type="number"
step="0.01"
min="0"
{...form.register(`itens.${index}.valor_unitario`, { valueAsNumber: true })}
/>
</FormField>
<div className="w-28 text-right">
<p className="text-xs text-muted-foreground">Subtotal</p>
<p className="font-medium tabular-nums text-foreground">
{formatCurrency((item?.quantidade || 0) * (item?.valor_unitario || 0))}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Remover item"
onClick={() => itensArray.remove(index)}
>
<Trash2 className="size-4" />
</Button>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Parcelas</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Select
value={modoParcelas}
onValueChange={(value) => form.setValue('modoParcelas', value as 'automatico' | 'manual')}
>
<SelectTrigger className="w-full sm:w-64" aria-label="Modo de parcelamento">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="automatico">Gerar parcelas automaticamente</SelectItem>
<SelectItem value="manual">Definir parcelas manualmente</SelectItem>
</SelectContent>
</Select>
{modoParcelas === 'automatico' ? (
<div className="flex flex-col gap-3">
<FormField
label="Número de parcelas"
helpText="Vencimentos calculados de 30 em 30 dias a partir da data da venda"
error={form.formState.errors.numeroParcelas?.message}
className="w-40"
>
<Input
type="number"
min="1"
{...form.register('numeroParcelas', {
setValueAs: (v) => (v === '' ? undefined : Number(v)),
})}
/>
</FormField>
{vencimentosPreview.length > 0 && (
<ul className="flex flex-col gap-1 text-sm text-muted-foreground">
{vencimentosPreview.map((date, i) => (
<li key={i} className="flex justify-between tabular-nums">
<span>Parcela {i + 1}</span>
<span>{formatDate(date)}</span>
</li>
))}
</ul>
)}
</div>
) : (
<div className="flex flex-col gap-3">
{form.formState.errors.parcelasManuais?.message && (
<p className="text-sm text-destructive">{form.formState.errors.parcelasManuais.message}</p>
)}
{parcelasArray.fields.map((field, index) => (
<div key={field.id} className="flex items-end gap-2">
<FormField label={`Parcela ${index + 1} — valor`} className="flex-1">
<Input
type="number"
step="0.01"
min="0"
{...form.register(`parcelasManuais.${index}.valor`, { valueAsNumber: true })}
/>
</FormField>
<FormField label="Vencimento" className="flex-1">
<Input type="date" {...form.register(`parcelasManuais.${index}.vencimento`)} />
</FormField>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Remover parcela"
onClick={() => parcelasArray.remove(index)}
>
<Trash2 className="size-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="self-start"
onClick={() => parcelasArray.append({ valor: 0, vencimento: dataVenda || todayIso() })}
>
<Plus /> Adicionar parcela
</Button>
<div
className={`rounded-lg p-3 text-sm ${
parcelasBatem ? 'bg-success/10 text-success' : 'bg-danger/10 text-danger'
}`}
>
Soma das parcelas: {formatCurrency(somaParcelasManuais)} —{' '}
{parcelasBatem ? 'bate com o total' : `diferença de ${formatCurrency(Math.abs(diffParcelas) / 100)}`}
</div>
</div>
)}
</CardContent>
</Card>
</div>
<div className="hidden flex-col gap-4 lg:flex">
<ResumoCard
subtotalItens={subtotalItens}
desconto={desconto}
total={total}
descontoExcedeItens={descontoExcedeItens}
formErrorDesconto={form.formState.errors.desconto?.message}
registerDesconto={form.register('desconto', { valueAsNumber: true })}
mode={mode}
submittingDraft={submittingDraft}
submittingConfirmOrSave={submittingConfirmOrSave}
onSaveDraft={onSaveDraft ? () => { const input = buildInput(form.getValues()); if (input) onSaveDraft(input); } : undefined}
onConfirmOrSave={() => { const input = buildInput(form.getValues()); if (input) onConfirmOrSave(input); }}
onCancel={onCancel}
/>
</div>
</div>
{/* Mobile: resumo + ações fixos no rodapé, sempre visíveis */}
<div className="fixed inset-x-0 bottom-16 z-10 border-t border-border bg-surface p-4 shadow-lg lg:hidden">
<div className="mb-3 flex items-center justify-between">
<span className="text-sm text-muted-foreground">Total da venda</span>
<span className="text-lg font-semibold tabular-nums text-foreground">{formatCurrency(total)}</span>
</div>
<div className="flex gap-2">
{onSaveDraft && (
<Button
type="button"
variant="outline"
className="flex-1"
loading={submittingDraft}
onClick={() => {
const input = buildInput(form.getValues());
if (input) onSaveDraft(input);
}}
>
Orçamento
</Button>
)}
<Button
type="button"
className="flex-1"
loading={submittingConfirmOrSave}
onClick={() => {
const input = buildInput(form.getValues());
if (input) onConfirmOrSave(input);
}}
>
{mode === 'create' ? 'Confirmar venda' : 'Salvar alterações'}
</Button>
</div>
</div>
<CustomerQuickCreateDialog
open={quickCreateOpen}
onOpenChange={setQuickCreateOpen}
onCreated={(customer) => form.setValue('customer_id', customer.id, { shouldValidate: true })}
/>
</div>
);
}
function ResumoCard({
subtotalItens,
desconto,
total,
descontoExcedeItens,
formErrorDesconto,
registerDesconto,
mode,
submittingDraft,
submittingConfirmOrSave,
onSaveDraft,
onConfirmOrSave,
onCancel,
}: {
subtotalItens: number;
desconto: number;
total: number;
descontoExcedeItens: boolean;
formErrorDesconto?: string;
registerDesconto: UseFormRegisterReturn<'desconto'>;
mode: 'create' | 'edit';
submittingDraft?: boolean;
submittingConfirmOrSave?: boolean;
onSaveDraft?: () => void;
onConfirmOrSave: () => void;
onCancel: () => void;
}) {
return (
<Card className="sticky top-4">
<CardHeader>
<CardTitle>Resumo</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<FormField label="Desconto geral (R$)" error={formErrorDesconto || (descontoExcedeItens ? 'Desconto maior que a soma dos itens' : undefined)}>
<Input type="number" step="0.01" min="0" {...registerDesconto} />
</FormField>
<div className="flex flex-col gap-1.5 border-t border-border pt-3 text-sm">
<div className="flex justify-between text-muted-foreground">
<span>Subtotal itens</span>
<span className="tabular-nums">{formatCurrency(subtotalItens)}</span>
</div>
<div className="flex justify-between text-muted-foreground">
<span>Desconto</span>
<span className="tabular-nums">-{formatCurrency(desconto)}</span>
</div>
<div className="flex justify-between text-base font-semibold text-foreground">
<span>Total</span>
<span className="tabular-nums">{formatCurrency(total)}</span>
</div>
</div>
<div className="flex flex-col gap-2">
{onSaveDraft && (
<Button type="button" variant="outline" loading={submittingDraft} onClick={onSaveDraft}>
Salvar como orçamento
</Button>
)}
<Button type="button" loading={submittingConfirmOrSave} onClick={onConfirmOrSave}>
{mode === 'create' ? 'Confirmar venda' : 'Salvar alterações'}
</Button>
<Button type="button" variant="ghost" onClick={onCancel}>
Cancelar
</Button>
</div>
</CardContent>
</Card>
);
}
+115
View File
@@ -0,0 +1,115 @@
'use client';
import * as React from 'react';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
import { Sidebar } from '@/components/layout/sidebar';
import { Topbar } from '@/components/layout/topbar';
import { Drawer } from '@/components/layout/drawer';
import { BottomTabBar } from '@/components/layout/bottom-tab-bar';
import { Breadcrumb, type BreadcrumbEntry } from '@/components/layout/breadcrumb';
import { persistSidebarCollapsed } from '@/lib/layout/sidebar-cookie';
import type { NavItem } from '@/lib/layout/nav-items';
interface AppShellProps {
/** Logo/marca completa (sidebar desktop expandida e header do drawer). */
brand: React.ReactNode;
/** Só o símbolo — sidebar desktop recolhida e topbar mobile. */
brandCollapsed: React.ReactNode;
items: NavItem[];
bottomHrefs: string[];
footer: React.ReactNode;
/** Rodapé enxuto (só avatar + logout) para a sidebar recolhida. */
footerCollapsed: React.ReactNode;
initialCollapsed: boolean;
children: React.ReactNode;
}
function findBreadcrumb(items: NavItem[], pathname: string): BreadcrumbEntry[] {
for (const item of items) {
if (item.children) {
const child = item.children.find(
(c) => pathname === c.href || pathname.startsWith(`${c.href}/`),
);
if (child) return [{ label: item.label }, { label: child.label }];
}
if (pathname === item.href || pathname.startsWith(`${item.href}/`)) {
return [{ label: item.label }];
}
}
return [];
}
/**
* Casco de navegação das áreas autenticadas (Etapa 14): compõe
* Sidebar/Topbar/Drawer/BottomTabBar num único lugar para não duplicar essa
* orquestração entre o shell do master e o do tenant — cada um só passa
* marca, itens de menu e rodapé de usuário diferentes.
*/
export function AppShell({
brand,
brandCollapsed,
items,
bottomHrefs,
footer,
footerCollapsed,
initialCollapsed,
children,
}: AppShellProps) {
const pathname = usePathname();
const [collapsed, setCollapsed] = React.useState(initialCollapsed);
const [drawerOpen, setDrawerOpen] = React.useState(false);
const toggleCollapsed = React.useCallback(() => {
setCollapsed((prev) => {
const next = !prev;
persistSidebarCollapsed(next);
return next;
});
}, []);
const bottomItems = React.useMemo(
() =>
bottomHrefs
.map((href) => items.find((item) => item.href === href))
.filter((item): item is NavItem => Boolean(item)),
[items, bottomHrefs],
);
const moreItems = React.useMemo(
() => items.filter((item) => !bottomHrefs.includes(item.href)),
[items, bottomHrefs],
);
const breadcrumbItems = React.useMemo(() => findBreadcrumb(items, pathname), [items, pathname]);
return (
<div className="min-h-dvh bg-neutral-50">
<Sidebar
brand={brand}
brandCollapsed={brandCollapsed}
items={items}
footer={footer}
footerCollapsed={footerCollapsed}
collapsed={collapsed}
onToggleCollapsed={toggleCollapsed}
/>
<Drawer open={drawerOpen} onOpenChange={setDrawerOpen} brand={brand} items={items} footer={footer} />
<div
className={cn(
'flex min-h-dvh flex-col transition-[padding] duration-150',
collapsed ? 'md:pl-16' : 'md:pl-60',
)}
>
<Topbar
breadcrumb={<Breadcrumb items={breadcrumbItems} />}
mobileBrand={brandCollapsed}
onMenuClick={() => setDrawerOpen(true)}
/>
<main className="flex-1 p-4 pb-20 md:p-6 md:pb-6">{children}</main>
</div>
<BottomTabBar items={bottomItems} moreItems={moreItems} />
</div>
);
}
@@ -0,0 +1,82 @@
'use client';
import * as React from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Ellipsis } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { NavList } from '@/components/layout/nav-list';
import type { NavItem } from '@/lib/layout/nav-items';
function isActive(pathname: string, href: string): boolean {
return pathname === href || pathname.startsWith(`${href}/`);
}
interface BottomTabBarProps {
/** Os 4 itens principais, já resolvidos na ordem de exibição. */
items: NavItem[];
/** Restante do menu, exibido no sheet "Mais". */
moreItems: NavItem[];
}
/** Bottom tab bar mobile (`<md`) — 4 itens fixos + "Mais" com o restante do menu. */
export function BottomTabBar({ items, moreItems }: BottomTabBarProps) {
const pathname = usePathname();
const [moreOpen, setMoreOpen] = React.useState(false);
const moreActive = moreItems.some(
(item) => isActive(pathname, item.href) || item.children?.some((child) => isActive(pathname, child.href)),
);
return (
<>
<nav
aria-label="Navegação principal"
className="fixed inset-x-0 bottom-0 z-20 flex h-16 items-stretch border-t border-border bg-surface md:hidden"
>
{items.map((item) => {
const Icon = item.icon;
const active = isActive(pathname, item.href);
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? 'page' : undefined}
className={cn(
'flex flex-1 flex-col items-center justify-center gap-0.5 text-[11px] font-medium text-neutral-500',
active && 'text-primary-on-light',
)}
>
<Icon className="size-5" aria-hidden />
{item.label}
</Link>
);
})}
<button
type="button"
onClick={() => setMoreOpen(true)}
aria-haspopup="dialog"
className={cn(
'flex flex-1 flex-col items-center justify-center gap-0.5 text-[11px] font-medium text-neutral-500',
moreActive && 'text-primary-on-light',
)}
>
<Ellipsis className="size-5" aria-hidden />
Mais
</button>
</nav>
<Sheet open={moreOpen} onOpenChange={setMoreOpen}>
<SheetContent side="bottom" className="max-h-[70vh] gap-0 p-0">
<SheetHeader>
<SheetTitle>Mais opções</SheetTitle>
</SheetHeader>
<div className="overflow-y-auto px-3 pb-4">
<NavList items={moreItems} onNavigate={() => setMoreOpen(false)} />
</div>
</SheetContent>
</Sheet>
</>
);
}
+46
View File
@@ -0,0 +1,46 @@
import Link from 'next/link';
import { ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface BreadcrumbEntry {
label: string;
href?: string;
}
/**
* Gerado manualmente a partir do item de menu ativo em `AppShell` nesta
* primeira versão (Etapa 14) — automatizar a partir da árvore de rotas do
* Next é uma melhoria natural de uma etapa futura, quando houver páginas
* com mais de 2 níveis (ex: detalhe dentro de uma lista).
*/
export function Breadcrumb({ items, className }: { items: BreadcrumbEntry[]; className?: string }) {
if (items.length === 0) return null;
return (
<nav aria-label="Breadcrumb" className={cn('min-w-0', className)}>
<ol className="flex min-w-0 items-center gap-1.5 text-sm">
{items.map((item, index) => {
const last = index === items.length - 1;
return (
<li key={`${item.label}-${index}`} className="flex min-w-0 items-center gap-1.5">
{index > 0 && <ChevronRight className="size-3.5 shrink-0 text-neutral-500" aria-hidden />}
{item.href && !last ? (
<Link href={item.href} className="truncate text-neutral-500 hover:text-foreground">
{item.label}
</Link>
) : (
<span
className={cn('truncate', last ? 'font-medium text-foreground' : 'text-neutral-500')}
aria-current={last ? 'page' : undefined}
>
{item.label}
</span>
)}
</li>
);
})}
</ol>
</nav>
);
}
+40
View File
@@ -0,0 +1,40 @@
'use client';
import * as React from 'react';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Separator } from '@/components/ui/separator';
import { NavList } from '@/components/layout/nav-list';
import type { NavItem } from '@/lib/layout/nav-items';
interface DrawerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
brand: React.ReactNode;
items: NavItem[];
footer: React.ReactNode;
}
/**
* Menu deslizante mobile — réplica do menu completo da sidebar desktop.
* Fecha ao navegar (`NavList.onNavigate`) ou ao tocar fora (comportamento
* padrão do `Sheet`/Radix Dialog).
*/
export function Drawer({ open, onOpenChange, brand, items, footer }: DrawerProps) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="left" className="w-4/5 gap-0 p-0">
<SheetHeader className="h-16 flex-row items-center gap-2 px-4 py-0">
{brand}
<SheetTitle className="sr-only">Menu de navegação</SheetTitle>
</SheetHeader>
<Separator />
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-4">
<NavList items={items} onNavigate={() => onOpenChange(false)} />
</div>
<Separator />
<div className="flex flex-col gap-2 p-3">{footer}</div>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,63 @@
'use client';
import * as React from 'react';
import { AppShell } from '@/components/layout/app-shell';
import { UserFooter } from '@/components/layout/user-footer';
import { useAuth } from '@/lib/auth/auth-context';
import { MASTER_NAV_ITEMS, MASTER_BOTTOM_HREFS } from '@/lib/layout/nav-items';
function MasterBrandSymbol() {
return (
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-brand-master text-sm font-bold text-white">
SV
</span>
);
}
/** Identidade fixa do painel master — nunca whitelabel (ver `TenantShell`). */
function MasterBrand() {
return (
<>
<MasterBrandSymbol />
<span className="truncate text-sm font-semibold text-neutral-950">SeConectai Vendas</span>
</>
);
}
export function MasterShell({
initialCollapsed,
children,
}: {
initialCollapsed: boolean;
children: React.ReactNode;
}) {
const { logout } = useAuth();
const handleLogout = React.useCallback(() => {
void logout();
}, [logout]);
return (
// `Sidebar`/`BottomTabBar`/`NavList` destacam o item ativo com os tokens
// `--color-primary(-on-light)` — dinâmicos por tenant (Etapa 13). O
// master tem identidade fixa (seção 6 da arquitetura), então essa
// sobrescrita local troca esses tokens por `--brand-master` só dentro
// desta árvore, sem tocar no `:root` (evitaria vazar pro `/app` do
// tenant na mesma aba).
<div
style={{ '--color-primary': 'var(--brand-master)', '--color-primary-on-light': 'var(--brand-master)' } as React.CSSProperties}
>
<AppShell
brand={<MasterBrand />}
brandCollapsed={<MasterBrandSymbol />}
items={MASTER_NAV_ITEMS}
bottomHrefs={MASTER_BOTTOM_HREFS}
footer={<UserFooter label="Administrador master" onLogout={handleLogout} />}
footerCollapsed={<UserFooter label="Administrador master" compact onLogout={handleLogout} />}
initialCollapsed={initialCollapsed}
>
{children}
</AppShell>
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
'use client';
import * as React from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { NavItem } from '@/lib/layout/nav-items';
function isActive(pathname: string, href: string): boolean {
return pathname === href || pathname.startsWith(`${href}/`);
}
function isGroupActive(pathname: string, item: NavItem): boolean {
if (isActive(pathname, item.href)) return true;
return item.children?.some((child) => isActive(pathname, child.href)) ?? false;
}
interface NavListProps {
items: NavItem[];
/** Modo ícones-apenas (sidebar recolhida) — grupos viram link direto pro primeiro filho. */
collapsed?: boolean;
/** Disparado ao navegar (o Drawer usa isso para fechar). */
onNavigate?: () => void;
}
/**
* Lista de navegação com estado de item/grupo ativo — compartilhada entre
* `Sidebar` (desktop) e `Drawer` (mobile) para não duplicar essa lógica em
* dois lugares.
*/
export function NavList({ items, collapsed = false, onNavigate }: NavListProps) {
const pathname = usePathname();
const [openHref, setOpenHref] = React.useState<string | null>(null);
return (
<nav className="flex flex-col gap-1">
{items.map((item) => {
const Icon = item.icon;
const active = isGroupActive(pathname, item);
if (item.children && !collapsed) {
const open = openHref === item.href || (openHref === null && active);
return (
<div key={item.href}>
<button
type="button"
onClick={() => setOpenHref(open ? '' : item.href)}
aria-expanded={open}
className={cn(
'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-neutral-500 transition-colors hover:bg-muted hover:text-foreground',
active && 'bg-primary/10 text-primary-on-light',
)}
>
<Icon className="size-4.5 shrink-0" aria-hidden />
<span className="flex-1 text-left">{item.label}</span>
<ChevronDown
className={cn('size-4 shrink-0 transition-transform', open && 'rotate-180')}
aria-hidden
/>
</button>
{open && (
<div className="ml-4 flex flex-col gap-0.5 border-l border-border py-1 pl-4">
{item.children.map((child) => (
<Link
key={child.href}
href={child.href}
onClick={onNavigate}
aria-current={isActive(pathname, child.href) ? 'page' : undefined}
className={cn(
'rounded-lg px-3 py-2 text-sm text-neutral-500 transition-colors hover:bg-muted hover:text-foreground',
isActive(pathname, child.href) && 'font-medium text-primary-on-light',
)}
>
{child.label}
</Link>
))}
</div>
)}
</div>
);
}
return (
<Link
key={item.href}
href={item.href}
onClick={onNavigate}
aria-current={active ? 'page' : undefined}
title={collapsed ? item.label : undefined}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-neutral-500 transition-colors hover:bg-muted hover:text-foreground',
active && 'bg-primary/10 text-primary-on-light',
collapsed && 'mx-auto size-11 justify-center px-0 py-0',
)}
>
<Icon className="size-4.5 shrink-0" aria-hidden />
{!collapsed && <span>{item.label}</span>}
</Link>
);
})}
</nav>
);
}
+66
View File
@@ -0,0 +1,66 @@
'use client';
import * as React from 'react';
import { PanelLeftClose, PanelLeftOpen } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { NavList } from '@/components/layout/nav-list';
import type { NavItem } from '@/lib/layout/nav-items';
interface SidebarProps {
brand: React.ReactNode;
brandCollapsed: React.ReactNode;
items: NavItem[];
footer: React.ReactNode;
footerCollapsed: React.ReactNode;
collapsed: boolean;
onToggleCollapsed: () => void;
}
/**
* Sidebar fixa (desktop, `md+`). Itens de menu e rodapé de usuário chegam
* via props — sem nada hardcoded — para ser reaproveitada entre o shell do
* master e o do tenant com conteúdo diferente (Etapa 14).
*/
export function Sidebar({
brand,
brandCollapsed,
items,
footer,
footerCollapsed,
collapsed,
onToggleCollapsed,
}: SidebarProps) {
return (
<aside
className={cn(
'fixed inset-y-0 left-0 z-30 hidden flex-col border-r border-border bg-surface transition-[width] duration-150 md:flex',
collapsed ? 'w-16' : 'w-60',
)}
>
<div className={cn('flex h-16 shrink-0 items-center gap-2 overflow-hidden px-4', collapsed && 'justify-center px-2')}>
{collapsed ? brandCollapsed : brand}
</div>
<Separator />
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-4">
<NavList items={items} collapsed={collapsed} />
</div>
<Separator />
<div className={cn('flex flex-col gap-2 p-3', collapsed && 'items-center')}>
{collapsed ? footerCollapsed : footer}
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={onToggleCollapsed}
aria-label={collapsed ? 'Expandir menu' : 'Recolher menu'}
className={collapsed ? 'self-center' : 'self-end'}
>
{collapsed ? <PanelLeftOpen className="size-4" /> : <PanelLeftClose className="size-4" />}
</Button>
</div>
</aside>
);
}
@@ -0,0 +1,94 @@
'use client';
import * as React from 'react';
import Image from 'next/image';
import { AppShell } from '@/components/layout/app-shell';
import { UserFooter } from '@/components/layout/user-footer';
import { useAuth } from '@/lib/auth/auth-context';
import { TenantThemeProvider, useTenantTheme } from '@/lib/theme/theme-provider';
import { TENANT_NAV_ITEMS, TENANT_BOTTOM_HREFS, filterByRole } from '@/lib/layout/nav-items';
import type { UserRole } from '@/lib/auth/jwt';
const ROLE_LABELS: Record<UserRole, string> = {
tenant_admin: 'Administrador',
vendedor: 'Vendedor',
financeiro: 'Financeiro',
visualizador: 'Visualizador',
};
/** Logo/nome resolvidos do tema do tenant (Etapa 13); cai para a marca padrão sem tema. */
function TenantBrand({ collapsed = false }: { collapsed?: boolean }) {
const { theme } = useTenantTheme();
const name = theme?.nome ?? 'SeConectai Vendas';
return (
<>
{theme?.logoUrl ? (
<Image
src={theme.logoUrl}
alt={name}
width={36}
height={36}
className="size-9 shrink-0 rounded-lg object-contain"
/>
) : (
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary text-sm font-bold text-primary-foreground">
{name.charAt(0).toUpperCase()}
</span>
)}
{!collapsed && <span className="truncate text-sm font-semibold text-neutral-950">{name}</span>}
</>
);
}
function TenantShellInner({
initialCollapsed,
children,
}: {
initialCollapsed: boolean;
children: React.ReactNode;
}) {
const { user, logout } = useAuth();
const handleLogout = React.useCallback(() => {
void logout();
}, [logout]);
const items = React.useMemo(() => filterByRole(TENANT_NAV_ITEMS, user?.role), [user?.role]);
const roleLabel = user?.role ? ROLE_LABELS[user.role] : 'Usuário';
return (
<AppShell
brand={<TenantBrand />}
brandCollapsed={<TenantBrand collapsed />}
items={items}
bottomHrefs={TENANT_BOTTOM_HREFS}
footer={<UserFooter label={roleLabel} onLogout={handleLogout} />}
footerCollapsed={<UserFooter label={roleLabel} compact onLogout={handleLogout} />}
initialCollapsed={initialCollapsed}
>
{children}
</AppShell>
);
}
/**
* Shell da área `/app`. Resolve o tema do tenant a partir do `tenant_id` do
* usuário autenticado (Etapa 13) e filtra os itens de menu por `role` — ex:
* "Equipe" só aparece para `tenant_admin` (Etapa 14, critério de aceite).
*/
export function TenantShell({
initialCollapsed,
children,
}: {
initialCollapsed: boolean;
children: React.ReactNode;
}) {
const { user } = useAuth();
return (
<TenantThemeProvider tenantId={user?.tenant_id ?? null}>
<TenantShellInner initialCollapsed={initialCollapsed}>{children}</TenantShellInner>
</TenantThemeProvider>
);
}
+52
View File
@@ -0,0 +1,52 @@
'use client';
import * as React from 'react';
import { Bell, Menu, Search } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
interface TopbarProps {
breadcrumb: React.ReactNode;
/** Logo compacta (só símbolo) exibida no lugar do breadcrumb no mobile. */
mobileBrand: React.ReactNode;
onMenuClick: () => void;
}
/**
* Topbar fixa no topo. Desktop: breadcrumb + busca rápida + notificações.
* Mobile (`<md`): logo símbolo + hambúrguer (abre o `Drawer`) + notificações
* — a busca funcional real fica para a Etapa 18/19, aqui só o componente.
*/
export function Topbar({ breadcrumb, mobileBrand, onMenuClick }: TopbarProps) {
return (
<header className="sticky top-0 z-20 flex h-16 items-center gap-3 border-b border-border bg-surface px-4 md:px-6">
<Button
type="button"
variant="ghost"
size="icon-sm"
className="md:hidden"
onClick={onMenuClick}
aria-label="Abrir menu"
>
<Menu className="size-5" />
</Button>
<div className="flex min-w-0 flex-1 items-center gap-3">
<div className="md:hidden">{mobileBrand}</div>
<div className="hidden min-w-0 md:block">{breadcrumb}</div>
</div>
<div className="hidden max-w-xs flex-1 items-center md:flex">
<div className="relative w-full">
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-neutral-500" />
<Input placeholder="Buscar clientes, vendas..." className="pl-9" aria-label="Busca rápida" />
</div>
</div>
<Button type="button" variant="ghost" size="icon-sm" aria-label="Notificações">
<Bell className="size-5" />
</Button>
</header>
);
}
@@ -0,0 +1,48 @@
'use client';
import { LogOut } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
interface UserFooterProps {
label: string;
sublabel?: string;
onLogout: () => void;
/** Sidebar recolhida/drawer fechado: só avatar + botão de sair, sem texto. */
compact?: boolean;
}
/**
* Rodapé de identidade + logout da sidebar/drawer (Etapa 14). O access
* token (`lib/auth/jwt.ts`) não carrega nome/e-mail do usuário — só
* `sub`/`tipo`/`tenant_id`/`role` — então a identificação aqui é o papel do
* usuário, não o nome; ver observação da Etapa 14 no controle de progresso.
*/
export function UserFooter({ label, sublabel, onLogout, compact = false }: UserFooterProps) {
const initial = label.charAt(0).toUpperCase();
return (
<div className={cn('flex w-full items-center gap-2', compact && 'w-auto flex-col')}>
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-sm font-semibold text-foreground">
{initial}
</span>
{!compact && (
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">{label}</p>
{sublabel && <p className="truncate text-xs text-neutral-500">{sublabel}</p>}
</div>
)}
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={onLogout}
aria-label="Sair"
title="Sair"
>
<LogOut className="size-4" />
</Button>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { Slot } from 'radix-ui';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
destructive:
'bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20',
outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
link: 'text-primary underline-offset-4 hover:underline',
},
},
defaultVariants: {
variant: 'default',
},
},
);
function Badge({
className,
variant = 'default',
asChild = false,
...props
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : 'span';
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };
+95
View File
@@ -0,0 +1,95 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { Slot } from 'radix-ui';
import { Loader2Icon } from 'lucide-react';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
primary: 'bg-primary text-primary-foreground hover:bg-primary/90',
secondary:
'bg-secondary text-secondary-foreground border-border hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
ghost:
'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground',
outline:
'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
// `sm` fica abaixo de 44px de propósito: reservado para ações densas
// (ex: linha de tabela), nunca a única forma de disparar uma ação —
// ver Etapas/V2/20-controle-de-progresso-frontend.md, Etapa 12.
size: {
sm: 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
md: 'h-11 min-w-11 gap-2 px-4 has-data-[icon=inline-end]:pr-3.5 has-data-[icon=inline-start]:pl-3.5',
lg: 'h-12 min-w-11 gap-2 px-5 text-base has-data-[icon=inline-end]:pr-4 has-data-[icon=inline-start]:pl-4',
icon: 'size-11',
'icon-sm': 'size-8',
'icon-lg': 'size-12',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
},
);
type ButtonProps = React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
loading?: boolean;
};
function Button({
className,
variant,
size,
asChild = false,
loading = false,
disabled,
children,
...props
}: ButtonProps) {
// `Slot.Root` (Radix) exige exatamente um filho React — o wrapper de
// loading/span abaixo (usado no `<button>` nativo) sempre passaria 2
// filhos (o `{loading && ...}` condicional + o `<span>`), quebrando o
// `asChild`. Sem estado de loading fazer sentido quando quem renderiza de
// verdade é o filho (ex: um `Link`), `asChild` passa `children` direto.
if (asChild) {
return (
<Slot.Root
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
>
{children}
</Slot.Root>
);
}
return (
<button
data-slot="button"
data-variant={variant}
data-size={size}
aria-busy={loading || undefined}
className={cn(buttonVariants({ variant, size, className }), loading && 'relative')}
disabled={disabled || loading}
{...props}
>
{loading && <Loader2Icon className="absolute size-4 animate-spin" aria-hidden />}
<span className={cn('inline-flex items-center gap-2', loading && 'invisible')}>
{children}
</span>
</button>
);
}
export { Button, buttonVariants };
+186
View File
@@ -0,0 +1,186 @@
'use client';
import * as React from 'react';
import { DayPicker, getDefaultClassNames, type DayButton, type Locale } from 'react-day-picker';
import { cn } from '@/lib/utils';
import { Button, buttonVariants } from '@/components/ui/button';
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from 'lucide-react';
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = 'label',
buttonVariant = 'ghost',
locale,
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>['variant'];
}) {
const defaultClassNames = getDefaultClassNames();
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
'group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className,
)}
captionLayout={captionLayout}
locale={locale}
formatters={{
formatMonthDropdown: (date) => date.toLocaleString(locale?.code, { month: 'short' }),
...formatters,
}}
classNames={{
root: cn('w-fit', defaultClassNames.root),
months: cn('relative flex flex-col gap-4 md:flex-row', defaultClassNames.months),
month: cn('flex w-full flex-col gap-4', defaultClassNames.month),
nav: cn(
'absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1',
defaultClassNames.nav,
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
defaultClassNames.button_previous,
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
defaultClassNames.button_next,
),
month_caption: cn(
'flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)',
defaultClassNames.month_caption,
),
dropdowns: cn(
'flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium',
defaultClassNames.dropdowns,
),
dropdown_root: cn('relative rounded-(--cell-radius)', defaultClassNames.dropdown_root),
dropdown: cn('absolute inset-0 bg-popover opacity-0', defaultClassNames.dropdown),
caption_label: cn(
'font-medium select-none',
captionLayout === 'label'
? 'text-sm'
: 'flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground',
defaultClassNames.caption_label,
),
month_grid: cn('w-full border-collapse', defaultClassNames.month_grid),
weekdays: cn('flex', defaultClassNames.weekdays),
weekday: cn(
'flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none',
defaultClassNames.weekday,
),
week: cn('mt-2 flex w-full', defaultClassNames.week),
week_number_header: cn('w-(--cell-size) select-none', defaultClassNames.week_number_header),
week_number: cn(
'text-[0.8rem] text-muted-foreground select-none',
defaultClassNames.week_number,
),
day: cn(
'group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)',
props.showWeekNumber
? '[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)'
: '[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)',
defaultClassNames.day,
),
range_start: cn(
'relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted',
defaultClassNames.range_start,
),
range_middle: cn('rounded-none', defaultClassNames.range_middle),
range_end: cn(
'relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted',
defaultClassNames.range_end,
),
today: cn(
'rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none',
defaultClassNames.today,
),
outside: cn(
'text-muted-foreground aria-selected:text-muted-foreground',
defaultClassNames.outside,
),
disabled: cn('text-muted-foreground opacity-50', defaultClassNames.disabled),
hidden: cn('invisible', defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return <div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />;
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === 'left') {
return <ChevronLeftIcon className={cn('size-4', className)} {...props} />;
}
if (orientation === 'right') {
return <ChevronRightIcon className={cn('size-4', className)} {...props} />;
}
return <ChevronDownIcon className={cn('size-4', className)} {...props} />;
},
DayButton: ({ ...props }) => <CalendarDayButton locale={locale} {...props} />,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
);
},
...components,
}}
{...props}
/>
);
}
function CalendarDayButton({
className,
day,
modifiers,
locale,
...props
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString(locale?.code)}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
'relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70',
defaultClassNames.day,
className,
)}
{...props}
/>
);
}
export { Calendar, CalendarDayButton };
+88
View File
@@ -0,0 +1,88 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Card({
className,
size = 'default',
...props
}: React.ComponentProps<'div'> & { size?: 'default' | 'sm' }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
'group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl',
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-header"
className={cn(
'group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)',
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-title"
className={cn(
'text-base leading-snug font-medium group-data-[size=sm]/card:text-sm',
className,
)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div data-slot="card-content" className={cn('px-(--card-spacing)', className)} {...props} />
);
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-footer"
className={cn(
'flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)',
className,
)}
{...props}
/>
);
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
+109
View File
@@ -0,0 +1,109 @@
'use client';
import * as React from 'react';
import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
export interface ComboboxOption {
value: string;
label: string;
}
interface ComboboxProps {
options: ComboboxOption[];
value?: string;
onChange: (value: string) => void;
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
disabled?: boolean;
loading?: boolean;
className?: string;
id?: string;
'aria-invalid'?: boolean;
'aria-describedby'?: string;
}
/**
* `Select` com busca, usado nas listas longas do sistema (cliente,
* produto/serviço) — seção 7 do schema de frontend. Composição de
* Command + Popover, não do `Select` nativo do Radix.
*/
function Combobox({
options,
value,
onChange,
placeholder = 'Selecionar…',
searchPlaceholder = 'Buscar…',
emptyText = 'Nenhum resultado encontrado.',
disabled,
loading,
className,
id,
...aria
}: ComboboxProps) {
const [open, setOpen] = React.useState(false);
const selected = options.find((option) => option.value === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
id={id}
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled || loading}
loading={loading}
className={cn(
'w-full justify-between font-normal',
!selected && 'text-muted-foreground',
className,
)}
{...aria}
>
{selected ? selected.label : placeholder}
<ChevronsUpDownIcon className="opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>{emptyText}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => {
onChange(option.value);
setOpen(false);
}}
>
<CheckIcon
className={cn('mr-2', option.value === value ? 'opacity-100' : 'opacity-0')}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
export { Combobox };
+180
View File
@@ -0,0 +1,180 @@
'use client';
import * as React from 'react';
import { Command as CommandPrimitive } from 'cmdk';
import { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { InputGroup, InputGroupAddon } from '@/components/ui/input-group';
import { SearchIcon, CheckIcon } from 'lucide-react';
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
'flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground',
className,
)}
{...props}
/>
);
}
function CommandDialog({
title = 'Command Palette',
description = 'Search for a command to run...',
children,
className,
showCloseButton = false,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn('top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0', className)}
showCloseButton={showCloseButton}
>
{children}
</DialogContent>
</Dialog>
);
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
'w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
<InputGroupAddon>
<SearchIcon className="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
);
}
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
'no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none',
className,
)}
{...props}
/>
);
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn('py-6 text-center text-sm', className)}
{...props}
/>
);
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
'overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground',
className,
)}
{...props}
/>
);
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn('-mx-1 h-px bg-border', className)}
{...props}
/>
);
}
function CommandItem({
className,
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className,
)}
{...props}
>
{children}
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
);
}
function CommandShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="command-shortcut"
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground',
className,
)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};
@@ -0,0 +1,86 @@
import * as React from 'react';
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
interface DataPaginationProps {
/** 1-indexado, compatível com `page`/`limit`/`meta.total` da API (Etapas 1–8 do backend). */
page: number;
limit: number;
total: number;
onPageChange: (page: number) => void;
className?: string;
}
function getPageNumbers(page: number, totalPages: number): (number | 'ellipsis')[] {
if (totalPages <= 7) return Array.from({ length: totalPages }, (_, i) => i + 1);
const pages = new Set([1, totalPages, page, page - 1, page + 1]);
const sorted = [...pages].filter((p) => p >= 1 && p <= totalPages).sort((a, b) => a - b);
const result: (number | 'ellipsis')[] = [];
sorted.forEach((p, i) => {
if (i > 0 && p - sorted[i - 1] > 1) result.push('ellipsis');
result.push(p);
});
return result;
}
/** Controles de página compatíveis com os parâmetros de paginação do backend (Etapa 12). */
function DataPagination({ page, limit, total, onPageChange, className }: DataPaginationProps) {
const totalPages = Math.max(1, Math.ceil(total / limit));
const firstItem = total === 0 ? 0 : (page - 1) * limit + 1;
const lastItem = Math.min(page * limit, total);
return (
<nav
aria-label="Paginação"
className={cn('flex flex-col items-center justify-between gap-3 sm:flex-row', className)}
>
<p className="text-sm text-muted-foreground">
{total === 0 ? 'Nenhum resultado' : `Mostrando ${firstItem}–${lastItem} de ${total}`}
</p>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
aria-label="Página anterior"
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
>
<ChevronLeftIcon />
</Button>
{getPageNumbers(page, totalPages).map((p, index) =>
p === 'ellipsis' ? (
<span key={`ellipsis-${index}`} className="flex size-11 items-center justify-center">
<MoreHorizontalIcon className="size-4 text-muted-foreground" aria-hidden />
</span>
) : (
<Button
key={p}
variant={p === page ? 'outline' : 'ghost'}
size="icon"
aria-label={`Página ${p}`}
aria-current={p === page ? 'page' : undefined}
onClick={() => onPageChange(p)}
>
{p}
</Button>
),
)}
<Button
variant="ghost"
size="icon"
aria-label="Próxima página"
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
>
<ChevronRightIcon />
</Button>
</div>
</nav>
);
}
export { DataPagination };
+306
View File
@@ -0,0 +1,306 @@
'use client';
import * as React from 'react';
import { ArrowDownIcon, ArrowUpIcon, ChevronDownIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useIsDesktop } from '@/hooks/use-media-query';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Card } from '@/components/ui/card';
import { SkeletonTableRow, SkeletonCard } from '@/components/ui/skeleton';
import { EmptyState } from '@/components/ui/empty-state';
import { DataPagination } from '@/components/ui/data-pagination';
export interface DataTableSort {
columnId: string;
direction: 'asc' | 'desc';
}
export interface DataTableColumn<T> {
id: string;
header: string;
cell: (row: T) => React.ReactNode;
sortable?: boolean;
/** Campo em destaque no card mobile (limite recomendado: 1–2 por tabela). */
primary?: boolean;
/** Não mostrado no card mobile (ex: coluna técnica só relevante em tela larga). */
hideOnMobileCard?: boolean;
className?: string;
}
interface DataTableProps<T> {
columns: DataTableColumn<T>[];
data: T[];
keyField: (row: T) => string;
loading?: boolean;
sort?: DataTableSort;
onSortChange?: (sort: DataTableSort) => void;
emptyTitle?: string;
emptyDescription?: string;
emptyAction?: React.ReactNode;
rowActions?: (row: T) => React.ReactNode;
onRowClick?: (row: T) => void;
pagination?: {
page: number;
limit: number;
total: number;
onPageChange: (page: number) => void;
};
className?: string;
}
function SortButton<T>({
column,
sort,
onSortChange,
}: {
column: DataTableColumn<T>;
sort?: DataTableSort;
onSortChange: (sort: DataTableSort) => void;
}) {
const active = sort?.columnId === column.id;
const nextDirection = active && sort?.direction === 'asc' ? 'desc' : 'asc';
return (
<button
type="button"
className="-my-2 -ml-1 flex min-h-11 items-center gap-1 rounded-sm py-2 pl-1 font-medium hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
onClick={() => onSortChange({ columnId: column.id, direction: nextDirection })}
>
{column.header}
{active ? (
sort.direction === 'asc' ? (
<ArrowUpIcon className="size-3.5" aria-hidden />
) : (
<ArrowDownIcon className="size-3.5" aria-hidden />
)
) : (
<ArrowUpIcon className="size-3.5 opacity-0 group-hover:opacity-30" aria-hidden />
)}
<span className="sr-only">
{active ? `ordenado ${sort.direction === 'asc' ? 'crescente' : 'decrescente'}` : 'ordenar'}
</span>
</button>
);
}
function MobileRowCard<T>({
row,
columns,
rowActions,
onRowClick,
}: {
row: T;
columns: DataTableColumn<T>[];
rowActions?: (row: T) => React.ReactNode;
onRowClick?: (row: T) => void;
}) {
const [expanded, setExpanded] = React.useState(false);
const primaryColumns = columns.filter((c) => c.primary);
const secondaryColumns = columns.filter((c) => !c.primary && !c.hideOnMobileCard);
return (
<Card
className={cn('flex flex-col gap-3 p-4', onRowClick && 'cursor-pointer')}
onClick={onRowClick ? () => onRowClick(row) : undefined}
role={onRowClick ? 'button' : undefined}
tabIndex={onRowClick ? 0 : undefined}
onKeyDown={
onRowClick
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onRowClick(row);
}
}
: undefined
}
>
{primaryColumns.length > 0 && (
<div className="flex flex-col gap-0.5">
{primaryColumns.map((column) => (
<div key={column.id} className="text-base font-medium text-foreground">
{column.cell(row)}
</div>
))}
</div>
)}
{secondaryColumns.length > 0 && (
<>
<button
type="button"
className="-mx-2 flex min-h-11 items-center gap-1 self-start px-2 text-xs font-medium text-muted-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
onClick={(e) => {
e.stopPropagation();
setExpanded((v) => !v);
}}
aria-expanded={expanded}
>
{expanded ? 'Ver menos' : 'Ver mais'}
<ChevronDownIcon
className={cn('size-3.5 transition-transform', expanded && 'rotate-180')}
aria-hidden
/>
</button>
{expanded && (
<dl className="flex flex-col gap-2 border-t border-border pt-3">
{secondaryColumns.map((column) => (
<div key={column.id} className="flex items-center justify-between gap-4 text-sm">
<dt className="text-muted-foreground">{column.header}</dt>
<dd className="tabular-nums text-foreground">{column.cell(row)}</dd>
</div>
))}
</dl>
)}
</>
)}
{rowActions && (
<div
className="flex items-center gap-2 border-t border-border pt-3"
onClick={(e) => e.stopPropagation()}
>
{rowActions(row)}
</div>
)}
</Card>
);
}
/**
* Componente central do design system (Etapa 12). Abaixo de `md` renderiza
* como lista de Cards empilhados em vez de fazer scroll horizontal na
* tabela — cada `column.primary` vira um campo em destaque no card, o
* resto fica atrás de "Ver mais".
*/
function DataTable<T>({
columns,
data,
keyField,
loading,
sort,
onSortChange,
emptyTitle = 'Nenhum resultado encontrado',
emptyDescription,
emptyAction,
rowActions,
onRowClick,
pagination,
className,
}: DataTableProps<T>) {
const isDesktop = useIsDesktop();
if (loading) {
return (
<div className={className}>
{isDesktop ? (
<div className="rounded-lg border border-border">
{Array.from({ length: 5 }).map((_, i) => (
<SkeletonTableRow key={i} columns={columns.length + (rowActions ? 1 : 0)} />
))}
</div>
) : (
<div className="flex flex-col gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<SkeletonCard key={i} />
))}
</div>
)}
</div>
);
}
if (data.length === 0) {
return (
<div className={className}>
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
</div>
);
}
return (
<div className={cn('flex flex-col gap-4', className)}>
{isDesktop ? (
// `overflow-x-auto` (não `overflow-hidden`): com muitas colunas perto
// do breakpoint `md`, a tabela pode ficar mais larga que o
// container — sem scroll horizontal, a(s) última(s) coluna(s)
// simplesmente sumiam cortadas em vez de ficarem acessíveis
// (achado da Etapa 15, testando `/master/tenants` em 768px).
<div className="overflow-x-auto overflow-y-hidden rounded-lg border border-border">
<Table>
<TableHeader>
<TableRow>
{columns.map((column) => (
<TableHead key={column.id} className={cn('group', column.className)}>
{column.sortable && onSortChange ? (
<SortButton column={column} sort={sort} onSortChange={onSortChange} />
) : (
column.header
)}
</TableHead>
))}
{rowActions && (
<TableHead className="w-px">
<span className="sr-only">Ações</span>
</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{data.map((row) => (
<TableRow
key={keyField(row)}
className={onRowClick ? 'cursor-pointer' : undefined}
onClick={onRowClick ? () => onRowClick(row) : undefined}
tabIndex={onRowClick ? 0 : undefined}
onKeyDown={
onRowClick
? (e) => {
if (e.key === 'Enter') onRowClick(row);
}
: undefined
}
>
{columns.map((column) => (
<TableCell key={column.id} className={cn('tabular-nums', column.className)}>
{column.cell(row)}
</TableCell>
))}
{rowActions && (
<TableCell onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2">{rowActions(row)}</div>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="flex flex-col gap-3">
{data.map((row) => (
<MobileRowCard
key={keyField(row)}
row={row}
columns={columns}
rowActions={rowActions}
onRowClick={onRowClick}
/>
))}
</div>
)}
{pagination && <DataPagination {...pagination} />}
</div>
);
}
export { DataTable };
+128
View File
@@ -0,0 +1,128 @@
'use client';
import * as React from 'react';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { CalendarIcon } from 'lucide-react';
import type { DateRange } from 'react-day-picker';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
interface DatePickerProps {
value?: Date;
onChange: (date: Date | undefined) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
id?: string;
'aria-invalid'?: boolean;
'aria-describedby'?: string;
}
/** Seleção de data única — usada em formulários (ex: vencimento de parcela). */
function DatePicker({
value,
onChange,
placeholder = 'Selecionar data',
disabled,
className,
id,
...aria
}: DatePickerProps) {
const [open, setOpen] = React.useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
id={id}
variant="outline"
disabled={disabled}
className={cn(
'w-full justify-start font-normal',
!value && 'text-muted-foreground',
className,
)}
{...aria}
>
<CalendarIcon />
{value ? format(value, "dd 'de' MMMM 'de' yyyy", { locale: ptBR }) : placeholder}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={value}
onSelect={(date) => {
onChange(date);
setOpen(false);
}}
locale={ptBR}
autoFocus
/>
</PopoverContent>
</Popover>
);
}
interface DateRangePickerProps {
value?: DateRange;
onChange: (range: DateRange | undefined) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
id?: string;
}
/** Seleção de período — usada nos filtros de dashboard/listagens (seção 7 do schema). */
function DateRangePicker({
value,
onChange,
placeholder = 'Selecionar período',
disabled,
className,
id,
}: DateRangePickerProps) {
const [open, setOpen] = React.useState(false);
const label = React.useMemo(() => {
if (!value?.from) return placeholder;
if (!value.to) return format(value.from, 'dd/MM/yyyy', { locale: ptBR });
return `${format(value.from, 'dd/MM/yyyy', { locale: ptBR })} – ${format(value.to, 'dd/MM/yyyy', { locale: ptBR })}`;
}, [value, placeholder]);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
id={id}
variant="outline"
disabled={disabled}
className={cn(
'w-full justify-start font-normal',
!value?.from && 'text-muted-foreground',
className,
)}
>
<CalendarIcon />
{label}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="range"
selected={value}
onSelect={onChange}
locale={ptBR}
numberOfMonths={2}
autoFocus
/>
</PopoverContent>
</Popover>
);
}
export { DatePicker, DateRangePicker };
+145
View File
@@ -0,0 +1,145 @@
'use client';
import * as React from 'react';
import { Dialog as DialogPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { XIcon } from 'lucide-react';
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
'fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0',
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
'fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm">
<XIcon />
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div data-slot="dialog-header" className={cn('flex flex-col gap-2', className)} {...props} />
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<'div'> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
'-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn('text-base leading-none font-medium', className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
'text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground',
className,
)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+41
View File
@@ -0,0 +1,41 @@
import * as React from 'react';
import { InboxIcon, type LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
interface EmptyStateProps {
title: string;
description?: string;
icon?: LucideIcon;
action?: React.ReactNode;
className?: string;
}
/** Usado em toda listagem vazia do sistema (Etapa 12), ex: "Nenhum cliente cadastrado ainda". */
function EmptyState({
title,
description,
icon: Icon = InboxIcon,
action,
className,
}: EmptyStateProps) {
return (
<div
className={cn(
'flex flex-col items-center justify-center gap-3 px-4 py-12 text-center',
className,
)}
>
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
<Icon className="size-6 text-muted-foreground" aria-hidden />
</div>
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-foreground">{title}</p>
{description && <p className="text-sm text-muted-foreground">{description}</p>}
</div>
{action}
</div>
);
}
export { EmptyState };
+66
View File
@@ -0,0 +1,66 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Label } from '@/components/ui/label';
interface FormFieldProps {
label: string;
children: React.ReactElement<{
id?: string;
'aria-invalid'?: boolean;
'aria-describedby'?: string;
}>;
error?: string;
helpText?: string;
required?: boolean;
className?: string;
id?: string;
}
/**
* Wrapper obrigatório para label + input + erro + texto de ajuda —
* nenhuma tela deve montar esse trio manualmente (Etapa 12). Injeta
* `id`/`aria-invalid`/`aria-describedby` no único filho, então `children`
* precisa ser um componente que espalha `...props` no elemento nativo
* (Input, Textarea, SelectTrigger etc).
*/
function FormField({ label, children, error, helpText, required, className, id }: FormFieldProps) {
const autoId = React.useId();
const fieldId = id ?? autoId;
const errorId = `${fieldId}-error`;
const helpId = `${fieldId}-help`;
const describedBy =
[error ? errorId : null, helpText ? helpId : null].filter(Boolean).join(' ') || undefined;
return (
<div className={cn('flex flex-col gap-1.5', className)}>
<Label htmlFor={fieldId}>
{label}
{required && (
<span className="text-destructive" aria-hidden>
{' '}
*
</span>
)}
</Label>
{React.cloneElement(children, {
id: fieldId,
'aria-invalid': !!error,
'aria-describedby': describedBy,
})}
{helpText && !error && (
<p id={helpId} className="text-xs text-muted-foreground">
{helpText}
</p>
)}
{error && (
<p id={errorId} role="alert" className="text-xs text-destructive">
{error}
</p>
)}
</div>
);
}
export { FormField };
+144
View File
@@ -0,0 +1,144 @@
'use client';
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
'group/input-group relative flex h-11 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5',
className,
)}
{...props}
/>
);
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
'inline-start': 'order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]',
'inline-end': 'order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]',
'block-start':
'order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2',
'block-end':
'order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2',
},
},
defaultVariants: {
align: 'inline-start',
},
},
);
function InputGroupAddon({
className,
align = 'inline-start',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest('button')) {
return;
}
e.currentTarget.parentElement?.querySelector('input')?.focus();
}}
{...props}
/>
);
}
const inputGroupButtonVariants = cva('flex items-center gap-2 text-sm shadow-none', {
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: '',
'icon-xs': 'size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0',
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
},
},
defaultVariants: {
size: 'xs',
},
});
function InputGroupButton({
className,
type = 'button',
variant = 'ghost',
size = 'xs',
...props
}: Omit<React.ComponentProps<typeof Button>, 'size'> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
);
}
function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function InputGroupInput({ className, ...props }: React.ComponentProps<'input'>) {
return (
<Input
data-slot="input-group-control"
className={cn(
'flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent',
className,
)}
{...props}
/>
);
}
function InputGroupTextarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
'flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent',
className,
)}
{...props}
/>
);
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
};
+19
View File
@@ -0,0 +1,19 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
'h-11 w-full min-w-0 rounded-lg border border-input bg-transparent px-3 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm',
className,
)}
{...props}
/>
);
}
export { Input };
+21
View File
@@ -0,0 +1,21 @@
'use client';
import * as React from 'react';
import { Label as LabelPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className,
)}
{...props}
/>
);
}
export { Label };
+74
View File
@@ -0,0 +1,74 @@
'use client';
import * as React from 'react';
import { Popover as PopoverPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 flex w-72 origin-(--radix-popover-content-transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
function PopoverHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="popover-header"
className={cn('flex flex-col gap-0.5 text-sm', className)}
{...props}
/>
);
}
function PopoverTitle({ className, ...props }: React.ComponentProps<'h2'>) {
return <div data-slot="popover-title" className={cn('font-medium', className)} {...props} />;
}
function PopoverDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot="popover-description"
className={cn('text-muted-foreground', className)}
{...props}
/>
);
}
export {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
};
@@ -0,0 +1,85 @@
'use client';
import * as React from 'react';
import { useIsDesktop } from '@/hooks/use-media-query';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
interface ResponsiveDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: string;
children: React.ReactNode;
footer?: React.ReactNode;
className?: string;
}
/**
* Um único componente para modal (Etapa 12): centralizado no desktop
* (`Dialog`), bottom sheet no mobile (`Sheet` com `side="bottom"`) —
* escolhido em vez de tela cheia porque a maioria dos formulários deste
* projeto (confirmações, cadastro rápido de cliente, edição de parcela)
* é curta o bastante para caber sem precisar ocupar a tela inteira;
* `max-h-[85vh]` com scroll interno cobre os casos mais longos. Alterna
* no mesmo breakpoint `md` usado pela `Table`.
*/
function ResponsiveDialog({
open,
onOpenChange,
title,
description,
children,
footer,
className,
}: ResponsiveDialogProps) {
const isDesktop = useIsDesktop();
if (isDesktop) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={className}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
{children}
{footer && <DialogFooter>{footer}</DialogFooter>}
</DialogContent>
</Dialog>
);
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="bottom"
className={`max-h-[85vh] overflow-y-auto rounded-t-xl ${className ?? ''}`}
>
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
{description && <SheetDescription>{description}</SheetDescription>}
</SheetHeader>
<div className="px-4">{children}</div>
{footer && <SheetFooter>{footer}</SheetFooter>}
</SheetContent>
</Sheet>
);
}
export { ResponsiveDialog };
+185
View File
@@ -0,0 +1,185 @@
'use client';
import * as React from 'react';
import { Select as SelectPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from 'lucide-react';
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn('scroll-my-1 p-1', className)}
{...props}
/>
);
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = 'default',
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: 'sm' | 'default';
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-full items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-3 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-11 data-[size=sm]:h-8 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = 'item-aligned',
align = 'center',
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === 'item-aligned'}
className={cn(
'relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
'data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)',
position === 'popper' && '',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn('px-1.5 py-1 text-xs text-muted-foreground', className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn('pointer-events-none -mx-1 my-1 h-px bg-border', className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
+28
View File
@@ -0,0 +1,28 @@
'use client';
import * as React from 'react';
import { Separator as SeparatorPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
function Separator({
className,
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch',
className,
)}
{...props}
/>
);
}
export { Separator };
+130
View File
@@ -0,0 +1,130 @@
'use client';
import * as React from 'react';
import { Dialog as SheetPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { XIcon } from 'lucide-react';
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
'fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0',
className,
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = 'right',
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: 'top' | 'right' | 'bottom' | 'left';
showCloseButton?: boolean;
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
data-side={side}
className={cn(
'fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close data-slot="sheet-close" asChild>
<Button variant="ghost" className="absolute top-3 right-3" size="icon-sm">
<XIcon />
<span className="sr-only">Close</span>
</Button>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sheet-header"
className={cn('flex flex-col gap-0.5 p-4', className)}
{...props}
/>
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sheet-footer"
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
);
}
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn('text-base font-medium text-foreground', className)}
{...props}
/>
);
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};
+34
View File
@@ -0,0 +1,34 @@
import { cn } from '@/lib/utils';
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="skeleton"
className={cn('animate-pulse rounded-md bg-muted', className)}
{...props}
/>
);
}
/** Linha de texto — usada em qualquer trecho aguardando conteúdo textual. */
function SkeletonText({ className, ...props }: React.ComponentProps<'div'>) {
return <Skeleton className={cn('h-4 w-full', className)} {...props} />;
}
/** Bloco de card — usada em grids de dashboard enquanto os dados carregam. */
function SkeletonCard({ className, ...props }: React.ComponentProps<'div'>) {
return <Skeleton className={cn('h-32 w-full rounded-xl', className)} {...props} />;
}
/** Linha de tabela — usada pela `DataTable` no estado de loading. */
function SkeletonTableRow({ columns = 4 }: { columns?: number }) {
return (
<div className="flex items-center gap-4 border-b border-border px-2 py-3 last:border-0">
{Array.from({ length: columns }).map((_, index) => (
<Skeleton key={index} className="h-4 flex-1" />
))}
</div>
);
}
export { Skeleton, SkeletonText, SkeletonCard, SkeletonTableRow };
+45
View File
@@ -0,0 +1,45 @@
'use client';
import { useTheme } from 'next-themes';
import { Toaster as Sonner, type ToasterProps } from 'sonner';
import {
CircleCheckIcon,
InfoIcon,
TriangleAlertIcon,
OctagonXIcon,
Loader2Icon,
} from 'lucide-react';
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme();
return (
<Sonner
theme={theme as ToasterProps['theme']}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
'--border-radius': 'var(--radius)',
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: 'cn-toast',
},
}}
{...props}
/>
);
};
export { Toaster };
+58
View File
@@ -0,0 +1,58 @@
import { cn } from '@/lib/utils';
export const STATUS_PILL_TONES = ['success', 'warning', 'danger', 'primary', 'neutral'] as const;
type StatusPillTone = (typeof STATUS_PILL_TONES)[number];
/**
* Status de negócio já existentes no backend (Etapas 1–8) → cor semântica
* + label em português, resolvidos uma única vez aqui para não repetir
* esse mapeamento em cada tela (Etapa 12).
*/
export const STATUS_PILL_MAP = {
pendente: { tone: 'warning', label: 'Pendente' },
pago: { tone: 'success', label: 'Pago' },
atrasado: { tone: 'danger', label: 'Atrasado' },
cancelado: { tone: 'neutral', label: 'Cancelado' },
ativo: { tone: 'success', label: 'Ativo' },
inadimplente: { tone: 'danger', label: 'Inadimplente' },
suspenso: { tone: 'warning', label: 'Suspenso' },
trial: { tone: 'primary', label: 'Trial' },
confirmada: { tone: 'primary', label: 'Confirmada' },
concluida: { tone: 'success', label: 'Concluída' },
orcamento: { tone: 'neutral', label: 'Orçamento' },
cancelada: { tone: 'neutral', label: 'Cancelada' },
} as const satisfies Record<string, { tone: StatusPillTone; label: string }>;
export type BusinessStatus = keyof typeof STATUS_PILL_MAP;
const toneClasses: Record<StatusPillTone, string> = {
success: 'bg-success/10 text-success',
warning: 'bg-warning/10 text-warning',
danger: 'bg-danger/10 text-danger',
primary: 'bg-primary/10 text-primary',
neutral: 'bg-neutral-500/10 text-neutral-500',
};
interface StatusPillProps {
status: BusinessStatus;
className?: string;
}
function StatusPill({ status, className }: StatusPillProps) {
const config = STATUS_PILL_MAP[status];
return (
<span
data-slot="status-pill"
className={cn(
'inline-flex h-5 w-fit shrink-0 items-center justify-center rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap',
toneClasses[config.tone],
className,
)}
>
{config.label}
</span>
);
}
export { StatusPill };

Some files were not shown because too many files have changed in this diff Show More