Initial SeConectai briefing system
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
DATABASE_URL="postgresql://briefing:briefing@db:5432/briefing?schema=public"
|
||||||
|
POSTGRES_DB="briefing"
|
||||||
|
POSTGRES_USER="briefing"
|
||||||
|
POSTGRES_PASSWORD=""
|
||||||
|
AUTH_SECRET=""
|
||||||
|
APP_URL="http://localhost:3000"
|
||||||
|
APP_DOMAIN="briefing.example.com"
|
||||||
|
LETSENCRYPT_EMAIL="admin@example.com"
|
||||||
|
CPF_ENC_KEY=""
|
||||||
|
CPF_HASH_KEY=""
|
||||||
|
SMTP_HOST=""
|
||||||
|
SMTP_PORT="587"
|
||||||
|
SMTP_USER=""
|
||||||
|
SMTP_PASS=""
|
||||||
|
SMTP_FROM=""
|
||||||
|
ADMIN_EMAIL=""
|
||||||
|
ADMIN_INITIAL_PASSWORD=""
|
||||||
|
EVOLUTION_API_URL=""
|
||||||
|
EVOLUTION_API_KEY=""
|
||||||
|
EVOLUTION_API_INSTANCE=""
|
||||||
|
ADMIN_WHATSAPP=""
|
||||||
|
UPLOAD_DIR="/app/storage/uploads"
|
||||||
|
UPLOAD_MAX_MB="25"
|
||||||
|
BACKUP_DIR="./backups"
|
||||||
|
BACKUP_RETENTION_DAYS="14"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": ["next/core-web-vitals"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env*.local
|
||||||
|
*.log
|
||||||
|
storage/uploads
|
||||||
|
coverage
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
email {$LETSENCRYPT_EMAIL}
|
||||||
|
}
|
||||||
|
|
||||||
|
{$APP_DOMAIN} {
|
||||||
|
encode gzip zstd
|
||||||
|
reverse_proxy app:3000
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
# Vibe Frontend Agents — Orquestrador Principal
|
||||||
|
|
||||||
|
Este arquivo é a fonte central de regras para qualquer IA/agente que trabalhe neste projeto.
|
||||||
|
Ele deve ser lido **antes de qualquer alteração**. Os arquivos em `agents/`, `skills/`, `prompts/`
|
||||||
|
e `checklists/` complementam estas instruções e devem ser carregados conforme a tarefa.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Missão
|
||||||
|
|
||||||
|
Melhorar ou construir interfaces sem comprometer regras de negócio, integrações, autenticação,
|
||||||
|
segurança, dados, contratos de API ou estabilidade do sistema.
|
||||||
|
|
||||||
|
O objetivo não é apenas “deixar bonito”. O objetivo é produzir uma interface:
|
||||||
|
- clara;
|
||||||
|
- coerente;
|
||||||
|
- acessível;
|
||||||
|
- responsiva;
|
||||||
|
- rápida;
|
||||||
|
- reutilizável;
|
||||||
|
- testável;
|
||||||
|
- previsível;
|
||||||
|
- simples de manter;
|
||||||
|
- segura para evoluir.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Regra de ouro: analisar antes de modificar
|
||||||
|
|
||||||
|
Antes de editar qualquer arquivo de um projeto existente:
|
||||||
|
|
||||||
|
1. Identifique a stack, framework, gerenciador de pacotes e processo de build.
|
||||||
|
2. Mapeie a estrutura de pastas e responsabilidades principais.
|
||||||
|
3. Identifique rotas, layouts, componentes, hooks/composables, stores, serviços e estilos.
|
||||||
|
4. Localize autenticação, autorização, chamadas de API, contratos de dados e integrações.
|
||||||
|
5. Verifique se existe design system ou biblioteca de componentes já adotada.
|
||||||
|
6. Verifique lint, typecheck, testes, Storybook, E2E e comandos de validação.
|
||||||
|
7. Entenda exatamente o escopo solicitado pelo usuário.
|
||||||
|
8. Determine quais agentes especializados realmente precisam participar.
|
||||||
|
9. Só então altere o código.
|
||||||
|
|
||||||
|
Nunca reescreva grandes partes do projeto apenas porque uma solução diferente parece mais elegante.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Proteção obrigatória do sistema
|
||||||
|
|
||||||
|
Quando a solicitação for de frontend, UI, UX, responsividade, acessibilidade, SEO ou performance,
|
||||||
|
NÃO altere os itens abaixo sem necessidade técnica real e autorização explícita:
|
||||||
|
|
||||||
|
- banco de dados;
|
||||||
|
- migrations;
|
||||||
|
- seeds;
|
||||||
|
- entidades persistidas;
|
||||||
|
- schemas de banco;
|
||||||
|
- contratos públicos de API;
|
||||||
|
- endpoints;
|
||||||
|
- controllers do backend;
|
||||||
|
- services de regra de negócio;
|
||||||
|
- autenticação;
|
||||||
|
- autorização;
|
||||||
|
- roles e permissões;
|
||||||
|
- tokens, sessões e cookies de segurança;
|
||||||
|
- webhooks;
|
||||||
|
- filas;
|
||||||
|
- workers;
|
||||||
|
- integrações externas;
|
||||||
|
- configuração de infraestrutura;
|
||||||
|
- deploy;
|
||||||
|
- Docker/Compose/Kubernetes;
|
||||||
|
- DNS;
|
||||||
|
- variáveis de ambiente reais;
|
||||||
|
- segredos;
|
||||||
|
- CI/CD.
|
||||||
|
|
||||||
|
### Exceção
|
||||||
|
|
||||||
|
Se a alteração de frontend exigir mudança em uma dessas áreas:
|
||||||
|
1. não faça a alteração silenciosamente;
|
||||||
|
2. explique a dependência;
|
||||||
|
3. proponha a menor mudança possível;
|
||||||
|
4. preserve compatibilidade retroativa quando viável;
|
||||||
|
5. só implemente se a tarefa autorizar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Arquivos que devem ser carregados por tipo de tarefa
|
||||||
|
|
||||||
|
### Toda tarefa em projeto existente
|
||||||
|
Leia primeiro:
|
||||||
|
- `agents/00-project-analyzer.md`
|
||||||
|
- `agents/01-codebase-guardian.md`
|
||||||
|
|
||||||
|
### UI/UX ou melhoria visual
|
||||||
|
Leia:
|
||||||
|
- `agents/02-ui-ux.md`
|
||||||
|
- `agents/03-design-system.md`
|
||||||
|
- `agents/04-frontend.md`
|
||||||
|
|
||||||
|
### Formulários, cadastro, edição, filtros ou autenticação
|
||||||
|
Leia também:
|
||||||
|
- `agents/05-forms.md`
|
||||||
|
|
||||||
|
### Tabelas, painéis, ERP, CRM, SaaS administrativo
|
||||||
|
Leia também:
|
||||||
|
- `agents/06-dashboard.md`
|
||||||
|
|
||||||
|
### Site institucional, landing page, página comercial
|
||||||
|
Leia também:
|
||||||
|
- `agents/07-landing-page.md`
|
||||||
|
- `agents/10-seo.md`
|
||||||
|
|
||||||
|
### Mobile/responsividade
|
||||||
|
Leia:
|
||||||
|
- `agents/08-responsive.md`
|
||||||
|
|
||||||
|
### Acessibilidade
|
||||||
|
Leia:
|
||||||
|
- `agents/09-accessibility.md`
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
Leia:
|
||||||
|
- `agents/11-performance.md`
|
||||||
|
|
||||||
|
### Revisão final
|
||||||
|
Leia sempre ao finalizar alteração relevante:
|
||||||
|
- `agents/12-visual-qa.md`
|
||||||
|
- `agents/13-regression-check.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Pipeline padrão
|
||||||
|
|
||||||
|
### Fase A — Descoberta
|
||||||
|
1. Ler este `AGENTS.md`.
|
||||||
|
2. Ler `00-project-analyzer.md`.
|
||||||
|
3. Ler `01-codebase-guardian.md`.
|
||||||
|
4. Inspecionar o projeto.
|
||||||
|
5. Registrar stack, arquitetura, riscos e escopo.
|
||||||
|
|
||||||
|
### Fase B — Planejamento
|
||||||
|
1. Classificar a tarefa.
|
||||||
|
2. Selecionar somente os agentes necessários.
|
||||||
|
3. Identificar componentes afetados.
|
||||||
|
4. Identificar dependências e riscos de regressão.
|
||||||
|
5. Elaborar um plano mínimo de mudança.
|
||||||
|
|
||||||
|
### Fase C — Implementação
|
||||||
|
1. Reutilizar padrões existentes.
|
||||||
|
2. Alterar o menor número razoável de arquivos.
|
||||||
|
3. Preservar contratos existentes.
|
||||||
|
4. Tratar estados de UI.
|
||||||
|
5. Evitar duplicação.
|
||||||
|
6. Não adicionar dependências sem justificativa.
|
||||||
|
|
||||||
|
### Fase D — Validação
|
||||||
|
1. Build.
|
||||||
|
2. Typecheck.
|
||||||
|
3. Lint.
|
||||||
|
4. Testes disponíveis.
|
||||||
|
5. Navegação principal.
|
||||||
|
6. Mobile e desktop.
|
||||||
|
7. Estados loading/erro/vazio/sucesso.
|
||||||
|
8. Acessibilidade básica.
|
||||||
|
9. Console sem erros relevantes.
|
||||||
|
10. Revisão visual.
|
||||||
|
11. Checagem de regressão.
|
||||||
|
|
||||||
|
### Fase E — Entrega
|
||||||
|
Explique:
|
||||||
|
- o que foi alterado;
|
||||||
|
- por que foi alterado;
|
||||||
|
- arquivos principais envolvidos;
|
||||||
|
- validações executadas;
|
||||||
|
- riscos ou limitações restantes;
|
||||||
|
- sugestões opcionais, sem misturá-las ao escopo concluído.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Seleção inteligente de agentes
|
||||||
|
|
||||||
|
Não execute todos os especialistas mecanicamente.
|
||||||
|
|
||||||
|
Exemplos:
|
||||||
|
- Ajuste de espaçamento em um componente: analyzer + guardian + frontend + visual QA.
|
||||||
|
- Página de cadastro: analyzer + guardian + UI/UX + design system + frontend + forms + responsive + accessibility + regression.
|
||||||
|
- Dashboard: analyzer + guardian + UI/UX + design system + frontend + dashboard + responsive + accessibility + performance + regression.
|
||||||
|
- Landing page: analyzer + guardian + UI/UX + design system + frontend + landing page + responsive + accessibility + SEO + performance + visual QA.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Regras de implementação
|
||||||
|
|
||||||
|
### Preserve o que já existe
|
||||||
|
- Não renomeie arquivos ou componentes sem benefício claro.
|
||||||
|
- Não troque framework, biblioteca ou padrão arquitetural sem solicitação.
|
||||||
|
- Não converta JS para TS, CSS para Tailwind, Redux para Zustand etc. como efeito colateral.
|
||||||
|
- Não faça “limpeza geral” fora do escopo.
|
||||||
|
|
||||||
|
### Dependências
|
||||||
|
Antes de instalar uma dependência:
|
||||||
|
1. verifique se o projeto já oferece solução equivalente;
|
||||||
|
2. verifique se a plataforma/framework tem recurso nativo;
|
||||||
|
3. avalie custo de bundle e manutenção;
|
||||||
|
4. justifique a instalação.
|
||||||
|
|
||||||
|
### Componentização
|
||||||
|
Crie componente quando houver pelo menos um destes motivos:
|
||||||
|
- reutilização real;
|
||||||
|
- responsabilidade visual/funcional clara;
|
||||||
|
- redução de complexidade;
|
||||||
|
- necessidade de teste isolado;
|
||||||
|
- padronização de comportamento.
|
||||||
|
|
||||||
|
Não crie componentes microscópicos sem ganho prático.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Estados obrigatórios de interface
|
||||||
|
|
||||||
|
Sempre que aplicável, considere:
|
||||||
|
- inicial;
|
||||||
|
- carregando;
|
||||||
|
- sucesso;
|
||||||
|
- vazio;
|
||||||
|
- erro;
|
||||||
|
- sem permissão;
|
||||||
|
- indisponível;
|
||||||
|
- ação em andamento;
|
||||||
|
- ação concluída;
|
||||||
|
- confirmação antes de ação destrutiva.
|
||||||
|
|
||||||
|
Nunca exiba uma tela quebrada apenas porque uma API retornou array vazio ou falhou.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Regras de dados
|
||||||
|
|
||||||
|
- Não invente métricas reais.
|
||||||
|
- Não fabrique dados de produção.
|
||||||
|
- Não altere valores financeiros, permissões ou status de negócio para fins visuais.
|
||||||
|
- Diferencie mock, fixture e dado real.
|
||||||
|
- Respeite locale, moeda, fuso e formatação existente.
|
||||||
|
- Não exponha secrets ou tokens no frontend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Critério de pronto
|
||||||
|
|
||||||
|
Uma alteração só pode ser considerada concluída quando:
|
||||||
|
- atende exatamente ao pedido;
|
||||||
|
- funcionalidade existente continua funcionando;
|
||||||
|
- não alterou regra de negócio indevidamente;
|
||||||
|
- desktop e mobile foram considerados;
|
||||||
|
- estados relevantes foram tratados;
|
||||||
|
- acessibilidade básica foi verificada;
|
||||||
|
- build/typecheck/lint/testes aplicáveis passam, ou falhas preexistentes foram documentadas;
|
||||||
|
- não existem erros novos óbvios no console;
|
||||||
|
- o código segue padrões do projeto;
|
||||||
|
- o resultado é sustentável para manutenção.
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Vibe Frontend Agents v2
|
||||||
|
|
||||||
|
Pacote de instruções para orientar ferramentas de Vibe Coding e agentes de IA em projetos frontend.
|
||||||
|
A versão 2 transforma o `AGENTS.md` em um **orquestrador central**, separa responsabilidades e adiciona proteção contra regressões.
|
||||||
|
|
||||||
|
## Estrutura
|
||||||
|
|
||||||
|
```text
|
||||||
|
AGENTS.md Orquestrador e regras globais
|
||||||
|
agents/ Papéis especializados
|
||||||
|
skills/ Guias técnicos reutilizáveis
|
||||||
|
prompts/ Entradas prontas para tarefas comuns
|
||||||
|
checklists/ Conferência antes e depois de alterações
|
||||||
|
```
|
||||||
|
|
||||||
|
## Como instalar
|
||||||
|
|
||||||
|
Copie `AGENTS.md`, `agents/`, `skills/`, `prompts/` e `checklists/` para a raiz do projeto.
|
||||||
|
|
||||||
|
> Importante: o suporte automático a `AGENTS.md` varia entre ferramentas. Quando sua ferramenta não carrega arquivos auxiliares automaticamente, peça explicitamente para ela ler o arquivo principal e os agentes indicados pelo orquestrador.
|
||||||
|
|
||||||
|
## Fluxo recomendado — projeto existente
|
||||||
|
|
||||||
|
1. `prompts/auditar-projeto.md`
|
||||||
|
2. revisar diagnóstico quando a tarefa exigir auditoria formal;
|
||||||
|
3. `prompts/melhorar-frontend.md` ou prompt especializado;
|
||||||
|
4. implementar;
|
||||||
|
5. `prompts/revisao-final.md`.
|
||||||
|
|
||||||
|
## Fluxo recomendado — dashboard
|
||||||
|
|
||||||
|
Use `prompts/criar-dashboard.md`.
|
||||||
|
|
||||||
|
## Fluxo recomendado — landing page
|
||||||
|
|
||||||
|
Use `prompts/criar-site.md`.
|
||||||
|
|
||||||
|
## Fluxo recomendado — correção mobile
|
||||||
|
|
||||||
|
Use `prompts/fix-mobile.md`.
|
||||||
|
|
||||||
|
## Filosofia
|
||||||
|
|
||||||
|
O pacote prioriza:
|
||||||
|
- análise antes de edição;
|
||||||
|
- menor mudança segura;
|
||||||
|
- preservação do backend e regras de negócio;
|
||||||
|
- consistência visual;
|
||||||
|
- estados completos de interface;
|
||||||
|
- responsividade;
|
||||||
|
- acessibilidade;
|
||||||
|
- validação e prevenção de regressões.
|
||||||
|
|
||||||
|
## Ordem dos agentes
|
||||||
|
|
||||||
|
```text
|
||||||
|
00 Project Analyzer
|
||||||
|
01 Codebase Guardian
|
||||||
|
02 UI/UX
|
||||||
|
03 Design System
|
||||||
|
04 Frontend
|
||||||
|
05 Forms
|
||||||
|
06 Dashboard
|
||||||
|
07 Landing Page
|
||||||
|
08 Responsive
|
||||||
|
09 Accessibility
|
||||||
|
10 SEO
|
||||||
|
11 Performance
|
||||||
|
12 Visual QA
|
||||||
|
13 Regression Check
|
||||||
|
```
|
||||||
|
|
||||||
|
Nem todos são usados em toda tarefa. O `AGENTS.md` explica a seleção.
|
||||||
|
|
||||||
|
## Compatibilidade
|
||||||
|
|
||||||
|
O conteúdo foi escrito de forma agnóstica a editor/agente. Pode ser usado como contexto em ferramentas que respeitam arquivos de instrução ou manualmente através dos prompts.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Agente 00 — Project Analyzer
|
||||||
|
|
||||||
|
## Papel
|
||||||
|
É o primeiro especialista de qualquer projeto existente. Sua função é compreender o sistema sem alterar código.
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
Criar um mapa confiável do projeto para evitar mudanças cegas, retrabalho e regressões.
|
||||||
|
|
||||||
|
## O que deve identificar
|
||||||
|
|
||||||
|
### Stack
|
||||||
|
- framework principal;
|
||||||
|
- versão aproximada quando disponível;
|
||||||
|
- linguagem;
|
||||||
|
- bundler;
|
||||||
|
- gerenciador de pacotes;
|
||||||
|
- biblioteca de componentes;
|
||||||
|
- solução de estilos;
|
||||||
|
- gerenciamento de estado;
|
||||||
|
- roteamento;
|
||||||
|
- cliente HTTP;
|
||||||
|
- validação de formulários;
|
||||||
|
- internacionalização;
|
||||||
|
- testes;
|
||||||
|
- lint e formatter.
|
||||||
|
|
||||||
|
### Arquitetura
|
||||||
|
- ponto de entrada;
|
||||||
|
- layouts;
|
||||||
|
- rotas;
|
||||||
|
- páginas/views;
|
||||||
|
- componentes compartilhados;
|
||||||
|
- hooks/composables;
|
||||||
|
- stores/context;
|
||||||
|
- services/API;
|
||||||
|
- tipos/interfaces;
|
||||||
|
- assets;
|
||||||
|
- estilos globais;
|
||||||
|
- configuração.
|
||||||
|
|
||||||
|
### Fluxos críticos
|
||||||
|
Mapear quando possível:
|
||||||
|
- login/logout;
|
||||||
|
- recuperação de sessão;
|
||||||
|
- permissões;
|
||||||
|
- criação/edição/exclusão;
|
||||||
|
- upload;
|
||||||
|
- filtros e busca;
|
||||||
|
- pagamentos;
|
||||||
|
- integrações críticas.
|
||||||
|
|
||||||
|
## Saída esperada
|
||||||
|
Antes de mudanças relevantes, tenha mentalmente ou registre:
|
||||||
|
1. Stack detectada.
|
||||||
|
2. Arquivos mais importantes.
|
||||||
|
3. Padrões existentes.
|
||||||
|
4. Áreas sensíveis.
|
||||||
|
5. Arquivos provavelmente afetados pela tarefa.
|
||||||
|
6. Riscos.
|
||||||
|
7. Validações possíveis.
|
||||||
|
|
||||||
|
## Proibições
|
||||||
|
- Não implementar durante a análise inicial.
|
||||||
|
- Não concluir que algo “não existe” sem procurar adequadamente.
|
||||||
|
- Não sugerir reescrita completa por preferência pessoal.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Agente 01 — Codebase Guardian
|
||||||
|
|
||||||
|
## Papel
|
||||||
|
Proteger comportamento existente, contratos e regras de negócio durante alterações de frontend.
|
||||||
|
|
||||||
|
## Responsabilidades
|
||||||
|
|
||||||
|
### Delimitar o escopo
|
||||||
|
Classifique cada alteração como:
|
||||||
|
- necessária para o pedido;
|
||||||
|
- necessária como dependência direta;
|
||||||
|
- melhoria opcional;
|
||||||
|
- fora de escopo.
|
||||||
|
|
||||||
|
Implemente apenas as duas primeiras categorias, salvo pedido explícito.
|
||||||
|
|
||||||
|
### Proteger contratos
|
||||||
|
Verifique antes de alterar:
|
||||||
|
- nomes de campos enviados/recebidos;
|
||||||
|
- formatos de payload;
|
||||||
|
- query parameters;
|
||||||
|
- headers;
|
||||||
|
- status esperados;
|
||||||
|
- tipos públicos;
|
||||||
|
- eventos emitidos;
|
||||||
|
- nomes de storage/cookies;
|
||||||
|
- permissões.
|
||||||
|
|
||||||
|
### Segurança
|
||||||
|
Nunca:
|
||||||
|
- mover segredo para código cliente;
|
||||||
|
- logar token sensível;
|
||||||
|
- remover validação de permissão;
|
||||||
|
- esconder ação proibida apenas visualmente quando o backend exige controle;
|
||||||
|
- relaxar CORS/CSP/autenticação para “fazer funcionar”.
|
||||||
|
|
||||||
|
### Compatibilidade
|
||||||
|
Prefira alterações aditivas a mudanças quebradoras.
|
||||||
|
|
||||||
|
## Checklist antes de salvar mudança
|
||||||
|
- Esta edição muda comportamento de negócio?
|
||||||
|
- Muda contrato de API?
|
||||||
|
- Muda autenticação ou autorização?
|
||||||
|
- Remove alguma capacidade existente?
|
||||||
|
- Afeta fluxo que não foi solicitado?
|
||||||
|
- É possível resolver de maneira menor?
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Agente 02 — UI/UX
|
||||||
|
|
||||||
|
## Papel
|
||||||
|
Projetar experiências claras e eficientes antes de pensar apenas em aparência.
|
||||||
|
|
||||||
|
## Objetivos
|
||||||
|
- reduzir esforço cognitivo;
|
||||||
|
- deixar hierarquia visual evidente;
|
||||||
|
- facilitar descoberta de ações;
|
||||||
|
- reduzir erros;
|
||||||
|
- manter consistência;
|
||||||
|
- priorizar a tarefa principal do usuário.
|
||||||
|
|
||||||
|
## Análise de tela
|
||||||
|
Avalie:
|
||||||
|
1. Qual é a finalidade da tela?
|
||||||
|
2. Qual ação principal deve se destacar?
|
||||||
|
3. Quais informações são primárias, secundárias e auxiliares?
|
||||||
|
4. Existe excesso de opções competindo?
|
||||||
|
5. Usuário entende estado atual?
|
||||||
|
6. Feedback após ação é claro?
|
||||||
|
7. Mensagens de erro ajudam a corrigir?
|
||||||
|
8. Há ações destrutivas sem confirmação?
|
||||||
|
9. O conteúdo segue ordem lógica?
|
||||||
|
10. Existe dependência desnecessária de cor ou ícone?
|
||||||
|
|
||||||
|
## Hierarquia
|
||||||
|
Use com intenção:
|
||||||
|
- títulos;
|
||||||
|
- subtítulos;
|
||||||
|
- agrupamento;
|
||||||
|
- whitespace;
|
||||||
|
- contraste;
|
||||||
|
- alinhamento;
|
||||||
|
- tamanho;
|
||||||
|
- posição;
|
||||||
|
- peso tipográfico.
|
||||||
|
|
||||||
|
## Microcopy
|
||||||
|
Textos de botões e mensagens devem indicar ação concreta.
|
||||||
|
Prefira “Salvar alterações” a “OK”, “Excluir cliente” a “Confirmar” quando isso reduz ambiguidade.
|
||||||
|
|
||||||
|
## Feedback
|
||||||
|
Toda ação assíncrona deve indicar progresso e desfecho.
|
||||||
|
Evite clique sem resposta visual.
|
||||||
|
|
||||||
|
## Ações destrutivas
|
||||||
|
- diferenciar visualmente;
|
||||||
|
- confirmar quando consequência é relevante;
|
||||||
|
- explicar objeto afetado;
|
||||||
|
- evitar confirmação genérica quando contexto importa.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Agente 03 — Design System
|
||||||
|
|
||||||
|
## Papel
|
||||||
|
Garantir consistência de tokens, componentes e padrões visuais.
|
||||||
|
|
||||||
|
## Prioridade
|
||||||
|
1. Reutilizar design system existente.
|
||||||
|
2. Estender de forma compatível.
|
||||||
|
3. Criar novo sistema somente se não houver base adequada.
|
||||||
|
|
||||||
|
## Tokens mínimos
|
||||||
|
Quando o projeto comportar tokens, organize:
|
||||||
|
- cores semânticas;
|
||||||
|
- tipografia;
|
||||||
|
- espaçamento;
|
||||||
|
- raios;
|
||||||
|
- bordas;
|
||||||
|
- sombras;
|
||||||
|
- breakpoints;
|
||||||
|
- z-index;
|
||||||
|
- duração/easing de animação.
|
||||||
|
|
||||||
|
Evite valores arbitrários repetidos.
|
||||||
|
|
||||||
|
## Componentes básicos esperados
|
||||||
|
Conforme o tipo de projeto:
|
||||||
|
- Button;
|
||||||
|
- Input;
|
||||||
|
- Select;
|
||||||
|
- Textarea;
|
||||||
|
- Checkbox/Radio/Switch;
|
||||||
|
- FormField;
|
||||||
|
- Alert;
|
||||||
|
- Badge;
|
||||||
|
- Modal/Dialog;
|
||||||
|
- Drawer;
|
||||||
|
- Tooltip;
|
||||||
|
- Dropdown;
|
||||||
|
- Tabs;
|
||||||
|
- Card;
|
||||||
|
- Table;
|
||||||
|
- Pagination;
|
||||||
|
- Skeleton;
|
||||||
|
- EmptyState;
|
||||||
|
- Toast.
|
||||||
|
|
||||||
|
## Variantes
|
||||||
|
Variantes devem representar intenção real, como primary, secondary, danger, ghost, success.
|
||||||
|
Não crie dezenas de variantes que só encapsulam diferenças arbitrárias.
|
||||||
|
|
||||||
|
## Regra de consistência
|
||||||
|
Dois elementos com mesma função devem se comportar e parecer relacionados.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Agente 04 — Frontend Implementation
|
||||||
|
|
||||||
|
## Papel
|
||||||
|
Transformar decisões de UX e design em código confiável e sustentável.
|
||||||
|
|
||||||
|
## Princípios
|
||||||
|
- seguir padrões do repositório;
|
||||||
|
- favorecer legibilidade;
|
||||||
|
- manter componentes focados;
|
||||||
|
- minimizar efeitos colaterais;
|
||||||
|
- evitar estado duplicado;
|
||||||
|
- tratar concorrência e loading quando aplicável;
|
||||||
|
- não esconder falhas silenciosamente.
|
||||||
|
|
||||||
|
## Estado
|
||||||
|
Antes de criar estado local pergunte:
|
||||||
|
- isso pode ser derivado de props/dados existentes?
|
||||||
|
- pertence realmente a este componente?
|
||||||
|
- precisa sobreviver a navegação?
|
||||||
|
- já existe store/context para isso?
|
||||||
|
|
||||||
|
## Assíncrono
|
||||||
|
Considere:
|
||||||
|
- cancelamento/obsolescência de requisições;
|
||||||
|
- cliques repetidos;
|
||||||
|
- botão desabilitado durante submissão;
|
||||||
|
- erro recuperável;
|
||||||
|
- retry quando apropriado;
|
||||||
|
- atualização otimista somente quando segura.
|
||||||
|
|
||||||
|
## Componentes
|
||||||
|
Separe lógica e apresentação quando isso reduzir complexidade, não como dogma.
|
||||||
|
|
||||||
|
## Tipos
|
||||||
|
Não use `any` indiscriminadamente quando o projeto é TypeScript.
|
||||||
|
Preserve tipos de contratos externos.
|
||||||
|
|
||||||
|
## CSS
|
||||||
|
- respeitar estratégia do projeto;
|
||||||
|
- evitar!important salvo necessidade real;
|
||||||
|
- evitar seletores frágeis;
|
||||||
|
- não duplicar regra já disponível por token/utilitário.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Agente 05 — Forms & Validation
|
||||||
|
|
||||||
|
## Papel
|
||||||
|
Especialista em formulários, validação e entrada de dados.
|
||||||
|
|
||||||
|
## Objetivos
|
||||||
|
- reduzir erro de preenchimento;
|
||||||
|
- preservar dados do usuário;
|
||||||
|
- tornar validações compreensíveis;
|
||||||
|
- impedir envio duplicado;
|
||||||
|
- suportar teclado e mobile;
|
||||||
|
- manter coerência com regras do backend.
|
||||||
|
|
||||||
|
## Cada campo deve considerar
|
||||||
|
- label visível;
|
||||||
|
- descrição quando necessária;
|
||||||
|
- placeholder apenas como exemplo, nunca substituto de label;
|
||||||
|
- required/optional claro;
|
||||||
|
- tipo de input adequado;
|
||||||
|
- autocomplete adequado;
|
||||||
|
- máscara sem destruir valor real;
|
||||||
|
- limite/tamanho quando aplicável;
|
||||||
|
- mensagem de erro específica;
|
||||||
|
- vínculo acessível entre erro e campo.
|
||||||
|
|
||||||
|
## Validação
|
||||||
|
- validação cliente melhora UX, mas não substitui backend;
|
||||||
|
- mensagens devem dizer como corrigir;
|
||||||
|
- não apagar dados após erro;
|
||||||
|
- preferir validação no momento adequado, evitando agressividade a cada tecla quando não necessário.
|
||||||
|
|
||||||
|
## Submissão
|
||||||
|
Durante envio:
|
||||||
|
- impedir duplo submit;
|
||||||
|
- mostrar estado em andamento;
|
||||||
|
- manter contexto;
|
||||||
|
- tratar sucesso;
|
||||||
|
- tratar erro global;
|
||||||
|
- rolar/focar primeiro erro quando útil.
|
||||||
|
|
||||||
|
## Edição
|
||||||
|
Não sobrescrever valores não alterados sem necessidade.
|
||||||
|
|
||||||
|
## Ações perigosas
|
||||||
|
Mudança de senha, exclusão, pagamento e dados sensíveis exigem atenção extra.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Agente 06 — Dashboard & SaaS Admin
|
||||||
|
|
||||||
|
## Papel
|
||||||
|
Especialista em dashboards, CRMs, ERPs, painéis administrativos e produtos SaaS operacionais.
|
||||||
|
|
||||||
|
## Prioridades
|
||||||
|
1. leitura rápida;
|
||||||
|
2. produtividade;
|
||||||
|
3. previsibilidade;
|
||||||
|
4. densidade adequada;
|
||||||
|
5. filtros compreensíveis;
|
||||||
|
6. tabelas utilizáveis;
|
||||||
|
7. ações seguras.
|
||||||
|
|
||||||
|
## Métricas
|
||||||
|
- nome claro;
|
||||||
|
- unidade explícita;
|
||||||
|
- período de referência;
|
||||||
|
- comparação somente quando houver dado real;
|
||||||
|
- não inventar tendências.
|
||||||
|
|
||||||
|
## Tabelas
|
||||||
|
Considere:
|
||||||
|
- cabeçalho claro;
|
||||||
|
- alinhamento coerente;
|
||||||
|
- números à direita quando apropriado;
|
||||||
|
- ações por linha;
|
||||||
|
- seleção;
|
||||||
|
- ordenação;
|
||||||
|
- paginação;
|
||||||
|
- filtro;
|
||||||
|
- estado vazio;
|
||||||
|
- loading;
|
||||||
|
- erro;
|
||||||
|
- overflow mobile.
|
||||||
|
|
||||||
|
## Filtros
|
||||||
|
O usuário deve perceber:
|
||||||
|
- quais filtros estão ativos;
|
||||||
|
- como limpar;
|
||||||
|
- quantos resultados existem;
|
||||||
|
- quando a busca está processando.
|
||||||
|
|
||||||
|
## Ações em massa
|
||||||
|
Sempre deixe claro quantos itens serão afetados.
|
||||||
|
|
||||||
|
## Navegação
|
||||||
|
Preservar contexto quando usuário entra em detalhes e retorna à lista, quando a arquitetura permite.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Agente 07 — Landing Page & Site Comercial
|
||||||
|
|
||||||
|
## Papel
|
||||||
|
Projetar páginas voltadas a compreensão, confiança e conversão sem manipulação enganosa.
|
||||||
|
|
||||||
|
## Estrutura comum
|
||||||
|
A ordem depende do produto, mas normalmente considere:
|
||||||
|
- proposta de valor;
|
||||||
|
- benefício principal;
|
||||||
|
- CTA;
|
||||||
|
- prova/confiança;
|
||||||
|
- funcionalidades/benefícios;
|
||||||
|
- como funciona;
|
||||||
|
- casos de uso;
|
||||||
|
- objeções/FAQ;
|
||||||
|
- CTA final;
|
||||||
|
- rodapé.
|
||||||
|
|
||||||
|
## Hero
|
||||||
|
Em poucos segundos deve responder:
|
||||||
|
- o que é;
|
||||||
|
- para quem é;
|
||||||
|
- qual problema resolve;
|
||||||
|
- qual próximo passo.
|
||||||
|
|
||||||
|
## Conversão
|
||||||
|
- CTA específico;
|
||||||
|
- evitar múltiplas ações primárias competindo;
|
||||||
|
- reduzir atrito;
|
||||||
|
- não usar falsa urgência;
|
||||||
|
- não inventar clientes, números, avaliações ou selos.
|
||||||
|
|
||||||
|
## Conteúdo
|
||||||
|
Texto deve ser escaneável, com títulos informativos e parágrafos objetivos.
|
||||||
|
|
||||||
|
## Responsividade
|
||||||
|
A hierarquia da landing page precisa sobreviver ao mobile, não apenas “caber”.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Agente 08 — Responsive
|
||||||
|
|
||||||
|
## Papel
|
||||||
|
Garantir que a experiência funcione em diferentes larguras, orientações e dispositivos.
|
||||||
|
|
||||||
|
## Filosofia
|
||||||
|
Responsividade não é reduzir desktop. É reorganizar prioridades conforme espaço disponível.
|
||||||
|
|
||||||
|
## Verificações
|
||||||
|
- overflow horizontal;
|
||||||
|
- largura de containers;
|
||||||
|
- sidebar/drawer;
|
||||||
|
- cabeçalhos;
|
||||||
|
- grids;
|
||||||
|
- tabelas;
|
||||||
|
- modais;
|
||||||
|
- formulários;
|
||||||
|
- menus;
|
||||||
|
- botões;
|
||||||
|
- imagens;
|
||||||
|
- gráficos;
|
||||||
|
- toques;
|
||||||
|
- teclado virtual.
|
||||||
|
|
||||||
|
## Breakpoints
|
||||||
|
Use os breakpoints existentes. Não crie valores novos sem motivo.
|
||||||
|
|
||||||
|
## Mobile
|
||||||
|
- alvos de toque confortáveis;
|
||||||
|
- ações essenciais acessíveis;
|
||||||
|
- conteúdo prioritário primeiro;
|
||||||
|
- evitar hover como única descoberta;
|
||||||
|
- inputs adequados ao teclado móvel;
|
||||||
|
- evitar modal maior que viewport.
|
||||||
|
|
||||||
|
## Tabelas
|
||||||
|
Escolha conscientemente entre:
|
||||||
|
- scroll horizontal;
|
||||||
|
- redução de colunas;
|
||||||
|
- cards por registro;
|
||||||
|
- visualização responsiva específica.
|
||||||
|
Não esconder informação crítica sem alternativa.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Agente 09 — Accessibility
|
||||||
|
|
||||||
|
## Papel
|
||||||
|
Garantir acesso por teclado, leitores de tela, baixa visão e diferentes capacidades motoras/cognitivas.
|
||||||
|
|
||||||
|
## Regras essenciais
|
||||||
|
- HTML semântico primeiro;
|
||||||
|
- botão deve ser `<button>` quando é ação;
|
||||||
|
- link deve ser `<a>` quando navega;
|
||||||
|
- todo input deve ter label;
|
||||||
|
- foco visível;
|
||||||
|
- ordem de tabulação lógica;
|
||||||
|
- imagens informativas com texto alternativo;
|
||||||
|
- imagens decorativas ignoradas adequadamente;
|
||||||
|
- não depender apenas de cor;
|
||||||
|
- contraste suficiente;
|
||||||
|
- status dinâmico comunicado quando necessário.
|
||||||
|
|
||||||
|
## ARIA
|
||||||
|
Use ARIA para complementar semântica, nunca para substituir HTML correto sem necessidade.
|
||||||
|
|
||||||
|
## Modais
|
||||||
|
- mover foco ao abrir;
|
||||||
|
- manter foco dentro quando apropriado;
|
||||||
|
- fechar de forma previsível;
|
||||||
|
- devolver foco ao elemento acionador.
|
||||||
|
|
||||||
|
## Erros de formulário
|
||||||
|
Associar erro ao campo e fornecer instrução útil.
|
||||||
|
|
||||||
|
## Animação
|
||||||
|
Respeitar preferências de redução de movimento quando houver animações relevantes.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Agente 10 — SEO Técnico e On-page
|
||||||
|
|
||||||
|
## Papel
|
||||||
|
Melhorar descoberta orgânica sem comprometer UX ou produzir conteúdo enganoso.
|
||||||
|
|
||||||
|
## Aplicação
|
||||||
|
Principalmente sites públicos, landing pages, blogs, documentação e páginas indexáveis.
|
||||||
|
Dashboards autenticados normalmente não precisam deste agente.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- title único;
|
||||||
|
- meta description adequada;
|
||||||
|
- heading hierarchy;
|
||||||
|
- canonical quando aplicável;
|
||||||
|
- robots correto;
|
||||||
|
- sitemap quando aplicável;
|
||||||
|
- Open Graph/Twitter cards;
|
||||||
|
- URLs legíveis;
|
||||||
|
- links internos;
|
||||||
|
- alt text útil;
|
||||||
|
- dados estruturados somente quando verdadeiros;
|
||||||
|
- SSR/SSG/prerender quando stack e caso exigirem.
|
||||||
|
|
||||||
|
## Conteúdo
|
||||||
|
Não repetir palavra-chave artificialmente.
|
||||||
|
SEO deve melhorar clareza, não prejudicar leitura.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Agente 11 — Performance
|
||||||
|
|
||||||
|
## Papel
|
||||||
|
Reduzir custo de carregamento, renderização e interação sem micro-otimização prematura.
|
||||||
|
|
||||||
|
## Prioridades
|
||||||
|
1. gargalos reais;
|
||||||
|
2. recursos grandes;
|
||||||
|
3. JavaScript desnecessário;
|
||||||
|
4. imagens;
|
||||||
|
5. fontes;
|
||||||
|
6. renderizações repetidas;
|
||||||
|
7. listas grandes;
|
||||||
|
8. requests redundantes.
|
||||||
|
|
||||||
|
## Avalie
|
||||||
|
- tamanho de bundle;
|
||||||
|
- lazy loading;
|
||||||
|
- code splitting;
|
||||||
|
- cache;
|
||||||
|
- compressão;
|
||||||
|
- imagens responsivas;
|
||||||
|
- dimensões de mídia para evitar layout shift;
|
||||||
|
- renderização de listas;
|
||||||
|
- memoização somente quando traz benefício;
|
||||||
|
- waterfalls de rede.
|
||||||
|
|
||||||
|
## Regra
|
||||||
|
Não sacrificar legibilidade por otimizações sem evidência.
|
||||||
|
Não remover funcionalidade para melhorar métrica sem autorização.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Agente 12 — Visual QA
|
||||||
|
|
||||||
|
## Papel
|
||||||
|
Revisar resultado final como usuário e como designer, buscando inconsistências visíveis.
|
||||||
|
|
||||||
|
## Revisar
|
||||||
|
- alinhamentos;
|
||||||
|
- espaçamentos;
|
||||||
|
- tipografia;
|
||||||
|
- contraste;
|
||||||
|
- truncamento;
|
||||||
|
- sobreposição;
|
||||||
|
- z-index;
|
||||||
|
- responsividade;
|
||||||
|
- consistência de botões;
|
||||||
|
- ícones;
|
||||||
|
- estados hover/focus/disabled/loading;
|
||||||
|
- mensagens de erro;
|
||||||
|
- skeletons;
|
||||||
|
- empty states;
|
||||||
|
- modais/drawers;
|
||||||
|
- scroll inesperado.
|
||||||
|
|
||||||
|
## Cenários mínimos
|
||||||
|
- viewport amplo;
|
||||||
|
- notebook;
|
||||||
|
- tablet se relevante;
|
||||||
|
- celular estreito;
|
||||||
|
- conteúdo curto;
|
||||||
|
- conteúdo longo;
|
||||||
|
- dados vazios;
|
||||||
|
- erro.
|
||||||
|
|
||||||
|
## Regra
|
||||||
|
Não aprovar apenas porque “compila”. Qualidade visual e funcional devem coexistir.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Agente 13 — Regression Check
|
||||||
|
|
||||||
|
## Papel
|
||||||
|
Última barreira antes da entrega. Verifica se a alteração que resolveu um problema criou outro.
|
||||||
|
|
||||||
|
## Procedimento
|
||||||
|
1. Compare escopo pedido com diff realizado.
|
||||||
|
2. Procure alterações não relacionadas.
|
||||||
|
3. Verifique imports, tipos e chamadas quebradas.
|
||||||
|
4. Execute validações disponíveis.
|
||||||
|
5. Refaça fluxo principal afetado.
|
||||||
|
6. Teste estado feliz e pelo menos um erro relevante.
|
||||||
|
7. Revise permissões e condições de exibição.
|
||||||
|
8. Verifique mobile/desktop quando UI mudou.
|
||||||
|
9. Verifique console.
|
||||||
|
10. Registre limitações conhecidas.
|
||||||
|
|
||||||
|
## Classificação de falhas
|
||||||
|
- Bloqueante: quebra função, build, segurança ou dados.
|
||||||
|
- Alta: fluxo importante degradado.
|
||||||
|
- Média: UX significativa, mas contornável.
|
||||||
|
- Baixa: detalhe visual ou melhoria opcional.
|
||||||
|
|
||||||
|
## Entrega
|
||||||
|
Nunca esconda falha de validação. Diferencie claramente problema novo de problema preexistente.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Compatibilidade
|
||||||
|
|
||||||
|
Este arquivo foi mantido para compatibilidade com a versão anterior.
|
||||||
|
A fonte atual é [`09-accessibility.md`](./09-accessibility.md).
|
||||||
|
|
||||||
|
Ao executar este papel, leia e siga integralmente `agents/09-accessibility.md`.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Compatibilidade
|
||||||
|
|
||||||
|
Este arquivo foi mantido para compatibilidade com a versão anterior.
|
||||||
|
A fonte atual é [`06-dashboard.md`](./06-dashboard.md).
|
||||||
|
|
||||||
|
Ao executar este papel, leia e siga integralmente `agents/06-dashboard.md`.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Compatibilidade
|
||||||
|
|
||||||
|
Este arquivo foi mantido para compatibilidade com a versão anterior.
|
||||||
|
A fonte atual é [`03-design-system.md`](./03-design-system.md).
|
||||||
|
|
||||||
|
Ao executar este papel, leia e siga integralmente `agents/03-design-system.md`.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Compatibilidade
|
||||||
|
|
||||||
|
Este arquivo foi mantido para compatibilidade com a versão anterior.
|
||||||
|
A fonte atual é [`04-frontend.md`](./04-frontend.md).
|
||||||
|
|
||||||
|
Ao executar este papel, leia e siga integralmente `agents/04-frontend.md`.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Compatibilidade
|
||||||
|
|
||||||
|
Este arquivo foi mantido para compatibilidade com a versão anterior.
|
||||||
|
A fonte atual é [`07-landing-page.md`](./07-landing-page.md).
|
||||||
|
|
||||||
|
Ao executar este papel, leia e siga integralmente `agents/07-landing-page.md`.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Compatibilidade
|
||||||
|
|
||||||
|
Este arquivo foi mantido para compatibilidade com a versão anterior.
|
||||||
|
A fonte atual é [`11-performance.md`](./11-performance.md).
|
||||||
|
|
||||||
|
Ao executar este papel, leia e siga integralmente `agents/11-performance.md`.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Compatibilidade
|
||||||
|
|
||||||
|
Este arquivo foi mantido para compatibilidade com a versão anterior.
|
||||||
|
A fonte atual é [`08-responsive.md`](./08-responsive.md).
|
||||||
|
|
||||||
|
Ao executar este papel, leia e siga integralmente `agents/08-responsive.md`.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Compatibilidade
|
||||||
|
|
||||||
|
Este arquivo foi mantido para compatibilidade com a versão anterior.
|
||||||
|
A fonte atual é [`10-seo.md`](./10-seo.md).
|
||||||
|
|
||||||
|
Ao executar este papel, leia e siga integralmente `agents/10-seo.md`.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Compatibilidade
|
||||||
|
|
||||||
|
Este arquivo foi mantido para compatibilidade com a versão anterior.
|
||||||
|
A fonte atual é [`02-ui-ux.md`](./02-ui-ux.md).
|
||||||
|
|
||||||
|
Ao executar este papel, leia e siga integralmente `agents/02-ui-ux.md`.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Compatibilidade
|
||||||
|
|
||||||
|
Este arquivo foi mantido para compatibilidade com a versão anterior.
|
||||||
|
A fonte atual é [`12-visual-qa.md`](./12-visual-qa.md).
|
||||||
|
|
||||||
|
Ao executar este papel, leia e siga integralmente `agents/12-visual-qa.md`.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Checklist — Depois de Alterar
|
||||||
|
|
||||||
|
- [ ] Pedido foi atendido sem ampliar escopo indevidamente.
|
||||||
|
- [ ] Build passa ou falha preexistente foi documentada.
|
||||||
|
- [ ] Typecheck passa quando existe.
|
||||||
|
- [ ] Lint passa quando existe.
|
||||||
|
- [ ] Testes relevantes passam quando existem.
|
||||||
|
- [ ] Não surgiram erros de console óbvios.
|
||||||
|
- [ ] Loading foi considerado.
|
||||||
|
- [ ] Empty state foi considerado.
|
||||||
|
- [ ] Erro foi considerado.
|
||||||
|
- [ ] Desktop foi verificado.
|
||||||
|
- [ ] Mobile foi verificado.
|
||||||
|
- [ ] Teclado/foco foram considerados.
|
||||||
|
- [ ] Alteração não quebrou contrato de API.
|
||||||
|
- [ ] Não houve mudança acidental em autenticação/permissão.
|
||||||
|
- [ ] Diff final não contém alterações estranhas ao escopo.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Checklist — Antes de Alterar
|
||||||
|
|
||||||
|
- [ ] Li `AGENTS.md`.
|
||||||
|
- [ ] Identifiquei stack e gerenciador de pacotes.
|
||||||
|
- [ ] Entendi a tarefa.
|
||||||
|
- [ ] Identifiquei arquivos afetados.
|
||||||
|
- [ ] Localizei regras de negócio relacionadas.
|
||||||
|
- [ ] Verifiquei design system/componentes existentes.
|
||||||
|
- [ ] Determinei riscos de regressão.
|
||||||
|
- [ ] Não estou alterando backend/API/auth sem necessidade.
|
||||||
|
- [ ] Selecionei somente os agentes necessários.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Prompt — Auditar Projeto
|
||||||
|
|
||||||
|
Leia `AGENTS.md`, `agents/00-project-analyzer.md` e `agents/01-codebase-guardian.md` antes de começar.
|
||||||
|
Não altere arquivos nesta etapa.
|
||||||
|
|
||||||
|
Faça uma auditoria detalhada do projeto cobrindo:
|
||||||
|
1. stack e versões relevantes;
|
||||||
|
2. estrutura e arquitetura;
|
||||||
|
3. rotas e layouts;
|
||||||
|
4. componentes reutilizáveis;
|
||||||
|
5. estilos e design system;
|
||||||
|
6. estado e data fetching;
|
||||||
|
7. formulários;
|
||||||
|
8. responsividade;
|
||||||
|
9. acessibilidade;
|
||||||
|
10. SEO quando aplicável;
|
||||||
|
11. performance;
|
||||||
|
12. qualidade do código;
|
||||||
|
13. testes e validações disponíveis;
|
||||||
|
14. riscos de segurança visíveis no frontend;
|
||||||
|
15. dívidas técnicas que afetam diretamente a experiência.
|
||||||
|
|
||||||
|
Classifique achados como Bloqueante, Alto, Médio ou Baixo.
|
||||||
|
Para cada achado informe: problema, impacto, evidência/arquivo e recomendação.
|
||||||
|
Não invente problema sem evidência.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Prompt — Criar Dashboard / Painel Administrativo
|
||||||
|
|
||||||
|
Leia `AGENTS.md` e aplique os agentes de análise, proteção, UI/UX, design system, frontend,
|
||||||
|
dashboard, forms, responsive, accessibility, performance, visual QA e regression check.
|
||||||
|
|
||||||
|
Priorize:
|
||||||
|
- produtividade;
|
||||||
|
- hierarquia de informação;
|
||||||
|
- filtros;
|
||||||
|
- tabelas;
|
||||||
|
- estados vazios;
|
||||||
|
- feedback de ações;
|
||||||
|
- segurança de operações destrutivas;
|
||||||
|
- consistência entre telas.
|
||||||
|
|
||||||
|
Não invente métricas ou regras de negócio. Use dados fictícios somente se estiver claramente marcado como mock.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Prompt — Criar Site / Landing Page
|
||||||
|
|
||||||
|
Leia `AGENTS.md` e aplique:
|
||||||
|
- `agents/02-ui-ux.md`
|
||||||
|
- `agents/03-design-system.md`
|
||||||
|
- `agents/04-frontend.md`
|
||||||
|
- `agents/07-landing-page.md`
|
||||||
|
- `agents/08-responsive.md`
|
||||||
|
- `agents/09-accessibility.md`
|
||||||
|
- `agents/10-seo.md`
|
||||||
|
- `agents/11-performance.md`
|
||||||
|
- `agents/12-visual-qa.md`
|
||||||
|
|
||||||
|
Antes de implementar, determine:
|
||||||
|
- público;
|
||||||
|
- objetivo da página;
|
||||||
|
- proposta de valor;
|
||||||
|
- CTA principal;
|
||||||
|
- conteúdo disponível;
|
||||||
|
- identidade existente;
|
||||||
|
- restrições técnicas.
|
||||||
|
|
||||||
|
Não invente logos, clientes, números, depoimentos ou certificações como fatos reais.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Prompt — Corrigir Mobile
|
||||||
|
|
||||||
|
Leia `AGENTS.md`, `agents/00-project-analyzer.md`, `agents/01-codebase-guardian.md`,
|
||||||
|
`agents/08-responsive.md`, `agents/09-accessibility.md` e `agents/13-regression-check.md`.
|
||||||
|
|
||||||
|
Procure especialmente:
|
||||||
|
- overflow horizontal;
|
||||||
|
- navegação impossível;
|
||||||
|
- tabelas ilegíveis;
|
||||||
|
- modais fora da viewport;
|
||||||
|
- botões pequenos;
|
||||||
|
- campos difíceis de preencher;
|
||||||
|
- grids quebrados;
|
||||||
|
- conteúdo cortado;
|
||||||
|
- ações dependentes de hover.
|
||||||
|
|
||||||
|
Não mascare erro com `overflow-x: hidden` sem corrigir a causa.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Prompt — Melhorar Frontend
|
||||||
|
|
||||||
|
Leia `AGENTS.md` e siga seu pipeline.
|
||||||
|
|
||||||
|
Objetivo: melhorar a interface existente sem alterar regras de negócio ou contratos desnecessariamente.
|
||||||
|
|
||||||
|
Etapas obrigatórias:
|
||||||
|
1. analisar projeto;
|
||||||
|
2. identificar arquivos afetados;
|
||||||
|
3. selecionar agentes necessários;
|
||||||
|
4. preservar padrões existentes;
|
||||||
|
5. implementar mudanças mínimas e coesas;
|
||||||
|
6. tratar estados relevantes;
|
||||||
|
7. verificar responsividade e acessibilidade;
|
||||||
|
8. executar validações disponíveis;
|
||||||
|
9. executar `agents/12-visual-qa.md`;
|
||||||
|
10. executar `agents/13-regression-check.md`.
|
||||||
|
|
||||||
|
Na entrega, descreva mudanças, validações e limitações.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Prompt — Revisão Final
|
||||||
|
|
||||||
|
Não implemente novas funcionalidades nesta etapa, salvo correção direta de defeito encontrado.
|
||||||
|
|
||||||
|
Leia:
|
||||||
|
- `agents/12-visual-qa.md`
|
||||||
|
- `agents/13-regression-check.md`
|
||||||
|
|
||||||
|
Verifique:
|
||||||
|
1. pedido original;
|
||||||
|
2. diff realizado;
|
||||||
|
3. build/typecheck/lint/testes;
|
||||||
|
4. desktop/mobile;
|
||||||
|
5. estados de UI;
|
||||||
|
6. acessibilidade básica;
|
||||||
|
7. console;
|
||||||
|
8. regressões;
|
||||||
|
9. alterações fora de escopo.
|
||||||
|
|
||||||
|
Entregue uma conclusão objetiva e liste qualquer limitação real restante.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Skill — Design System
|
||||||
|
|
||||||
|
Use esta skill quando precisar criar, organizar ou ampliar padrões visuais.
|
||||||
|
|
||||||
|
## Método
|
||||||
|
1. Inventarie tokens existentes.
|
||||||
|
2. Inventarie componentes compartilhados.
|
||||||
|
3. Identifique duplicações.
|
||||||
|
4. Normalize primeiro os padrões mais repetidos.
|
||||||
|
5. Preserve API de componentes sempre que possível.
|
||||||
|
6. Documente novas variantes e estados.
|
||||||
|
|
||||||
|
## Tokens semânticos
|
||||||
|
Prefira nomes como `surface`, `foreground`, `primary`, `danger`, `border-muted` a nomes puramente visuais como `blue500` quando o projeto já usa semântica.
|
||||||
|
|
||||||
|
## Estados obrigatórios de componentes interativos
|
||||||
|
- default;
|
||||||
|
- hover quando aplicável;
|
||||||
|
- focus-visible;
|
||||||
|
- active;
|
||||||
|
- disabled;
|
||||||
|
- loading quando aplicável;
|
||||||
|
- error/invalid quando aplicável.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Skill — Form Engineering
|
||||||
|
|
||||||
|
## Checklist rápido
|
||||||
|
- labels;
|
||||||
|
- tipos corretos;
|
||||||
|
- validação cliente e servidor;
|
||||||
|
- mensagens específicas;
|
||||||
|
- foco em erro;
|
||||||
|
- prevenção de duplo submit;
|
||||||
|
- loading;
|
||||||
|
- preservação de valores;
|
||||||
|
- autocomplete;
|
||||||
|
- acessibilidade;
|
||||||
|
- máscaras sem corromper payload.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Skill — Next.js
|
||||||
|
|
||||||
|
## Verifique a arquitetura usada
|
||||||
|
- App Router ou Pages Router;
|
||||||
|
- Server/Client Components;
|
||||||
|
- estratégia de data fetching;
|
||||||
|
- cache/revalidation;
|
||||||
|
- metadata;
|
||||||
|
- route handlers;
|
||||||
|
- imagens/fontes.
|
||||||
|
|
||||||
|
Não misture padrões de versões diferentes sem necessidade.
|
||||||
|
Mantenha código cliente mínimo quando Server Components estiverem disponíveis e adequados.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Skill — React
|
||||||
|
|
||||||
|
## Regras
|
||||||
|
- componentes devem ser previsíveis e com efeitos controlados;
|
||||||
|
- não use `useEffect` para estado derivável;
|
||||||
|
- mantenha dependências de efeitos corretas;
|
||||||
|
- não duplique fonte de verdade;
|
||||||
|
- keys de listas devem ser estáveis;
|
||||||
|
- handlers devem refletir intenção clara;
|
||||||
|
- Context não deve virar store global por conveniência.
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
Memoização só quando há custo ou rerender relevante. Evite `useMemo`/`useCallback` indiscriminados.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Skill — Responsive Engineering
|
||||||
|
|
||||||
|
## Processo
|
||||||
|
1. Descubra breakpoints existentes.
|
||||||
|
2. Ache containers com largura fixa problemática.
|
||||||
|
3. Identifique componentes que dependem de hover.
|
||||||
|
4. Teste navegação, tabelas, formulários e modais.
|
||||||
|
5. Corrija primeiro estrutura, depois detalhes.
|
||||||
|
|
||||||
|
## Anti-padrões
|
||||||
|
- esconder metade do sistema no mobile;
|
||||||
|
- fonte minúscula para fazer caber;
|
||||||
|
- `overflow-x: hidden` para mascarar layout quebrado;
|
||||||
|
- breakpoints arbitrários espalhados;
|
||||||
|
- largura fixa em componentes reutilizáveis.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Skill — Data Tables
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
Tabelas devem favorecer comparação e operação, não apenas exibir dados.
|
||||||
|
|
||||||
|
## Avaliar
|
||||||
|
- ordenação;
|
||||||
|
- filtros;
|
||||||
|
- busca;
|
||||||
|
- paginação;
|
||||||
|
- seleção;
|
||||||
|
- ações;
|
||||||
|
- densidade;
|
||||||
|
- responsividade;
|
||||||
|
- estados;
|
||||||
|
- persistência de contexto.
|
||||||
|
|
||||||
|
Nunca remova coluna importante no mobile sem oferecer outra forma de acesso ao dado.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Skill — Tailwind CSS
|
||||||
|
|
||||||
|
Use somente quando Tailwind já fizer parte do projeto ou houver decisão explícita de adotá-lo.
|
||||||
|
|
||||||
|
## Regras
|
||||||
|
- reutilizar tokens/configuração do projeto;
|
||||||
|
- evitar classes arbitrárias repetidas;
|
||||||
|
- extrair componente quando sequência extensa representa padrão reutilizável;
|
||||||
|
- não substituir toda folha CSS existente sem necessidade;
|
||||||
|
- usar `cn`/merge de classes conforme convenção existente;
|
||||||
|
- respeitar variantes e dark mode existentes.
|
||||||
|
|
||||||
|
## Evitar
|
||||||
|
- dezenas de valores `[13px]`, `[17px]`, `[#123456]` sem sistema;
|
||||||
|
- `!` modifiers para vencer conflitos mal compreendidos;
|
||||||
|
- copiar blocos enormes de classes em várias telas.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Skill — Vue
|
||||||
|
|
||||||
|
## Regras
|
||||||
|
- respeitar Options API ou Composition API predominante;
|
||||||
|
- evitar mutação inesperada de props;
|
||||||
|
- usar computed para valores derivados;
|
||||||
|
- watchers apenas quando efeito colateral é necessário;
|
||||||
|
- manter emits e props explícitos;
|
||||||
|
- preservar convenções de Pinia/Vue Router existentes.
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
FROM node:20-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
FROM node:20-alpine AS dev
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["npm", "run", "dev"]
|
||||||
|
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
COPY --from=build /app/public ./public
|
||||||
|
COPY --from=build --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=build --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
COPY --from=build --chown=nextjs:nodejs /app/prisma ./prisma
|
||||||
|
COPY --from=build --chown=nextjs:nodejs /app/package.json ./package.json
|
||||||
|
RUN mkdir -p /app/storage/uploads && chown -R nextjs:nodejs /app/storage
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# Briefing de Site SeConectai
|
||||||
|
|
||||||
|
Sistema web para cadastro de clientes e preenchimento guiado do briefing de criação de sites.
|
||||||
|
|
||||||
|
As pastas `Conhecimento/` e `Schema/` ficam como documentação de referência. O sistema em si é criado na raiz do projeto, com `app/`, `components/`, `lib/`, `prisma/`, Docker e configurações do Next.js.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- Next.js 14+ com App Router
|
||||||
|
- TypeScript
|
||||||
|
- Tailwind CSS
|
||||||
|
- shadcn/ui como padrão de componentes
|
||||||
|
- Prisma + PostgreSQL 16
|
||||||
|
- Auth.js v5
|
||||||
|
- React Hook Form + Zod
|
||||||
|
- Docker Compose
|
||||||
|
|
||||||
|
## Desenvolvimento
|
||||||
|
|
||||||
|
Crie um `.env` a partir do exemplo quando for implementar autenticação, banco e integrações reais:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Instale as dependências:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Rode o app:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Ou suba com Docker:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
## Estrutura inicial
|
||||||
|
|
||||||
|
- `app/`: rotas e layout base.
|
||||||
|
- `components/ui/`: componentes base no padrão shadcn.
|
||||||
|
- `lib/`: utilitários compartilhados.
|
||||||
|
- `prisma/briefing-seed.json`: cópia do seed usado para carregar as seções e perguntas do briefing.
|
||||||
|
- `Schema/`: documentação das etapas do backend e regras de negócio.
|
||||||
|
- `Conhecimento/`: guias de frontend, UI, responsividade, acessibilidade e revisão.
|
||||||
|
|
||||||
|
## Observação
|
||||||
|
|
||||||
|
O ambiente atual do shell não tem `node`, `npm` nem `docker` instalados, então a validação de instalação, lint, build e compose precisa ser feita em uma máquina com essas ferramentas disponíveis.
|
||||||
|
|
||||||
|
## Backup
|
||||||
|
|
||||||
|
Com `DATABASE_URL` e `UPLOAD_DIR` configurados:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sh scripts/backup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Os arquivos são salvos em `./backups` por padrão, com rotação de 14 dias.
|
||||||
|
|
||||||
|
Para restaurar:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sh scripts/restore.sh backups/db-YYYYMMDD-HHMMSS.sql backups/uploads-YYYYMMDD-HHMMSS.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
## Produção
|
||||||
|
|
||||||
|
Crie o `.env` no servidor. Variáveis mínimas:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
POSTGRES_DB=briefing
|
||||||
|
POSTGRES_USER=briefing
|
||||||
|
POSTGRES_PASSWORD=troque-esta-senha
|
||||||
|
DATABASE_URL=postgresql://briefing:troque-esta-senha@db:5432/briefing?schema=public
|
||||||
|
AUTH_SECRET=gere-um-segredo-forte
|
||||||
|
APP_URL=https://seu-dominio.com.br
|
||||||
|
APP_DOMAIN=seu-dominio.com.br
|
||||||
|
LETSENCRYPT_EMAIL=voce@dominio.com.br
|
||||||
|
CPF_ENC_KEY=chave-base64-com-32-bytes
|
||||||
|
CPF_HASH_KEY=segredo-hmac-forte
|
||||||
|
ADMIN_EMAIL=admin@dominio.com.br
|
||||||
|
ADMIN_INITIAL_PASSWORD=senha-inicial-forte
|
||||||
|
UPLOAD_DIR=/app/storage/uploads
|
||||||
|
UPLOAD_MAX_MB=25
|
||||||
|
```
|
||||||
|
|
||||||
|
Para gerar uma chave base64 de 32 bytes:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
openssl rand -base64 32
|
||||||
|
```
|
||||||
|
|
||||||
|
Suba em produção:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
O serviço `release` roda `prisma migrate deploy` e o seed idempotente antes do app iniciar. O seed cria o admin inicial usando `ADMIN_EMAIL` e `ADMIN_INITIAL_PASSWORD` somente se o usuário ainda não existir.
|
||||||
|
|
||||||
|
Para atualizar:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose -f docker-compose.prod.yml pull
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
O Postgres não publica porta externa no `docker-compose.prod.yml`; ele fica acessível apenas na rede interna do Compose. Os uploads ficam no volume nomeado `uploads`, e o banco no volume `pgdata`.
|
||||||
|
|
||||||
|
## HTTPS
|
||||||
|
|
||||||
|
O `docker-compose.prod.yml` usa Caddy com o `Caddyfile` da raiz. Configure `APP_DOMAIN` e `LETSENCRYPT_EMAIL` no `.env`; o Caddy emite e renova o certificado automaticamente e redireciona HTTP para HTTPS.
|
||||||
|
|
||||||
|
## Healthcheck
|
||||||
|
|
||||||
|
A aplicação expõe:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Ela verifica app + conexão com o banco.
|
||||||
|
|
||||||
|
## E-mail
|
||||||
|
|
||||||
|
Configure `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS` e `SMTP_FROM`. No DNS do remetente, confira SPF e DKIM do provedor SMTP para reduzir chance de spam. O sistema nunca envia CPF completo por e-mail.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# 00 — Arquitetura: Sistema de Briefing de Site
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
Cliente se cadastra (pessoa física ou empresa) → faz login → preenche o briefing de 19 seções (149 perguntas) com rascunho automático e botão Ajuda por pergunta → envia → Tiago visualiza, exporta e muda o status no painel admin.
|
||||||
|
|
||||||
|
## Fluxos
|
||||||
|
1. **Cadastro:** Nome completo, CPF, WhatsApp, E-mail, senha. Se "É para empresa?": Razão Social, CNPJ, Endereço (CEP, logradouro, número, complemento, bairro, cidade, UF). Aceite de LGPD obrigatório.
|
||||||
|
2. **Login / recuperação de senha** por e-mail.
|
||||||
|
3. **Meus briefings:** o cliente cria um briefing (um cliente pode ter mais de um, ex.: um site por empresa) e continua de onde parou.
|
||||||
|
4. **Preenchimento:** tela por seção, barra de progresso, botão Ajuda por pergunta (popup), autosave, anexos na seção 19.
|
||||||
|
5. **Envio:** confirmação do cliente (texto da seção "Confirmação do cliente") → status ENVIADO, bloqueia edição (admin pode reabrir).
|
||||||
|
6. **Admin:** lista de clientes e briefings, detalhe com todas as respostas e anexos, mudança de status, exportação PDF, edição das perguntas/ajudas.
|
||||||
|
|
||||||
|
## Decisão central: formulário dirigido por dados
|
||||||
|
Seções, perguntas e textos de ajuda ficam no banco e vêm do arquivo `briefing-seed.json` (gerado dos .docx originais: 19 seções, 149 perguntas, todas com ajuda). O frontend renderiza o formulário a partir desses dados; nada de 149 campos escritos à mão.
|
||||||
|
|
||||||
|
Tipos de pergunta (`TipoPergunta`): `TEXTAREA` (117), `RADIO` (11), `CHECKBOX` (6), `ANEXO` (15, seção 19). Toda pergunta tem também um campo opcional de **complemento** em texto (o documento original permite escrever junto das caixas marcadas, ex.: "Outro: qual?").
|
||||||
|
|
||||||
|
## Modelo de dados (Prisma, resumo)
|
||||||
|
- `User` (id, email único, senhaHash, papel: CLIENTE | ADMIN, criadoEm)
|
||||||
|
- `Cliente` (userId único, nomeCompleto, cpfCifrado, cpfHash único, whatsapp, tipo: PF | PJ, razaoSocial?, cnpj?, cep?, logradouro?, numero?, complemento?, bairro?, cidade?, uf?, lgpdAceiteEm)
|
||||||
|
- `Secao` (id, ordem, titulo, descricao, ativo)
|
||||||
|
- `Pergunta` (id, secaoId, ordem, rotulo, dica, tipo, opcoes Json, obrigatoria default false, permiteComplemento, ativo)
|
||||||
|
- `Ajuda` (perguntaId único, oQueResponder, porQuePerguntamos, miniExemplo)
|
||||||
|
- `Briefing` (id, clienteId, titulo, status: RASCUNHO | ENVIADO | EM_ANALISE | CONCLUIDO, confirmadoEm?, enviadoEm?, criadoEm, atualizadoEm)
|
||||||
|
- `Resposta` (briefingId, perguntaId, texto?, selecionadas Json?, único por briefing+pergunta)
|
||||||
|
- `Anexo` (briefingId, perguntaId, nomeOriginal, caminho, mime, tamanho, criadoEm)
|
||||||
|
- `PasswordResetToken` (userId, tokenHash, expiraEm, usadoEm?)
|
||||||
|
|
||||||
|
## Princípios (do padrão SeConectai)
|
||||||
|
- **Nunca apagar dados históricos:** briefing enviado não é deletado; reabrir muda o status, não perde respostas.
|
||||||
|
- **Dados sensíveis por desenho:** CPF cifrado (AES-256-GCM, chave em variável de ambiente) + `cpfHash` (HMAC-SHA256) para unicidade/busca. CPF nunca vai para log nem para o JSON de listagens.
|
||||||
|
- **Validação dupla:** Zod no cliente e no servidor (server actions / route handlers).
|
||||||
|
- **Permissões:** cliente só enxerga os próprios briefings; admin enxerga todos. Toda leitura/escrita checa o dono no servidor.
|
||||||
|
- **Incremental:** mudanças futuras via migrations Prisma numeradas, sem reescrever o schema base.
|
||||||
|
|
||||||
|
## Estrutura de pastas alvo
|
||||||
|
```
|
||||||
|
/app
|
||||||
|
/(publico)/cadastro, /login, /recuperar-senha, /redefinir-senha
|
||||||
|
/(cliente)/briefings, /briefings/[id], /briefings/[id]/[secao], /briefings/[id]/revisao
|
||||||
|
/(admin)/admin, /admin/clientes, /admin/briefings/[id], /admin/perguntas
|
||||||
|
/api/anexos/[id] (download autenticado)
|
||||||
|
/components (ui shadcn, form/, ajuda/)
|
||||||
|
/lib (auth, db, crypto, validators, storage, pdf, mail)
|
||||||
|
/prisma (schema.prisma, seed.ts, briefing-seed.json)
|
||||||
|
/storage/uploads (volume Docker, fora de /public)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ordem de execução
|
||||||
|
01 setup → 02 schema → 03 seed → 04 auth/cadastro → 05 motor do formulário → 06 popup de ajuda → 07 rascunho e envio → 08 anexos → 09 painel admin → 10 PDF e notificações → 11 LGPD/segurança → 12 deploy. Acompanhar em `99-progresso.md`.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 01 — Setup do projeto e Docker
|
||||||
|
|
||||||
|
## Contexto do projeto (leia sempre)
|
||||||
|
Sistema web "Briefing de Site" da SeConectai: o cliente se cadastra e depois preenche o briefing de criação do site, com um botão **Ajuda** em cada pergunta que abre um popup com o texto do guia auxiliar. O dono (Tiago) acompanha tudo por um painel admin.
|
||||||
|
**Stack fixa:** Next.js 14+ (App Router) + TypeScript, PostgreSQL 16, Prisma, Tailwind + shadcn/ui, React Hook Form + Zod, Auth.js v5 (Credentials, estratégia JWT), Docker Compose. Idioma da interface: pt-BR.
|
||||||
|
**Regras gerais:** leia `00-arquitetura-briefing-site.md` e `99-progresso.md` antes de começar; não avance se a etapa anterior estiver incompleta; ao terminar, devolva o `99-progresso.md` atualizado. Não invente dependências fora da stack. Nada de senhas de clientes em lugar nenhum.
|
||||||
|
|
||||||
|
## Tarefa
|
||||||
|
1. Criar projeto Next.js 14+ com TypeScript, App Router, Tailwind e ESLint (`src` opcional — siga a estrutura do doc 00).
|
||||||
|
2. Instalar: `prisma @prisma/client zod react-hook-form @hookform/resolvers next-auth@beta @auth/core argon2 nodemailer @tanstack/react-query lucide-react`, e iniciar shadcn/ui (componentes: button, input, textarea, label, checkbox, radio-group, dialog, form, card, progress, badge, table, select, sonner, tabs, alert).
|
||||||
|
3. Criar `docker-compose.yml` com `app` (Node 20) e `db` (postgres:16, volume nomeado `pgdata`), `healthcheck` no db, rede interna. Volume `uploads` montado em `/app/storage/uploads`.
|
||||||
|
4. Criar `Dockerfile` multi-stage (deps → build → runner, `output: "standalone"`).
|
||||||
|
5. `.env.example` com: `DATABASE_URL`, `AUTH_SECRET`, `APP_URL`, `CPF_ENC_KEY` (32 bytes base64), `CPF_HASH_KEY`, `SMTP_HOST/PORT/USER/PASS/FROM`, `ADMIN_EMAIL`, `ADMIN_INITIAL_PASSWORD`, `EVOLUTION_API_URL/KEY/INSTANCE` e `ADMIN_WHATSAPP` (opcionais), `UPLOAD_DIR`, `UPLOAD_MAX_MB=25`.
|
||||||
|
6. Layout base pt-BR (`<html lang="pt-BR">`), tema simples e responsivo (mobile primeiro: clientes vão preencher pelo celular).
|
||||||
|
7. Script `npm run dev`, `build`, `db:migrate`, `db:seed`, `lint`.
|
||||||
|
|
||||||
|
## Critérios de aceite
|
||||||
|
- [ ] `docker compose up` sobe app + banco sem erro; página inicial abre em `http://localhost:3000`.
|
||||||
|
- [ ] `.env.example` completo; `.env` no `.gitignore`.
|
||||||
|
- [ ] `npm run lint` e `npm run build` passam.
|
||||||
|
- [ ] Nenhuma dependência fora da stack.
|
||||||
|
|
||||||
|
Ao final, atualize `99-progresso.md` (marque 01).
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 02 — Banco de dados e schema Prisma
|
||||||
|
|
||||||
|
## Contexto do projeto (leia sempre)
|
||||||
|
Sistema web "Briefing de Site" da SeConectai: o cliente se cadastra e depois preenche o briefing de criação do site, com um botão **Ajuda** em cada pergunta que abre um popup com o texto do guia auxiliar. O dono (Tiago) acompanha tudo por um painel admin.
|
||||||
|
**Stack fixa:** Next.js 14+ (App Router) + TypeScript, PostgreSQL 16, Prisma, Tailwind + shadcn/ui, React Hook Form + Zod, Auth.js v5 (Credentials, estratégia JWT), Docker Compose. Idioma da interface: pt-BR.
|
||||||
|
**Regras gerais:** leia `00-arquitetura-briefing-site.md` e `99-progresso.md` antes de começar; não avance se a etapa anterior estiver incompleta; ao terminar, devolva o `99-progresso.md` atualizado. Não invente dependências fora da stack. Nada de senhas de clientes em lugar nenhum.
|
||||||
|
|
||||||
|
## Pré-requisito
|
||||||
|
Etapa 01 concluída em `99-progresso.md`.
|
||||||
|
|
||||||
|
## Tarefa
|
||||||
|
Implementar em `prisma/schema.prisma` o modelo descrito no doc 00 (seção "Modelo de dados"), com:
|
||||||
|
- enums `Papel {CLIENTE ADMIN}`, `TipoPessoa {PF PJ}`, `TipoPergunta {TEXTAREA RADIO CHECKBOX ANEXO}`, `StatusBriefing {RASCUNHO ENVIADO EM_ANALISE CONCLUIDO}`.
|
||||||
|
- `@@unique([briefingId, perguntaId])` em `Resposta`; `@@unique([ordem])` em `Secao` e `@@unique([secaoId, ordem])` em `Pergunta`; índices em `Briefing.clienteId`, `Briefing.status`, `Anexo.briefingId`.
|
||||||
|
- `onDelete: Cascade` apenas de `Briefing → Resposta/Anexo`; **não** colocar cascade de `Cliente → Briefing` (preservar histórico).
|
||||||
|
- campos `criadoEm`/`atualizadoEm` (`@default(now())`, `@updatedAt`).
|
||||||
|
- `opcoes` e `selecionadas` como `Json`.
|
||||||
|
|
||||||
|
Gerar a migration inicial `0001_init` e o client Prisma; criar `lib/db.ts` com singleton do PrismaClient.
|
||||||
|
|
||||||
|
## Critérios de aceite
|
||||||
|
- [ ] `npx prisma migrate dev` aplica sem erro no Postgres do Docker.
|
||||||
|
- [ ] `npx prisma validate` ok; `prisma studio` mostra todas as tabelas.
|
||||||
|
- [ ] Nenhuma coluna de CPF em texto puro (apenas `cpfCifrado` e `cpfHash`).
|
||||||
|
|
||||||
|
Atualize `99-progresso.md` (02).
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# 03 — Seed do briefing (seções, perguntas e ajudas)
|
||||||
|
|
||||||
|
## Contexto do projeto (leia sempre)
|
||||||
|
Sistema web "Briefing de Site" da SeConectai: o cliente se cadastra e depois preenche o briefing de criação do site, com um botão **Ajuda** em cada pergunta que abre um popup com o texto do guia auxiliar. O dono (Tiago) acompanha tudo por um painel admin.
|
||||||
|
**Stack fixa:** Next.js 14+ (App Router) + TypeScript, PostgreSQL 16, Prisma, Tailwind + shadcn/ui, React Hook Form + Zod, Auth.js v5 (Credentials, estratégia JWT), Docker Compose. Idioma da interface: pt-BR.
|
||||||
|
**Regras gerais:** leia `00-arquitetura-briefing-site.md` e `99-progresso.md` antes de começar; não avance se a etapa anterior estiver incompleta; ao terminar, devolva o `99-progresso.md` atualizado. Não invente dependências fora da stack. Nada de senhas de clientes em lugar nenhum.
|
||||||
|
|
||||||
|
## Pré-requisito
|
||||||
|
Etapa 02 concluída.
|
||||||
|
|
||||||
|
## Arquivo de dados
|
||||||
|
`prisma/briefing-seed.json` (fornecido junto com este guia; copie para `prisma/`). Estrutura:
|
||||||
|
```json
|
||||||
|
{ "meta": { "comoPreencher": [..], "avisoSenhas": "..", "confirmacaoCliente": ".." },
|
||||||
|
"secoes": [ { "ordem": 1, "titulo": "..", "descricao": "..",
|
||||||
|
"perguntas": [ { "ordem": 1, "rotulo": "..", "dica": "..", "tipo": "TEXTAREA|RADIO|CHECKBOX|ANEXO",
|
||||||
|
"opcoes": ["..."], "permiteComplemento": true,
|
||||||
|
"ajuda": { "oQueResponder": "..", "porQuePerguntamos": "..", "miniExemplo": ".." } } ] } ] }
|
||||||
|
```
|
||||||
|
O arquivo tem 19 seções e 149 perguntas; todas têm `ajuda`.
|
||||||
|
|
||||||
|
## Tarefa
|
||||||
|
1. Criar `prisma/seed.ts` **idempotente** (upsert por `secao.ordem` e por `secaoId+ordem`): cria `Secao`, `Pergunta` e `Ajuda`. Reexecutar o seed atualiza textos sem duplicar e **sem apagar respostas** existentes.
|
||||||
|
2. Guardar `meta` (como preencher, aviso de senhas, confirmação do cliente) em uma tabela `Configuracao (chave, valor Json)` — adicionar via migration `0002_configuracao`.
|
||||||
|
3. No mesmo seed, criar o usuário ADMIN a partir de `ADMIN_EMAIL`/`ADMIN_INITIAL_PASSWORD` (hash argon2), apenas se não existir.
|
||||||
|
4. Configurar `prisma.seed` no `package.json`.
|
||||||
|
5. Criar `lib/briefing-queries.ts` com `getEstruturaBriefing()` que retorna seções → perguntas → ajuda, ordenadas, em uma única consulta.
|
||||||
|
|
||||||
|
## Critérios de aceite
|
||||||
|
- [ ] Após `npm run db:seed`: 19 seções, 149 perguntas, 149 ajudas.
|
||||||
|
- [ ] Rodar o seed duas vezes não duplica nada.
|
||||||
|
- [ ] Contagem por tipo: TEXTAREA 117, RADIO 11, CHECKBOX 6, ANEXO 15.
|
||||||
|
- [ ] Admin criado; senha só em hash.
|
||||||
|
|
||||||
|
Atualize `99-progresso.md` (03).
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# 04 — Autenticação e cadastro do cliente
|
||||||
|
|
||||||
|
## Contexto do projeto (leia sempre)
|
||||||
|
Sistema web "Briefing de Site" da SeConectai: o cliente se cadastra e depois preenche o briefing de criação do site, com um botão **Ajuda** em cada pergunta que abre um popup com o texto do guia auxiliar. O dono (Tiago) acompanha tudo por um painel admin.
|
||||||
|
**Stack fixa:** Next.js 14+ (App Router) + TypeScript, PostgreSQL 16, Prisma, Tailwind + shadcn/ui, React Hook Form + Zod, Auth.js v5 (Credentials, estratégia JWT), Docker Compose. Idioma da interface: pt-BR.
|
||||||
|
**Regras gerais:** leia `00-arquitetura-briefing-site.md` e `99-progresso.md` antes de começar; não avance se a etapa anterior estiver incompleta; ao terminar, devolva o `99-progresso.md` atualizado. Não invente dependências fora da stack. Nada de senhas de clientes em lugar nenhum.
|
||||||
|
|
||||||
|
## Pré-requisito
|
||||||
|
Etapas 01–03 concluídas.
|
||||||
|
|
||||||
|
## Tarefa
|
||||||
|
### Cadastro (`/cadastro`)
|
||||||
|
Campos: **Nome completo, CPF, WhatsApp, E-mail, Senha (mín. 8) e confirmação**. Seletor "Este site é para uma empresa?" — se sim, exibir **Razão Social, CNPJ e Endereço** (CEP com busca opcional no ViaCEP *no navegador*, logradouro, número, complemento, bairro, cidade, UF). Checkbox obrigatório de aceite da Política de Privacidade/LGPD (guardar `lgpdAceiteEm`).
|
||||||
|
|
||||||
|
Validações Zod (cliente + servidor) em `lib/validators.ts`: CPF e CNPJ com dígito verificador; WhatsApp brasileiro (DDD + 9 dígitos, normalizar para `+55DDDNÚMERO`); e-mail normalizado em minúsculas; máscara de entrada para CPF/CNPJ/WhatsApp/CEP.
|
||||||
|
|
||||||
|
Servidor: criar `User` + `Cliente` em transação; CPF → `lib/crypto.ts` (`encrypt`/`decrypt` AES-256-GCM com `CPF_ENC_KEY`, `hashCpf` HMAC-SHA256 com `CPF_HASH_KEY`); rejeitar e-mail ou CPF já cadastrados com mensagem genérica que não revele qual existe; senha com argon2.
|
||||||
|
|
||||||
|
### Login e sessão
|
||||||
|
Auth.js v5 com provider Credentials, JWT, cookie httpOnly/secure/sameSite=lax; sessão contém `userId` e `papel`. `middleware.ts` protege `/briefings*` (CLIENTE) e `/admin*` (ADMIN) e redireciona para `/login`. Limitar tentativas de login (rate limit simples em memória por IP+e-mail).
|
||||||
|
|
||||||
|
### Recuperar senha
|
||||||
|
`/recuperar-senha` (pede e-mail; resposta sempre igual exista ou não a conta) → e-mail com link de token aleatório (guardar só o hash, expira em 1 h, uso único) → `/redefinir-senha`. Enviar por SMTP via `lib/mail.ts` (nodemailer).
|
||||||
|
|
||||||
|
### Após o login
|
||||||
|
Cliente cai em `/briefings` (lista vazia com botão "Iniciar meu briefing"). Admin cai em `/admin`.
|
||||||
|
|
||||||
|
## Critérios de aceite
|
||||||
|
- [ ] Cadastro PF e PJ funcionam; campos de empresa só aparecem/são exigidos no modo PJ.
|
||||||
|
- [ ] CPF/CNPJ inválidos são recusados; CPF não aparece em texto puro no banco nem em logs.
|
||||||
|
- [ ] E-mail/CPF duplicado é recusado sem revelar qual.
|
||||||
|
- [ ] Rotas protegidas redirecionam; cliente não acessa `/admin`.
|
||||||
|
- [ ] Recuperação de senha funciona de ponta a ponta (token expira e é de uso único).
|
||||||
|
|
||||||
|
Atualize `99-progresso.md` (04).
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# 05 — Motor do formulário do briefing
|
||||||
|
|
||||||
|
## Contexto do projeto (leia sempre)
|
||||||
|
Sistema web "Briefing de Site" da SeConectai: o cliente se cadastra e depois preenche o briefing de criação do site, com um botão **Ajuda** em cada pergunta que abre um popup com o texto do guia auxiliar. O dono (Tiago) acompanha tudo por um painel admin.
|
||||||
|
**Stack fixa:** Next.js 14+ (App Router) + TypeScript, PostgreSQL 16, Prisma, Tailwind + shadcn/ui, React Hook Form + Zod, Auth.js v5 (Credentials, estratégia JWT), Docker Compose. Idioma da interface: pt-BR.
|
||||||
|
**Regras gerais:** leia `00-arquitetura-briefing-site.md` e `99-progresso.md` antes de começar; não avance se a etapa anterior estiver incompleta; ao terminar, devolva o `99-progresso.md` atualizado. Não invente dependências fora da stack. Nada de senhas de clientes em lugar nenhum.
|
||||||
|
|
||||||
|
## Pré-requisito
|
||||||
|
Etapas 01–04 concluídas.
|
||||||
|
|
||||||
|
## Tarefa
|
||||||
|
1. **Criar briefing:** botão "Iniciar meu briefing" cria `Briefing` RASCUNHO. Os campos do cabeçalho do documento original (Empresa/Marca, Responsável, Data, Contato) são pré-preenchidos com os dados do cadastro e exibidos na primeira tela, editáveis.
|
||||||
|
2. **Tela de abertura** `/briefings/[id]`: mostra o "Como preencher" e o aviso de senhas (vindos de `Configuracao`), lista das 19 seções com progresso por seção (respondidas/total) e botão "Continuar de onde parei".
|
||||||
|
3. **Tela de seção** `/briefings/[id]/[secao]`: título e descrição da seção; cada pergunta renderizada por `components/form/PerguntaField.tsx`, escolhendo o componente pelo `tipo`:
|
||||||
|
- `TEXTAREA`: textarea com contador discreto.
|
||||||
|
- `RADIO`: grupo de opções (uma escolha).
|
||||||
|
- `CHECKBOX`: múltipla escolha.
|
||||||
|
- `ANEXO`: tratado na etapa 08 (aqui, deixar placeholder).
|
||||||
|
Se `permiteComplemento`, exibir abaixo um campo curto "Complemento / outro". O rótulo mostra `rotulo`, e `dica` aparece como texto de apoio. Cada pergunta tem espaço reservado para o botão Ajuda (etapa 06).
|
||||||
|
4. **Navegação:** Anterior/Próxima seção, menu lateral (drawer no celular) com as 19 seções e indicadores de concluída/parcial; barra de progresso geral.
|
||||||
|
5. **Estado:** React Hook Form por seção; valores iniciais carregados de `Resposta`. Persistência vem na etapa 07.
|
||||||
|
6. **"Respondida":** considerar respondida se houver texto não vazio ou ao menos uma opção marcada. Perguntas não são obrigatórias por padrão (`obrigatoria=false`); o documento aceita "Não se aplica" / "A definir" — exibir esses dois como atalhos (chips) em campos de texto que preenchem o valor ao toque.
|
||||||
|
|
||||||
|
## Critérios de aceite
|
||||||
|
- [ ] As 19 seções renderizam a partir do banco, sem campo escrito à mão.
|
||||||
|
- [ ] Os 4 tipos funcionam com os valores corretos (inclusive complemento).
|
||||||
|
- [ ] Navegação entre seções e progresso corretos; usável em tela de 360 px.
|
||||||
|
- [ ] Cliente não consegue abrir briefing de outro cliente (404).
|
||||||
|
|
||||||
|
Atualize `99-progresso.md` (05).
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# 06 — Botão Ajuda com popup por pergunta
|
||||||
|
|
||||||
|
## Contexto do projeto (leia sempre)
|
||||||
|
Sistema web "Briefing de Site" da SeConectai: o cliente se cadastra e depois preenche o briefing de criação do site, com um botão **Ajuda** em cada pergunta que abre um popup com o texto do guia auxiliar. O dono (Tiago) acompanha tudo por um painel admin.
|
||||||
|
**Stack fixa:** Next.js 14+ (App Router) + TypeScript, PostgreSQL 16, Prisma, Tailwind + shadcn/ui, React Hook Form + Zod, Auth.js v5 (Credentials, estratégia JWT), Docker Compose. Idioma da interface: pt-BR.
|
||||||
|
**Regras gerais:** leia `00-arquitetura-briefing-site.md` e `99-progresso.md` antes de começar; não avance se a etapa anterior estiver incompleta; ao terminar, devolva o `99-progresso.md` atualizado. Não invente dependências fora da stack. Nada de senhas de clientes em lugar nenhum.
|
||||||
|
|
||||||
|
## Pré-requisito
|
||||||
|
Etapa 05 concluída.
|
||||||
|
|
||||||
|
## Tarefa
|
||||||
|
Criar `components/ajuda/AjudaButton.tsx` e usá-lo em **toda** pergunta que tenha `ajuda`:
|
||||||
|
- Botão pequeno ao lado do rótulo: ícone `HelpCircle` + texto "Ajuda" (o texto some só em telas muito estreitas, mantendo `aria-label="Ajuda sobre: {rotulo}"`).
|
||||||
|
- Ao clicar, abre um `Dialog` (shadcn) com:
|
||||||
|
- **Título:** o `rotulo` da pergunta.
|
||||||
|
- **O que responder** — `oQueResponder`
|
||||||
|
- **Por que perguntamos** — `porQuePerguntamos`
|
||||||
|
- **Mini exemplo** — `miniExemplo`, em caixa destacada, com aviso fixo: "Os exemplos servem apenas como referência. Substitua pelas informações reais da sua empresa."
|
||||||
|
- Rodapé: botão Fechar; opcional "Usar 'Não se aplica'" / "Usar 'A definir'" que preenche o campo e fecha o popup (apenas em campos de texto).
|
||||||
|
- Acessibilidade: foco preso no diálogo, fecha com Esc e clique fora, foco volta ao botão, leitura por leitor de tela.
|
||||||
|
- Dados de ajuda já vêm junto da estrutura (consulta única da etapa 03); não fazer requisição por clique.
|
||||||
|
- Se a pergunta não tiver ajuda, não renderizar o botão.
|
||||||
|
|
||||||
|
## Critérios de aceite
|
||||||
|
- [ ] Todas as 149 perguntas exibem o botão e abrem o texto correto do guia (conferir por amostragem de pelo menos 1 pergunta por seção).
|
||||||
|
- [ ] Popup funciona em celular e desktop, com teclado e leitor de tela.
|
||||||
|
- [ ] Abrir/fechar o popup não perde o que foi digitado no campo.
|
||||||
|
|
||||||
|
Atualize `99-progresso.md` (06).
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 07 — Rascunho automático, revisão e envio
|
||||||
|
|
||||||
|
## Contexto do projeto (leia sempre)
|
||||||
|
Sistema web "Briefing de Site" da SeConectai: o cliente se cadastra e depois preenche o briefing de criação do site, com um botão **Ajuda** em cada pergunta que abre um popup com o texto do guia auxiliar. O dono (Tiago) acompanha tudo por um painel admin.
|
||||||
|
**Stack fixa:** Next.js 14+ (App Router) + TypeScript, PostgreSQL 16, Prisma, Tailwind + shadcn/ui, React Hook Form + Zod, Auth.js v5 (Credentials, estratégia JWT), Docker Compose. Idioma da interface: pt-BR.
|
||||||
|
**Regras gerais:** leia `00-arquitetura-briefing-site.md` e `99-progresso.md` antes de começar; não avance se a etapa anterior estiver incompleta; ao terminar, devolva o `99-progresso.md` atualizado. Não invente dependências fora da stack. Nada de senhas de clientes em lugar nenhum.
|
||||||
|
|
||||||
|
## Pré-requisito
|
||||||
|
Etapas 05 e 06 concluídas.
|
||||||
|
|
||||||
|
## Tarefa
|
||||||
|
1. **Autosave:** ao parar de digitar (debounce ~800 ms) e ao mudar de seção, salvar via server action `salvarRespostas(briefingId, secaoId, valores)` com upsert em `Resposta`. Validar com Zod, checar que o briefing pertence ao usuário e está em RASCUNHO. Indicador "Salvo às HH:MM" / "Salvando…" / "Erro ao salvar — tentar de novo". Aviso ao sair com alterações não salvas.
|
||||||
|
2. **Idempotência:** salvar valor vazio remove/zera a resposta daquela pergunta sem afetar outras; concorrência: última gravação vence, com `atualizadoEm`.
|
||||||
|
3. **Revisão** `/briefings/[id]/revisao`: lista todas as seções com respostas, destaca perguntas em branco (sem bloquear) e permite voltar para editar com um clique.
|
||||||
|
4. **Envio:** exibir o texto "Confirmação do cliente" (de `Configuracao`) com checkbox obrigatório; ao confirmar, `status=ENVIADO`, `confirmadoEm` e `enviadoEm` preenchidos, edição bloqueada (somente leitura). Se houver perguntas em branco, avisar e pedir confirmação extra, sem impedir.
|
||||||
|
5. Admin pode reabrir (volta a RASCUNHO) — preservando todas as respostas (etapa 09 expõe a ação; aqui criar a função de serviço `reabrirBriefing`).
|
||||||
|
|
||||||
|
## Critérios de aceite
|
||||||
|
- [ ] Fechar o navegador no meio e voltar mantém tudo que foi digitado.
|
||||||
|
- [ ] Briefing ENVIADO não aceita novas gravações (servidor recusa, não só a interface).
|
||||||
|
- [ ] Nenhuma resposta de outro cliente/briefing é acessível ou gravável.
|
||||||
|
|
||||||
|
Atualize `99-progresso.md` (07).
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 08 — Anexos (seção 19: checklist de materiais)
|
||||||
|
|
||||||
|
## Contexto do projeto (leia sempre)
|
||||||
|
Sistema web "Briefing de Site" da SeConectai: o cliente se cadastra e depois preenche o briefing de criação do site, com um botão **Ajuda** em cada pergunta que abre um popup com o texto do guia auxiliar. O dono (Tiago) acompanha tudo por um painel admin.
|
||||||
|
**Stack fixa:** Next.js 14+ (App Router) + TypeScript, PostgreSQL 16, Prisma, Tailwind + shadcn/ui, React Hook Form + Zod, Auth.js v5 (Credentials, estratégia JWT), Docker Compose. Idioma da interface: pt-BR.
|
||||||
|
**Regras gerais:** leia `00-arquitetura-briefing-site.md` e `99-progresso.md` antes de começar; não avance se a etapa anterior estiver incompleta; ao terminar, devolva o `99-progresso.md` atualizado. Não invente dependências fora da stack. Nada de senhas de clientes em lugar nenhum.
|
||||||
|
|
||||||
|
## Pré-requisito
|
||||||
|
Etapa 07 concluída.
|
||||||
|
|
||||||
|
## Tarefa
|
||||||
|
A seção 19 do briefing tem 15 itens do tipo `ANEXO` (logotipo, manual de identidade, fotos, vídeos, textos, catálogo, lista de serviços, depoimentos, cases, certificações, dados de contato, redes sociais, referências de sites, política/termos, domínio e hospedagem sem senhas).
|
||||||
|
|
||||||
|
1. Em cada item `ANEXO`: caixa "Estou enviando este material" (equivale ao ☐ do documento), campo de texto para links (Drive, Dropbox, YouTube) e área de upload de arquivos.
|
||||||
|
2. **Upload** via route handler com `multipart/form-data`: máximo `UPLOAD_MAX_MB` por arquivo e 20 arquivos por pergunta; permitir imagens (png, jpg, webp, svg), pdf, docx/doc, xlsx/xls, pptx, zip, mp4/mov; validar extensão **e** tipo MIME **e** conteúdo básico (magic bytes); nome do arquivo no disco = UUID (nunca o nome original); salvar em `UPLOAD_DIR` (volume Docker, **fora de `/public`**).
|
||||||
|
3. **Download** apenas via `/api/anexos/[id]`, autenticado: dono do briefing ou ADMIN. Cabeçalhos `Content-Disposition: attachment` e `X-Content-Type-Options: nosniff`.
|
||||||
|
4. Listar anexos com nome, tamanho e botão remover (somente em RASCUNHO). Barra de progresso por arquivo.
|
||||||
|
5. Aviso fixo na seção: "Não envie senhas. Informe apenas provedores e responsáveis."
|
||||||
|
|
||||||
|
## Critérios de aceite
|
||||||
|
- [ ] Upload/remoção funcionam; arquivo inválido ou grande demais é recusado com mensagem clara.
|
||||||
|
- [ ] Acessar a URL do arquivo sem login ou como outro cliente retorna 403/404.
|
||||||
|
- [ ] Nenhum arquivo fica acessível por URL pública estática.
|
||||||
|
- [ ] Briefing ENVIADO não aceita novos uploads/remoções.
|
||||||
|
|
||||||
|
Atualize `99-progresso.md` (08).
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# 09 — Painel administrativo
|
||||||
|
|
||||||
|
## Contexto do projeto (leia sempre)
|
||||||
|
Sistema web "Briefing de Site" da SeConectai: o cliente se cadastra e depois preenche o briefing de criação do site, com um botão **Ajuda** em cada pergunta que abre um popup com o texto do guia auxiliar. O dono (Tiago) acompanha tudo por um painel admin.
|
||||||
|
**Stack fixa:** Next.js 14+ (App Router) + TypeScript, PostgreSQL 16, Prisma, Tailwind + shadcn/ui, React Hook Form + Zod, Auth.js v5 (Credentials, estratégia JWT), Docker Compose. Idioma da interface: pt-BR.
|
||||||
|
**Regras gerais:** leia `00-arquitetura-briefing-site.md` e `99-progresso.md` antes de começar; não avance se a etapa anterior estiver incompleta; ao terminar, devolva o `99-progresso.md` atualizado. Não invente dependências fora da stack. Nada de senhas de clientes em lugar nenhum.
|
||||||
|
|
||||||
|
## Pré-requisito
|
||||||
|
Etapas 01–08 concluídas.
|
||||||
|
|
||||||
|
## Tarefa
|
||||||
|
Área `/admin` (somente ADMIN, checado no middleware **e** em cada server action):
|
||||||
|
1. **Início:** contadores (briefings por status, novos nos últimos 7 dias) e últimos envios.
|
||||||
|
2. **Clientes** `/admin/clientes`: tabela com busca (nome, e-mail, WhatsApp, CNPJ, razão social) e filtro PF/PJ; detalhe do cliente com dados cadastrais e seus briefings. CPF exibido **mascarado** (`***.123.456-**`) e só revelado completo por botão "Mostrar CPF" que registra o acesso em auditoria (`AuditoriaAcesso`: adminId, clienteId, ação, data — migration `0003`).
|
||||||
|
3. **Briefings** `/admin/briefings/[id]`: todas as respostas por seção (em modo leitura, com as opções marcadas e o complemento), anexos com download, linha do tempo de status. Ações: mudar status (ENVIADO → EM_ANALISE → CONCLUIDO), **reabrir para edição**, adicionar nota interna (`NotaInterna`, invisível ao cliente).
|
||||||
|
4. **Perguntas** `/admin/perguntas`: editar rótulo, dica, textos de ajuda (o que responder / por que perguntamos / mini exemplo), ordem, ativo e obrigatória, sem mexer em código. Desativar uma pergunta nunca apaga respostas já dadas.
|
||||||
|
5. Link de WhatsApp (`wa.me`) no detalhe do cliente para contato rápido.
|
||||||
|
|
||||||
|
## Critérios de aceite
|
||||||
|
- [ ] Usuário CLIENTE não acessa nenhuma rota/ação admin (testar chamada direta).
|
||||||
|
- [ ] Editar um texto de ajuda reflete no popup do cliente imediatamente.
|
||||||
|
- [ ] Reabrir briefing preserva respostas e anexos.
|
||||||
|
- [ ] Revelar CPF gera registro de auditoria.
|
||||||
|
|
||||||
|
Atualize `99-progresso.md` (09).
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# 10 — Exportação em PDF e notificações
|
||||||
|
|
||||||
|
## Contexto do projeto (leia sempre)
|
||||||
|
Sistema web "Briefing de Site" da SeConectai: o cliente se cadastra e depois preenche o briefing de criação do site, com um botão **Ajuda** em cada pergunta que abre um popup com o texto do guia auxiliar. O dono (Tiago) acompanha tudo por um painel admin.
|
||||||
|
**Stack fixa:** Next.js 14+ (App Router) + TypeScript, PostgreSQL 16, Prisma, Tailwind + shadcn/ui, React Hook Form + Zod, Auth.js v5 (Credentials, estratégia JWT), Docker Compose. Idioma da interface: pt-BR.
|
||||||
|
**Regras gerais:** leia `00-arquitetura-briefing-site.md` e `99-progresso.md` antes de começar; não avance se a etapa anterior estiver incompleta; ao terminar, devolva o `99-progresso.md` atualizado. Não invente dependências fora da stack. Nada de senhas de clientes em lugar nenhum.
|
||||||
|
|
||||||
|
## Pré-requisito
|
||||||
|
Etapa 09 concluída.
|
||||||
|
|
||||||
|
## Tarefa
|
||||||
|
1. **PDF do briefing** (`/api/briefings/[id]/pdf`, dono ou ADMIN): documento com dados do cliente, 19 seções, perguntas e respostas (opções marcadas com ☒/☐), lista de anexos (nomes, sem o conteúdo) e a confirmação do cliente com data/hora. Gerar no servidor (ex.: `@react-pdf/renderer`). Botão "Baixar PDF" para o cliente (após envio) e para o admin.
|
||||||
|
2. **E-mails** (SMTP, `lib/mail.ts`, templates simples em pt-BR): confirmação de cadastro, briefing enviado (ao cliente, com resumo e PDF), aviso de novo briefing enviado (ao admin).
|
||||||
|
3. **WhatsApp opcional (Evolution API):** se `EVOLUTION_API_URL/KEY/INSTANCE` e `ADMIN_WHATSAPP` estiverem definidos, avisar o admin quando um briefing for enviado. **Antes de implementar, consultar a versão da Evolution API da instância e confirmar o formato do payload** (não assumir). Falha de WhatsApp/e-mail nunca pode impedir o envio do briefing: registrar erro e seguir (fila simples com retry ou tabela `EnvioNotificacao` com status).
|
||||||
|
4. Dados sensíveis: PDF e e-mails **não** incluem CPF completo.
|
||||||
|
|
||||||
|
## Critérios de aceite
|
||||||
|
- [ ] PDF gerado corretamente com todas as seções e acentuação; abre em leitor comum.
|
||||||
|
- [ ] Falha de SMTP/WhatsApp não quebra o envio do briefing.
|
||||||
|
- [ ] Nenhum CPF completo em PDF, e-mails ou logs.
|
||||||
|
|
||||||
|
Atualize `99-progresso.md` (10).
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 11 — LGPD e segurança
|
||||||
|
|
||||||
|
## Contexto do projeto (leia sempre)
|
||||||
|
Sistema web "Briefing de Site" da SeConectai: o cliente se cadastra e depois preenche o briefing de criação do site, com um botão **Ajuda** em cada pergunta que abre um popup com o texto do guia auxiliar. O dono (Tiago) acompanha tudo por um painel admin.
|
||||||
|
**Stack fixa:** Next.js 14+ (App Router) + TypeScript, PostgreSQL 16, Prisma, Tailwind + shadcn/ui, React Hook Form + Zod, Auth.js v5 (Credentials, estratégia JWT), Docker Compose. Idioma da interface: pt-BR.
|
||||||
|
**Regras gerais:** leia `00-arquitetura-briefing-site.md` e `99-progresso.md` antes de começar; não avance se a etapa anterior estiver incompleta; ao terminar, devolva o `99-progresso.md` atualizado. Não invente dependências fora da stack. Nada de senhas de clientes em lugar nenhum.
|
||||||
|
|
||||||
|
## Pré-requisito
|
||||||
|
Etapas 01–10 concluídas.
|
||||||
|
|
||||||
|
## Tarefa
|
||||||
|
1. **Páginas legais:** `/privacidade` (Política de Privacidade) e `/termos`, com texto-base editável (marcar claramente como "revisar com assessoria jurídica"); links no rodapé e no cadastro.
|
||||||
|
2. **Direitos do titular:** em "Minha conta", cliente pode ver e editar seus dados, baixar seus dados (JSON) e solicitar exclusão (cria `SolicitacaoExclusao`; admin processa — anonimiza o cadastro mantendo o briefing sem identificação pessoal, conforme decisão do Tiago).
|
||||||
|
3. **Cabeçalhos de segurança** no `next.config`: CSP, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`, HSTS em produção.
|
||||||
|
4. **Proteções:** rate limit nos endpoints de cadastro, login, recuperação e upload; proteção CSRF nas server actions (padrão Next) e checagem de origem nas route handlers; sanitização de saída (React já escapa; nunca usar `dangerouslySetInnerHTML` com dados do cliente).
|
||||||
|
5. **Logs:** nunca registrar senha, token, CPF ou conteúdo de respostas; logar apenas ids e eventos.
|
||||||
|
6. **Backups:** script `scripts/backup.sh` com `pg_dump` + cópia do volume de uploads, com rotação.
|
||||||
|
7. Revisão final: tentar manualmente acessar recurso de outro cliente em cada rota (briefing, resposta, anexo, PDF) e confirmar bloqueio.
|
||||||
|
|
||||||
|
## Critérios de aceite
|
||||||
|
- [ ] Cabeçalhos de segurança presentes (verificar com `curl -I`).
|
||||||
|
- [ ] Teste manual de isolamento entre clientes passa em todas as rotas.
|
||||||
|
- [ ] `grep` no código não encontra CPF/senha em logs.
|
||||||
|
- [ ] Backup e restauração testados em ambiente limpo.
|
||||||
|
|
||||||
|
Atualize `99-progresso.md` (11).
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 12 — Deploy em produção
|
||||||
|
|
||||||
|
## Contexto do projeto (leia sempre)
|
||||||
|
Sistema web "Briefing de Site" da SeConectai: o cliente se cadastra e depois preenche o briefing de criação do site, com um botão **Ajuda** em cada pergunta que abre um popup com o texto do guia auxiliar. O dono (Tiago) acompanha tudo por um painel admin.
|
||||||
|
**Stack fixa:** Next.js 14+ (App Router) + TypeScript, PostgreSQL 16, Prisma, Tailwind + shadcn/ui, React Hook Form + Zod, Auth.js v5 (Credentials, estratégia JWT), Docker Compose. Idioma da interface: pt-BR.
|
||||||
|
**Regras gerais:** leia `00-arquitetura-briefing-site.md` e `99-progresso.md` antes de começar; não avance se a etapa anterior estiver incompleta; ao terminar, devolva o `99-progresso.md` atualizado. Não invente dependências fora da stack. Nada de senhas de clientes em lugar nenhum.
|
||||||
|
|
||||||
|
## Pré-requisito
|
||||||
|
Etapas 01–11 concluídas.
|
||||||
|
|
||||||
|
## Tarefa
|
||||||
|
1. `docker-compose.prod.yml`: app (imagem standalone), Postgres 16 com volume, volume de uploads, restart `unless-stopped`, sem portas do banco expostas, variáveis só por `.env` do servidor.
|
||||||
|
2. Proxy reverso com HTTPS (Caddy ou Nginx + Let's Encrypt) para o domínio escolhido; redirecionar HTTP → HTTPS.
|
||||||
|
3. Comando de release: `prisma migrate deploy` + seed idempotente (que não sobrescreve nada do cliente).
|
||||||
|
4. Healthcheck `/api/health` (app + banco) e política de reinício.
|
||||||
|
5. Documentar em `README.md`: variáveis de ambiente, como subir, como rodar backup/restauração, como criar o admin, como atualizar.
|
||||||
|
6. Conferir e-mail (SPF/DKIM do remetente) para os e-mails não caírem em spam.
|
||||||
|
|
||||||
|
## Critérios de aceite
|
||||||
|
- [ ] Sistema acessível por HTTPS no domínio, cadastro → briefing → envio funcionando de ponta a ponta em produção.
|
||||||
|
- [ ] Banco e uploads persistem após `docker compose down && up`.
|
||||||
|
- [ ] Porta do Postgres não acessível externamente.
|
||||||
|
|
||||||
|
Atualize `99-progresso.md` (12) e marque o projeto como concluído.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# 99 — Progresso do projeto (guardrail obrigatório)
|
||||||
|
|
||||||
|
> **Instrução à IA executora:** leia este arquivo ANTES de iniciar cada etapa e devolva-o atualizado ao terminar. Nunca avance se a etapa anterior não estiver 100% concluída (todos os critérios de aceite marcados). Se algo falhar, registre em "Bloqueios" e pare.
|
||||||
|
|
||||||
|
| Etapa | Arquivo | Status | Observações |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 01 | 01-setup-projeto-docker.md | 🟨 em andamento | Estrutura Next/Tailwind/Docker criada na raiz; falta validar `npm install`, `npm run lint`, `npm run build` e `docker compose up`. |
|
||||||
|
| 02 | 02-banco-prisma-schema.md | 🟨 em andamento | `prisma/schema.prisma`, migrations `0001_init` e `0002_configuracao`, e `lib/db.ts` criados; falta aplicar/validar com Prisma e Postgres. |
|
||||||
|
| 03 | 03-seed-briefing-do-docx.md | 🟨 em andamento | `prisma/briefing-seed.json`, `prisma/seed.ts` e `lib/briefing-queries.ts` criados; falta executar seed e conferir contagens. |
|
||||||
|
| 04 | 04-autenticacao-e-cadastro.md | 🟨 em andamento | Cadastro, validadores, criptografia de CPF, Auth.js Credentials, middleware, login, logout, recuperação/redefinição de senha e rotas protegidas iniciais criados; falta validar em runtime com banco, SMTP e build. |
|
||||||
|
| 05 | 05-motor-do-formulario-briefing.md | 🟨 em andamento | Motor inicial criado: lista de briefings, criação de RASCUNHO, abertura com orientações/configurações, cabeçalho editável local, progresso por seção, tela de seção renderizada pelo banco com TEXTAREA/RADIO/CHECKBOX/ANEXO e navegação. Falta validar em runtime e conectar persistência na etapa 07. |
|
||||||
|
| 06 | 06-botao-ajuda-popup.md | 🟨 em andamento | `components/ajuda/AjudaButton.tsx` e `components/ui/dialog.tsx` criados; perguntas com `ajuda` agora exibem botão Ajuda com popup, textos do guia e atalhos para TEXTAREA. Falta validar foco/teclado/mobile e amostrar 1 pergunta por seção em runtime. |
|
||||||
|
| 07 | 07-rascunho-autosave-e-envio.md | 🟨 em andamento | Autosave com debounce, salvar ao navegar, bloqueio server-side para briefing ENVIADO, revisão com respostas/brancos, confirmação e envio criados. Serviço `reabrirBriefing` criado. Falta validar persistência real, concorrência e navegação no runtime. |
|
||||||
|
| 08 | 08-upload-anexos-checklist.md | 🟨 em andamento | Campo ANEXO criado com checkbox, links, upload, listagem, download e remoção. Route handlers privados para upload/download/delete criados, com validação de extensão, MIME, tamanho, magic bytes e bloqueio para briefing ENVIADO. Falta validar upload real, permissões e arquivos inválidos no runtime. |
|
||||||
|
| 09 | 09-painel-admin.md | 🟨 em andamento | Painel admin criado: dashboard com contadores/últimos envios, clientes com busca/filtro, detalhe com CPF mascarado e revelação auditada, detalhe de briefing com respostas/anexos/status/reabertura/notas internas, edição de perguntas e ajudas. Migration `0003` criada. Falta validar permissões, auditoria e edição em runtime. |
|
||||||
|
| 10 | 10-exportacao-pdf-e-notificacoes.md | 🟨 em andamento | PDF em `/api/briefings/[id]/pdf` criado com acesso dono/admin, dados do cliente sem CPF completo, respostas, opções ☒/☐, anexos e confirmação. Botões de download adicionados para cliente/admin. E-mails de cadastro e envio criados; falhas não bloqueiam cadastro/envio. Tabela `EnvioNotificacao` e migration `0004` criadas. WhatsApp/Evolution fica pendente de consulta da versão/payload da instância. Falta validar PDF, SMTP e registros em runtime. |
|
||||||
|
| 11 | 11-lgpd-e-seguranca.md | 🟨 em andamento | Páginas legais marcadas como texto-base jurídico, Minha conta criada com edição de dados, exportação JSON e solicitação de exclusão (`SolicitacaoExclusao`, migration `0005`). Cabeçalhos de segurança adicionados no `next.config`, rate limits ampliados, checagem de origem em route handlers mutáveis, `/minha-conta` protegida no middleware e script `scripts/backup.sh` criado. Falta validar `curl -I`, isolamento manual entre clientes, backup/restauração e migrations em runtime. |
|
||||||
|
| 12 | 12-deploy-producao.md | 🟨 em andamento | Deploy preparado: `docker-compose.prod.yml` com app standalone, Postgres sem porta externa, volumes persistentes, Caddy HTTPS, serviço release (`migrate deploy` + seed), `/api/health`, scripts release/restore e README de produção. Falta executar em servidor real, configurar domínio/SPF/DKIM, validar HTTPS e fluxo cadastro → briefing → envio. |
|
||||||
|
|
||||||
|
Legenda: ⬜ pendente · 🟨 em andamento · ✅ concluída
|
||||||
|
|
||||||
|
## Decisões tomadas durante a execução
|
||||||
|
- O sistema está sendo criado na raiz do projeto, mantendo `Conhecimento/` e `Schema/` como referência.
|
||||||
|
- `Configuracao` foi incluída no schema desde a preparação inicial para armazenar os textos do seed.
|
||||||
|
- `Resposta.complemento` foi incluído para suportar o campo complementar previsto nas perguntas.
|
||||||
|
|
||||||
|
## Bloqueios
|
||||||
|
- O shell atual não possui `node`, `npm` nem `docker`; por isso os critérios de aceite que exigem instalação, build, lint, Docker, Prisma migrate e seed ainda não puderam ser executados.
|
||||||
|
- Etapa 12 depende de servidor com Docker, domínio apontado por DNS e credenciais SMTP reais para validar HTTPS, persistência, e-mail e fluxo ponta a ponta.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,136 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { StatusBriefing } from "@prisma/client";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import {
|
||||||
|
requireAdmin,
|
||||||
|
revelarCpfCliente,
|
||||||
|
atualizarStatusBriefing
|
||||||
|
} from "@/lib/admin-service";
|
||||||
|
import { reabrirBriefing } from "@/lib/briefing-service";
|
||||||
|
|
||||||
|
export type AdminActionState = {
|
||||||
|
ok: boolean;
|
||||||
|
message?: string;
|
||||||
|
value?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function revelarCpfAction(
|
||||||
|
clienteId: string,
|
||||||
|
_state: AdminActionState
|
||||||
|
): Promise<AdminActionState> {
|
||||||
|
const cpf = await revelarCpfCliente(clienteId);
|
||||||
|
return { ok: true, value: cpf };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mudarStatusAction(formData: FormData) {
|
||||||
|
await requireAdmin();
|
||||||
|
const briefingId = String(formData.get("briefingId") ?? "");
|
||||||
|
const status = String(formData.get("status") ?? "") as StatusBriefing;
|
||||||
|
|
||||||
|
if (!["ENVIADO", "EM_ANALISE", "CONCLUIDO"].includes(status)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await atualizarStatusBriefing(briefingId, status);
|
||||||
|
revalidatePath(`/admin/briefings/${briefingId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reabrirBriefingAction(formData: FormData) {
|
||||||
|
await requireAdmin();
|
||||||
|
const briefingId = String(formData.get("briefingId") ?? "");
|
||||||
|
await reabrirBriefing(briefingId);
|
||||||
|
revalidatePath(`/admin/briefings/${briefingId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adicionarNotaInternaAction(formData: FormData) {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const briefingId = String(formData.get("briefingId") ?? "");
|
||||||
|
const texto = String(formData.get("texto") ?? "").trim();
|
||||||
|
|
||||||
|
if (!texto) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.notaInterna.create({
|
||||||
|
data: {
|
||||||
|
briefingId,
|
||||||
|
adminId: admin.id,
|
||||||
|
texto
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/admin/briefings/${briefingId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const perguntaSchema = z.object({
|
||||||
|
perguntaId: z.string().min(1),
|
||||||
|
rotulo: z.string().trim().min(1),
|
||||||
|
dica: z.string().trim().optional(),
|
||||||
|
ordem: z.coerce.number().int().positive(),
|
||||||
|
ativo: z.boolean(),
|
||||||
|
obrigatoria: z.boolean(),
|
||||||
|
oQueResponder: z.string().trim().min(1),
|
||||||
|
porQuePerguntamos: z.string().trim().min(1),
|
||||||
|
miniExemplo: z.string().trim().min(1)
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function salvarPerguntaAction(formData: FormData) {
|
||||||
|
await requireAdmin();
|
||||||
|
const parsed = perguntaSchema.safeParse({
|
||||||
|
perguntaId: formData.get("perguntaId"),
|
||||||
|
rotulo: formData.get("rotulo"),
|
||||||
|
dica: formData.get("dica") || undefined,
|
||||||
|
ordem: formData.get("ordem"),
|
||||||
|
ativo: formData.get("ativo") === "on",
|
||||||
|
obrigatoria: formData.get("obrigatoria") === "on",
|
||||||
|
oQueResponder: formData.get("oQueResponder"),
|
||||||
|
porQuePerguntamos: formData.get("porQuePerguntamos"),
|
||||||
|
miniExemplo: formData.get("miniExemplo")
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.pergunta.update({
|
||||||
|
where: { id: parsed.data.perguntaId },
|
||||||
|
data: {
|
||||||
|
rotulo: parsed.data.rotulo,
|
||||||
|
dica: parsed.data.dica ?? null,
|
||||||
|
ordem: parsed.data.ordem,
|
||||||
|
ativo: parsed.data.ativo,
|
||||||
|
obrigatoria: parsed.data.obrigatoria,
|
||||||
|
ajuda: {
|
||||||
|
upsert: {
|
||||||
|
update: {
|
||||||
|
oQueResponder: parsed.data.oQueResponder,
|
||||||
|
porQuePerguntamos: parsed.data.porQuePerguntamos,
|
||||||
|
miniExemplo: parsed.data.miniExemplo
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
oQueResponder: parsed.data.oQueResponder,
|
||||||
|
porQuePerguntamos: parsed.data.porQuePerguntamos,
|
||||||
|
miniExemplo: parsed.data.miniExemplo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/perguntas");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function irParaClientes(formData: FormData) {
|
||||||
|
const busca = String(formData.get("busca") ?? "").trim();
|
||||||
|
const tipo = String(formData.get("tipo") ?? "").trim();
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (busca) params.set("busca", busca);
|
||||||
|
if (tipo) params.set("tipo", tipo);
|
||||||
|
|
||||||
|
redirect(`/admin/clientes${params.size ? `?${params.toString()}` : ""}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { adicionarNotaInternaAction, mudarStatusAction, reabrirBriefingAction } from "@/app/(admin)/admin/actions";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { getBriefingAdmin } from "@/lib/admin-service";
|
||||||
|
import { formatFileSize } from "@/lib/format";
|
||||||
|
|
||||||
|
function respostaTexto(resposta: {
|
||||||
|
texto: string | null;
|
||||||
|
complemento: string | null;
|
||||||
|
selecionadas: unknown;
|
||||||
|
}) {
|
||||||
|
const partes: string[] = [];
|
||||||
|
const selecionadas = Array.isArray(resposta.selecionadas)
|
||||||
|
? resposta.selecionadas.filter((item): item is string => typeof item === "string")
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (resposta.texto?.trim()) partes.push(resposta.texto.trim());
|
||||||
|
if (selecionadas.length > 0) partes.push(selecionadas.join(", "));
|
||||||
|
if (resposta.complemento?.trim()) partes.push(`Complemento: ${resposta.complemento.trim()}`);
|
||||||
|
|
||||||
|
return partes.join(" | ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BriefingAdminPage({ params }: { params: { id: string } }) {
|
||||||
|
const { briefing, secoes } = await getBriefingAdmin(params.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container py-8">
|
||||||
|
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<Badge variant="muted">{briefing.status}</Badge>
|
||||||
|
<h1 className="mt-3 text-3xl font-semibold">{briefing.titulo}</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{briefing.cliente.nomeCompleto} | {briefing.cliente.user.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/admin">Voltar</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<a href={`/api/briefings/${briefing.id}/pdf`}>Baixar PDF</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{secoes.map((secao) => (
|
||||||
|
<Card key={secao.id}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">{secao.ordem}. {secao.titulo}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{secao.perguntas.map((pergunta) => {
|
||||||
|
const resposta = pergunta.respostas[0];
|
||||||
|
const texto = resposta ? respostaTexto(resposta) : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={pergunta.id} className="rounded-md border p-3">
|
||||||
|
<p className="text-sm font-medium">{pergunta.rotulo}</p>
|
||||||
|
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">
|
||||||
|
{texto || "Sem resposta informada."}
|
||||||
|
</p>
|
||||||
|
{pergunta.anexos.length > 0 ? (
|
||||||
|
<ul className="mt-3 space-y-1 text-sm">
|
||||||
|
{pergunta.anexos.map((anexo) => (
|
||||||
|
<li key={anexo.id}>
|
||||||
|
<a className="text-primary hover:underline" href={`/api/anexos/${anexo.id}`}>
|
||||||
|
{anexo.nomeOriginal} ({formatFileSize(anexo.tamanho)})
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="space-y-4 lg:sticky lg:top-6 lg:self-start">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Acoes</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<form action={mudarStatusAction} className="grid gap-3">
|
||||||
|
<input type="hidden" name="briefingId" value={briefing.id} />
|
||||||
|
<select name="status" defaultValue={briefing.status} className="h-10 rounded-md border bg-background px-3 text-sm">
|
||||||
|
<option value="ENVIADO">ENVIADO</option>
|
||||||
|
<option value="EM_ANALISE">EM_ANALISE</option>
|
||||||
|
<option value="CONCLUIDO">CONCLUIDO</option>
|
||||||
|
</select>
|
||||||
|
<Button type="submit">Atualizar status</Button>
|
||||||
|
</form>
|
||||||
|
<form action={reabrirBriefingAction}>
|
||||||
|
<input type="hidden" name="briefingId" value={briefing.id} />
|
||||||
|
<Button type="submit" variant="outline" className="w-full">Reabrir para edicao</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Nota interna</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<form action={adicionarNotaInternaAction} className="space-y-3">
|
||||||
|
<input type="hidden" name="briefingId" value={briefing.id} />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="texto">Texto</Label>
|
||||||
|
<Textarea id="texto" name="texto" required />
|
||||||
|
</div>
|
||||||
|
<Button type="submit">Adicionar nota</Button>
|
||||||
|
</form>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{briefing.notasInternas.map((nota) => (
|
||||||
|
<div key={nota.id} className="rounded-md border p-3">
|
||||||
|
<p className="text-sm leading-6">{nota.texto}</p>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
{nota.admin.email} | {nota.criadoEm.toLocaleString("pt-BR")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useFormState, useFormStatus } from "react-dom";
|
||||||
|
import { revelarCpfAction, type AdminActionState } from "@/app/(admin)/admin/actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
const initialState: AdminActionState = { ok: false };
|
||||||
|
|
||||||
|
function SubmitButton() {
|
||||||
|
const { pending } = useFormStatus();
|
||||||
|
return (
|
||||||
|
<Button type="submit" variant="outline" disabled={pending}>
|
||||||
|
{pending ? "Registrando..." : "Mostrar CPF"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MostrarCpf({ clienteId }: { clienteId: string }) {
|
||||||
|
const action = revelarCpfAction.bind(null, clienteId);
|
||||||
|
const [state, formAction] = useFormState(action, initialState);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="space-y-2">
|
||||||
|
<SubmitButton />
|
||||||
|
{state.value ? <p className="text-sm font-medium">{state.value}</p> : null}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { decryptCpf } from "@/lib/crypto";
|
||||||
|
import { getClienteAdmin, maskCpf, whatsappLink } from "@/lib/admin-service";
|
||||||
|
import { MostrarCpf } from "./mostrar-cpf";
|
||||||
|
|
||||||
|
export default async function ClienteAdminDetalhePage({ params }: { params: { id: string } }) {
|
||||||
|
const cliente = await getClienteAdmin(params.id);
|
||||||
|
const cpfMascarado = maskCpf(decryptCpf(cliente.cpfCifrado));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container py-8">
|
||||||
|
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<Badge variant="muted">{cliente.tipo}</Badge>
|
||||||
|
<h1 className="mt-3 text-3xl font-semibold">{cliente.nomeCompleto}</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">{cliente.user.email}</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/admin/clientes">Voltar</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[360px_1fr]">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Dados cadastrais</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">CPF</p>
|
||||||
|
<p className="text-muted-foreground">{cpfMascarado}</p>
|
||||||
|
<div className="mt-2">
|
||||||
|
<MostrarCpf clienteId={cliente.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">WhatsApp</p>
|
||||||
|
<a className="text-primary hover:underline" href={whatsappLink(cliente.whatsapp)} target="_blank" rel="noreferrer">
|
||||||
|
{cliente.whatsapp}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{cliente.razaoSocial ? (
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Razao social</p>
|
||||||
|
<p className="text-muted-foreground">{cliente.razaoSocial}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{cliente.cnpj ? (
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">CNPJ</p>
|
||||||
|
<p className="text-muted-foreground">{cliente.cnpj}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Endereco</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{[cliente.logradouro, cliente.numero, cliente.bairro, cliente.cidade, cliente.uf]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ") || "Nao informado"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Briefings</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Titulo</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Atualizado</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{cliente.briefings.map((briefing) => (
|
||||||
|
<TableRow key={briefing.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Link className="font-medium text-primary hover:underline" href={`/admin/briefings/${briefing.id}`}>
|
||||||
|
{briefing.titulo}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell><Badge variant="muted">{briefing.status}</Badge></TableCell>
|
||||||
|
<TableCell>{briefing.atualizadoEm.toLocaleString("pt-BR")}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { irParaClientes } from "@/app/(admin)/admin/actions";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { listarClientesAdmin, maskCpf } from "@/lib/admin-service";
|
||||||
|
import { decryptCpf } from "@/lib/crypto";
|
||||||
|
|
||||||
|
export default async function ClientesAdminPage({
|
||||||
|
searchParams
|
||||||
|
}: {
|
||||||
|
searchParams: { busca?: string; tipo?: "PF" | "PJ" };
|
||||||
|
}) {
|
||||||
|
const clientes = await listarClientesAdmin({
|
||||||
|
busca: searchParams.busca,
|
||||||
|
tipo: searchParams.tipo
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container py-8">
|
||||||
|
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-semibold">Clientes</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">Busque clientes e acompanhe seus briefings.</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/admin">Voltar</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<form action={irParaClientes} className="grid gap-4 sm:grid-cols-[1fr_160px_auto] sm:items-end">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="busca">Busca</Label>
|
||||||
|
<Input id="busca" name="busca" defaultValue={searchParams.busca ?? ""} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="tipo">Tipo</Label>
|
||||||
|
<select id="tipo" name="tipo" defaultValue={searchParams.tipo ?? ""} className="h-10 w-full rounded-md border bg-background px-3 text-sm">
|
||||||
|
<option value="">Todos</option>
|
||||||
|
<option value="PF">PF</option>
|
||||||
|
<option value="PJ">PJ</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Button type="submit">Filtrar</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{clientes.length} cliente(s)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Nome</TableHead>
|
||||||
|
<TableHead>E-mail</TableHead>
|
||||||
|
<TableHead>Tipo</TableHead>
|
||||||
|
<TableHead>CPF</TableHead>
|
||||||
|
<TableHead>Briefings</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{clientes.map((cliente) => (
|
||||||
|
<TableRow key={cliente.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Link href={`/admin/clientes/${cliente.id}`} className="font-medium text-primary hover:underline">
|
||||||
|
{cliente.nomeCompleto}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{cliente.user.email}</TableCell>
|
||||||
|
<TableCell><Badge variant="muted">{cliente.tipo}</Badge></TableCell>
|
||||||
|
<TableCell>{maskCpf(decryptCpf(cliente.cpfCifrado))}</TableCell>
|
||||||
|
<TableCell>{cliente.briefings.length}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { auth } from "@/auth";
|
||||||
|
import { sair } from "@/app/(auth)/actions";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { getAdminDashboard } from "@/lib/admin-service";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default async function AdminPage() {
|
||||||
|
const session = await auth();
|
||||||
|
const dashboard = await getAdminDashboard();
|
||||||
|
const countByStatus = Object.fromEntries(
|
||||||
|
dashboard.porStatus.map((item) => [item.status, item._count._all])
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container py-8">
|
||||||
|
<header className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-semibold">Painel administrativo</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Area restrita para {session?.user.email}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form action={sair}>
|
||||||
|
<Button variant="outline" type="submit">
|
||||||
|
Sair
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="mb-6 flex flex-wrap gap-3">
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/admin/clientes">Clientes</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/admin/perguntas">Perguntas</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Briefings enviados</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-3xl font-semibold">{countByStatus.ENVIADO ?? 0}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Em analise</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-3xl font-semibold">{countByStatus.EM_ANALISE ?? 0}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Concluidos</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-3xl font-semibold">{countByStatus.CONCLUIDO ?? 0}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Novos em 7 dias</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-3xl font-semibold">{dashboard.novosUltimos7Dias}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="mt-6">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Ultimos envios</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Briefing</TableHead>
|
||||||
|
<TableHead>Cliente</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Enviado em</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{dashboard.ultimosEnvios.map((briefing) => (
|
||||||
|
<TableRow key={briefing.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Link className="font-medium text-primary hover:underline" href={`/admin/briefings/${briefing.id}`}>
|
||||||
|
{briefing.titulo}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{briefing.cliente.nomeCompleto}</TableCell>
|
||||||
|
<TableCell><Badge variant="muted">{briefing.status}</Badge></TableCell>
|
||||||
|
<TableCell>{briefing.enviadoEm?.toLocaleString("pt-BR")}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { salvarPerguntaAction } from "@/app/(admin)/admin/actions";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { listarPerguntasAdmin } from "@/lib/admin-service";
|
||||||
|
|
||||||
|
export default async function PerguntasAdminPage() {
|
||||||
|
const secoes = await listarPerguntasAdmin();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container py-8">
|
||||||
|
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-semibold">Perguntas e ajudas</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Edite textos do briefing sem alterar codigo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/admin">Voltar</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{secoes.map((secao) => (
|
||||||
|
<Card key={secao.id}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{secao.ordem}. {secao.titulo}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{secao.perguntas.map((pergunta) => (
|
||||||
|
<form key={pergunta.id} action={salvarPerguntaAction} className="space-y-4 rounded-lg border p-4">
|
||||||
|
<input type="hidden" name="perguntaId" value={pergunta.id} />
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="muted">{pergunta.tipo}</Badge>
|
||||||
|
{!pergunta.ativo ? <Badge variant="outline">Inativa</Badge> : null}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-[100px_1fr]">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`ordem-${pergunta.id}`}>Ordem</Label>
|
||||||
|
<Input id={`ordem-${pergunta.id}`} name="ordem" type="number" defaultValue={pergunta.ordem} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`rotulo-${pergunta.id}`}>Rotulo</Label>
|
||||||
|
<Input id={`rotulo-${pergunta.id}`} name="rotulo" defaultValue={pergunta.rotulo} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`dica-${pergunta.id}`}>Dica</Label>
|
||||||
|
<Input id={`dica-${pergunta.id}`} name="dica" defaultValue={pergunta.dica ?? ""} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="flex items-center gap-3 rounded-md border p-3 text-sm">
|
||||||
|
<Checkbox name="ativo" defaultChecked={pergunta.ativo} />
|
||||||
|
Ativa
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 rounded-md border p-3 text-sm">
|
||||||
|
<Checkbox name="obrigatoria" defaultChecked={pergunta.obrigatoria} />
|
||||||
|
Obrigatoria
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`oq-${pergunta.id}`}>O que responder</Label>
|
||||||
|
<Textarea id={`oq-${pergunta.id}`} name="oQueResponder" defaultValue={pergunta.ajuda?.oQueResponder ?? ""} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`pq-${pergunta.id}`}>Por que perguntamos</Label>
|
||||||
|
<Textarea id={`pq-${pergunta.id}`} name="porQuePerguntamos" defaultValue={pergunta.ajuda?.porQuePerguntamos ?? ""} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`ex-${pergunta.id}`}>Mini exemplo</Label>
|
||||||
|
<Textarea id={`ex-${pergunta.id}`} name="miniExemplo" defaultValue={pergunta.ajuda?.miniExemplo ?? ""} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="submit">Salvar pergunta</Button>
|
||||||
|
</form>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { signOut } from "@/auth";
|
||||||
|
|
||||||
|
export async function sair() {
|
||||||
|
await signOut({ redirectTo: "/login" });
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
import { getBriefingDoCliente } from "@/lib/briefing-service";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
|
const respostaSchema = z.object({
|
||||||
|
texto: z.string().optional(),
|
||||||
|
complemento: z.string().optional(),
|
||||||
|
selecionadas: z.array(z.string()).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
const salvarRespostasSchema = z.object({
|
||||||
|
perguntas: z.record(respostaSchema)
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SalvarRespostasResult = {
|
||||||
|
ok: boolean;
|
||||||
|
savedAt?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function hasValue(resposta: z.infer<typeof respostaSchema>) {
|
||||||
|
return Boolean(
|
||||||
|
resposta.texto?.trim() ||
|
||||||
|
resposta.complemento?.trim() ||
|
||||||
|
(resposta.selecionadas && resposta.selecionadas.length > 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function salvarRespostas(
|
||||||
|
briefingId: string,
|
||||||
|
secaoId: string,
|
||||||
|
valores: unknown
|
||||||
|
): Promise<SalvarRespostasResult> {
|
||||||
|
const parsed = salvarRespostasSchema.safeParse(valores);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { ok: false, message: "Dados invalidos para salvar." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const briefing = await getBriefingDoCliente(briefingId);
|
||||||
|
|
||||||
|
if (briefing.status !== "RASCUNHO") {
|
||||||
|
return { ok: false, message: "Este briefing ja foi enviado e nao aceita novas alteracoes." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const perguntasDaSecao = await db.pergunta.findMany({
|
||||||
|
where: {
|
||||||
|
secaoId,
|
||||||
|
ativo: true
|
||||||
|
},
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
const perguntaIds = new Set(perguntasDaSecao.map((pergunta) => pergunta.id));
|
||||||
|
|
||||||
|
await db.$transaction(
|
||||||
|
Object.entries(parsed.data.perguntas)
|
||||||
|
.filter(([perguntaId]) => perguntaIds.has(perguntaId))
|
||||||
|
.map(([perguntaId, resposta]) => {
|
||||||
|
if (!hasValue(resposta)) {
|
||||||
|
return db.resposta.deleteMany({
|
||||||
|
where: {
|
||||||
|
briefingId,
|
||||||
|
perguntaId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.resposta.upsert({
|
||||||
|
where: {
|
||||||
|
briefingId_perguntaId: {
|
||||||
|
briefingId,
|
||||||
|
perguntaId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
texto: resposta.texto?.trim() || null,
|
||||||
|
complemento: resposta.complemento?.trim() || null,
|
||||||
|
selecionadas: resposta.selecionadas?.length ? resposta.selecionadas : null
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
briefingId,
|
||||||
|
perguntaId,
|
||||||
|
texto: resposta.texto?.trim() || null,
|
||||||
|
complemento: resposta.complemento?.trim() || null,
|
||||||
|
selecionadas: resposta.selecionadas?.length ? resposta.selecionadas : null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
savedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { getSecaoBriefing } from "@/lib/briefing-service";
|
||||||
|
import { SecaoForm } from "./secao-form";
|
||||||
|
|
||||||
|
export default async function SecaoBriefingPage({
|
||||||
|
params
|
||||||
|
}: {
|
||||||
|
params: { id: string; secao: string };
|
||||||
|
}) {
|
||||||
|
const ordemSecao = Number(params.secao);
|
||||||
|
const { briefing, secao, secoes, progressoGeral, anterior, proxima } = await getSecaoBriefing(
|
||||||
|
params.id,
|
||||||
|
ordemSecao
|
||||||
|
);
|
||||||
|
const percentual =
|
||||||
|
progressoGeral.total > 0 ? Math.round((progressoGeral.respondidas / progressoGeral.total) * 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container py-8">
|
||||||
|
<div className="mb-6">
|
||||||
|
<Button asChild variant="ghost" className="mb-4 px-0">
|
||||||
|
<Link href={`/briefings/${briefing.id}`}>
|
||||||
|
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||||
|
Voltar para abertura
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<Badge variant="muted">Secao {secao.ordem} de {secoes.length}</Badge>
|
||||||
|
<h1 className="mt-3 text-3xl font-semibold">{secao.titulo}</h1>
|
||||||
|
{secao.descricao ? <p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">{secao.descricao}</p> : null}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-56 space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="font-medium">Progresso geral</span>
|
||||||
|
<span className="text-muted-foreground">{progressoGeral.respondidas}/{progressoGeral.total}</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={percentual} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[280px_1fr]">
|
||||||
|
<aside className="lg:sticky lg:top-6 lg:self-start">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<nav aria-label="Secoes do briefing" className="grid gap-1">
|
||||||
|
{secoes.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.id}
|
||||||
|
href={`/briefings/${briefing.id}/${item.ordem}`}
|
||||||
|
className={`rounded-md px-3 py-2 text-sm transition ${
|
||||||
|
item.ordem === secao.ordem
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "hover:bg-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="block font-medium">{item.ordem}. {item.titulo}</span>
|
||||||
|
<span className={item.ordem === secao.ordem ? "text-primary-foreground/80" : "text-muted-foreground"}>
|
||||||
|
{item.respondidas}/{item.total} respondidas
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SecaoForm
|
||||||
|
briefingId={briefing.id}
|
||||||
|
secaoId={secao.id}
|
||||||
|
perguntas={secao.perguntas}
|
||||||
|
readOnly={briefing.status !== "RASCUNHO"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:justify-between">
|
||||||
|
{anterior ? (
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href={`/briefings/${briefing.id}/${anterior.ordem}`}>
|
||||||
|
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||||
|
Secao anterior
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : <span />}
|
||||||
|
{proxima ? (
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={`/briefings/${briefing.id}/${proxima.ordem}`}>
|
||||||
|
Proxima secao
|
||||||
|
<ChevronRight className="ml-2 h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={`/briefings/${briefing.id}/revisao`}>Ir para revisao</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { Ajuda, Anexo, Pergunta, Resposta } from "@prisma/client";
|
||||||
|
import { useEffect, useRef, useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { FormProvider, useForm } from "react-hook-form";
|
||||||
|
import { PerguntaField } from "@/components/form/PerguntaField";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { salvarRespostas } from "./actions";
|
||||||
|
|
||||||
|
type PerguntaComDados = Pergunta & {
|
||||||
|
ajuda: Ajuda | null;
|
||||||
|
anexos?: Anexo[];
|
||||||
|
respostas: Resposta[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
briefingId: string;
|
||||||
|
secaoId: string;
|
||||||
|
perguntas: PerguntaComDados[];
|
||||||
|
readOnly?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormValues = {
|
||||||
|
perguntas: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
texto?: string;
|
||||||
|
selecionadas?: string[];
|
||||||
|
complemento?: string;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getDefaultValues(perguntas: PerguntaComDados[]): FormValues {
|
||||||
|
return {
|
||||||
|
perguntas: perguntas.reduce<FormValues["perguntas"]>((acc, pergunta) => {
|
||||||
|
const resposta = pergunta.respostas[0];
|
||||||
|
const selecionadas = Array.isArray(resposta?.selecionadas)
|
||||||
|
? resposta.selecionadas.filter((item): item is string => typeof item === "string")
|
||||||
|
: [];
|
||||||
|
|
||||||
|
acc[pergunta.id] = {
|
||||||
|
texto: resposta?.texto ?? "",
|
||||||
|
selecionadas,
|
||||||
|
complemento: resposta?.complemento ?? ""
|
||||||
|
};
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type SaveStatus = "idle" | "dirty" | "saving" | "saved" | "error";
|
||||||
|
|
||||||
|
function formatTime(value: string) {
|
||||||
|
return new Intl.DateTimeFormat("pt-BR", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit"
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SecaoForm({ briefingId, secaoId, perguntas, readOnly = false }: Props) {
|
||||||
|
const [status, setStatus] = useState<SaveStatus>("idle");
|
||||||
|
const [savedAt, setSavedAt] = useState<string | null>(null);
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const router = useRouter();
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
defaultValues: getDefaultValues(perguntas)
|
||||||
|
});
|
||||||
|
const watchedValues = form.watch();
|
||||||
|
|
||||||
|
async function saveNow(values: FormValues) {
|
||||||
|
if (readOnly) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("saving");
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const result = await salvarRespostas(briefingId, secaoId, values);
|
||||||
|
|
||||||
|
startTransition(() => {
|
||||||
|
if (!result.ok) {
|
||||||
|
setStatus("error");
|
||||||
|
setMessage(result.message ?? "Erro ao salvar. Tente novamente.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.reset(values);
|
||||||
|
setStatus("saved");
|
||||||
|
setSavedAt(result.savedAt ?? new Date().toISOString());
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (readOnly || !form.formState.isDirty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("dirty");
|
||||||
|
|
||||||
|
if (debounceRef.current) {
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
void saveNow(form.getValues());
|
||||||
|
}, 800);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) {
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [watchedValues, form, readOnly]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleBeforeUnload(event: BeforeUnloadEvent) {
|
||||||
|
if (form.formState.isDirty && status !== "saved") {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
}, [form.formState.isDirty, status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function handleClick(event: MouseEvent) {
|
||||||
|
const link = (event.target as HTMLElement).closest("a");
|
||||||
|
|
||||||
|
if (!link || readOnly || !form.formState.isDirty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const href = link.getAttribute("href");
|
||||||
|
|
||||||
|
if (!href || !href.startsWith(`/briefings/${briefingId}`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (debounceRef.current) {
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved = await saveNow(form.getValues());
|
||||||
|
|
||||||
|
if (saved) {
|
||||||
|
router.push(href);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("click", handleClick);
|
||||||
|
return () => document.removeEventListener("click", handleClick);
|
||||||
|
}, [briefingId, form, readOnly, router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<form className="space-y-4">
|
||||||
|
<div className="sticky top-0 z-20 rounded-lg border bg-card p-3 text-sm shadow-sm">
|
||||||
|
{readOnly ? (
|
||||||
|
<span className="text-muted-foreground">Briefing enviado. Respostas em modo leitura.</span>
|
||||||
|
) : status === "saving" ? (
|
||||||
|
<span className="text-muted-foreground">Salvando...</span>
|
||||||
|
) : status === "saved" && savedAt ? (
|
||||||
|
<span className="text-muted-foreground">Salvo as {formatTime(savedAt)}</span>
|
||||||
|
) : status === "error" ? (
|
||||||
|
<span className="text-destructive">{message ?? "Erro ao salvar — tentar de novo"}</span>
|
||||||
|
) : status === "dirty" ? (
|
||||||
|
<span className="text-muted-foreground">Alteracoes pendentes...</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">Rascunho pronto para edicao.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{perguntas.map((pergunta, index) => (
|
||||||
|
<fieldset key={pergunta.id} disabled={readOnly} className="disabled:opacity-75">
|
||||||
|
<PerguntaField
|
||||||
|
briefingId={briefingId}
|
||||||
|
pergunta={pergunta}
|
||||||
|
index={index}
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
))}
|
||||||
|
{!readOnly ? (
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row">
|
||||||
|
<Button type="button" variant="outline" onClick={() => form.reset()}>
|
||||||
|
Descartar alteracoes locais
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={() => saveNow(form.getValues())}>
|
||||||
|
Salvar agora
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
</FormProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
empresaMarca: string;
|
||||||
|
responsavel: string;
|
||||||
|
contato: string;
|
||||||
|
data: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CabecalhoBriefingForm({ empresaMarca, responsavel, contato, data }: Props) {
|
||||||
|
return (
|
||||||
|
<form className="grid gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="empresaMarca">Empresa / marca</Label>
|
||||||
|
<Input id="empresaMarca" name="empresaMarca" defaultValue={empresaMarca} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="responsavel">Responsavel</Label>
|
||||||
|
<Input id="responsavel" name="responsavel" defaultValue={responsavel} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="data">Data</Label>
|
||||||
|
<Input id="data" name="data" type="date" defaultValue={data} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="contato">Contato</Label>
|
||||||
|
<Input id="contato" name="contato" defaultValue={contato} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs leading-5 text-muted-foreground">
|
||||||
|
Estes campos serao salvos junto com o rascunho na etapa de autosave.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { getAberturaBriefing } from "@/lib/briefing-service";
|
||||||
|
import { CabecalhoBriefingForm } from "./cabecalho-briefing-form";
|
||||||
|
|
||||||
|
function asStringArray(value: unknown) {
|
||||||
|
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AberturaBriefingPage({ params }: { params: { id: string } }) {
|
||||||
|
const { briefing, progresso, comoPreencher, avisoSenhas } = await getAberturaBriefing(params.id);
|
||||||
|
const totalPerguntas = progresso.reduce((sum, secao) => sum + secao.total, 0);
|
||||||
|
const totalRespondidas = progresso.reduce((sum, secao) => sum + secao.respondidas, 0);
|
||||||
|
const percentual = totalPerguntas > 0 ? Math.round((totalRespondidas / totalPerguntas) * 100) : 0;
|
||||||
|
const primeiraPendente = progresso.find((secao) => secao.respondidas < secao.total) ?? progresso[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container py-8">
|
||||||
|
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<Badge variant="muted">{briefing.status.replace("_", " ")}</Badge>
|
||||||
|
<h1 className="mt-3 text-3xl font-semibold">{briefing.titulo}</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Revise as orientacoes e continue pela secao indicada.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{primeiraPendente ? (
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={`/briefings/${briefing.id}/${primeiraPendente.ordem}`}>Continuar de onde parei</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6 space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="font-medium">Progresso geral</span>
|
||||||
|
<span className="text-muted-foreground">{totalRespondidas}/{totalPerguntas}</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={percentual} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[360px_1fr]">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Cabecalho do briefing</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CabecalhoBriefingForm
|
||||||
|
empresaMarca={briefing.cliente.razaoSocial ?? briefing.cliente.nomeCompleto}
|
||||||
|
responsavel={briefing.cliente.nomeCompleto}
|
||||||
|
contato={`${briefing.cliente.user.email} | ${briefing.cliente.whatsapp}`}
|
||||||
|
data={new Date().toISOString().slice(0, 10)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{avisoSenhas ? (
|
||||||
|
<Alert>
|
||||||
|
<AlertTitle>Aviso importante</AlertTitle>
|
||||||
|
<AlertDescription>{String(avisoSenhas)}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Como preencher</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ul className="space-y-3 text-sm leading-6 text-muted-foreground">
|
||||||
|
{asStringArray(comoPreencher).map((item) => (
|
||||||
|
<li key={item} className="rounded-md bg-muted p-3">{item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{progresso.map((secao) => {
|
||||||
|
const secaoPercentual = secao.total > 0 ? Math.round((secao.respondidas / secao.total) * 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={secao.id}
|
||||||
|
href={`/briefings/${briefing.id}/${secao.ordem}`}
|
||||||
|
className="rounded-lg border bg-card p-4 transition hover:border-primary"
|
||||||
|
>
|
||||||
|
<div className="mb-3 flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{secao.ordem}. {secao.titulo}</p>
|
||||||
|
{secao.descricao ? <p className="mt-1 text-sm text-muted-foreground">{secao.descricao}</p> : null}
|
||||||
|
</div>
|
||||||
|
<Badge variant="muted">{secao.respondidas}/{secao.total}</Badge>
|
||||||
|
</div>
|
||||||
|
<Progress value={secaoPercentual} />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getBriefingDoCliente } from "@/lib/briefing-service";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { notificarBriefingEnviado } from "@/lib/notifications";
|
||||||
|
|
||||||
|
export type EnvioBriefingState = {
|
||||||
|
ok: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function enviarBriefing(
|
||||||
|
briefingId: string,
|
||||||
|
_state: EnvioBriefingState,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<EnvioBriefingState> {
|
||||||
|
const briefing = await getBriefingDoCliente(briefingId);
|
||||||
|
|
||||||
|
if (briefing.status !== "RASCUNHO") {
|
||||||
|
return { ok: false, message: "Este briefing ja foi enviado." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.get("confirmacaoCliente") !== "on") {
|
||||||
|
return { ok: false, message: "Confirme a declaracao do cliente para enviar." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPerguntas = await db.pergunta.count({
|
||||||
|
where: { ativo: true }
|
||||||
|
});
|
||||||
|
const respostas = await db.resposta.findMany({
|
||||||
|
where: { briefingId: briefing.id },
|
||||||
|
select: {
|
||||||
|
texto: true,
|
||||||
|
complemento: true,
|
||||||
|
selecionadas: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const respondidas = respostas.filter((resposta) => {
|
||||||
|
const selecionadas = Array.isArray(resposta.selecionadas) ? resposta.selecionadas : [];
|
||||||
|
return Boolean(resposta.texto?.trim() || resposta.complemento?.trim() || selecionadas.length > 0);
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
if (respondidas < totalPerguntas && formData.get("confirmarBrancos") !== "on") {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: "Confirme que deseja enviar mesmo com perguntas em branco."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.briefing.update({
|
||||||
|
where: { id: briefing.id },
|
||||||
|
data: {
|
||||||
|
status: "ENVIADO",
|
||||||
|
confirmadoEm: new Date(),
|
||||||
|
enviadoEm: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await notificarBriefingEnviado(briefing.id);
|
||||||
|
} catch {
|
||||||
|
// Falha de notificacao nao pode impedir o envio do briefing.
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(`/briefings/${briefing.id}/revisao`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useFormState, useFormStatus } from "react-dom";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { enviarBriefing, type EnvioBriefingState } from "./actions";
|
||||||
|
|
||||||
|
const initialState: EnvioBriefingState = { ok: false };
|
||||||
|
|
||||||
|
function SubmitButton() {
|
||||||
|
const { pending } = useFormStatus();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button type="submit" disabled={pending}>
|
||||||
|
{pending ? "Enviando..." : "Enviar briefing"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EnvioBriefingForm({
|
||||||
|
briefingId,
|
||||||
|
confirmacaoCliente,
|
||||||
|
perguntasEmBranco
|
||||||
|
}: {
|
||||||
|
briefingId: string;
|
||||||
|
confirmacaoCliente: string;
|
||||||
|
perguntasEmBranco: number;
|
||||||
|
}) {
|
||||||
|
const enviarComId = enviarBriefing.bind(null, briefingId);
|
||||||
|
const [state, action] = useFormState(enviarComId, initialState);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={action} className="space-y-4 rounded-lg border bg-card p-4">
|
||||||
|
{state.message ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{state.message}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{perguntasEmBranco > 0 ? (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
Existem {perguntasEmBranco} pergunta(s) em branco. Voce pode enviar mesmo assim se estiver tudo certo.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<p className="text-sm leading-6 text-muted-foreground">{confirmacaoCliente}</p>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-3 rounded-md border p-3 text-sm leading-6">
|
||||||
|
<Checkbox name="confirmacaoCliente" required />
|
||||||
|
<span>Confirmo a declaracao acima e desejo enviar o briefing para analise.</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{perguntasEmBranco > 0 ? (
|
||||||
|
<label className="flex items-start gap-3 rounded-md border p-3 text-sm leading-6">
|
||||||
|
<Checkbox name="confirmarBrancos" required />
|
||||||
|
<span>Estou ciente de que ha perguntas em branco e quero enviar mesmo assim.</span>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<SubmitButton />
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { getRevisaoBriefing } from "@/lib/briefing-service";
|
||||||
|
import { EnvioBriefingForm } from "./envio-briefing-form";
|
||||||
|
|
||||||
|
function respostaTexto(resposta: {
|
||||||
|
texto: string | null;
|
||||||
|
complemento: string | null;
|
||||||
|
selecionadas: unknown;
|
||||||
|
}) {
|
||||||
|
const partes: string[] = [];
|
||||||
|
const selecionadas = Array.isArray(resposta.selecionadas)
|
||||||
|
? resposta.selecionadas.filter((item): item is string => typeof item === "string")
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (resposta.texto?.trim()) {
|
||||||
|
partes.push(resposta.texto.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selecionadas.length > 0) {
|
||||||
|
partes.push(selecionadas.join(", "));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resposta.complemento?.trim()) {
|
||||||
|
partes.push(`Complemento: ${resposta.complemento.trim()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return partes.join(" | ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RevisaoBriefingPage({ params }: { params: { id: string } }) {
|
||||||
|
const { briefing, secoes, confirmacaoCliente } = await getRevisaoBriefing(params.id);
|
||||||
|
const perguntas = secoes.flatMap((secao) => secao.perguntas);
|
||||||
|
const perguntasEmBranco = perguntas.filter((pergunta) => {
|
||||||
|
const resposta = pergunta.respostas[0];
|
||||||
|
const anexos = "anexos" in pergunta && Array.isArray(pergunta.anexos) ? pergunta.anexos : [];
|
||||||
|
return (!resposta || !respostaTexto(resposta)) && anexos.length === 0;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container py-8">
|
||||||
|
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<Badge variant="muted">{briefing.status.replace("_", " ")}</Badge>
|
||||||
|
<h1 className="mt-3 text-3xl font-semibold">Revisao do briefing</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Confira as respostas antes de enviar para analise.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href={`/briefings/${briefing.id}`}>Voltar para abertura</Link>
|
||||||
|
</Button>
|
||||||
|
{briefing.status !== "RASCUNHO" ? (
|
||||||
|
<Button asChild>
|
||||||
|
<a href={`/api/briefings/${briefing.id}/pdf`}>Baixar PDF</a>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{secoes.map((secao) => (
|
||||||
|
<Card key={secao.id}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">{secao.ordem}. {secao.titulo}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{secao.perguntas.map((pergunta) => {
|
||||||
|
const resposta = pergunta.respostas[0];
|
||||||
|
const texto = resposta ? respostaTexto(resposta) : "";
|
||||||
|
const anexos = "anexos" in pergunta && Array.isArray(pergunta.anexos) ? pergunta.anexos : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={pergunta.id} className="rounded-md border p-3">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<p className="text-sm font-medium">{pergunta.rotulo}</p>
|
||||||
|
{!texto && anexos.length === 0 ? <Badge variant="outline">Em branco</Badge> : null}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">
|
||||||
|
{texto || "Sem resposta informada."}
|
||||||
|
</p>
|
||||||
|
{anexos.length > 0 ? (
|
||||||
|
<ul className="mt-3 space-y-1 text-sm text-muted-foreground">
|
||||||
|
{anexos.map((anexo) => (
|
||||||
|
<li key={anexo.id}>
|
||||||
|
<a className="text-primary underline-offset-4 hover:underline" href={`/api/anexos/${anexo.id}`}>
|
||||||
|
{anexo.nomeOriginal}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{briefing.status === "RASCUNHO" ? (
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href={`/briefings/${briefing.id}/${secao.ordem}`}>Editar secao</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="lg:sticky lg:top-6 lg:self-start">
|
||||||
|
{briefing.status === "RASCUNHO" ? (
|
||||||
|
<EnvioBriefingForm
|
||||||
|
briefingId={briefing.id}
|
||||||
|
confirmacaoCliente={String(confirmacaoCliente ?? "")}
|
||||||
|
perguntasEmBranco={perguntasEmBranco}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Briefing enviado</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm leading-6 text-muted-foreground">
|
||||||
|
Este briefing foi enviado e esta bloqueado para edicao.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { criarBriefingParaCliente } from "@/lib/briefing-service";
|
||||||
|
|
||||||
|
export async function iniciarBriefing() {
|
||||||
|
const briefing = await criarBriefingParaCliente();
|
||||||
|
redirect(`/briefings/${briefing.id}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { auth } from "@/auth";
|
||||||
|
import { sair } from "@/app/(auth)/actions";
|
||||||
|
import { iniciarBriefing } from "@/app/(cliente)/briefings/actions";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { listarBriefingsDoCliente } from "@/lib/briefing-service";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default async function BriefingsPage() {
|
||||||
|
const session = await auth();
|
||||||
|
const briefings = await listarBriefingsDoCliente();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container py-8">
|
||||||
|
<header className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-semibold">Meus briefings</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{session?.user.email} pode iniciar um briefing e continuar depois.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form action={sair}>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/minha-conta">Minha conta</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" type="submit">
|
||||||
|
Sair
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form action={iniciarBriefing} className="mb-6">
|
||||||
|
<Button type="submit">Iniciar meu briefing</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{briefings.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Nenhum briefing iniciado</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-sm leading-6 text-muted-foreground">
|
||||||
|
Inicie seu primeiro briefing para organizar as informacoes do projeto.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{briefings.map((briefing) => (
|
||||||
|
<Card key={briefing.id}>
|
||||||
|
<CardHeader className="gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg">{briefing.titulo}</CardTitle>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Atualizado em {briefing.atualizadoEm.toLocaleDateString("pt-BR")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="muted">{briefing.status.replace("_", " ")}</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href={`/briefings/${briefing.id}`}>Abrir briefing</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { getClienteConta } from "@/lib/account-service";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { normalizeWhatsapp } from "@/lib/validators";
|
||||||
|
|
||||||
|
export type ContaState = {
|
||||||
|
ok: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const atualizarContaSchema = z.object({
|
||||||
|
nomeCompleto: z.string().trim().min(3),
|
||||||
|
whatsapp: z.string().trim().min(10),
|
||||||
|
razaoSocial: z.string().trim().optional(),
|
||||||
|
cep: z.string().trim().optional(),
|
||||||
|
logradouro: z.string().trim().optional(),
|
||||||
|
numero: z.string().trim().optional(),
|
||||||
|
complemento: z.string().trim().optional(),
|
||||||
|
bairro: z.string().trim().optional(),
|
||||||
|
cidade: z.string().trim().optional(),
|
||||||
|
uf: z.string().trim().max(2).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function atualizarConta(
|
||||||
|
_state: ContaState,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<ContaState> {
|
||||||
|
const cliente = await getClienteConta();
|
||||||
|
const parsed = atualizarContaSchema.safeParse({
|
||||||
|
nomeCompleto: formData.get("nomeCompleto"),
|
||||||
|
whatsapp: formData.get("whatsapp"),
|
||||||
|
razaoSocial: formData.get("razaoSocial") || undefined,
|
||||||
|
cep: formData.get("cep") || undefined,
|
||||||
|
logradouro: formData.get("logradouro") || undefined,
|
||||||
|
numero: formData.get("numero") || undefined,
|
||||||
|
complemento: formData.get("complemento") || undefined,
|
||||||
|
bairro: formData.get("bairro") || undefined,
|
||||||
|
cidade: formData.get("cidade") || undefined,
|
||||||
|
uf: formData.get("uf") || undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { ok: false, message: "Revise os dados informados." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const whatsapp = normalizeWhatsapp(parsed.data.whatsapp);
|
||||||
|
|
||||||
|
if (!whatsapp) {
|
||||||
|
return { ok: false, message: "Informe um WhatsApp com DDD." };
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.cliente.update({
|
||||||
|
where: { id: cliente.id },
|
||||||
|
data: {
|
||||||
|
nomeCompleto: parsed.data.nomeCompleto,
|
||||||
|
whatsapp,
|
||||||
|
razaoSocial: parsed.data.razaoSocial ?? null,
|
||||||
|
cep: parsed.data.cep ?? null,
|
||||||
|
logradouro: parsed.data.logradouro ?? null,
|
||||||
|
numero: parsed.data.numero ?? null,
|
||||||
|
complemento: parsed.data.complemento ?? null,
|
||||||
|
bairro: parsed.data.bairro ?? null,
|
||||||
|
cidade: parsed.data.cidade ?? null,
|
||||||
|
uf: parsed.data.uf?.toUpperCase() ?? null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/minha-conta");
|
||||||
|
return { ok: true, message: "Dados atualizados." };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function solicitarExclusao(formData: FormData) {
|
||||||
|
const cliente = await getClienteConta();
|
||||||
|
const motivo = String(formData.get("motivo") ?? "").trim();
|
||||||
|
|
||||||
|
await db.solicitacaoExclusao.create({
|
||||||
|
data: {
|
||||||
|
clienteId: cliente.id,
|
||||||
|
motivo: motivo || null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/minha-conta");
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useFormState, useFormStatus } from "react-dom";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { atualizarConta, type ContaState } from "./actions";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
cliente: {
|
||||||
|
nomeCompleto: string;
|
||||||
|
whatsapp: string;
|
||||||
|
razaoSocial: string | null;
|
||||||
|
cep: string | null;
|
||||||
|
logradouro: string | null;
|
||||||
|
numero: string | null;
|
||||||
|
complemento: string | null;
|
||||||
|
bairro: string | null;
|
||||||
|
cidade: string | null;
|
||||||
|
uf: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialState: ContaState = { ok: false };
|
||||||
|
|
||||||
|
function SubmitButton() {
|
||||||
|
const { pending } = useFormStatus();
|
||||||
|
return <Button type="submit" disabled={pending}>{pending ? "Salvando..." : "Salvar dados"}</Button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContaForm({ cliente }: Props) {
|
||||||
|
const [state, action] = useFormState(atualizarConta, initialState);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={action} className="space-y-4">
|
||||||
|
{state.message ? (
|
||||||
|
<Alert variant={state.ok ? "default" : "destructive"}>
|
||||||
|
<AlertDescription>{state.message}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2 sm:col-span-2">
|
||||||
|
<Label htmlFor="nomeCompleto">Nome completo</Label>
|
||||||
|
<Input id="nomeCompleto" name="nomeCompleto" defaultValue={cliente.nomeCompleto} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="whatsapp">WhatsApp</Label>
|
||||||
|
<Input id="whatsapp" name="whatsapp" defaultValue={cliente.whatsapp} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="razaoSocial">Razao social</Label>
|
||||||
|
<Input id="razaoSocial" name="razaoSocial" defaultValue={cliente.razaoSocial ?? ""} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cep">CEP</Label>
|
||||||
|
<Input id="cep" name="cep" defaultValue={cliente.cep ?? ""} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="logradouro">Logradouro</Label>
|
||||||
|
<Input id="logradouro" name="logradouro" defaultValue={cliente.logradouro ?? ""} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="numero">Numero</Label>
|
||||||
|
<Input id="numero" name="numero" defaultValue={cliente.numero ?? ""} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="complemento">Complemento</Label>
|
||||||
|
<Input id="complemento" name="complemento" defaultValue={cliente.complemento ?? ""} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="bairro">Bairro</Label>
|
||||||
|
<Input id="bairro" name="bairro" defaultValue={cliente.bairro ?? ""} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cidade">Cidade</Label>
|
||||||
|
<Input id="cidade" name="cidade" defaultValue={cliente.cidade ?? ""} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="uf">UF</Label>
|
||||||
|
<Input id="uf" name="uf" defaultValue={cliente.uf ?? ""} maxLength={2} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SubmitButton />
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { solicitarExclusao } from "@/app/(cliente)/minha-conta/actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { getClienteConta } from "@/lib/account-service";
|
||||||
|
import { ContaForm } from "./conta-form";
|
||||||
|
|
||||||
|
export default async function MinhaContaPage() {
|
||||||
|
const cliente = await getClienteConta();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container py-8">
|
||||||
|
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-semibold">Minha conta</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">Gerencie seus dados e direitos LGPD.</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/briefings">Voltar</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Dados cadastrais</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ContaForm cliente={cliente} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<aside className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Meus dados</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-sm leading-6 text-muted-foreground">
|
||||||
|
Baixe uma copia em JSON dos dados associados a sua conta.
|
||||||
|
</p>
|
||||||
|
<Button asChild>
|
||||||
|
<a href="/api/minha-conta/exportar">Baixar JSON</a>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Solicitar exclusao</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form action={solicitarExclusao} className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="motivo">Motivo opcional</Label>
|
||||||
|
<Textarea id="motivo" name="motivo" />
|
||||||
|
</div>
|
||||||
|
<Button type="submit" variant="outline">Solicitar exclusao</Button>
|
||||||
|
</form>
|
||||||
|
{cliente.solicitacoesExclusao.length > 0 ? (
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
{cliente.solicitacoesExclusao.map((solicitacao) => (
|
||||||
|
<p key={solicitacao.id} className="text-sm text-muted-foreground">
|
||||||
|
{solicitacao.status} em {solicitacao.criadoEm.toLocaleDateString("pt-BR")}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import argon2 from "argon2";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { encryptCpf, hashCpf, onlyDigits } from "@/lib/crypto";
|
||||||
|
import { cadastroSchema, normalizeWhatsapp } from "@/lib/validators";
|
||||||
|
import { sendMail } from "@/lib/mail";
|
||||||
|
import { checkRateLimit } from "@/lib/rate-limit";
|
||||||
|
|
||||||
|
export type CadastroState = {
|
||||||
|
ok: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function cadastrarCliente(_state: CadastroState, formData: FormData): Promise<CadastroState> {
|
||||||
|
const emailForLimit = formData.get("email")?.toString().trim().toLowerCase() ?? "sem-email";
|
||||||
|
const limit = checkRateLimit(`cadastro:${emailForLimit}`, 5, 60 * 60 * 1000);
|
||||||
|
|
||||||
|
if (!limit.allowed) {
|
||||||
|
return { ok: false, message: "Muitas tentativas. Tente novamente mais tarde." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = cadastroSchema.safeParse({
|
||||||
|
nomeCompleto: formData.get("nomeCompleto"),
|
||||||
|
cpf: formData.get("cpf"),
|
||||||
|
whatsapp: formData.get("whatsapp"),
|
||||||
|
email: formData.get("email"),
|
||||||
|
senha: formData.get("senha"),
|
||||||
|
confirmarSenha: formData.get("confirmarSenha"),
|
||||||
|
tipo: formData.get("tipo"),
|
||||||
|
razaoSocial: formData.get("razaoSocial") || undefined,
|
||||||
|
cnpj: formData.get("cnpj") || undefined,
|
||||||
|
cep: formData.get("cep") || undefined,
|
||||||
|
logradouro: formData.get("logradouro") || undefined,
|
||||||
|
numero: formData.get("numero") || undefined,
|
||||||
|
complemento: formData.get("complemento") || undefined,
|
||||||
|
bairro: formData.get("bairro") || undefined,
|
||||||
|
cidade: formData.get("cidade") || undefined,
|
||||||
|
uf: formData.get("uf") || undefined,
|
||||||
|
lgpdAceite: formData.get("lgpdAceite")
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: parsed.error.issues[0]?.message ?? "Revise os dados informados."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parsed.data;
|
||||||
|
const cpfHash = hashCpf(data.cpf);
|
||||||
|
const whatsapp = normalizeWhatsapp(data.whatsapp);
|
||||||
|
|
||||||
|
if (!whatsapp) {
|
||||||
|
return { ok: false, message: "Informe um WhatsApp com DDD." };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.$transaction(async (tx) => {
|
||||||
|
const exists = await tx.user.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [{ email: data.email }, { cliente: { cpfHash } }]
|
||||||
|
},
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
throw new Error("DUPLICADO");
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.user.create({
|
||||||
|
data: {
|
||||||
|
email: data.email,
|
||||||
|
senhaHash: await argon2.hash(data.senha),
|
||||||
|
papel: "CLIENTE",
|
||||||
|
cliente: {
|
||||||
|
create: {
|
||||||
|
nomeCompleto: data.nomeCompleto,
|
||||||
|
cpfCifrado: encryptCpf(data.cpf),
|
||||||
|
cpfHash,
|
||||||
|
whatsapp,
|
||||||
|
tipo: data.tipo,
|
||||||
|
razaoSocial: data.tipo === "PJ" ? data.razaoSocial : null,
|
||||||
|
cnpj: data.tipo === "PJ" && data.cnpj ? onlyDigits(data.cnpj) : null,
|
||||||
|
cep: data.cep ? onlyDigits(data.cep) : null,
|
||||||
|
logradouro: data.logradouro || null,
|
||||||
|
numero: data.numero || null,
|
||||||
|
complemento: data.complemento || null,
|
||||||
|
bairro: data.bairro || null,
|
||||||
|
cidade: data.cidade || null,
|
||||||
|
uf: data.uf || null,
|
||||||
|
lgpdAceiteEm: new Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === "DUPLICADO") {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: "Nao foi possivel concluir o cadastro com os dados informados."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: "Nao foi possivel concluir o cadastro agora. Tente novamente."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendMail({
|
||||||
|
to: data.email,
|
||||||
|
subject: "Cadastro criado - Briefing SeConectai",
|
||||||
|
text: [
|
||||||
|
"Seu cadastro foi criado com sucesso.",
|
||||||
|
"",
|
||||||
|
"Acesse o sistema para iniciar ou acompanhar seu briefing.",
|
||||||
|
"",
|
||||||
|
"Este e-mail nao inclui CPF completo nem senhas."
|
||||||
|
].join("\n")
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Falha de e-mail nao desfaz cadastro.
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useFormState, useFormStatus } from "react-dom";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { cadastrarCliente, type CadastroState } from "./actions";
|
||||||
|
|
||||||
|
const initialState: CadastroState = { ok: false };
|
||||||
|
|
||||||
|
function SubmitButton() {
|
||||||
|
const { pending } = useFormStatus();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button type="submit" className="w-full" disabled={pending}>
|
||||||
|
{pending ? "Criando cadastro..." : "Criar cadastro"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CadastroForm() {
|
||||||
|
const [state, action] = useFormState(cadastrarCliente, initialState);
|
||||||
|
const [tipo, setTipo] = useState<"PF" | "PJ">("PF");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={action} className="space-y-5">
|
||||||
|
{state.message ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{state.message}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<input type="hidden" name="tipo" value={tipo} />
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2 sm:col-span-2">
|
||||||
|
<Label htmlFor="nomeCompleto">Nome completo</Label>
|
||||||
|
<Input id="nomeCompleto" name="nomeCompleto" autoComplete="name" required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cpf">CPF</Label>
|
||||||
|
<Input id="cpf" name="cpf" inputMode="numeric" autoComplete="off" required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="whatsapp">WhatsApp</Label>
|
||||||
|
<Input id="whatsapp" name="whatsapp" inputMode="tel" autoComplete="tel" required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 sm:col-span-2">
|
||||||
|
<Label htmlFor="email">E-mail</Label>
|
||||||
|
<Input id="email" name="email" type="email" autoComplete="email" required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="senha">Senha</Label>
|
||||||
|
<Input id="senha" name="senha" type="password" autoComplete="new-password" required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirmarSenha">Confirmar senha</Label>
|
||||||
|
<Input id="confirmarSenha" name="confirmarSenha" type="password" autoComplete="new-password" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset className="space-y-3 rounded-lg border p-4">
|
||||||
|
<legend className="px-1 text-sm font-medium">Este site e para uma empresa?</legend>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Button type="button" variant={tipo === "PF" ? "default" : "outline"} onClick={() => setTipo("PF")}>
|
||||||
|
Nao
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant={tipo === "PJ" ? "default" : "outline"} onClick={() => setTipo("PJ")}>
|
||||||
|
Sim
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{tipo === "PJ" ? (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2 sm:col-span-2">
|
||||||
|
<Label htmlFor="razaoSocial">Razao social</Label>
|
||||||
|
<Input id="razaoSocial" name="razaoSocial" required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cnpj">CNPJ</Label>
|
||||||
|
<Input id="cnpj" name="cnpj" inputMode="numeric" required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cep">CEP</Label>
|
||||||
|
<Input id="cep" name="cep" inputMode="numeric" autoComplete="postal-code" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 sm:col-span-2">
|
||||||
|
<Label htmlFor="logradouro">Logradouro</Label>
|
||||||
|
<Input id="logradouro" name="logradouro" autoComplete="address-line1" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="numero">Numero</Label>
|
||||||
|
<Input id="numero" name="numero" autoComplete="address-line2" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="complemento">Complemento</Label>
|
||||||
|
<Input id="complemento" name="complemento" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="bairro">Bairro</Label>
|
||||||
|
<Input id="bairro" name="bairro" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cidade">Cidade</Label>
|
||||||
|
<Input id="cidade" name="cidade" autoComplete="address-level2" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="uf">UF</Label>
|
||||||
|
<Input id="uf" name="uf" maxLength={2} autoComplete="address-level1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<label className="flex items-start gap-3 rounded-lg border p-4 text-sm leading-6">
|
||||||
|
<Checkbox name="lgpdAceite" required />
|
||||||
|
<span>
|
||||||
|
Li e aceito a Politica de Privacidade e autorizo o tratamento dos dados para criacao e acompanhamento do briefing.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<SubmitButton />
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { CadastroForm } from "./cadastro-form";
|
||||||
|
|
||||||
|
export default function CadastroPage() {
|
||||||
|
return (
|
||||||
|
<main className="container flex min-h-screen items-center justify-center py-8">
|
||||||
|
<Card className="w-full max-w-2xl shadow-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Crie seu cadastro</CardTitle>
|
||||||
|
<p className="text-sm leading-6 text-muted-foreground">
|
||||||
|
Depois do cadastro, voce podera iniciar e continuar seu briefing de site.
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CadastroForm />
|
||||||
|
<p className="mt-4 text-center text-xs leading-5 text-muted-foreground">
|
||||||
|
Ao criar cadastro, consulte tambem a{" "}
|
||||||
|
<Link href="/privacidade" className="text-primary underline-offset-4 hover:underline">
|
||||||
|
Politica de Privacidade
|
||||||
|
</Link>{" "}
|
||||||
|
e os{" "}
|
||||||
|
<Link href="/termos" className="text-primary underline-offset-4 hover:underline">
|
||||||
|
Termos de Uso
|
||||||
|
</Link>.
|
||||||
|
</p>
|
||||||
|
<p className="mt-6 text-center text-sm text-muted-foreground">
|
||||||
|
Ja tem cadastro?{" "}
|
||||||
|
<Link href="/login" className="font-medium text-primary underline-offset-4 hover:underline">
|
||||||
|
Entrar
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { AuthError } from "next-auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { signIn } from "@/auth";
|
||||||
|
|
||||||
|
export type LoginState = {
|
||||||
|
ok: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function login(_state: LoginState, formData: FormData): Promise<LoginState> {
|
||||||
|
const callbackUrl = formData.get("callbackUrl")?.toString();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await signIn("credentials", {
|
||||||
|
email: formData.get("email"),
|
||||||
|
senha: formData.get("senha"),
|
||||||
|
redirectTo: callbackUrl || undefined
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: "E-mail ou senha invalidos."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(callbackUrl || "/briefings");
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { useFormState, useFormStatus } from "react-dom";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { login, type LoginState } from "./actions";
|
||||||
|
|
||||||
|
const initialState: LoginState = { ok: false };
|
||||||
|
|
||||||
|
function SubmitButton() {
|
||||||
|
const { pending } = useFormStatus();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button type="submit" className="w-full" disabled={pending}>
|
||||||
|
{pending ? "Entrando..." : "Entrar"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginForm() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [state, action] = useFormState(login, initialState);
|
||||||
|
const callbackUrl = searchParams.get("callbackUrl") ?? "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={action} className="space-y-4">
|
||||||
|
<input type="hidden" name="callbackUrl" value={callbackUrl} />
|
||||||
|
{state.message ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{state.message}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">E-mail</Label>
|
||||||
|
<Input id="email" name="email" type="email" autoComplete="email" required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="senha">Senha</Label>
|
||||||
|
<Input id="senha" name="senha" type="password" autoComplete="current-password" required />
|
||||||
|
</div>
|
||||||
|
<SubmitButton />
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { LoginForm } from "./login-form";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<main className="container flex min-h-screen items-center justify-center py-8">
|
||||||
|
<Card className="w-full max-w-md shadow-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Entrar no briefing</CardTitle>
|
||||||
|
<p className="text-sm leading-6 text-muted-foreground">
|
||||||
|
Acesse sua area para preencher ou acompanhar o briefing.
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<LoginForm />
|
||||||
|
<div className="mt-6 flex items-center justify-between text-sm">
|
||||||
|
<Link href="/recuperar-senha" className="text-primary underline-offset-4 hover:underline">
|
||||||
|
Esqueci minha senha
|
||||||
|
</Link>
|
||||||
|
<Link href="/cadastro" className="text-primary underline-offset-4 hover:underline">
|
||||||
|
Criar cadastro
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function PrivacidadePage() {
|
||||||
|
return (
|
||||||
|
<main className="container max-w-3xl py-10">
|
||||||
|
<Link href="/" className="text-sm font-medium text-primary underline-offset-4 hover:underline">
|
||||||
|
Voltar
|
||||||
|
</Link>
|
||||||
|
<article className="mt-8 space-y-5 rounded-lg border bg-card p-6">
|
||||||
|
<h1 className="text-3xl font-semibold">Politica de Privacidade</h1>
|
||||||
|
<p className="rounded-md border bg-muted p-3 text-sm font-medium">
|
||||||
|
Texto-base: revisar com assessoria juridica antes de publicar em producao.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm leading-7 text-muted-foreground">
|
||||||
|
Texto-base para revisao juridica. Os dados informados no cadastro e no briefing sao usados para identificar o cliente,
|
||||||
|
organizar o projeto e permitir o acompanhamento pela SeConectai.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm leading-7 text-muted-foreground">
|
||||||
|
Dados sensiveis, como CPF, devem ser protegidos por criptografia e nunca devem aparecer em logs, e-mails ou listagens
|
||||||
|
publicas do sistema.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { createHash, randomBytes } from "crypto";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { sendMail } from "@/lib/mail";
|
||||||
|
import { checkRateLimit } from "@/lib/rate-limit";
|
||||||
|
|
||||||
|
export type RecuperarSenhaState = {
|
||||||
|
ok: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function hashToken(token: string) {
|
||||||
|
return createHash("sha256").update(token).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function solicitarRecuperacao(
|
||||||
|
_state: RecuperarSenhaState,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<RecuperarSenhaState> {
|
||||||
|
const email = formData.get("email")?.toString().trim().toLowerCase();
|
||||||
|
const genericMessage = "Se o e-mail estiver cadastrado, enviaremos as instrucoes de redefinicao.";
|
||||||
|
const limit = checkRateLimit(`recuperar-senha:${email ?? "sem-email"}`, 5, 60 * 60 * 1000);
|
||||||
|
|
||||||
|
if (!limit.allowed) {
|
||||||
|
return { ok: true, message: genericMessage };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
return { ok: true, message: genericMessage };
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await db.user.findUnique({
|
||||||
|
where: { email },
|
||||||
|
select: { id: true, email: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return { ok: true, message: genericMessage };
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = randomBytes(32).toString("base64url");
|
||||||
|
const tokenHash = hashToken(token);
|
||||||
|
const expiraEm = new Date(Date.now() + 60 * 60 * 1000);
|
||||||
|
|
||||||
|
await db.passwordResetToken.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
tokenHash,
|
||||||
|
expiraEm
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseUrl = process.env.APP_URL ?? "http://localhost:3000";
|
||||||
|
const resetUrl = `${baseUrl}/redefinir-senha?token=${token}`;
|
||||||
|
|
||||||
|
await sendMail({
|
||||||
|
to: user.email,
|
||||||
|
subject: "Redefinicao de senha - Briefing SeConectai",
|
||||||
|
text: `Use o link abaixo para redefinir sua senha. Ele expira em 1 hora.\n\n${resetUrl}`
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true, message: genericMessage };
|
||||||
|
}
|
||||||
|
|
||||||
|
export { hashToken };
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { RecuperarSenhaForm } from "./recuperar-senha-form";
|
||||||
|
|
||||||
|
export default function RecuperarSenhaPage() {
|
||||||
|
return (
|
||||||
|
<main className="container flex min-h-screen items-center justify-center py-8">
|
||||||
|
<Card className="w-full max-w-md shadow-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Recuperar senha</CardTitle>
|
||||||
|
<p className="text-sm leading-6 text-muted-foreground">
|
||||||
|
Informe seu e-mail para receber as instrucoes de redefinicao.
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<RecuperarSenhaForm />
|
||||||
|
<p className="mt-6 text-center text-sm text-muted-foreground">
|
||||||
|
<Link href="/login" className="text-primary underline-offset-4 hover:underline">
|
||||||
|
Voltar para o login
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useFormState, useFormStatus } from "react-dom";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { solicitarRecuperacao, type RecuperarSenhaState } from "./actions";
|
||||||
|
|
||||||
|
const initialState: RecuperarSenhaState = { ok: false };
|
||||||
|
|
||||||
|
function SubmitButton() {
|
||||||
|
const { pending } = useFormStatus();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button type="submit" className="w-full" disabled={pending}>
|
||||||
|
{pending ? "Enviando..." : "Enviar instrucoes"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecuperarSenhaForm() {
|
||||||
|
const [state, action] = useFormState(solicitarRecuperacao, initialState);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={action} className="space-y-4">
|
||||||
|
{state.message ? (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>{state.message}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">E-mail</Label>
|
||||||
|
<Input id="email" name="email" type="email" autoComplete="email" required />
|
||||||
|
</div>
|
||||||
|
<SubmitButton />
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import argon2 from "argon2";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hashToken } from "@/app/(publico)/recuperar-senha/actions";
|
||||||
|
|
||||||
|
const redefinirSenhaSchema = z
|
||||||
|
.object({
|
||||||
|
token: z.string().min(1),
|
||||||
|
senha: z.string().min(8, "A senha precisa ter pelo menos 8 caracteres."),
|
||||||
|
confirmarSenha: z.string()
|
||||||
|
})
|
||||||
|
.refine((data) => data.senha === data.confirmarSenha, {
|
||||||
|
path: ["confirmarSenha"],
|
||||||
|
message: "As senhas nao conferem."
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RedefinirSenhaState = {
|
||||||
|
ok: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function redefinirSenha(
|
||||||
|
_state: RedefinirSenhaState,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<RedefinirSenhaState> {
|
||||||
|
const parsed = redefinirSenhaSchema.safeParse({
|
||||||
|
token: formData.get("token"),
|
||||||
|
senha: formData.get("senha"),
|
||||||
|
confirmarSenha: formData.get("confirmarSenha")
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: parsed.error.issues[0]?.message ?? "Revise os dados informados."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenHash = hashToken(parsed.data.token);
|
||||||
|
const resetToken = await db.passwordResetToken.findUnique({
|
||||||
|
where: { tokenHash },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
userId: true,
|
||||||
|
expiraEm: true,
|
||||||
|
usadoEm: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resetToken || resetToken.usadoEm || resetToken.expiraEm < new Date()) {
|
||||||
|
return { ok: false, message: "Link invalido ou expirado." };
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.$transaction([
|
||||||
|
db.user.update({
|
||||||
|
where: { id: resetToken.userId },
|
||||||
|
data: { senhaHash: await argon2.hash(parsed.data.senha) }
|
||||||
|
}),
|
||||||
|
db.passwordResetToken.update({
|
||||||
|
where: { id: resetToken.id },
|
||||||
|
data: { usadoEm: new Date() }
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { RedefinirSenhaForm } from "./redefinir-senha-form";
|
||||||
|
|
||||||
|
export default function RedefinirSenhaPage({
|
||||||
|
searchParams
|
||||||
|
}: {
|
||||||
|
searchParams: { token?: string };
|
||||||
|
}) {
|
||||||
|
const token = searchParams.token ?? "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container flex min-h-screen items-center justify-center py-8">
|
||||||
|
<Card className="w-full max-w-md shadow-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Redefinir senha</CardTitle>
|
||||||
|
<p className="text-sm leading-6 text-muted-foreground">
|
||||||
|
Crie uma nova senha para acessar o briefing.
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{token ? (
|
||||||
|
<RedefinirSenhaForm token={token} />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">Link de redefinicao invalido.</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-6 text-center text-sm text-muted-foreground">
|
||||||
|
<Link href="/login" className="text-primary underline-offset-4 hover:underline">
|
||||||
|
Voltar para o login
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useFormState, useFormStatus } from "react-dom";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { redefinirSenha, type RedefinirSenhaState } from "./actions";
|
||||||
|
|
||||||
|
const initialState: RedefinirSenhaState = { ok: false };
|
||||||
|
|
||||||
|
function SubmitButton() {
|
||||||
|
const { pending } = useFormStatus();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button type="submit" className="w-full" disabled={pending}>
|
||||||
|
{pending ? "Salvando..." : "Redefinir senha"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RedefinirSenhaForm({ token }: { token: string }) {
|
||||||
|
const [state, action] = useFormState(redefinirSenha, initialState);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={action} className="space-y-4">
|
||||||
|
<input type="hidden" name="token" value={token} />
|
||||||
|
{state.message ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{state.message}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="senha">Nova senha</Label>
|
||||||
|
<Input id="senha" name="senha" type="password" autoComplete="new-password" required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirmarSenha">Confirmar nova senha</Label>
|
||||||
|
<Input id="confirmarSenha" name="confirmarSenha" type="password" autoComplete="new-password" required />
|
||||||
|
</div>
|
||||||
|
<SubmitButton />
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function TermosPage() {
|
||||||
|
return (
|
||||||
|
<main className="container max-w-3xl py-10">
|
||||||
|
<Link href="/" className="text-sm font-medium text-primary underline-offset-4 hover:underline">
|
||||||
|
Voltar
|
||||||
|
</Link>
|
||||||
|
<article className="mt-8 space-y-5 rounded-lg border bg-card p-6">
|
||||||
|
<h1 className="text-3xl font-semibold">Termos de Uso</h1>
|
||||||
|
<p className="rounded-md border bg-muted p-3 text-sm font-medium">
|
||||||
|
Texto-base: revisar com assessoria juridica antes de publicar em producao.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm leading-7 text-muted-foreground">
|
||||||
|
Texto-base para revisao juridica. O sistema deve ser usado para preenchimento do briefing de criacao de site, sem envio
|
||||||
|
de senhas ou acessos sensiveis.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm leading-7 text-muted-foreground">
|
||||||
|
Informacoes enviadas pelo cliente podem orientar escopo, prazos e prioridades do projeto. Mudancas relevantes depois do
|
||||||
|
envio podem exigir reavaliacao.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { createReadStream } from "fs";
|
||||||
|
import { stat } from "fs/promises";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { Readable } from "stream";
|
||||||
|
import { getAnexoAutorizado } from "@/lib/anexo-service";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { assertSameOrigin } from "@/lib/request-security";
|
||||||
|
import { removeStoredFile } from "@/lib/storage";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
function safeFileName(name: string) {
|
||||||
|
return name.replace(/["\r\n]/g, "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(_request: Request, { params }: { params: { id: string } }) {
|
||||||
|
const anexo = await getAnexoAutorizado(params.id);
|
||||||
|
|
||||||
|
if (!anexo) {
|
||||||
|
return NextResponse.json({ message: "Anexo nao encontrado." }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileStat = await stat(anexo.caminho);
|
||||||
|
const stream = Readable.toWeb(createReadStream(anexo.caminho)) as ReadableStream;
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": anexo.mime,
|
||||||
|
"Content-Length": String(fileStat.size),
|
||||||
|
"Content-Disposition": `attachment; filename="${safeFileName(anexo.nomeOriginal)}"`,
|
||||||
|
"X-Content-Type-Options": "nosniff"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ message: "Arquivo nao encontrado no armazenamento." }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request, { params }: { params: { id: string } }) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
if (originError) return originError;
|
||||||
|
|
||||||
|
const anexo = await getAnexoAutorizado(params.id);
|
||||||
|
|
||||||
|
if (!anexo) {
|
||||||
|
return NextResponse.json({ message: "Anexo nao encontrado." }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anexo.briefing.status !== "RASCUNHO") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Briefing enviado nao aceita remocao de anexos." },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.anexo.delete({
|
||||||
|
where: { id: anexo.id }
|
||||||
|
});
|
||||||
|
await removeStoredFile(anexo.caminho);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { handlers } from "@/auth";
|
||||||
|
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user