Compare commits
7 Commits
dev-1.2
...
1.5-crm-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3be732b1cc | ||
|
|
21fbdd3692 | ||
|
|
dfb91c8ba5 | ||
|
|
99d828869a | ||
|
|
2a112f169d | ||
|
|
2f1cf2bb2a | ||
|
|
04c954c3d9 |
@@ -8,7 +8,9 @@
|
||||
"Bash(docker logs:*)",
|
||||
"Bash(docker exec:*)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(docker-compose restart:*)"
|
||||
"Bash(docker-compose restart:*)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(docker-compose build:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
10
.vscode/tasks.json
vendored
Normal file
10
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "build-agency-frontend",
|
||||
"type": "shell",
|
||||
"command": "docker compose build agency"
|
||||
}
|
||||
]
|
||||
}
|
||||
0
1. docs/planos-aggios.md
Normal file
0
1. docs/planos-aggios.md
Normal file
173
1. docs/planos-roadmap.md
Normal file
173
1. docs/planos-roadmap.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# Sistema de Planos - Roadmap
|
||||
|
||||
## Status: Estrutura Frontend Criada ✅
|
||||
|
||||
### O que foi criado no Frontend:
|
||||
1. **Menu Item** adicionado em `/superadmin/layout.tsx`
|
||||
- Nova rota: `/superadmin/plans`
|
||||
|
||||
2. **Página Principal de Planos** (`/superadmin/plans/page.tsx`)
|
||||
- Lista todos os planos em grid
|
||||
- Mostra: nome, descrição, faixa de usuários, preços, features, diferenciais
|
||||
- Botão "Novo Plano"
|
||||
- Botões Editar e Deletar
|
||||
- Status visual (ativo/inativo)
|
||||
|
||||
3. **Página de Edição de Plano** (`/superadmin/plans/[id]/page.tsx`)
|
||||
- Formulário completo para editar:
|
||||
- Informações básicas (nome, slug, descrição)
|
||||
- Faixa de usuários (min/max)
|
||||
- Preços (mensal/anual)
|
||||
- Armazenamento (GB)
|
||||
- Status (ativo/inativo)
|
||||
- TODO: Editor de Features e Diferenciais
|
||||
|
||||
---
|
||||
|
||||
## Próximos Passos - Backend
|
||||
|
||||
### 1. Modelo de Dados (Domain)
|
||||
```go
|
||||
// internal/domain/plan.go
|
||||
type Plan struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Description string `json:"description"`
|
||||
MinUsers int `json:"min_users"`
|
||||
MaxUsers int `json:"max_users"` // -1 = unlimited
|
||||
MonthlyPrice *decimal.Decimal `json:"monthly_price"`
|
||||
AnnualPrice *decimal.Decimal `json:"annual_price"`
|
||||
Features pq.StringArray `json:"features"` // CRM, ERP, etc
|
||||
Differentiators pq.StringArray `json:"differentiators"`
|
||||
StorageGB int `json:"storage_gb"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Subscription struct {
|
||||
ID string `json:"id"`
|
||||
AgencyID string `json:"agency_id"`
|
||||
PlanID string `json:"plan_id"`
|
||||
BillingType string `json:"billing_type"` // monthly/annual
|
||||
CurrentUsers int `json:"current_users"`
|
||||
Status string `json:"status"` // active/suspended/cancelled
|
||||
StartDate time.Time `json:"start_date"`
|
||||
RenewalDate time.Time `json:"renewal_date"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Migrations
|
||||
- `001_create_plans_table.sql`
|
||||
- `002_create_agency_subscriptions_table.sql`
|
||||
- `003_add_plan_id_to_agencies.sql`
|
||||
|
||||
### 3. Repository
|
||||
- `PlanRepository` (CRUD)
|
||||
- `SubscriptionRepository` (CRUD)
|
||||
|
||||
### 4. Service
|
||||
- `PlanService` (validações, lógica)
|
||||
- `SubscriptionService` (validar limite de usuários, etc)
|
||||
|
||||
### 5. Handlers (API)
|
||||
```
|
||||
GET /api/admin/plans - Listar planos
|
||||
POST /api/admin/plans - Criar plano
|
||||
GET /api/admin/plans/:id - Obter plano
|
||||
PUT /api/admin/plans/:id - Atualizar plano
|
||||
DELETE /api/admin/plans/:id - Deletar plano
|
||||
|
||||
GET /api/admin/subscriptions - Listar subscrições
|
||||
```
|
||||
|
||||
### 6. Seeds
|
||||
- Seed dos 4 planos padrão (Ignição, Órbita, Cosmos, Enterprise)
|
||||
|
||||
---
|
||||
|
||||
## Dados Padrão para Seed
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "Ignição",
|
||||
"slug": "ignition",
|
||||
"description": "Ideal para pequenas agências iniciantes",
|
||||
"min_users": 1,
|
||||
"max_users": 30,
|
||||
"monthly_price": 199.99,
|
||||
"annual_price": 1919.90,
|
||||
"features": ["CRM", "ERP", "Projetos", "Helpdesk", "Pagamentos", "Contratos", "Documentos"],
|
||||
"differentiators": [],
|
||||
"storage_gb": 1,
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"name": "Órbita",
|
||||
"slug": "orbit",
|
||||
"description": "Para agências em crescimento",
|
||||
"min_users": 31,
|
||||
"max_users": 100,
|
||||
"monthly_price": 399.99,
|
||||
"annual_price": 3839.90,
|
||||
"features": ["CRM", "ERP", "Projetos", "Helpdesk", "Pagamentos", "Contratos", "Documentos"],
|
||||
"differentiators": ["Suporte prioritário"],
|
||||
"storage_gb": 1,
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"name": "Cosmos",
|
||||
"slug": "cosmos",
|
||||
"description": "Para agências consolidadas",
|
||||
"min_users": 101,
|
||||
"max_users": 300,
|
||||
"monthly_price": 799.99,
|
||||
"annual_price": 7679.90,
|
||||
"features": ["CRM", "ERP", "Projetos", "Helpdesk", "Pagamentos", "Contratos", "Documentos"],
|
||||
"differentiators": ["Gerente de conta dedicado", "API integrações"],
|
||||
"storage_gb": 1,
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"name": "Enterprise",
|
||||
"slug": "enterprise",
|
||||
"description": "Solução customizada para grandes agências",
|
||||
"min_users": 301,
|
||||
"max_users": -1,
|
||||
"monthly_price": null,
|
||||
"annual_price": null,
|
||||
"features": ["CRM", "ERP", "Projetos", "Helpdesk", "Pagamentos", "Contratos", "Documentos"],
|
||||
"differentiators": ["Armazenamento customizado", "Treinamento personalizado"],
|
||||
"storage_gb": 1,
|
||||
"is_active": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integração com Agências
|
||||
|
||||
Quando agência se cadastra:
|
||||
1. Seleciona um plano
|
||||
2. Sistema cria `Subscription` com status `active` ou `pending_payment`
|
||||
3. Agência herda limite de usuários do plano
|
||||
4. Ao criar usuário: validar se não ultrapassou limite
|
||||
|
||||
---
|
||||
|
||||
## Features Futuras
|
||||
- [ ] Editor de Features e Diferenciais (drag-drop no frontend)
|
||||
- [ ] Planos promocionais (duplicar existente, editar preço)
|
||||
- [ ] Validações de limite de usuários por plano
|
||||
- [ ] Dashboard com uso atual vs limite
|
||||
- [ ] Alertas quando próximo do limite
|
||||
- [ ] Integração com Stripe/PagSeguro
|
||||
|
||||
---
|
||||
|
||||
**Pronto para começar?**
|
||||
183
README.md
183
README.md
@@ -4,27 +4,104 @@ Plataforma composta por serviços de autenticação, painel administrativo (supe
|
||||
|
||||
## Visão geral
|
||||
- **Objetivo**: permitir que superadministradores cadastrem e gerenciem agências (tenants) enquanto o site institucional apresenta informações públicas da empresa.
|
||||
- **Stack**: Go (backend), Next.js 14 (dashboard e site), PostgreSQL, Traefik, Docker.
|
||||
- **Status**: fluxo de autenticação e gestão de agências concluído; ambiente dockerizável pronto para uso local.
|
||||
- **Stack**: Go (backend), Next.js 16 (dashboard e site), PostgreSQL, Traefik, Docker.
|
||||
- **Status**: Sistema multi-tenant completo com CRM Beta (leads, funis, campanhas), portal do cliente, segurança cross-tenant validada, branding dinâmico e file serving via API.
|
||||
|
||||
## Componentes principais
|
||||
- `backend/`: API Go com serviços de autenticação, operadores e CRUD de agências (endpoints `/api/admin/agencies` e `/api/admin/agencies/{id}`).
|
||||
- `backend/`: API Go com serviços de autenticação, operadores e CRUD de agências (endpoints `/api/admin/agencies` e `/api/admin/agencies/{id}`). Inclui handlers para CRM (leads, funis, campanhas), portal do cliente e exportação de dados.
|
||||
- `front-end-agency/`: Painel Next.js para agências - branding dinâmico, upload de logos, gestão de perfil, CRM completo com Kanban, portal de cadastro de clientes e autenticação tenant-aware.
|
||||
- `front-end-dash.aggios.app/`: painel Next.js – login do superadmin, listagem de agências, exibição detalhada e exclusão definitiva.
|
||||
- `frontend-aggios.app/`: site institucional Next.js com suporte a temas claro/escuro e compartilhamento de tokens de design.
|
||||
- `backend/internal/data/postgres/`: scripts de inicialização do banco (estrutura base de tenants e usuários).
|
||||
- `backend/internal/data/postgres/`: scripts de inicialização do banco (estrutura base de tenants e usuários) + migrações para CRM, funis e autenticação de clientes.
|
||||
- `traefik/`: reverse proxy e certificados automatizados.
|
||||
|
||||
## Funcionalidades entregues
|
||||
- **Redesign da Interface (v1.2)**: Adoção de design "Flat" (sem sombras), focado em bordas e limpeza visual em todas as rotas principais (Login, Dashboard, Agências, Cadastro).
|
||||
- **Gestão Avançada de Agências**:
|
||||
- Listagem com filtros robustos: Busca textual, Status (Ativo/Inativo) e Filtros de Data (Presets de 7/15/30 dias e intervalo personalizado).
|
||||
- Detalhamento completo da agência com visualização de logo, cores e dados cadastrais.
|
||||
- Edição e Exclusão de agências.
|
||||
- **Login de Superadmin**: Autenticação via JWT com restrição de rotas protegidas.
|
||||
- **Cadastro de Agências**: Criação de tenant e usuário administrador atrelado.
|
||||
- **Proxy Interno**: Camada de API no Next.js (`app/api/...`) garantindo chamadas autenticadas e seguras ao backend Go.
|
||||
- **Site Institucional**: Suporte a dark mode, componentes compartilhados e tokens de design centralizados.
|
||||
- **Documentação**: Atualizada em `1. docs/` com fluxos, arquiteturas e changelog.
|
||||
|
||||
### **v1.5 - CRM Beta: Leads, Funis e Portal do Cliente (24/12/2025)**
|
||||
- **🎯 Gestão Completa de Leads**:
|
||||
- CRUD completo de leads com status, origem e pontuação
|
||||
- Sistema de importação de leads (CSV/Excel)
|
||||
- Filtros avançados por status, origem, responsável e cliente
|
||||
- Associação de leads a clientes específicos
|
||||
- Timeline de atividades e histórico de interações
|
||||
|
||||
- **📊 Funis de Vendas (Sales Funnels)**:
|
||||
- Criação e gestão de múltiplos funis personalizados
|
||||
- Board Kanban interativo com drag-and-drop
|
||||
- Estágios customizáveis com cores e ícones
|
||||
- Vinculação de funis a campanhas específicas
|
||||
- Métricas e conversão por estágio
|
||||
|
||||
- **🎪 Gestão de Campanhas**:
|
||||
- Criação de campanhas com período e orçamento
|
||||
- Vinculação de campanhas a clientes específicos
|
||||
- Acompanhamento de leads gerados por campanha
|
||||
- Dashboard de performance de campanhas
|
||||
|
||||
- **👥 Portal do Cliente**:
|
||||
- Sistema de registro público de clientes
|
||||
- Autenticação dedicada para clientes (JWT separado)
|
||||
- Dashboard personalizado com estatísticas
|
||||
- Visualização de leads e listas compartilhadas
|
||||
- Gestão de perfil e alteração de senha
|
||||
|
||||
- **🔗 Compartilhamento de Listas**:
|
||||
- Tokens únicos para compartilhamento de leads
|
||||
- URLs públicas para visualização de listas específicas
|
||||
- Controle de acesso via token com expiração
|
||||
|
||||
- **👔 Gestão de Colaboradores**:
|
||||
- Sistema de permissões (Owner, Admin, Member, Readonly)
|
||||
- Middleware de autenticação unificada (agência + cliente)
|
||||
- Controle granular de acesso a funcionalidades
|
||||
- Atribuição de leads a colaboradores específicos
|
||||
|
||||
- **📤 Exportação de Dados**:
|
||||
- Exportação de leads em CSV
|
||||
- Filtros aplicados na exportação
|
||||
- Formatação otimizada para planilhas
|
||||
|
||||
### **v1.4 - Segurança Multi-tenant e File Serving (13/12/2025)**
|
||||
- **🔒 Segurança Cross-Tenant Crítica**:
|
||||
- Validação de tenant_id em endpoints de login (bloqueio de cross-tenant authentication)
|
||||
- Validação de tenant em todas rotas protegidas via middleware
|
||||
- Mensagens de erro genéricas (sem exposição de arquitetura multi-tenant)
|
||||
- Logs detalhados de tentativas de acesso cross-tenant bloqueadas
|
||||
|
||||
- **📁 File Serving via API**:
|
||||
- Nova rota `/api/files/{bucket}/{path}` para servir arquivos do MinIO através do backend Go
|
||||
- Eliminação de dependência de DNS (`files.localhost`) - arquivos servidos via `api.localhost`
|
||||
- Headers de cache otimizados (Cache-Control: public, max-age=31536000)
|
||||
- CORS e content-type corretos automaticamente
|
||||
|
||||
- **🎨 Melhorias de UX**:
|
||||
- Mensagens de erro humanizadas no formulário de login (sem pop-ups/toasts)
|
||||
- Erros inline com ícones e cores apropriadas
|
||||
- Feedback em tempo real ao digitar (limpeza automática de erros)
|
||||
- Mensagens específicas para cada tipo de erro (401, 403, 404, 429, 5xx)
|
||||
|
||||
- **🔧 Melhorias Técnicas**:
|
||||
- Next.js middleware injetando headers `X-Tenant-Subdomain` para routing correto
|
||||
- TenantDetector middleware prioriza headers customizados sobre Host
|
||||
- Upload de logos retorna URLs via API ao invés de MinIO direto
|
||||
- Configuração MinIO com variáveis de ambiente `MINIO_SERVER_URL` e `MINIO_BROWSER_REDIRECT_URL`
|
||||
|
||||
### **v1.3 - Branding Dinâmico e Favicon (12/12/2025)**
|
||||
- **Branding Multi-tenant**: Logo, favicon e cores personalizadas por agência
|
||||
- **Favicon Dinâmico**: Atualização em tempo real via localStorage e SSR metadata
|
||||
- **Upload de Arquivos**: Sistema de upload para MinIO com bucket público
|
||||
- **Rate Limiting**: 1000 requisições/minuto por IP
|
||||
|
||||
### **v1.2 - Redesign Interface Flat**
|
||||
- Adoção de design "Flat" (sem sombras), focado em bordas e limpeza visual
|
||||
- Gestão avançada de agências com filtros robustos
|
||||
- Detalhamento completo com visualização de branding
|
||||
|
||||
### **v1.1 - Fundação Multi-tenant**
|
||||
- Login de Superadmin com JWT
|
||||
- Cadastro de Agências
|
||||
- Proxy Interno Next.js para chamadas autenticadas
|
||||
- Site Institucional com dark mode
|
||||
|
||||
## Executando o projeto
|
||||
1. **Pré-requisitos**: Docker Desktop e Node.js 20+ (para utilitários opcionais).
|
||||
@@ -34,15 +111,67 @@ Plataforma composta por serviços de autenticação, painel administrativo (supe
|
||||
docker-compose up --build
|
||||
```
|
||||
4. **Hosts locais**:
|
||||
- Painel: `https://dash.localhost`
|
||||
- Site: `https://aggios.app.localhost`
|
||||
- API: `https://api.localhost`
|
||||
- Painel SuperAdmin: `http://dash.localhost`
|
||||
- Painel Agência: `http://{agencia}.localhost` (ex: `http://idealpages.localhost`)
|
||||
- Portal do Cliente: `http://{agencia}.localhost/cliente` (cadastro e área logada)
|
||||
- Site: `http://aggios.app.localhost`
|
||||
- API: `http://api.localhost`
|
||||
- Console MinIO: `http://minio.localhost` (admin: minioadmin / M1n10_S3cur3_P@ss_2025!)
|
||||
5. **Credenciais padrão**: ver `backend/internal/data/postgres/init-db.sql` para usuário superadmin seed.
|
||||
|
||||
## Segurança
|
||||
- ✅ **Cross-Tenant Authentication**: Usuários não podem fazer login em agências que não pertencem
|
||||
- ✅ **Tenant Isolation**: Todas rotas protegidas validam tenant_id no JWT vs tenant_id do contexto
|
||||
- ✅ **Erro Handling**: Mensagens genéricas que não expõem arquitetura interna
|
||||
- ✅ **JWT Validation**: Tokens validados em cada requisição autenticada
|
||||
- ✅ **Rate Limiting**: 1000 req/min por IP para prevenir brute force
|
||||
|
||||
## Estrutura de diretórios (resumo)
|
||||
```
|
||||
backend/ API Go (config, domínio, handlers, serviços)
|
||||
internal/
|
||||
api/
|
||||
handlers/
|
||||
crm.go 🎯 CRUD de leads, funis e campanhas
|
||||
customer_portal.go 👥 Portal do cliente (auth, dashboard, leads)
|
||||
export.go 📤 Exportação de dados (CSV)
|
||||
collaborator.go 👔 Gestão de colaboradores
|
||||
files.go Handler para servir arquivos via API
|
||||
auth.go 🔒 Validação cross-tenant no login
|
||||
middleware/
|
||||
unified_auth.go 🔐 Autenticação unificada (agência + cliente)
|
||||
customer_auth.go 🔑 Middleware de autenticação de clientes
|
||||
collaborator_readonly.go 📖 Controle de permissões readonly
|
||||
auth.go 🔒 Validação tenant em rotas protegidas
|
||||
tenant.go 🔧 Detecção de tenant via headers
|
||||
domain/
|
||||
auth_unified.go 🆕 Domínios para autenticação unificada
|
||||
repository/
|
||||
crm_repository.go 🆕 Repositório de dados do CRM
|
||||
backend/internal/data/postgres/ Scripts SQL de seed
|
||||
migrations/
|
||||
015_create_crm_leads.sql 🆕 Estrutura de leads
|
||||
020_create_crm_funnels.sql 🆕 Sistema de funis
|
||||
018_add_customer_auth.sql 🆕 Autenticação de clientes
|
||||
front-end-agency/ Dashboard Next.js para Agências
|
||||
app/
|
||||
(agency)/
|
||||
crm/
|
||||
leads/ 🆕 Gestão de leads
|
||||
funis/[id]/ 🆕 Board Kanban de funis
|
||||
campanhas/ 🆕 Gestão de campanhas
|
||||
cliente/
|
||||
cadastro/ 🆕 Registro público de clientes
|
||||
(portal)/ 🆕 Portal do cliente autenticado
|
||||
share/leads/[token]/ 🆕 Compartilhamento de listas
|
||||
login/page.tsx Login com mensagens humanizadas
|
||||
components/
|
||||
crm/
|
||||
KanbanBoard.tsx 🆕 Board Kanban drag-and-drop
|
||||
CRMCustomerFilter.tsx 🆕 Filtros avançados de CRM
|
||||
team/
|
||||
TeamManagement.tsx 🆕 Gestão de equipe e permissões
|
||||
middleware.ts Injeção de headers tenant
|
||||
front-end-dash.aggios.app/ Dashboard Next.js Superadmin
|
||||
frontend-aggios.app/ Site institucional Next.js
|
||||
traefik/ Regras de roteamento e TLS
|
||||
@@ -51,15 +180,21 @@ traefik/ Regras de roteamento e TLS
|
||||
|
||||
## Testes e validação
|
||||
- Consultar `1. docs/TESTING_GUIDE.md` para cenários funcionais.
|
||||
- Requisições de verificação recomendadas:
|
||||
- `curl http://api.localhost/api/admin/agencies` (lista) – requer token JWT válido.
|
||||
- `curl http://dash.localhost/api/admin/agencies` (proxy Next) – usado pelo painel.
|
||||
- Fluxo manual via painel `dash.localhost/superadmin`.
|
||||
- **Testes de Segurança**:
|
||||
- ✅ Tentativa de login cross-tenant retorna 403
|
||||
- ✅ JWT de uma agência não funciona em outra agência
|
||||
- ✅ Logs registram tentativas de acesso cross-tenant
|
||||
- **Testes de File Serving**:
|
||||
- ✅ Upload de logo gera URL via API (`http://api.localhost/api/files/...`)
|
||||
- ✅ Imagens carregam sem problemas de CORS ou DNS
|
||||
- ✅ Cache headers aplicados corretamente
|
||||
|
||||
## Próximos passos sugeridos
|
||||
- Implementar soft delete e trilhas de auditoria para exclusão de agências.
|
||||
- Expandir testes automatizados (unitários e e2e) focados no fluxo do dashboard.
|
||||
- Disponibilizar pipeline CI/CD com validações de lint/build.
|
||||
- Implementar soft delete e trilhas de auditoria para exclusão de agências
|
||||
- Adicionar validação de permissões por tenant em rotas de files (se necessário)
|
||||
- Expandir testes automatizados (unitários e e2e) focados no fluxo do dashboard
|
||||
- Disponibilizar pipeline CI/CD com validações de lint/build
|
||||
|
||||
## Repositório
|
||||
- Principal: https://git.stackbyte.cloud/erik/aggios.app.git
|
||||
- Branch: 1.5-crm-beta (v1.5 - CRM Beta com leads, funis, campanhas e portal do cliente)
|
||||
@@ -19,7 +19,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server ./cmd/serv
|
||||
# Runtime image
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
RUN apk --no-cache add ca-certificates tzdata postgresql-client
|
||||
|
||||
WORKDIR /root/
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
|
||||
func initDB(cfg *config.Config) (*sql.DB, error) {
|
||||
connStr := fmt.Sprintf(
|
||||
"host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
"host=%s port=%s user=%s password=%s dbname=%s sslmode=disable client_encoding=UTF8",
|
||||
cfg.Database.Host,
|
||||
cfg.Database.Port,
|
||||
cfg.Database.User,
|
||||
@@ -56,22 +56,33 @@ func main() {
|
||||
companyRepo := repository.NewCompanyRepository(db)
|
||||
signupTemplateRepo := repository.NewSignupTemplateRepository(db)
|
||||
agencyTemplateRepo := repository.NewAgencyTemplateRepository(db)
|
||||
planRepo := repository.NewPlanRepository(db)
|
||||
subscriptionRepo := repository.NewSubscriptionRepository(db)
|
||||
crmRepo := repository.NewCRMRepository(db)
|
||||
solutionRepo := repository.NewSolutionRepository(db)
|
||||
|
||||
// Initialize services
|
||||
authService := service.NewAuthService(userRepo, tenantRepo, cfg)
|
||||
agencyService := service.NewAgencyService(userRepo, tenantRepo, cfg)
|
||||
tenantService := service.NewTenantService(tenantRepo)
|
||||
authService := service.NewAuthService(userRepo, tenantRepo, crmRepo, cfg)
|
||||
agencyService := service.NewAgencyService(userRepo, tenantRepo, cfg, db)
|
||||
tenantService := service.NewTenantService(tenantRepo, db)
|
||||
companyService := service.NewCompanyService(companyRepo)
|
||||
planService := service.NewPlanService(planRepo, subscriptionRepo)
|
||||
|
||||
// Initialize handlers
|
||||
healthHandler := handlers.NewHealthHandler()
|
||||
authHandler := handlers.NewAuthHandler(authService)
|
||||
agencyProfileHandler := handlers.NewAgencyHandler(tenantRepo)
|
||||
agencyProfileHandler := handlers.NewAgencyHandler(tenantRepo, cfg)
|
||||
agencyHandler := handlers.NewAgencyRegistrationHandler(agencyService, cfg)
|
||||
collaboratorHandler := handlers.NewCollaboratorHandler(userRepo, agencyService)
|
||||
tenantHandler := handlers.NewTenantHandler(tenantService)
|
||||
companyHandler := handlers.NewCompanyHandler(companyService)
|
||||
planHandler := handlers.NewPlanHandler(planService)
|
||||
crmHandler := handlers.NewCRMHandler(crmRepo)
|
||||
solutionHandler := handlers.NewSolutionHandler(solutionRepo)
|
||||
signupTemplateHandler := handlers.NewSignupTemplateHandler(signupTemplateRepo, userRepo, tenantRepo, agencyService)
|
||||
agencyTemplateHandler := handlers.NewAgencyTemplateHandler(agencyTemplateRepo, agencyService, userRepo, tenantRepo)
|
||||
filesHandler := handlers.NewFilesHandler(cfg)
|
||||
customerPortalHandler := handlers.NewCustomerPortalHandler(crmRepo, authService, cfg)
|
||||
|
||||
// Initialize upload handler
|
||||
uploadHandler, err := handlers.NewUploadHandler(cfg)
|
||||
@@ -79,6 +90,9 @@ func main() {
|
||||
log.Fatalf("❌ Erro ao inicializar upload handler: %v", err)
|
||||
}
|
||||
|
||||
// Initialize backup handler
|
||||
backupHandler := handlers.NewBackupHandler()
|
||||
|
||||
// Create middleware chain
|
||||
tenantDetector := middleware.TenantDetector(tenantRepo)
|
||||
corsMiddleware := middleware.CORS(cfg)
|
||||
@@ -100,7 +114,8 @@ func main() {
|
||||
router.HandleFunc("/api/health", healthHandler.Check)
|
||||
|
||||
// Auth
|
||||
router.HandleFunc("/api/auth/login", authHandler.Login)
|
||||
router.HandleFunc("/api/auth/login", authHandler.UnifiedLogin) // Nova rota unificada
|
||||
router.HandleFunc("/api/auth/login/legacy", authHandler.Login) // Antiga rota (deprecada)
|
||||
router.HandleFunc("/api/auth/register", agencyHandler.PublicRegister).Methods("POST")
|
||||
|
||||
// Public agency template registration (for creating new agencies)
|
||||
@@ -111,11 +126,23 @@ func main() {
|
||||
router.HandleFunc("/api/signup-templates/slug/{slug}", signupTemplateHandler.GetTemplateBySlug).Methods("GET")
|
||||
router.HandleFunc("/api/signup/register", signupTemplateHandler.PublicRegister).Methods("POST")
|
||||
|
||||
// Public plans (for signup flow)
|
||||
router.HandleFunc("/api/plans", planHandler.ListActivePlans).Methods("GET")
|
||||
router.HandleFunc("/api/plans/{id}", planHandler.GetActivePlan).Methods("GET")
|
||||
|
||||
// File upload (public for signup, will also work with auth)
|
||||
router.HandleFunc("/api/upload", uploadHandler.Upload).Methods("POST")
|
||||
|
||||
// Tenant check (public)
|
||||
router.HandleFunc("/api/tenant/check", tenantHandler.CheckExists).Methods("GET")
|
||||
router.HandleFunc("/api/tenant/config", tenantHandler.GetPublicConfig).Methods("GET")
|
||||
router.HandleFunc("/api/tenants/{id}/profile", tenantHandler.GetProfile).Methods("GET")
|
||||
|
||||
// Tenant branding (protected - used by both agency and customer portal)
|
||||
router.Handle("/api/tenant/branding", middleware.RequireAnyAuthenticated(cfg)(http.HandlerFunc(tenantHandler.GetBranding))).Methods("GET")
|
||||
|
||||
// Public customer registration (for agency portal signup)
|
||||
router.HandleFunc("/api/public/customers/register", crmHandler.PublicRegisterCustomer).Methods("POST")
|
||||
|
||||
// Hash generator (dev only - remove in production)
|
||||
router.HandleFunc("/api/hash", handlers.GenerateHash).Methods("POST")
|
||||
@@ -130,6 +157,12 @@ func main() {
|
||||
router.HandleFunc("/api/admin/agencies", tenantHandler.ListAll).Methods("GET")
|
||||
router.HandleFunc("/api/admin/agencies/{id}", agencyHandler.HandleAgency).Methods("GET", "PATCH", "DELETE")
|
||||
|
||||
// SUPERADMIN: Backup & Restore
|
||||
router.Handle("/api/superadmin/backups", authMiddleware(http.HandlerFunc(backupHandler.ListBackups))).Methods("GET")
|
||||
router.Handle("/api/superadmin/backup/create", authMiddleware(http.HandlerFunc(backupHandler.CreateBackup))).Methods("POST")
|
||||
router.Handle("/api/superadmin/backup/restore", authMiddleware(http.HandlerFunc(backupHandler.RestoreBackup))).Methods("POST")
|
||||
router.Handle("/api/superadmin/backup/download/{filename}", authMiddleware(http.HandlerFunc(backupHandler.DownloadBackup))).Methods("GET")
|
||||
|
||||
// SUPERADMIN: Agency template management
|
||||
router.Handle("/api/admin/agency-templates", authMiddleware(http.HandlerFunc(agencyTemplateHandler.ListTemplates))).Methods("GET")
|
||||
router.Handle("/api/admin/agency-templates", authMiddleware(http.HandlerFunc(agencyTemplateHandler.CreateTemplate))).Methods("POST")
|
||||
@@ -154,6 +187,40 @@ func main() {
|
||||
}
|
||||
}))).Methods("GET", "PUT", "PATCH", "DELETE")
|
||||
|
||||
// SUPERADMIN: Plans management
|
||||
planHandler.RegisterRoutes(router)
|
||||
|
||||
// SUPERADMIN: Solutions management
|
||||
router.Handle("/api/admin/solutions", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
solutionHandler.GetAllSolutions(w, r)
|
||||
case http.MethodPost:
|
||||
solutionHandler.CreateSolution(w, r)
|
||||
}
|
||||
}))).Methods("GET", "POST")
|
||||
|
||||
router.Handle("/api/admin/solutions/{id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
solutionHandler.GetSolution(w, r)
|
||||
case http.MethodPut, http.MethodPatch:
|
||||
solutionHandler.UpdateSolution(w, r)
|
||||
case http.MethodDelete:
|
||||
solutionHandler.DeleteSolution(w, r)
|
||||
}
|
||||
}))).Methods("GET", "PUT", "PATCH", "DELETE")
|
||||
|
||||
// SUPERADMIN: Plan <-> Solutions
|
||||
router.Handle("/api/admin/plans/{plan_id}/solutions", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
solutionHandler.GetPlanSolutions(w, r)
|
||||
case http.MethodPut:
|
||||
solutionHandler.SetPlanSolutions(w, r)
|
||||
}
|
||||
}))).Methods("GET", "PUT")
|
||||
|
||||
// ADMIN_AGENCIA: Client registration
|
||||
router.Handle("/api/agencies/clients/register", authMiddleware(http.HandlerFunc(agencyHandler.RegisterClient))).Methods("POST")
|
||||
|
||||
@@ -170,10 +237,193 @@ func main() {
|
||||
// Agency logo upload (protected)
|
||||
router.Handle("/api/agency/logo", authMiddleware(http.HandlerFunc(agencyProfileHandler.UploadLogo))).Methods("POST")
|
||||
|
||||
// File serving route (public - serves files from MinIO through API)
|
||||
router.PathPrefix("/api/files/{bucket}/").HandlerFunc(filesHandler.ServeFile).Methods("GET")
|
||||
|
||||
// Company routes (protected)
|
||||
router.Handle("/api/companies", authMiddleware(http.HandlerFunc(companyHandler.List))).Methods("GET")
|
||||
router.Handle("/api/companies/create", authMiddleware(http.HandlerFunc(companyHandler.Create))).Methods("POST")
|
||||
|
||||
// ==================== CRM ROUTES (TENANT) ====================
|
||||
|
||||
// Tenant solutions (which solutions the tenant has access to)
|
||||
router.Handle("/api/tenant/solutions", authMiddleware(http.HandlerFunc(solutionHandler.GetTenantSolutions))).Methods("GET")
|
||||
|
||||
// Dashboard
|
||||
router.Handle("/api/crm/dashboard", authMiddleware(http.HandlerFunc(crmHandler.GetDashboard))).Methods("GET")
|
||||
|
||||
// Customers
|
||||
router.Handle("/api/crm/customers", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
crmHandler.GetCustomers(w, r)
|
||||
case http.MethodPost:
|
||||
crmHandler.CreateCustomer(w, r)
|
||||
}
|
||||
}))).Methods("GET", "POST")
|
||||
|
||||
router.Handle("/api/crm/customers/{id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
crmHandler.GetCustomer(w, r)
|
||||
case http.MethodPut, http.MethodPatch:
|
||||
crmHandler.UpdateCustomer(w, r)
|
||||
case http.MethodDelete:
|
||||
crmHandler.DeleteCustomer(w, r)
|
||||
}
|
||||
}))).Methods("GET", "PUT", "PATCH", "DELETE")
|
||||
|
||||
// Lists
|
||||
router.Handle("/api/crm/lists", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
crmHandler.GetLists(w, r)
|
||||
case http.MethodPost:
|
||||
crmHandler.CreateList(w, r)
|
||||
}
|
||||
}))).Methods("GET", "POST")
|
||||
|
||||
router.Handle("/api/crm/lists/{id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
crmHandler.GetList(w, r)
|
||||
case http.MethodPut, http.MethodPatch:
|
||||
crmHandler.UpdateList(w, r)
|
||||
case http.MethodDelete:
|
||||
crmHandler.DeleteList(w, r)
|
||||
}
|
||||
}))).Methods("GET", "PUT", "PATCH", "DELETE")
|
||||
|
||||
router.Handle("/api/crm/lists/{id}/leads", authMiddleware(http.HandlerFunc(crmHandler.GetLeadsByList))).Methods("GET")
|
||||
|
||||
// Customer <-> List relationship
|
||||
router.Handle("/api/crm/customers/{customer_id}/lists/{list_id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
crmHandler.AddCustomerToList(w, r)
|
||||
case http.MethodDelete:
|
||||
crmHandler.RemoveCustomerFromList(w, r)
|
||||
}
|
||||
}))).Methods("POST", "DELETE")
|
||||
|
||||
// Leads
|
||||
router.Handle("/api/crm/leads", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
crmHandler.GetLeads(w, r)
|
||||
case http.MethodPost:
|
||||
crmHandler.CreateLead(w, r)
|
||||
}
|
||||
}))).Methods("GET", "POST")
|
||||
|
||||
router.Handle("/api/crm/leads/export", authMiddleware(http.HandlerFunc(crmHandler.ExportLeads))).Methods("GET")
|
||||
router.Handle("/api/crm/leads/import", authMiddleware(http.HandlerFunc(crmHandler.ImportLeads))).Methods("POST")
|
||||
router.Handle("/api/crm/leads/{leadId}/stage", authMiddleware(http.HandlerFunc(crmHandler.UpdateLeadStage))).Methods("PUT")
|
||||
|
||||
router.Handle("/api/crm/leads/{id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
crmHandler.GetLead(w, r)
|
||||
case http.MethodPut, http.MethodPatch:
|
||||
crmHandler.UpdateLead(w, r)
|
||||
case http.MethodDelete:
|
||||
crmHandler.DeleteLead(w, r)
|
||||
}
|
||||
}))).Methods("GET", "PUT", "PATCH", "DELETE")
|
||||
|
||||
// Funnels & Stages
|
||||
router.Handle("/api/crm/funnels", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
crmHandler.ListFunnels(w, r)
|
||||
case http.MethodPost:
|
||||
crmHandler.CreateFunnel(w, r)
|
||||
}
|
||||
}))).Methods("GET", "POST")
|
||||
|
||||
router.Handle("/api/crm/funnels/{id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
crmHandler.GetFunnel(w, r)
|
||||
case http.MethodPut:
|
||||
crmHandler.UpdateFunnel(w, r)
|
||||
case http.MethodDelete:
|
||||
crmHandler.DeleteFunnel(w, r)
|
||||
}
|
||||
}))).Methods("GET", "PUT", "DELETE")
|
||||
|
||||
router.Handle("/api/crm/funnels/{funnelId}/stages", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
crmHandler.ListStages(w, r)
|
||||
case http.MethodPost:
|
||||
crmHandler.CreateStage(w, r)
|
||||
}
|
||||
}))).Methods("GET", "POST")
|
||||
|
||||
router.Handle("/api/crm/stages/{id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPut:
|
||||
crmHandler.UpdateStage(w, r)
|
||||
case http.MethodDelete:
|
||||
crmHandler.DeleteStage(w, r)
|
||||
}
|
||||
}))).Methods("PUT", "DELETE")
|
||||
|
||||
// Lead ingest (integrations)
|
||||
router.Handle("/api/crm/leads/ingest", authMiddleware(http.HandlerFunc(crmHandler.IngestLead))).Methods("POST")
|
||||
|
||||
// Share tokens (generate)
|
||||
router.Handle("/api/crm/customers/share-token", authMiddleware(http.HandlerFunc(crmHandler.GenerateShareToken))).Methods("POST")
|
||||
|
||||
// Share data (public endpoint - no auth required)
|
||||
router.HandleFunc("/api/crm/share/{token}", crmHandler.GetSharedData).Methods("GET")
|
||||
|
||||
// ==================== CUSTOMER PORTAL ====================
|
||||
// Customer portal login (public endpoint)
|
||||
router.HandleFunc("/api/portal/login", customerPortalHandler.Login).Methods("POST")
|
||||
|
||||
// Customer portal dashboard (requires customer auth)
|
||||
router.Handle("/api/portal/dashboard", middleware.RequireCustomer(cfg)(http.HandlerFunc(customerPortalHandler.GetPortalDashboard))).Methods("GET")
|
||||
|
||||
// Customer portal leads (requires customer auth)
|
||||
router.Handle("/api/portal/leads", middleware.RequireCustomer(cfg)(http.HandlerFunc(customerPortalHandler.GetPortalLeads))).Methods("GET")
|
||||
|
||||
// Customer portal lists (requires customer auth)
|
||||
router.Handle("/api/portal/lists", middleware.RequireCustomer(cfg)(http.HandlerFunc(customerPortalHandler.GetPortalLists))).Methods("GET")
|
||||
|
||||
// Customer portal profile (requires customer auth)
|
||||
router.Handle("/api/portal/profile", middleware.RequireCustomer(cfg)(http.HandlerFunc(customerPortalHandler.GetPortalProfile))).Methods("GET")
|
||||
|
||||
// Customer portal change password (requires customer auth)
|
||||
router.Handle("/api/portal/change-password", middleware.RequireCustomer(cfg)(http.HandlerFunc(customerPortalHandler.ChangePassword))).Methods("POST")
|
||||
|
||||
// Customer portal logo upload (requires customer auth)
|
||||
router.Handle("/api/portal/logo", middleware.RequireCustomer(cfg)(http.HandlerFunc(customerPortalHandler.UploadLogo))).Methods("POST")
|
||||
|
||||
// ==================== AGENCY COLLABORATORS ====================
|
||||
// List collaborators (requires agency auth, owner only)
|
||||
router.Handle("/api/agency/collaborators", middleware.RequireAgencyUser(cfg)(http.HandlerFunc(collaboratorHandler.ListCollaborators))).Methods("GET")
|
||||
|
||||
// Invite collaborator (requires agency auth, owner only)
|
||||
router.Handle("/api/agency/collaborators/invite", middleware.RequireAgencyUser(cfg)(http.HandlerFunc(collaboratorHandler.InviteCollaborator))).Methods("POST")
|
||||
|
||||
// Remove collaborator (requires agency auth, owner only)
|
||||
router.Handle("/api/agency/collaborators/{id}", middleware.RequireAgencyUser(cfg)(http.HandlerFunc(collaboratorHandler.RemoveCollaborator))).Methods("DELETE")
|
||||
|
||||
// Generate customer portal access (agency staff)
|
||||
router.Handle("/api/crm/customers/{id}/portal-access", authMiddleware(http.HandlerFunc(crmHandler.GenerateCustomerPortalAccess))).Methods("POST")
|
||||
|
||||
// Lead <-> List relationship
|
||||
router.Handle("/api/crm/leads/{lead_id}/lists/{list_id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
crmHandler.AddLeadToList(w, r)
|
||||
case http.MethodDelete:
|
||||
crmHandler.RemoveLeadFromList(w, r)
|
||||
}
|
||||
}))).Methods("POST", "DELETE")
|
||||
|
||||
// Apply global middlewares: tenant -> cors -> security -> rateLimit -> router
|
||||
handler := tenantDetector(corsMiddleware(securityMiddleware(rateLimitMiddleware(router))))
|
||||
|
||||
|
||||
15
backend/generate_hash.go
Normal file
15
backend/generate_hash.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func main() {
|
||||
password := "Android@2020"
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println(string(hash))
|
||||
}
|
||||
@@ -7,5 +7,6 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/minio/minio-go/v7 v7.0.63
|
||||
github.com/xuri/excelize/v2 v2.8.1
|
||||
golang.org/x/crypto v0.27.0
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"aggios-app/backend/internal/api/middleware"
|
||||
@@ -172,20 +173,28 @@ func (h *AgencyHandler) UploadLogo(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate public URL
|
||||
logoURL := fmt.Sprintf("http://localhost:9000/%s/%s", bucketName, filename)
|
||||
// Generate public URL through API (not direct MinIO access)
|
||||
// This is more secure and doesn't require DNS configuration
|
||||
logoURL := fmt.Sprintf("http://api.localhost/api/files/%s/%s", bucketName, filename)
|
||||
|
||||
log.Printf("Logo uploaded successfully: %s", logoURL)
|
||||
|
||||
// Delete old logo file from MinIO if exists
|
||||
if currentLogoURL != "" && currentLogoURL != "https://via.placeholder.com/150" {
|
||||
// Extract object key from URL
|
||||
// Example: http://localhost:9000/aggios-logos/tenants/uuid/logo-123.png -> tenants/uuid/logo-123.png
|
||||
// Example: http://api.localhost/api/files/aggios-logos/tenants/uuid/logo-123.png -> tenants/uuid/logo-123.png
|
||||
oldFilename := ""
|
||||
if len(currentLogoURL) > 0 {
|
||||
// Split by bucket name
|
||||
if idx := len("http://localhost:9000/aggios-logos/"); idx < len(currentLogoURL) {
|
||||
oldFilename = currentLogoURL[idx:]
|
||||
// Split by /api/files/{bucket}/ to get the file path
|
||||
apiPrefix := fmt.Sprintf("http://api.localhost/api/files/%s/", bucketName)
|
||||
if strings.HasPrefix(currentLogoURL, apiPrefix) {
|
||||
oldFilename = strings.TrimPrefix(currentLogoURL, apiPrefix)
|
||||
} else {
|
||||
// Fallback for old MinIO URLs
|
||||
baseURL := fmt.Sprintf("%s/%s/", h.config.Minio.PublicURL, bucketName)
|
||||
if len(currentLogoURL) > len(baseURL) {
|
||||
oldFilename = currentLogoURL[len(baseURL):]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +211,8 @@ func (h *AgencyHandler) UploadLogo(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Update tenant record in database
|
||||
var err2 error
|
||||
log.Printf("Updating database: tenant_id=%s, logo_type=%s, logo_url=%s", tenantID, logoType, logoURL)
|
||||
|
||||
if logoType == "horizontal" {
|
||||
_, err2 = h.tenantRepo.DB().Exec("UPDATE tenants SET logo_horizontal_url = $1, updated_at = NOW() WHERE id = $2", logoURL, tenantID)
|
||||
} else {
|
||||
@@ -209,10 +220,12 @@ func (h *AgencyHandler) UploadLogo(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err2 != nil {
|
||||
log.Printf("Failed to update logo: %v", err2)
|
||||
http.Error(w, "Failed to update database", http.StatusInternalServerError)
|
||||
log.Printf("ERROR: Failed to update logo in database: %v", err2)
|
||||
http.Error(w, fmt.Sprintf("Failed to update database: %v", err2), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("SUCCESS: Logo saved to database successfully!")
|
||||
|
||||
// Return success response
|
||||
response := map[string]string{
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"aggios-app/backend/internal/api/middleware"
|
||||
"aggios-app/backend/internal/config"
|
||||
"aggios-app/backend/internal/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -13,11 +14,13 @@ import (
|
||||
|
||||
type AgencyHandler struct {
|
||||
tenantRepo *repository.TenantRepository
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
func NewAgencyHandler(tenantRepo *repository.TenantRepository) *AgencyHandler {
|
||||
func NewAgencyHandler(tenantRepo *repository.TenantRepository, cfg *config.Config) *AgencyHandler {
|
||||
return &AgencyHandler{
|
||||
tenantRepo: tenantRepo,
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"aggios-app/backend/internal/api/middleware"
|
||||
"aggios-app/backend/internal/domain"
|
||||
"aggios-app/backend/internal/service"
|
||||
)
|
||||
@@ -96,6 +97,23 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// VALIDAÇÃO DE SEGURANÇA: Verificar se o tenant do usuário corresponde ao subdomain acessado
|
||||
tenantIDFromContext := ""
|
||||
if ctxTenantID := r.Context().Value(middleware.TenantIDKey); ctxTenantID != nil {
|
||||
tenantIDFromContext, _ = ctxTenantID.(string)
|
||||
}
|
||||
|
||||
// Se foi detectado um tenant no contexto (não é superadmin ou site institucional)
|
||||
if tenantIDFromContext != "" && response.User.TenantID != nil {
|
||||
userTenantID := response.User.TenantID.String()
|
||||
if userTenantID != tenantIDFromContext {
|
||||
log.Printf("❌ LOGIN BLOCKED: User from tenant %s tried to login in tenant %s subdomain", userTenantID, tenantIDFromContext)
|
||||
http.Error(w, "Forbidden: Invalid credentials for this tenant", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
log.Printf("✅ TENANT LOGIN VALIDATION PASSED: %s", userTenantID)
|
||||
}
|
||||
|
||||
log.Printf("✅ Login successful for %s, role=%s", response.User.Email, response.User.Role)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
@@ -149,3 +167,94 @@ func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
"message": "Password changed successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// UnifiedLogin handles login for all user types (agency, customer, superadmin)
|
||||
func (h *AuthHandler) UnifiedLogin(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("🔐 UNIFIED LOGIN HANDLER CALLED - Method: %s", r.Method)
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
log.Printf("❌ Method not allowed: %s", r.Method)
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
log.Printf("❌ Failed to read body: %v", err)
|
||||
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
log.Printf("📥 Raw body: %s", string(bodyBytes))
|
||||
|
||||
sanitized := strings.TrimSpace(string(bodyBytes))
|
||||
var req domain.UnifiedLoginRequest
|
||||
if err := json.Unmarshal([]byte(sanitized), &req); err != nil {
|
||||
log.Printf("❌ JSON parse error: %v", err)
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📧 Unified login attempt for email: %s", req.Email)
|
||||
|
||||
response, err := h.authService.UnifiedLogin(req)
|
||||
if err != nil {
|
||||
log.Printf("❌ authService.UnifiedLogin error: %v", err)
|
||||
if err == service.ErrInvalidCredentials || strings.Contains(err.Error(), "não autorizado") {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// VALIDAÇÃO DE SEGURANÇA: Verificar se o tenant corresponde ao subdomain acessado
|
||||
tenantIDFromContext := ""
|
||||
if ctxTenantID := r.Context().Value(middleware.TenantIDKey); ctxTenantID != nil {
|
||||
tenantIDFromContext, _ = ctxTenantID.(string)
|
||||
}
|
||||
|
||||
// Se foi detectado um tenant no contexto E o usuário tem tenant
|
||||
if tenantIDFromContext != "" && response.TenantID != "" {
|
||||
if response.TenantID != tenantIDFromContext {
|
||||
log.Printf("❌ LOGIN BLOCKED: User from tenant %s tried to login in tenant %s subdomain",
|
||||
response.TenantID, tenantIDFromContext)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Credenciais inválidas para esta agência",
|
||||
})
|
||||
return
|
||||
}
|
||||
log.Printf("✅ TENANT LOGIN VALIDATION PASSED: %s", response.TenantID)
|
||||
}
|
||||
|
||||
log.Printf("✅ Unified login successful: email=%s, type=%s, role=%s",
|
||||
response.Email, response.UserType, response.Role)
|
||||
|
||||
// Montar resposta compatível com frontend antigo E com novos campos
|
||||
compatibleResponse := map[string]interface{}{
|
||||
"token": response.Token,
|
||||
"user": map[string]interface{}{
|
||||
"id": response.UserID,
|
||||
"email": response.Email,
|
||||
"name": response.Name,
|
||||
"role": response.Role,
|
||||
"tenant_id": response.TenantID,
|
||||
"user_type": response.UserType,
|
||||
},
|
||||
// Campos adicionais do sistema unificado
|
||||
"user_type": response.UserType,
|
||||
"user_id": response.UserID,
|
||||
"subdomain": response.Subdomain,
|
||||
"tenant_id": response.TenantID,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(compatibleResponse)
|
||||
}
|
||||
|
||||
264
backend/internal/api/handlers/backup.go
Normal file
264
backend/internal/api/handlers/backup.go
Normal file
@@ -0,0 +1,264 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type BackupHandler struct {
|
||||
backupDir string
|
||||
}
|
||||
|
||||
type BackupInfo struct {
|
||||
Filename string `json:"filename"`
|
||||
Size string `json:"size"`
|
||||
Date string `json:"date"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
func NewBackupHandler() *BackupHandler {
|
||||
// Usa o caminho montado no container
|
||||
backupDir := "/backups"
|
||||
|
||||
// Garante que o diretório existe
|
||||
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
|
||||
os.MkdirAll(backupDir, 0755)
|
||||
}
|
||||
|
||||
return &BackupHandler{
|
||||
backupDir: backupDir,
|
||||
}
|
||||
}
|
||||
|
||||
// ListBackups lista todos os backups disponíveis
|
||||
func (h *BackupHandler) ListBackups(w http.ResponseWriter, r *http.Request) {
|
||||
files, err := ioutil.ReadDir(h.backupDir)
|
||||
if err != nil {
|
||||
http.Error(w, "Error reading backups directory", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var backups []BackupInfo
|
||||
for _, file := range files {
|
||||
if strings.HasPrefix(file.Name(), "aggios_backup_") && strings.HasSuffix(file.Name(), ".sql") {
|
||||
// Extrai timestamp do nome do arquivo
|
||||
timestamp := strings.TrimPrefix(file.Name(), "aggios_backup_")
|
||||
timestamp = strings.TrimSuffix(timestamp, ".sql")
|
||||
|
||||
// Formata a data
|
||||
t, _ := time.Parse("2006-01-02_15-04-05", timestamp)
|
||||
dateStr := t.Format("02/01/2006 15:04:05")
|
||||
|
||||
// Formata o tamanho
|
||||
sizeMB := float64(file.Size()) / 1024
|
||||
sizeStr := fmt.Sprintf("%.2f KB", sizeMB)
|
||||
|
||||
backups = append(backups, BackupInfo{
|
||||
Filename: file.Name(),
|
||||
Size: sizeStr,
|
||||
Date: dateStr,
|
||||
Timestamp: timestamp,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Ordena por data (mais recente primeiro)
|
||||
sort.Slice(backups, func(i, j int) bool {
|
||||
return backups[i].Timestamp > backups[j].Timestamp
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"backups": backups,
|
||||
})
|
||||
}
|
||||
|
||||
// CreateBackup cria um novo backup do banco de dados
|
||||
func (h *BackupHandler) CreateBackup(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
timestamp := time.Now().Format("2006-01-02_15-04-05")
|
||||
filename := fmt.Sprintf("aggios_backup_%s.sql", timestamp)
|
||||
filepath := filepath.Join(h.backupDir, filename)
|
||||
|
||||
// Usa pg_dump diretamente (backend e postgres estão na mesma rede docker)
|
||||
dbPassword := os.Getenv("DB_PASSWORD")
|
||||
if dbPassword == "" {
|
||||
dbPassword = "A9g10s_S3cur3_P@ssw0rd_2025!"
|
||||
}
|
||||
|
||||
cmd := exec.Command("pg_dump",
|
||||
"-h", "postgres",
|
||||
"-U", "aggios",
|
||||
"-d", "aggios_db",
|
||||
"--no-password")
|
||||
|
||||
// Define a variável de ambiente para a senha
|
||||
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbPassword))
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Error creating backup: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Salva o backup no arquivo
|
||||
err = ioutil.WriteFile(filepath, output, 0644)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Error saving backup: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Limpa backups antigos (mantém apenas os últimos 10)
|
||||
h.cleanOldBackups()
|
||||
|
||||
fileInfo, _ := os.Stat(filepath)
|
||||
sizeMB := float64(fileInfo.Size()) / 1024
|
||||
sizeStr := fmt.Sprintf("%.2f KB", sizeMB)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"message": "Backup created successfully",
|
||||
"filename": filename,
|
||||
"size": sizeStr,
|
||||
})
|
||||
}
|
||||
|
||||
// RestoreBackup restaura um backup específico
|
||||
func (h *BackupHandler) RestoreBackup(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Filename == "" {
|
||||
http.Error(w, "Filename is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Valida que o arquivo existe e está no diretório correto
|
||||
backupPath := filepath.Join(h.backupDir, req.Filename)
|
||||
if !strings.HasPrefix(backupPath, h.backupDir) {
|
||||
http.Error(w, "Invalid filename", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
|
||||
http.Error(w, "Backup file not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Lê o conteúdo do backup
|
||||
backupContent, err := ioutil.ReadFile(backupPath)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Error reading backup: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Restaura o backup usando psql diretamente
|
||||
dbPassword := os.Getenv("DB_PASSWORD")
|
||||
if dbPassword == "" {
|
||||
dbPassword = "A9g10s_S3cur3_P@ssw0rd_2025!"
|
||||
}
|
||||
|
||||
cmd := exec.Command("psql",
|
||||
"-h", "postgres",
|
||||
"-U", "aggios",
|
||||
"-d", "aggios_db",
|
||||
"--no-password")
|
||||
cmd.Stdin = strings.NewReader(string(backupContent))
|
||||
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbPassword))
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Error restoring backup: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"message": "Backup restored successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// DownloadBackup permite fazer download de um backup
|
||||
func (h *BackupHandler) DownloadBackup(w http.ResponseWriter, r *http.Request) {
|
||||
// Extrai o filename da URL
|
||||
parts := strings.Split(r.URL.Path, "/")
|
||||
filename := parts[len(parts)-1]
|
||||
|
||||
if filename == "" {
|
||||
http.Error(w, "Filename is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Valida que o arquivo existe e está no diretório correto
|
||||
backupPath := filepath.Join(h.backupDir, filename)
|
||||
if !strings.HasPrefix(backupPath, h.backupDir) {
|
||||
http.Error(w, "Invalid filename", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
|
||||
http.Error(w, "Backup file not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Lê o arquivo
|
||||
data, err := ioutil.ReadFile(backupPath)
|
||||
if err != nil {
|
||||
http.Error(w, "Error reading file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Define headers para download
|
||||
w.Header().Set("Content-Type", "application/sql")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
// cleanOldBackups mantém apenas os últimos 10 backups
|
||||
func (h *BackupHandler) cleanOldBackups() {
|
||||
files, err := ioutil.ReadDir(h.backupDir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var backupFiles []os.FileInfo
|
||||
for _, file := range files {
|
||||
if strings.HasPrefix(file.Name(), "aggios_backup_") && strings.HasSuffix(file.Name(), ".sql") {
|
||||
backupFiles = append(backupFiles, file)
|
||||
}
|
||||
}
|
||||
|
||||
// Ordena por data de modificação (mais recente primeiro)
|
||||
sort.Slice(backupFiles, func(i, j int) bool {
|
||||
return backupFiles[i].ModTime().After(backupFiles[j].ModTime())
|
||||
})
|
||||
|
||||
// Remove backups antigos (mantém os 10 mais recentes)
|
||||
if len(backupFiles) > 10 {
|
||||
for _, file := range backupFiles[10:] {
|
||||
os.Remove(filepath.Join(h.backupDir, file.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
271
backend/internal/api/handlers/collaborator.go
Normal file
271
backend/internal/api/handlers/collaborator.go
Normal file
@@ -0,0 +1,271 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"aggios-app/backend/internal/api/middleware"
|
||||
"aggios-app/backend/internal/domain"
|
||||
"aggios-app/backend/internal/repository"
|
||||
"aggios-app/backend/internal/service"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// CollaboratorHandler handles agency collaborator management
|
||||
type CollaboratorHandler struct {
|
||||
userRepo *repository.UserRepository
|
||||
agencyServ *service.AgencyService
|
||||
}
|
||||
|
||||
// NewCollaboratorHandler creates a new collaborator handler
|
||||
func NewCollaboratorHandler(userRepo *repository.UserRepository, agencyServ *service.AgencyService) *CollaboratorHandler {
|
||||
return &CollaboratorHandler{
|
||||
userRepo: userRepo,
|
||||
agencyServ: agencyServ,
|
||||
}
|
||||
}
|
||||
|
||||
// AddCollaboratorRequest representa a requisição para adicionar um colaborador
|
||||
type AddCollaboratorRequest struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// CollaboratorResponse representa um colaborador
|
||||
type CollaboratorResponse struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
AgencyRole string `json:"agency_role"` // owner ou collaborator
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CollaboratorCreatedAt *time.Time `json:"collaborator_created_at,omitempty"`
|
||||
}
|
||||
|
||||
// ListCollaborators lista todos os colaboradores da agência (apenas owner pode ver)
|
||||
func (h *CollaboratorHandler) ListCollaborators(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
ownerID, _ := r.Context().Value(middleware.UserIDKey).(string)
|
||||
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
|
||||
agencyRole, _ := r.Context().Value("agency_role").(string)
|
||||
|
||||
// Apenas owner pode listar colaboradores
|
||||
if agencyRole != "owner" {
|
||||
log.Printf("❌ COLLABORATOR ACCESS BLOCKED: User %s tried to list collaborators", ownerID)
|
||||
http.Error(w, "Only agency owners can manage collaborators", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Buscar todos os usuários da agência
|
||||
tenantUUID := parseUUID(tenantID)
|
||||
if tenantUUID == nil {
|
||||
http.Error(w, "Invalid tenant ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
users, err := h.userRepo.ListByTenantID(*tenantUUID)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching collaborators: %v", err)
|
||||
http.Error(w, "Error fetching collaborators", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Formatar resposta
|
||||
collaborators := make([]CollaboratorResponse, 0)
|
||||
for _, user := range users {
|
||||
collaborators = append(collaborators, CollaboratorResponse{
|
||||
ID: user.ID.String(),
|
||||
Email: user.Email,
|
||||
Name: user.Name,
|
||||
AgencyRole: user.AgencyRole,
|
||||
CreatedAt: user.CreatedAt,
|
||||
CollaboratorCreatedAt: user.CollaboratorCreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"collaborators": collaborators,
|
||||
})
|
||||
}
|
||||
|
||||
// InviteCollaborator convida um novo colaborador para a agência (apenas owner pode fazer isso)
|
||||
func (h *CollaboratorHandler) InviteCollaborator(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
ownerID, _ := r.Context().Value(middleware.UserIDKey).(string)
|
||||
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
|
||||
agencyRole, _ := r.Context().Value("agency_role").(string)
|
||||
|
||||
// Apenas owner pode convidar colaboradores
|
||||
if agencyRole != "owner" {
|
||||
log.Printf("❌ COLLABORATOR INVITE BLOCKED: User %s tried to invite collaborator", ownerID)
|
||||
http.Error(w, "Only agency owners can invite collaborators", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
var req AddCollaboratorRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validar email
|
||||
if req.Email == "" {
|
||||
http.Error(w, "Email is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validar se email já existe
|
||||
exists, err := h.userRepo.EmailExists(req.Email)
|
||||
if err != nil {
|
||||
log.Printf("Error checking email: %v", err)
|
||||
http.Error(w, "Error processing request", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if exists {
|
||||
http.Error(w, "Email already registered", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
// Gerar senha temporária (8 caracteres aleatórios)
|
||||
tempPassword := generateTempPassword()
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(tempPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Printf("Error hashing password: %v", err)
|
||||
http.Error(w, "Error processing request", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Criar novo colaborador
|
||||
ownerUUID := parseUUID(ownerID)
|
||||
tenantUUID := parseUUID(tenantID)
|
||||
now := time.Now()
|
||||
|
||||
collaborator := &domain.User{
|
||||
TenantID: tenantUUID,
|
||||
Email: req.Email,
|
||||
Password: string(hashedPassword),
|
||||
Name: req.Name,
|
||||
Role: "ADMIN_AGENCIA",
|
||||
AgencyRole: "collaborator",
|
||||
CreatedBy: ownerUUID,
|
||||
CollaboratorCreatedAt: &now,
|
||||
}
|
||||
|
||||
if err := h.userRepo.Create(collaborator); err != nil {
|
||||
log.Printf("Error creating collaborator: %v", err)
|
||||
http.Error(w, "Error creating collaborator", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"message": "Collaborator invited successfully",
|
||||
"temporary_password": tempPassword,
|
||||
"collaborator": CollaboratorResponse{
|
||||
ID: collaborator.ID.String(),
|
||||
Email: collaborator.Email,
|
||||
Name: collaborator.Name,
|
||||
AgencyRole: collaborator.AgencyRole,
|
||||
CreatedAt: collaborator.CreatedAt,
|
||||
CollaboratorCreatedAt: collaborator.CollaboratorCreatedAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// RemoveCollaborator remove um colaborador da agência (apenas owner pode fazer isso)
|
||||
func (h *CollaboratorHandler) RemoveCollaborator(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
ownerID, _ := r.Context().Value(middleware.UserIDKey).(string)
|
||||
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
|
||||
agencyRole, _ := r.Context().Value("agency_role").(string)
|
||||
|
||||
// Apenas owner pode remover colaboradores
|
||||
if agencyRole != "owner" {
|
||||
log.Printf("❌ COLLABORATOR REMOVE BLOCKED: User %s tried to remove collaborator", ownerID)
|
||||
http.Error(w, "Only agency owners can remove collaborators", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
collaboratorID := r.URL.Query().Get("id")
|
||||
if collaboratorID == "" {
|
||||
http.Error(w, "Collaborator ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Converter ID para UUID
|
||||
collaboratorUUID := parseUUID(collaboratorID)
|
||||
if collaboratorUUID == nil {
|
||||
http.Error(w, "Invalid collaborator ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Buscar o colaborador
|
||||
collaborator, err := h.userRepo.GetByID(*collaboratorUUID)
|
||||
if err != nil {
|
||||
http.Error(w, "Collaborator not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Verificar se o colaborador pertence à mesma agência
|
||||
if collaborator.TenantID == nil || collaborator.TenantID.String() != tenantID {
|
||||
http.Error(w, "Collaborator not found in this agency", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Não permitir remover o owner
|
||||
if collaborator.AgencyRole == "owner" {
|
||||
http.Error(w, "Cannot remove the agency owner", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Remover colaborador
|
||||
if err := h.userRepo.Delete(*collaboratorUUID); err != nil {
|
||||
log.Printf("Error removing collaborator: %v", err)
|
||||
http.Error(w, "Error removing collaborator", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"message": "Collaborator removed successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// generateTempPassword gera uma senha temporária
|
||||
func generateTempPassword() string {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*"
|
||||
return randomString(12, charset)
|
||||
}
|
||||
|
||||
// randomString gera uma string aleatória
|
||||
func randomString(length int, charset string) string {
|
||||
b := make([]byte, length)
|
||||
for i := range b {
|
||||
b[i] = charset[i%len(charset)]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// parseUUID converte string para UUID
|
||||
func parseUUID(s string) *uuid.UUID {
|
||||
u, err := uuid.Parse(s)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &u
|
||||
}
|
||||
1877
backend/internal/api/handlers/crm.go
Normal file
1877
backend/internal/api/handlers/crm.go
Normal file
File diff suppressed because it is too large
Load Diff
465
backend/internal/api/handlers/customer_portal.go
Normal file
465
backend/internal/api/handlers/customer_portal.go
Normal file
@@ -0,0 +1,465 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"aggios-app/backend/internal/domain"
|
||||
"aggios-app/backend/internal/repository"
|
||||
"aggios-app/backend/internal/service"
|
||||
"aggios-app/backend/internal/config"
|
||||
"aggios-app/backend/internal/api/middleware"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type CustomerPortalHandler struct {
|
||||
crmRepo *repository.CRMRepository
|
||||
authService *service.AuthService
|
||||
cfg *config.Config
|
||||
minioClient *minio.Client
|
||||
}
|
||||
|
||||
func NewCustomerPortalHandler(crmRepo *repository.CRMRepository, authService *service.AuthService, cfg *config.Config) *CustomerPortalHandler {
|
||||
// Initialize MinIO client
|
||||
minioClient, err := minio.New(cfg.Minio.Endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(cfg.Minio.RootUser, cfg.Minio.RootPassword, ""),
|
||||
Secure: cfg.Minio.UseSSL,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("❌ Failed to create MinIO client for CustomerPortalHandler: %v", err)
|
||||
}
|
||||
|
||||
return &CustomerPortalHandler{
|
||||
crmRepo: crmRepo,
|
||||
authService: authService,
|
||||
cfg: cfg,
|
||||
minioClient: minioClient,
|
||||
}
|
||||
}
|
||||
|
||||
// CustomerLoginRequest representa a requisição de login do cliente
|
||||
type CustomerLoginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// CustomerLoginResponse representa a resposta de login do cliente
|
||||
type CustomerLoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
Customer *CustomerPortalInfo `json:"customer"`
|
||||
}
|
||||
|
||||
// CustomerPortalInfo representa informações seguras do cliente para o portal
|
||||
type CustomerPortalInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Company string `json:"company"`
|
||||
HasPortalAccess bool `json:"has_portal_access"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
}
|
||||
|
||||
// Login autentica um cliente e retorna um token JWT
|
||||
func (h *CustomerPortalHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var req CustomerLoginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Validar entrada
|
||||
if req.Email == "" || req.Password == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Email e senha são obrigatórios",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Buscar cliente por email
|
||||
customer, err := h.crmRepo.GetCustomerByEmail(req.Email)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Credenciais inválidas",
|
||||
})
|
||||
return
|
||||
}
|
||||
log.Printf("Error fetching customer: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Erro ao processar login",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Verificar se tem acesso ao portal
|
||||
if !customer.HasPortalAccess {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Acesso ao portal não autorizado. Entre em contato com o administrador.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Verificar senha
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(customer.PasswordHash), []byte(req.Password)); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Credenciais inválidas",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Atualizar último login
|
||||
if err := h.crmRepo.UpdateCustomerLastLogin(customer.ID); err != nil {
|
||||
log.Printf("Warning: Failed to update last login for customer %s: %v", customer.ID, err)
|
||||
}
|
||||
|
||||
// Gerar token JWT
|
||||
token, err := h.authService.GenerateCustomerToken(customer.ID, customer.TenantID, customer.Email)
|
||||
if err != nil {
|
||||
log.Printf("Error generating token: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Erro ao gerar token de autenticação",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Resposta de sucesso
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(CustomerLoginResponse{
|
||||
Token: token,
|
||||
Customer: &CustomerPortalInfo{
|
||||
ID: customer.ID,
|
||||
Name: customer.Name,
|
||||
Email: customer.Email,
|
||||
Company: customer.Company,
|
||||
HasPortalAccess: customer.HasPortalAccess,
|
||||
TenantID: customer.TenantID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetPortalDashboard retorna dados do dashboard para o cliente autenticado
|
||||
func (h *CustomerPortalHandler) GetPortalDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
customerID, _ := r.Context().Value(middleware.CustomerIDKey).(string)
|
||||
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
|
||||
|
||||
// Buscar leads do cliente
|
||||
leads, err := h.crmRepo.GetLeadsByCustomerID(customerID)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching leads: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Erro ao buscar leads",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Buscar informações do cliente
|
||||
customer, err := h.crmRepo.GetCustomerByID(customerID, tenantID)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching customer: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Erro ao buscar informações do cliente",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Calcular estatísticas
|
||||
rawStats := calculateLeadStats(leads)
|
||||
stats := map[string]interface{}{
|
||||
"total_leads": rawStats["total"],
|
||||
"active_leads": rawStats["novo"].(int) + rawStats["qualificado"].(int) + rawStats["negociacao"].(int),
|
||||
"converted": rawStats["convertido"],
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"customer": CustomerPortalInfo{
|
||||
ID: customer.ID,
|
||||
Name: customer.Name,
|
||||
Email: customer.Email,
|
||||
Company: customer.Company,
|
||||
HasPortalAccess: customer.HasPortalAccess,
|
||||
TenantID: customer.TenantID,
|
||||
},
|
||||
"leads": leads,
|
||||
"stats": stats,
|
||||
})
|
||||
}
|
||||
|
||||
// GetPortalLeads retorna apenas os leads do cliente
|
||||
func (h *CustomerPortalHandler) GetPortalLeads(w http.ResponseWriter, r *http.Request) {
|
||||
customerID, _ := r.Context().Value(middleware.CustomerIDKey).(string)
|
||||
|
||||
leads, err := h.crmRepo.GetLeadsByCustomerID(customerID)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching leads: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Erro ao buscar leads",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if leads == nil {
|
||||
leads = []domain.CRMLead{}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"leads": leads,
|
||||
})
|
||||
}
|
||||
|
||||
// GetPortalLists retorna as listas que possuem leads do cliente
|
||||
func (h *CustomerPortalHandler) GetPortalLists(w http.ResponseWriter, r *http.Request) {
|
||||
customerID, _ := r.Context().Value(middleware.CustomerIDKey).(string)
|
||||
|
||||
lists, err := h.crmRepo.GetListsByCustomerID(customerID)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching portal lists: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Erro ao buscar listas",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"lists": lists,
|
||||
})
|
||||
}
|
||||
|
||||
// GetPortalProfile retorna o perfil completo do cliente
|
||||
func (h *CustomerPortalHandler) GetPortalProfile(w http.ResponseWriter, r *http.Request) {
|
||||
customerID, _ := r.Context().Value(middleware.CustomerIDKey).(string)
|
||||
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
|
||||
|
||||
// Buscar informações do cliente
|
||||
customer, err := h.crmRepo.GetCustomerByID(customerID, tenantID)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching customer: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Erro ao buscar perfil",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Buscar leads para estatísticas
|
||||
leads, err := h.crmRepo.GetLeadsByCustomerID(customerID)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching leads for stats: %v", err)
|
||||
leads = []domain.CRMLead{}
|
||||
}
|
||||
|
||||
// Calcular estatísticas
|
||||
stats := calculateLeadStats(leads)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"customer": map[string]interface{}{
|
||||
"id": customer.ID,
|
||||
"name": customer.Name,
|
||||
"email": customer.Email,
|
||||
"phone": customer.Phone,
|
||||
"company": customer.Company,
|
||||
"logo_url": customer.LogoURL,
|
||||
"portal_last_login": customer.PortalLastLogin,
|
||||
"created_at": customer.CreatedAt,
|
||||
"total_leads": len(leads),
|
||||
"converted_leads": stats["convertido"].(int),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ChangePasswordRequest representa a requisição de troca de senha
|
||||
type CustomerChangePasswordRequest struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
// ChangePassword altera a senha do cliente
|
||||
func (h *CustomerPortalHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
customerID, _ := r.Context().Value(middleware.CustomerIDKey).(string)
|
||||
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
|
||||
|
||||
var req CustomerChangePasswordRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Validar entrada
|
||||
if req.CurrentPassword == "" || req.NewPassword == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Senha atual e nova senha são obrigatórias",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.NewPassword) < 6 {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "A nova senha deve ter no mínimo 6 caracteres",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Buscar cliente
|
||||
customer, err := h.crmRepo.GetCustomerByID(customerID, tenantID)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching customer: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Erro ao processar solicitação",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Verificar senha atual
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(customer.PasswordHash), []byte(req.CurrentPassword)); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Senha atual incorreta",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Gerar hash da nova senha
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Printf("Error hashing password: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Erro ao processar nova senha",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Atualizar senha no banco
|
||||
if err := h.crmRepo.UpdateCustomerPassword(customerID, string(hashedPassword)); err != nil {
|
||||
log.Printf("Error updating password: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Erro ao atualizar senha",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"message": "Senha alterada com sucesso",
|
||||
})
|
||||
}
|
||||
|
||||
// UploadLogo faz o upload do logo do cliente
|
||||
func (h *CustomerPortalHandler) UploadLogo(w http.ResponseWriter, r *http.Request) {
|
||||
customerID, _ := r.Context().Value(middleware.CustomerIDKey).(string)
|
||||
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
|
||||
|
||||
if h.minioClient == nil {
|
||||
http.Error(w, "Storage service unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse multipart form (2MB max)
|
||||
const maxLogoSize = 2 * 1024 * 1024
|
||||
if err := r.ParseMultipartForm(maxLogoSize); err != nil {
|
||||
http.Error(w, "File too large", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("logo")
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read file", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Validate file type
|
||||
contentType := header.Header.Get("Content-Type")
|
||||
if !strings.HasPrefix(contentType, "image/") {
|
||||
http.Error(w, "Only images are allowed", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
ext := filepath.Ext(header.Filename)
|
||||
if ext == "" {
|
||||
ext = ".png" // Default extension
|
||||
}
|
||||
filename := fmt.Sprintf("logo-%d%s", time.Now().Unix(), ext)
|
||||
objectPath := fmt.Sprintf("customers/%s/%s", customerID, filename)
|
||||
|
||||
// Upload to MinIO
|
||||
ctx := context.Background()
|
||||
bucketName := h.cfg.Minio.BucketName
|
||||
|
||||
_, err = h.minioClient.PutObject(ctx, bucketName, objectPath, file, header.Size, minio.PutObjectOptions{
|
||||
ContentType: contentType,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Error uploading to MinIO: %v", err)
|
||||
http.Error(w, "Failed to upload file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate public URL
|
||||
logoURL := fmt.Sprintf("%s/api/files/%s/%s", h.cfg.Minio.PublicURL, bucketName, objectPath)
|
||||
|
||||
// Update customer in database
|
||||
err = h.crmRepo.UpdateCustomerLogo(customerID, tenantID, logoURL)
|
||||
if err != nil {
|
||||
log.Printf("Error updating customer logo in DB: %v", err)
|
||||
http.Error(w, "Failed to update profile", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"logo_url": logoURL,
|
||||
})
|
||||
}
|
||||
210
backend/internal/api/handlers/export.go
Normal file
210
backend/internal/api/handlers/export.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"aggios-app/backend/internal/api/middleware"
|
||||
"aggios-app/backend/internal/domain"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
// ExportLeads handles exporting leads in different formats
|
||||
func (h *CRMHandler) ExportLeads(w http.ResponseWriter, r *http.Request) {
|
||||
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
|
||||
if tenantID == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "Missing tenant_id"})
|
||||
return
|
||||
}
|
||||
|
||||
format := r.URL.Query().Get("format")
|
||||
if format == "" {
|
||||
format = "csv"
|
||||
}
|
||||
|
||||
customerID := r.URL.Query().Get("customer_id")
|
||||
campaignID := r.URL.Query().Get("campaign_id")
|
||||
|
||||
var leads []domain.CRMLead
|
||||
var err error
|
||||
|
||||
if campaignID != "" {
|
||||
leads, err = h.repo.GetLeadsByListID(campaignID)
|
||||
} else if customerID != "" {
|
||||
leads, err = h.repo.GetLeadsByTenant(tenantID)
|
||||
// Filter by customer manually
|
||||
filtered := []domain.CRMLead{}
|
||||
for _, lead := range leads {
|
||||
if lead.CustomerID != nil && *lead.CustomerID == customerID {
|
||||
filtered = append(filtered, lead)
|
||||
}
|
||||
}
|
||||
leads = filtered
|
||||
} else {
|
||||
leads, err = h.repo.GetLeadsByTenant(tenantID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("ExportLeads: Error fetching leads: %v", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "Failed to fetch leads"})
|
||||
return
|
||||
}
|
||||
|
||||
switch strings.ToLower(format) {
|
||||
case "json":
|
||||
exportJSON(w, leads)
|
||||
case "xlsx", "excel":
|
||||
exportXLSX(w, leads)
|
||||
default:
|
||||
exportCSV(w, leads)
|
||||
}
|
||||
}
|
||||
|
||||
func exportJSON(w http.ResponseWriter, leads []domain.CRMLead) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=leads.json")
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"leads": leads,
|
||||
"count": len(leads),
|
||||
})
|
||||
}
|
||||
|
||||
func exportCSV(w http.ResponseWriter, leads []domain.CRMLead) {
|
||||
w.Header().Set("Content-Type", "text/csv")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=leads.csv")
|
||||
|
||||
writer := csv.NewWriter(w)
|
||||
defer writer.Flush()
|
||||
|
||||
// Header
|
||||
header := []string{"ID", "Nome", "Email", "Telefone", "Status", "Origem", "Notas", "Tags", "Criado Em"}
|
||||
writer.Write(header)
|
||||
|
||||
// Data
|
||||
for _, lead := range leads {
|
||||
tags := ""
|
||||
if len(lead.Tags) > 0 {
|
||||
tags = strings.Join(lead.Tags, ", ")
|
||||
}
|
||||
|
||||
phone := ""
|
||||
if lead.Phone != "" {
|
||||
phone = lead.Phone
|
||||
}
|
||||
|
||||
notes := ""
|
||||
if lead.Notes != "" {
|
||||
notes = lead.Notes
|
||||
}
|
||||
|
||||
row := []string{
|
||||
lead.ID,
|
||||
lead.Name,
|
||||
lead.Email,
|
||||
phone,
|
||||
lead.Status,
|
||||
lead.Source,
|
||||
notes,
|
||||
tags,
|
||||
lead.CreatedAt.Format("02/01/2006 15:04"),
|
||||
}
|
||||
writer.Write(row)
|
||||
}
|
||||
}
|
||||
|
||||
func exportXLSX(w http.ResponseWriter, leads []domain.CRMLead) {
|
||||
f := excelize.NewFile()
|
||||
defer f.Close()
|
||||
|
||||
sheetName := "Leads"
|
||||
index, err := f.NewSheet(sheetName)
|
||||
if err != nil {
|
||||
log.Printf("Error creating sheet: %v", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Set active sheet
|
||||
f.SetActiveSheet(index)
|
||||
|
||||
// Header style
|
||||
headerStyle, _ := f.NewStyle(&excelize.Style{
|
||||
Font: &excelize.Font{
|
||||
Bold: true,
|
||||
Size: 12,
|
||||
},
|
||||
Fill: excelize.Fill{
|
||||
Type: "pattern",
|
||||
Color: []string{"#4472C4"},
|
||||
Pattern: 1,
|
||||
},
|
||||
Alignment: &excelize.Alignment{
|
||||
Horizontal: "center",
|
||||
Vertical: "center",
|
||||
},
|
||||
})
|
||||
|
||||
// Headers
|
||||
headers := []string{"ID", "Nome", "Email", "Telefone", "Status", "Origem", "Notas", "Tags", "Criado Em"}
|
||||
for i, header := range headers {
|
||||
cell := fmt.Sprintf("%s1", string(rune('A'+i)))
|
||||
f.SetCellValue(sheetName, cell, header)
|
||||
f.SetCellStyle(sheetName, cell, cell, headerStyle)
|
||||
}
|
||||
|
||||
// Data
|
||||
for i, lead := range leads {
|
||||
row := i + 2
|
||||
|
||||
tags := ""
|
||||
if len(lead.Tags) > 0 {
|
||||
tags = strings.Join(lead.Tags, ", ")
|
||||
}
|
||||
|
||||
phone := ""
|
||||
if lead.Phone != "" {
|
||||
phone = lead.Phone
|
||||
}
|
||||
|
||||
notes := ""
|
||||
if lead.Notes != "" {
|
||||
notes = lead.Notes
|
||||
}
|
||||
|
||||
f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), lead.ID)
|
||||
f.SetCellValue(sheetName, fmt.Sprintf("B%d", row), lead.Name)
|
||||
f.SetCellValue(sheetName, fmt.Sprintf("C%d", row), lead.Email)
|
||||
f.SetCellValue(sheetName, fmt.Sprintf("D%d", row), phone)
|
||||
f.SetCellValue(sheetName, fmt.Sprintf("E%d", row), lead.Status)
|
||||
f.SetCellValue(sheetName, fmt.Sprintf("F%d", row), lead.Source)
|
||||
f.SetCellValue(sheetName, fmt.Sprintf("G%d", row), notes)
|
||||
f.SetCellValue(sheetName, fmt.Sprintf("H%d", row), tags)
|
||||
f.SetCellValue(sheetName, fmt.Sprintf("I%d", row), lead.CreatedAt.Format("02/01/2006 15:04"))
|
||||
}
|
||||
|
||||
// Auto-adjust column widths
|
||||
for i := 0; i < len(headers); i++ {
|
||||
col := string(rune('A' + i))
|
||||
f.SetColWidth(sheetName, col, col, 15)
|
||||
}
|
||||
f.SetColWidth(sheetName, "B", "B", 25) // Nome
|
||||
f.SetColWidth(sheetName, "C", "C", 30) // Email
|
||||
f.SetColWidth(sheetName, "G", "G", 40) // Notas
|
||||
|
||||
// Delete default sheet if exists
|
||||
f.DeleteSheet("Sheet1")
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=leads.xlsx")
|
||||
|
||||
if err := f.Write(w); err != nil {
|
||||
log.Printf("Error writing xlsx: %v", err)
|
||||
}
|
||||
}
|
||||
104
backend/internal/api/handlers/files.go
Normal file
104
backend/internal/api/handlers/files.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"aggios-app/backend/internal/config"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
)
|
||||
|
||||
type FilesHandler struct {
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
func NewFilesHandler(cfg *config.Config) *FilesHandler {
|
||||
return &FilesHandler{
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// ServeFile serves files from MinIO through the API
|
||||
func (h *FilesHandler) ServeFile(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
bucket := vars["bucket"]
|
||||
|
||||
// Get the file path (everything after /api/files/{bucket}/)
|
||||
prefix := fmt.Sprintf("/api/files/%s/", bucket)
|
||||
filePath := strings.TrimPrefix(r.URL.Path, prefix)
|
||||
|
||||
if filePath == "" {
|
||||
http.Error(w, "File path is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Whitelist de buckets públicos permitidos
|
||||
allowedBuckets := map[string]bool{
|
||||
"aggios-logos": true,
|
||||
}
|
||||
if !allowedBuckets[bucket] {
|
||||
log.Printf("🚫 Access denied to bucket: %s", bucket)
|
||||
http.Error(w, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Proteção contra path traversal
|
||||
if strings.Contains(filePath, "..") {
|
||||
log.Printf("🚫 Path traversal attempt detected: %s", filePath)
|
||||
http.Error(w, "Invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📁 Serving file: bucket=%s, path=%s", bucket, filePath)
|
||||
|
||||
// Initialize MinIO client
|
||||
minioClient, err := minio.New("aggios-minio:9000", &minio.Options{
|
||||
Creds: credentials.NewStaticV4("minioadmin", "M1n10_S3cur3_P@ss_2025!", ""),
|
||||
Secure: false,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to create MinIO client: %v", err)
|
||||
http.Error(w, "Storage service unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Get object from MinIO
|
||||
ctx := context.Background()
|
||||
object, err := minioClient.GetObject(ctx, bucket, filePath, minio.GetObjectOptions{})
|
||||
if err != nil {
|
||||
log.Printf("Failed to get object: %v", err)
|
||||
http.Error(w, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
defer object.Close()
|
||||
|
||||
// Get object info for content type and size
|
||||
objInfo, err := object.Stat()
|
||||
if err != nil {
|
||||
log.Printf("Failed to stat object: %v", err)
|
||||
http.Error(w, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Set appropriate headers
|
||||
w.Header().Set("Content-Type", objInfo.ContentType)
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", objInfo.Size))
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000") // Cache for 1 year
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
// Copy file content to response
|
||||
_, err = io.Copy(w, object)
|
||||
if err != nil {
|
||||
log.Printf("Failed to copy object content: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ File served successfully: %s", filePath)
|
||||
}
|
||||
274
backend/internal/api/handlers/plan.go
Normal file
274
backend/internal/api/handlers/plan.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"aggios-app/backend/internal/domain"
|
||||
"aggios-app/backend/internal/service"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// PlanHandler handles plan-related endpoints
|
||||
type PlanHandler struct {
|
||||
planService *service.PlanService
|
||||
}
|
||||
|
||||
// NewPlanHandler creates a new plan handler
|
||||
func NewPlanHandler(planService *service.PlanService) *PlanHandler {
|
||||
return &PlanHandler{
|
||||
planService: planService,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers plan routes
|
||||
func (h *PlanHandler) RegisterRoutes(r *mux.Router) {
|
||||
// Note: Route protection is done in main.go with authMiddleware wrapper
|
||||
r.HandleFunc("/api/admin/plans", h.CreatePlan).Methods(http.MethodPost)
|
||||
r.HandleFunc("/api/admin/plans", h.ListPlans).Methods(http.MethodGet)
|
||||
r.HandleFunc("/api/admin/plans/{id}", h.GetPlan).Methods(http.MethodGet)
|
||||
r.HandleFunc("/api/admin/plans/{id}", h.UpdatePlan).Methods(http.MethodPut)
|
||||
r.HandleFunc("/api/admin/plans/{id}", h.DeletePlan).Methods(http.MethodDelete)
|
||||
|
||||
// Public routes (for signup flow)
|
||||
r.HandleFunc("/api/plans", h.ListActivePlans).Methods(http.MethodGet)
|
||||
r.HandleFunc("/api/plans/{id}", h.GetActivePlan).Methods(http.MethodGet)
|
||||
}
|
||||
|
||||
// CreatePlan creates a new plan (admin only)
|
||||
func (h *PlanHandler) CreatePlan(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("📋 CREATE PLAN - Method: %s", r.Method)
|
||||
|
||||
var req domain.CreatePlanRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
log.Printf("❌ Invalid request body: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "Invalid request body", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
plan, err := h.planService.CreatePlan(&req)
|
||||
if err != nil {
|
||||
log.Printf("❌ Error creating plan: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch err {
|
||||
case service.ErrPlanSlugTaken:
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "Slug already taken", "message": err.Error()})
|
||||
case service.ErrInvalidUserRange:
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "Invalid user range", "message": err.Error()})
|
||||
default:
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "Internal server error", "message": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"message": "Plan created successfully",
|
||||
"plan": plan,
|
||||
})
|
||||
log.Printf("✅ Plan created: %s", plan.ID)
|
||||
}
|
||||
|
||||
// GetPlan retrieves a plan by ID (admin only)
|
||||
func (h *PlanHandler) GetPlan(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
idStr := vars["id"]
|
||||
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid plan ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
plan, err := h.planService.GetPlan(id)
|
||||
if err != nil {
|
||||
if err == service.ErrPlanNotFound {
|
||||
http.Error(w, "Plan not found", http.StatusNotFound)
|
||||
} else {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"plan": plan,
|
||||
})
|
||||
}
|
||||
|
||||
// ListPlans retrieves all plans (admin only)
|
||||
func (h *PlanHandler) ListPlans(w http.ResponseWriter, r *http.Request) {
|
||||
plans, err := h.planService.ListPlans()
|
||||
if err != nil {
|
||||
log.Printf("❌ Error listing plans: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"plans": plans,
|
||||
})
|
||||
log.Printf("✅ Listed %d plans", len(plans))
|
||||
}
|
||||
|
||||
// ListActivePlans retrieves all active plans (public)
|
||||
func (h *PlanHandler) ListActivePlans(w http.ResponseWriter, r *http.Request) {
|
||||
plans, err := h.planService.ListActivePlans()
|
||||
if err != nil {
|
||||
log.Printf("❌ Error listing active plans: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"plans": plans,
|
||||
})
|
||||
}
|
||||
|
||||
// GetActivePlan retrieves an active plan by ID (public)
|
||||
func (h *PlanHandler) GetActivePlan(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
idStr := vars["id"]
|
||||
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid plan ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
plan, err := h.planService.GetPlan(id)
|
||||
if err != nil {
|
||||
if err == service.ErrPlanNotFound {
|
||||
http.Error(w, "Plan not found", http.StatusNotFound)
|
||||
} else {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check if plan is active
|
||||
if !plan.IsActive {
|
||||
http.Error(w, "Plan not available", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"plan": plan,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdatePlan updates a plan (admin only)
|
||||
func (h *PlanHandler) UpdatePlan(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("📋 UPDATE PLAN - Method: %s", r.Method)
|
||||
|
||||
vars := mux.Vars(r)
|
||||
idStr := vars["id"]
|
||||
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid plan ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req domain.UpdatePlanRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
log.Printf("❌ Invalid request body: %v", err)
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
plan, err := h.planService.UpdatePlan(id, &req)
|
||||
if err != nil {
|
||||
log.Printf("❌ Error updating plan: %v", err)
|
||||
switch err {
|
||||
case service.ErrPlanNotFound:
|
||||
http.Error(w, "Plan not found", http.StatusNotFound)
|
||||
case service.ErrPlanSlugTaken:
|
||||
http.Error(w, err.Error(), http.StatusConflict)
|
||||
case service.ErrInvalidUserRange:
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
default:
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"message": "Plan updated successfully",
|
||||
"plan": plan,
|
||||
})
|
||||
log.Printf("✅ Plan updated: %s", plan.ID)
|
||||
}
|
||||
|
||||
// DeletePlan deletes a plan (admin only)
|
||||
func (h *PlanHandler) DeletePlan(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("📋 DELETE PLAN - Method: %s", r.Method)
|
||||
|
||||
vars := mux.Vars(r)
|
||||
idStr := vars["id"]
|
||||
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid plan ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.planService.DeletePlan(id)
|
||||
if err != nil {
|
||||
log.Printf("❌ Error deleting plan: %v", err)
|
||||
switch err {
|
||||
case service.ErrPlanNotFound:
|
||||
http.Error(w, "Plan not found", http.StatusNotFound)
|
||||
default:
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"message": "Plan deleted successfully",
|
||||
})
|
||||
log.Printf("✅ Plan deleted: %s", idStr)
|
||||
}
|
||||
|
||||
// GetPlanByUserCount returns a plan for a given user count
|
||||
func (h *PlanHandler) GetPlanByUserCount(w http.ResponseWriter, r *http.Request) {
|
||||
userCountStr := r.URL.Query().Get("user_count")
|
||||
if userCountStr == "" {
|
||||
http.Error(w, "user_count parameter required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
userCount, err := strconv.Atoi(userCountStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid user_count", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
plan, err := h.planService.GetPlanByUserCount(userCount)
|
||||
if err != nil {
|
||||
http.Error(w, "No plan available for this user count", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"plan": plan,
|
||||
})
|
||||
}
|
||||
252
backend/internal/api/handlers/solution.go
Normal file
252
backend/internal/api/handlers/solution.go
Normal file
@@ -0,0 +1,252 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"aggios-app/backend/internal/domain"
|
||||
"aggios-app/backend/internal/repository"
|
||||
"aggios-app/backend/internal/api/middleware"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type SolutionHandler struct {
|
||||
repo *repository.SolutionRepository
|
||||
}
|
||||
|
||||
func NewSolutionHandler(repo *repository.SolutionRepository) *SolutionHandler {
|
||||
return &SolutionHandler{repo: repo}
|
||||
}
|
||||
|
||||
// ==================== CRUD SOLUTIONS (SUPERADMIN) ====================
|
||||
|
||||
func (h *SolutionHandler) CreateSolution(w http.ResponseWriter, r *http.Request) {
|
||||
var solution domain.Solution
|
||||
if err := json.NewDecoder(r.Body).Decode(&solution); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Invalid request body",
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
solution.ID = uuid.New().String()
|
||||
|
||||
if err := h.repo.CreateSolution(&solution); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Failed to create solution",
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"solution": solution,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SolutionHandler) GetAllSolutions(w http.ResponseWriter, r *http.Request) {
|
||||
solutions, err := h.repo.GetAllSolutions()
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Failed to fetch solutions",
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if solutions == nil {
|
||||
solutions = []domain.Solution{}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"solutions": solutions,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SolutionHandler) GetSolution(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
solutionID := vars["id"]
|
||||
|
||||
solution, err := h.repo.GetSolutionByID(solutionID)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Solution not found",
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"solution": solution,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SolutionHandler) UpdateSolution(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
solutionID := vars["id"]
|
||||
|
||||
var solution domain.Solution
|
||||
if err := json.NewDecoder(r.Body).Decode(&solution); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Invalid request body",
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
solution.ID = solutionID
|
||||
|
||||
if err := h.repo.UpdateSolution(&solution); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Failed to update solution",
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"message": "Solution updated successfully",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SolutionHandler) DeleteSolution(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
solutionID := vars["id"]
|
||||
|
||||
if err := h.repo.DeleteSolution(solutionID); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Failed to delete solution",
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"message": "Solution deleted successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== TENANT SOLUTIONS (AGENCY) ====================
|
||||
|
||||
func (h *SolutionHandler) GetTenantSolutions(w http.ResponseWriter, r *http.Request) {
|
||||
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
|
||||
|
||||
log.Printf("🔍 GetTenantSolutions: tenantID=%s", tenantID)
|
||||
|
||||
if tenantID == "" {
|
||||
log.Printf("❌ GetTenantSolutions: Missing tenant_id")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Missing tenant_id",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
solutions, err := h.repo.GetTenantSolutions(tenantID)
|
||||
if err != nil {
|
||||
log.Printf("❌ GetTenantSolutions: Error fetching solutions: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Failed to fetch solutions",
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ GetTenantSolutions: Found %d solutions for tenant %s", len(solutions), tenantID)
|
||||
|
||||
if solutions == nil {
|
||||
solutions = []domain.Solution{}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"solutions": solutions,
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== PLAN SOLUTIONS ====================
|
||||
|
||||
func (h *SolutionHandler) GetPlanSolutions(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
planID := vars["plan_id"]
|
||||
|
||||
solutions, err := h.repo.GetPlanSolutions(planID)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Failed to fetch plan solutions",
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if solutions == nil {
|
||||
solutions = []domain.Solution{}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"solutions": solutions,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SolutionHandler) SetPlanSolutions(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
planID := vars["plan_id"]
|
||||
|
||||
var req struct {
|
||||
SolutionIDs []string `json:"solution_ids"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Invalid request body",
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.SetPlanSolutions(planID, req.SolutionIDs); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Failed to update plan solutions",
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"message": "Plan solutions updated successfully",
|
||||
})
|
||||
}
|
||||
@@ -2,10 +2,13 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"aggios-app/backend/internal/domain"
|
||||
"aggios-app/backend/internal/api/middleware"
|
||||
"aggios-app/backend/internal/service"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// TenantHandler handles tenant/agency listing endpoints
|
||||
@@ -27,14 +30,15 @@ func (h *TenantHandler) ListAll(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
tenants, err := h.tenantService.ListAll()
|
||||
tenants, err := h.tenantService.ListAllWithDetails()
|
||||
if err != nil {
|
||||
log.Printf("Error listing tenants with details: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if tenants == nil {
|
||||
tenants = []*domain.Tenant{}
|
||||
tenants = []map[string]interface{}{}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
@@ -67,3 +71,127 @@ func (h *TenantHandler) CheckExists(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// GetPublicConfig returns public branding info for a tenant by subdomain
|
||||
func (h *TenantHandler) GetPublicConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
subdomain := r.URL.Query().Get("subdomain")
|
||||
if subdomain == "" {
|
||||
http.Error(w, "subdomain is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
tenant, err := h.tenantService.GetBySubdomain(subdomain)
|
||||
if err != nil {
|
||||
if err == service.ErrTenantNotFound {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return only public info
|
||||
response := map[string]interface{}{
|
||||
"id": tenant.ID.String(),
|
||||
"name": tenant.Name,
|
||||
"primary_color": tenant.PrimaryColor,
|
||||
"secondary_color": tenant.SecondaryColor,
|
||||
"logo_url": tenant.LogoURL,
|
||||
"logo_horizontal_url": tenant.LogoHorizontalURL,
|
||||
}
|
||||
|
||||
log.Printf("📤 Returning tenant config for %s: logo_url=%s", subdomain, tenant.LogoURL)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// GetBranding returns branding info for the current authenticated tenant
|
||||
func (h *TenantHandler) GetBranding(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Get tenant from context (set by auth middleware)
|
||||
tenantID := r.Context().Value(middleware.TenantIDKey)
|
||||
if tenantID == nil {
|
||||
http.Error(w, "Tenant not found in context", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse tenant ID
|
||||
tid, err := uuid.Parse(tenantID.(string))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid tenant ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get tenant from database
|
||||
tenant, err := h.tenantService.GetByID(tid)
|
||||
if err != nil {
|
||||
http.Error(w, "Error fetching branding", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return branding info
|
||||
response := map[string]interface{}{
|
||||
"id": tenant.ID.String(),
|
||||
"name": tenant.Name,
|
||||
"primary_color": tenant.PrimaryColor,
|
||||
"secondary_color": tenant.SecondaryColor,
|
||||
"logo_url": tenant.LogoURL,
|
||||
"logo_horizontal_url": tenant.LogoHorizontalURL,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// GetProfile returns public tenant information by tenant ID
|
||||
func (h *TenantHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract tenant ID from URL path
|
||||
// URL format: /api/tenants/{id}/profile
|
||||
tenantIDStr := r.URL.Path[len("/api/tenants/"):]
|
||||
if idx := len(tenantIDStr) - len("/profile"); idx > 0 {
|
||||
tenantIDStr = tenantIDStr[:idx]
|
||||
}
|
||||
|
||||
if tenantIDStr == "" {
|
||||
http.Error(w, "tenant_id is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Para compatibilidade, aceitar tanto UUID quanto ID numérico
|
||||
// Primeiro tentar como UUID, se falhar buscar tenant diretamente
|
||||
tenant, err := h.tenantService.GetBySubdomain(tenantIDStr)
|
||||
if err != nil {
|
||||
log.Printf("Error getting tenant: %v", err)
|
||||
http.Error(w, "Tenant not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Return public info
|
||||
response := map[string]interface{}{
|
||||
"tenant": map[string]string{
|
||||
"company": tenant.Name,
|
||||
"primary_color": tenant.PrimaryColor,
|
||||
"secondary_color": tenant.SecondaryColor,
|
||||
"logo_url": tenant.LogoURL,
|
||||
"logo_horizontal_url": tenant.LogoHorizontalURL,
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -59,13 +60,50 @@ func Auth(cfg *config.Config) func(http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
// tenant_id pode ser nil para SuperAdmin
|
||||
var tenantID string
|
||||
var tenantIDFromJWT string
|
||||
if tenantIDClaim, ok := claims["tenant_id"]; ok && tenantIDClaim != nil {
|
||||
tenantID, _ = tenantIDClaim.(string)
|
||||
tenantIDFromJWT, _ = tenantIDClaim.(string)
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), UserIDKey, userID)
|
||||
ctx = context.WithValue(ctx, TenantIDKey, tenantID)
|
||||
// VALIDAÇÃO DE SEGURANÇA: Verificar user_type para impedir clientes de acessarem rotas de agência
|
||||
if userTypeClaim, ok := claims["user_type"]; ok && userTypeClaim != nil {
|
||||
userType, _ := userTypeClaim.(string)
|
||||
if userType == "customer" {
|
||||
log.Printf("❌ CUSTOMER ACCESS BLOCKED: Customer %s tried to access agency route %s", userID, r.RequestURI)
|
||||
http.Error(w, "Forbidden: Customers cannot access agency routes", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// VALIDAÇÃO DE SEGURANÇA: Verificar se o tenant_id do JWT corresponde ao subdomínio acessado
|
||||
// Pegar o tenant_id do contexto (detectado pelo TenantDetector middleware ANTES deste)
|
||||
tenantIDFromContext := ""
|
||||
if ctxTenantID := r.Context().Value(TenantIDKey); ctxTenantID != nil {
|
||||
tenantIDFromContext, _ = ctxTenantID.(string)
|
||||
}
|
||||
|
||||
log.Printf("🔐 AUTH VALIDATION: JWT tenant=%s | Context tenant=%s | Path=%s",
|
||||
tenantIDFromJWT, tenantIDFromContext, r.RequestURI)
|
||||
|
||||
// Se o usuário não é SuperAdmin (tem tenant_id) e está acessando uma agência (subdomain detectado)
|
||||
if tenantIDFromJWT != "" && tenantIDFromContext != "" {
|
||||
// Validar se o tenant_id do JWT corresponde ao tenant detectado
|
||||
if tenantIDFromJWT != tenantIDFromContext {
|
||||
log.Printf("❌ CROSS-TENANT ACCESS BLOCKED: User from tenant %s tried to access tenant %s",
|
||||
tenantIDFromJWT, tenantIDFromContext)
|
||||
http.Error(w, "Forbidden: You don't have access to this tenant", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
log.Printf("✅ TENANT VALIDATION PASSED: %s", tenantIDFromJWT)
|
||||
}
|
||||
|
||||
// Preservar TODOS os valores do contexto anterior (incluindo o tenantID do TenantDetector)
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, UserIDKey, userID)
|
||||
// Só sobrescrever o TenantIDKey se vier do JWT (para não perder o do TenantDetector)
|
||||
if tenantIDFromJWT != "" {
|
||||
ctx = context.WithValue(ctx, TenantIDKey, tenantIDFromJWT)
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
44
backend/internal/api/middleware/collaborator_readonly.go
Normal file
44
backend/internal/api/middleware/collaborator_readonly.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CheckCollaboratorReadOnly verifica se um colaborador está tentando fazer operações de escrita
|
||||
// Se sim, bloqueia com 403
|
||||
func CheckCollaboratorReadOnly(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verificar agency_role do contexto
|
||||
agencyRole, ok := r.Context().Value("agency_role").(string)
|
||||
if !ok {
|
||||
// Se não houver agency_role no contexto, é um customer, deixa passar
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Apenas colaboradores têm restrição de read-only
|
||||
if agencyRole != "collaborator" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Verificar se é uma operação de escrita
|
||||
method := r.Method
|
||||
if method == http.MethodPost || method == http.MethodPut || method == http.MethodDelete {
|
||||
// Verificar a rota
|
||||
path := r.URL.Path
|
||||
|
||||
// Bloquear operações de escrita em CRM
|
||||
if strings.Contains(path, "/api/crm/") {
|
||||
userID, _ := r.Context().Value(UserIDKey).(string)
|
||||
log.Printf("❌ COLLABORATOR WRITE BLOCKED: User %s (collaborator) tried %s %s", userID, method, path)
|
||||
http.Error(w, "Colaboradores têm acesso somente leitura", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
85
backend/internal/api/middleware/customer_auth.go
Normal file
85
backend/internal/api/middleware/customer_auth.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"aggios-app/backend/internal/config"
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
CustomerIDKey contextKey = "customer_id"
|
||||
)
|
||||
|
||||
// CustomerAuthMiddleware valida tokens JWT de clientes do portal
|
||||
func CustomerAuthMiddleware(cfg *config.Config) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Extrair token do header Authorization
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
http.Error(w, "Authorization header required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Remover "Bearer " prefix
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if tokenString == authHeader {
|
||||
http.Error(w, "Invalid authorization format", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse e validar token
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
// Verificar método de assinatura
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, jwt.ErrSignatureInvalid
|
||||
}
|
||||
return []byte(cfg.JWT.Secret), nil
|
||||
})
|
||||
|
||||
if err != nil || !token.Valid {
|
||||
log.Printf("Invalid token: %v", err)
|
||||
http.Error(w, "Invalid or expired token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Extrair claims
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
http.Error(w, "Invalid token claims", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Verificar se é token de customer
|
||||
tokenType, _ := claims["type"].(string)
|
||||
if tokenType != "customer_portal" {
|
||||
http.Error(w, "Invalid token type", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Extrair customer_id e tenant_id
|
||||
customerID, ok := claims["customer_id"].(string)
|
||||
if !ok {
|
||||
http.Error(w, "Invalid customer_id in token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
tenantID, ok := claims["tenant_id"].(string)
|
||||
if !ok {
|
||||
http.Error(w, "Invalid tenant_id in token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Adicionar ao contexto
|
||||
ctx := context.WithValue(r.Context(), CustomerIDKey, customerID)
|
||||
ctx = context.WithValue(ctx, TenantIDKey, tenantID)
|
||||
|
||||
// Prosseguir com a requisição
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -16,15 +16,25 @@ func TenantDetector(tenantRepo *repository.TenantRepository) func(http.Handler)
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Get host from X-Forwarded-Host header (set by Next.js proxy) or Host header
|
||||
host := r.Header.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = r.Header.Get("X-Original-Host")
|
||||
}
|
||||
if host == "" {
|
||||
host = r.Host
|
||||
}
|
||||
// Priority order: X-Tenant-Subdomain (set by Next.js middleware) > X-Forwarded-Host > X-Original-Host > Host
|
||||
tenantSubdomain := r.Header.Get("X-Tenant-Subdomain")
|
||||
|
||||
log.Printf("TenantDetector: host = %s (from headers), path = %s", host, r.RequestURI)
|
||||
var host string
|
||||
if tenantSubdomain != "" {
|
||||
// Use direct subdomain from Next.js middleware
|
||||
host = tenantSubdomain
|
||||
log.Printf("TenantDetector: using X-Tenant-Subdomain = %s", tenantSubdomain)
|
||||
} else {
|
||||
// Fallback to extracting from host headers
|
||||
host = r.Header.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = r.Header.Get("X-Original-Host")
|
||||
}
|
||||
if host == "" {
|
||||
host = r.Host
|
||||
}
|
||||
log.Printf("TenantDetector: host = %s (from headers), path = %s", host, r.RequestURI)
|
||||
}
|
||||
|
||||
// Extract subdomain
|
||||
// Examples:
|
||||
@@ -33,17 +43,28 @@ func TenantDetector(tenantRepo *repository.TenantRepository) func(http.Handler)
|
||||
// - dash.localhost -> dash (master admin)
|
||||
// - localhost -> (institutional site)
|
||||
|
||||
parts := strings.Split(host, ".")
|
||||
var subdomain string
|
||||
|
||||
if len(parts) >= 2 {
|
||||
// Has subdomain
|
||||
subdomain = parts[0]
|
||||
|
||||
// If we got the subdomain directly from X-Tenant-Subdomain, use it
|
||||
if tenantSubdomain != "" {
|
||||
subdomain = tenantSubdomain
|
||||
// Remove port if present
|
||||
if strings.Contains(subdomain, ":") {
|
||||
subdomain = strings.Split(subdomain, ":")[0]
|
||||
}
|
||||
} else {
|
||||
// Extract from host
|
||||
parts := strings.Split(host, ".")
|
||||
|
||||
if len(parts) >= 2 {
|
||||
// Has subdomain
|
||||
subdomain = parts[0]
|
||||
|
||||
// Remove port if present
|
||||
if strings.Contains(subdomain, ":") {
|
||||
subdomain = strings.Split(subdomain, ":")[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("TenantDetector: extracted subdomain = %s", subdomain)
|
||||
|
||||
104
backend/internal/api/middleware/unified_auth.go
Normal file
104
backend/internal/api/middleware/unified_auth.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"aggios-app/backend/internal/config"
|
||||
"aggios-app/backend/internal/domain"
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// UnifiedAuthMiddleware valida JWT unificado e permite múltiplos tipos de usuários
|
||||
func UnifiedAuthMiddleware(cfg *config.Config, allowedTypes ...domain.UserType) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Extrair token do header Authorization
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
log.Printf("🚫 UnifiedAuth: Missing Authorization header")
|
||||
http.Error(w, "Unauthorized: Missing token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Formato esperado: "Bearer <token>"
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
log.Printf("🚫 UnifiedAuth: Invalid Authorization format")
|
||||
http.Error(w, "Unauthorized: Invalid token format", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := parts[1]
|
||||
|
||||
// Parsear e validar token
|
||||
token, err := jwt.ParseWithClaims(tokenString, &domain.UnifiedClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(cfg.JWT.Secret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Printf("🚫 UnifiedAuth: Token parse error: %v", err)
|
||||
http.Error(w, "Unauthorized: Invalid token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*domain.UnifiedClaims)
|
||||
if !ok || !token.Valid {
|
||||
log.Printf("🚫 UnifiedAuth: Invalid token claims")
|
||||
http.Error(w, "Unauthorized: Invalid token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Verificar se o tipo de usuário é permitido
|
||||
if len(allowedTypes) > 0 {
|
||||
allowed := false
|
||||
for _, allowedType := range allowedTypes {
|
||||
if claims.UserType == allowedType {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
log.Printf("🚫 UnifiedAuth: User type %s not allowed (allowed: %v)", claims.UserType, allowedTypes)
|
||||
http.Error(w, "Forbidden: Insufficient permissions", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Adicionar informações ao contexto
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, UserIDKey, claims.UserID)
|
||||
ctx = context.WithValue(ctx, TenantIDKey, claims.TenantID)
|
||||
ctx = context.WithValue(ctx, "email", claims.Email)
|
||||
ctx = context.WithValue(ctx, "user_type", string(claims.UserType))
|
||||
ctx = context.WithValue(ctx, "role", claims.Role)
|
||||
|
||||
// Para compatibilidade com handlers de portal que esperam CustomerIDKey
|
||||
if claims.UserType == domain.UserTypeCustomer {
|
||||
ctx = context.WithValue(ctx, CustomerIDKey, claims.UserID)
|
||||
}
|
||||
|
||||
log.Printf("✅ UnifiedAuth: Authenticated user_id=%s, type=%s, role=%s, tenant=%s",
|
||||
claims.UserID, claims.UserType, claims.Role, claims.TenantID)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAgencyUser middleware que permite apenas usuários de agência (admin, colaborador)
|
||||
func RequireAgencyUser(cfg *config.Config) func(http.Handler) http.Handler {
|
||||
return UnifiedAuthMiddleware(cfg, domain.UserTypeAgency)
|
||||
}
|
||||
|
||||
// RequireCustomer middleware que permite apenas clientes
|
||||
func RequireCustomer(cfg *config.Config) func(http.Handler) http.Handler {
|
||||
return UnifiedAuthMiddleware(cfg, domain.UserTypeCustomer)
|
||||
}
|
||||
|
||||
// RequireAnyAuthenticated middleware que permite qualquer usuário autenticado
|
||||
func RequireAnyAuthenticated(cfg *config.Config) func(http.Handler) http.Handler {
|
||||
return UnifiedAuthMiddleware(cfg) // Sem filtro de tipo
|
||||
}
|
||||
@@ -49,6 +49,7 @@ type SecurityConfig struct {
|
||||
// MinioConfig holds MinIO configuration
|
||||
type MinioConfig struct {
|
||||
Endpoint string
|
||||
PublicURL string // URL pública para acesso ao MinIO (para gerar links)
|
||||
RootUser string
|
||||
RootPassword string
|
||||
UseSSL bool
|
||||
@@ -64,9 +65,9 @@ func Load() *Config {
|
||||
}
|
||||
|
||||
// Rate limit: more lenient in dev, strict in prod
|
||||
maxAttempts := 30
|
||||
maxAttempts := 1000 // Aumentado drasticamente para evitar 429 durante debug
|
||||
if env == "production" {
|
||||
maxAttempts = 5
|
||||
maxAttempts = 100 // Mais restritivo em produção
|
||||
}
|
||||
|
||||
return &Config{
|
||||
@@ -102,6 +103,7 @@ func Load() *Config {
|
||||
},
|
||||
Minio: MinioConfig{
|
||||
Endpoint: getEnvOrDefault("MINIO_ENDPOINT", "minio:9000"),
|
||||
PublicURL: getEnvOrDefault("MINIO_PUBLIC_URL", "http://localhost:9000"),
|
||||
RootUser: getEnvOrDefault("MINIO_ROOT_USER", "minioadmin"),
|
||||
RootPassword: getEnvOrDefault("MINIO_ROOT_PASSWORD", "changeme"),
|
||||
UseSSL: getEnvOrDefault("MINIO_USE_SSL", "false") == "true",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Migration: Add agency user roles and collaborator tracking
|
||||
-- Purpose: Support owner/collaborator hierarchy for agency users
|
||||
|
||||
-- 1. Add agency_role column to users table (owner or collaborator)
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS agency_role VARCHAR(50) DEFAULT 'owner' CHECK (agency_role IN ('owner', 'collaborator'));
|
||||
|
||||
-- 2. Add created_by column to track which user created this collaborator
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS created_by UUID REFERENCES users(id) ON DELETE SET NULL;
|
||||
|
||||
-- 3. Update existing ADMIN_AGENCIA users to have 'owner' agency_role
|
||||
UPDATE users SET agency_role = 'owner' WHERE role = 'ADMIN_AGENCIA' AND agency_role IS NULL;
|
||||
|
||||
-- 4. Add collaborator_created_at to track when the collaborator was added
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS collaborator_created_at TIMESTAMP WITH TIME ZONE;
|
||||
|
||||
-- 5. Create index for faster queries
|
||||
CREATE INDEX IF NOT EXISTS idx_users_agency_role ON users(tenant_id, agency_role);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_created_by ON users(created_by);
|
||||
42
backend/internal/domain/auth_unified.go
Normal file
42
backend/internal/domain/auth_unified.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package domain
|
||||
|
||||
import "github.com/golang-jwt/jwt/v5"
|
||||
|
||||
// UserType representa os diferentes tipos de usuários do sistema
|
||||
type UserType string
|
||||
|
||||
const (
|
||||
UserTypeAgency UserType = "agency_user" // Usuários das agências (admin, colaborador)
|
||||
UserTypeCustomer UserType = "customer" // Clientes do CRM
|
||||
// SUPERADMIN usa endpoint próprio /api/admin/*, não usa autenticação unificada
|
||||
)
|
||||
|
||||
// UnifiedClaims representa as claims do JWT unificado
|
||||
type UnifiedClaims struct {
|
||||
UserID string `json:"user_id"` // ID do usuário (user.id ou customer.id)
|
||||
UserType UserType `json:"user_type"` // Tipo de usuário
|
||||
TenantID string `json:"tenant_id,omitempty"` // ID do tenant (agência)
|
||||
Email string `json:"email"` // Email do usuário
|
||||
Role string `json:"role,omitempty"` // Role (para agency_user: ADMIN_AGENCIA, CLIENTE)
|
||||
AgencyRole string `json:"agency_role,omitempty"` // Agency role (owner ou collaborator)
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// UnifiedLoginRequest representa uma requisição de login unificada
|
||||
type UnifiedLoginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// UnifiedLoginResponse representa a resposta de login unificada
|
||||
type UnifiedLoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
UserType UserType `json:"user_type"`
|
||||
UserID string `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role,omitempty"` // Apenas para agency_user
|
||||
AgencyRole string `json:"agency_role,omitempty"` // owner ou collaborator
|
||||
TenantID string `json:"tenant_id,omitempty"` // ID do tenant
|
||||
Subdomain string `json:"subdomain,omitempty"` // Subdomínio da agência
|
||||
}
|
||||
135
backend/internal/domain/crm.go
Normal file
135
backend/internal/domain/crm.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CRMCustomer struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
TenantID string `json:"tenant_id" db:"tenant_id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Email string `json:"email" db:"email"`
|
||||
Phone string `json:"phone" db:"phone"`
|
||||
Company string `json:"company" db:"company"`
|
||||
Position string `json:"position" db:"position"`
|
||||
Address string `json:"address" db:"address"`
|
||||
City string `json:"city" db:"city"`
|
||||
State string `json:"state" db:"state"`
|
||||
ZipCode string `json:"zip_code" db:"zip_code"`
|
||||
Country string `json:"country" db:"country"`
|
||||
Notes string `json:"notes" db:"notes"`
|
||||
Tags []string `json:"tags" db:"tags"`
|
||||
LogoURL string `json:"logo_url" db:"logo_url"`
|
||||
IsActive bool `json:"is_active" db:"is_active"`
|
||||
CreatedBy string `json:"created_by" db:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
PasswordHash string `json:"-" db:"password_hash"`
|
||||
HasPortalAccess bool `json:"has_portal_access" db:"has_portal_access"`
|
||||
PortalLastLogin *time.Time `json:"portal_last_login,omitempty" db:"portal_last_login"`
|
||||
PortalCreatedAt *time.Time `json:"portal_created_at,omitempty" db:"portal_created_at"`
|
||||
}
|
||||
|
||||
type CRMList struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
TenantID string `json:"tenant_id" db:"tenant_id"`
|
||||
CustomerID *string `json:"customer_id" db:"customer_id"`
|
||||
FunnelID *string `json:"funnel_id" db:"funnel_id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Description string `json:"description" db:"description"`
|
||||
Color string `json:"color" db:"color"`
|
||||
CreatedBy string `json:"created_by" db:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
type CRMCustomerList struct {
|
||||
CustomerID string `json:"customer_id" db:"customer_id"`
|
||||
ListID string `json:"list_id" db:"list_id"`
|
||||
AddedAt time.Time `json:"added_at" db:"added_at"`
|
||||
AddedBy string `json:"added_by" db:"added_by"`
|
||||
}
|
||||
|
||||
// DTO com informações extras
|
||||
type CRMCustomerWithLists struct {
|
||||
CRMCustomer
|
||||
Lists []CRMList `json:"lists"`
|
||||
}
|
||||
|
||||
type CRMListWithCustomers struct {
|
||||
CRMList
|
||||
CustomerName string `json:"customer_name"`
|
||||
CustomerCount int `json:"customer_count"`
|
||||
LeadCount int `json:"lead_count"`
|
||||
}
|
||||
|
||||
// ==================== LEADS ====================
|
||||
|
||||
type CRMLead struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
TenantID string `json:"tenant_id" db:"tenant_id"`
|
||||
CustomerID *string `json:"customer_id" db:"customer_id"`
|
||||
FunnelID *string `json:"funnel_id" db:"funnel_id"`
|
||||
StageID *string `json:"stage_id" db:"stage_id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Email string `json:"email" db:"email"`
|
||||
Phone string `json:"phone" db:"phone"`
|
||||
Source string `json:"source" db:"source"`
|
||||
SourceMeta json.RawMessage `json:"source_meta" db:"source_meta"`
|
||||
Status string `json:"status" db:"status"`
|
||||
Notes string `json:"notes" db:"notes"`
|
||||
Tags []string `json:"tags" db:"tags"`
|
||||
IsActive bool `json:"is_active" db:"is_active"`
|
||||
CreatedBy string `json:"created_by" db:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
type CRMFunnel struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
TenantID string `json:"tenant_id" db:"tenant_id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Description string `json:"description" db:"description"`
|
||||
IsDefault bool `json:"is_default" db:"is_default"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
type CRMFunnelStage struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
FunnelID string `json:"funnel_id" db:"funnel_id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Description string `json:"description" db:"description"`
|
||||
Color string `json:"color" db:"color"`
|
||||
OrderIndex int `json:"order_index" db:"order_index"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
type CRMFunnelWithStages struct {
|
||||
CRMFunnel
|
||||
Stages []CRMFunnelStage `json:"stages"`
|
||||
}
|
||||
|
||||
type CRMLeadList struct {
|
||||
LeadID string `json:"lead_id" db:"lead_id"`
|
||||
ListID string `json:"list_id" db:"list_id"`
|
||||
AddedAt time.Time `json:"added_at" db:"added_at"`
|
||||
AddedBy string `json:"added_by" db:"added_by"`
|
||||
}
|
||||
|
||||
type CRMLeadWithLists struct {
|
||||
CRMLead
|
||||
Lists []CRMList `json:"lists"`
|
||||
}
|
||||
|
||||
type CRMShareToken struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
TenantID string `json:"tenant_id" db:"tenant_id"`
|
||||
CustomerID string `json:"customer_id" db:"customer_id"`
|
||||
Token string `json:"token" db:"token"`
|
||||
ExpiresAt *time.Time `json:"expires_at" db:"expires_at"`
|
||||
CreatedBy string `json:"created_by" db:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
}
|
||||
78
backend/internal/domain/plan.go
Normal file
78
backend/internal/domain/plan.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// Plan represents a subscription plan in the system
|
||||
type Plan struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Slug string `json:"slug" db:"slug"`
|
||||
Description string `json:"description" db:"description"`
|
||||
MinUsers int `json:"min_users" db:"min_users"`
|
||||
MaxUsers int `json:"max_users" db:"max_users"` // -1 means unlimited
|
||||
MonthlyPrice *decimal.Decimal `json:"monthly_price" db:"monthly_price"`
|
||||
AnnualPrice *decimal.Decimal `json:"annual_price" db:"annual_price"`
|
||||
Features pq.StringArray `json:"features" db:"features"`
|
||||
Differentiators pq.StringArray `json:"differentiators" db:"differentiators"`
|
||||
StorageGB int `json:"storage_gb" db:"storage_gb"`
|
||||
IsActive bool `json:"is_active" db:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
// CreatePlanRequest represents the request to create a new plan
|
||||
type CreatePlanRequest struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Slug string `json:"slug" validate:"required"`
|
||||
Description string `json:"description"`
|
||||
MinUsers int `json:"min_users" validate:"required,min=1"`
|
||||
MaxUsers int `json:"max_users" validate:"required"` // -1 for unlimited
|
||||
MonthlyPrice *float64 `json:"monthly_price"`
|
||||
AnnualPrice *float64 `json:"annual_price"`
|
||||
Features []string `json:"features"`
|
||||
Differentiators []string `json:"differentiators"`
|
||||
StorageGB int `json:"storage_gb" validate:"required,min=1"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// UpdatePlanRequest represents the request to update a plan
|
||||
type UpdatePlanRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Slug *string `json:"slug"`
|
||||
Description *string `json:"description"`
|
||||
MinUsers *int `json:"min_users"`
|
||||
MaxUsers *int `json:"max_users"`
|
||||
MonthlyPrice *float64 `json:"monthly_price"`
|
||||
AnnualPrice *float64 `json:"annual_price"`
|
||||
Features []string `json:"features"`
|
||||
Differentiators []string `json:"differentiators"`
|
||||
StorageGB *int `json:"storage_gb"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// Subscription represents an agency's subscription to a plan
|
||||
type Subscription struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
AgencyID uuid.UUID `json:"agency_id" db:"agency_id"`
|
||||
PlanID uuid.UUID `json:"plan_id" db:"plan_id"`
|
||||
BillingType string `json:"billing_type" db:"billing_type"` // monthly or annual
|
||||
CurrentUsers int `json:"current_users" db:"current_users"`
|
||||
Status string `json:"status" db:"status"` // active, suspended, cancelled
|
||||
StartDate time.Time `json:"start_date" db:"start_date"`
|
||||
RenewalDate time.Time `json:"renewal_date" db:"renewal_date"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
// CreateSubscriptionRequest represents the request to create a subscription
|
||||
type CreateSubscriptionRequest struct {
|
||||
AgencyID uuid.UUID `json:"agency_id" validate:"required"`
|
||||
PlanID uuid.UUID `json:"plan_id" validate:"required"`
|
||||
BillingType string `json:"billing_type" validate:"required,oneof=monthly annual"`
|
||||
}
|
||||
20
backend/internal/domain/solution.go
Normal file
20
backend/internal/domain/solution.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type Solution struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Slug string `json:"slug" db:"slug"`
|
||||
Icon string `json:"icon" db:"icon"`
|
||||
Description string `json:"description" db:"description"`
|
||||
IsActive bool `json:"is_active" db:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
type PlanSolution struct {
|
||||
PlanID string `json:"plan_id" db:"plan_id"`
|
||||
SolutionID string `json:"solution_id" db:"solution_id"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
}
|
||||
@@ -45,7 +45,15 @@ type CreateTenantRequest struct {
|
||||
|
||||
// AgencyDetails aggregates tenant info with its admin user for superadmin view
|
||||
type AgencyDetails struct {
|
||||
Tenant *Tenant `json:"tenant"`
|
||||
Admin *User `json:"admin,omitempty"`
|
||||
AccessURL string `json:"access_url"`
|
||||
Tenant *Tenant `json:"tenant"`
|
||||
Admin *User `json:"admin,omitempty"`
|
||||
Subscription *AgencySubscriptionInfo `json:"subscription,omitempty"`
|
||||
AccessURL string `json:"access_url"`
|
||||
}
|
||||
|
||||
type AgencySubscriptionInfo struct {
|
||||
PlanID string `json:"plan_id"`
|
||||
PlanName string `json:"plan_name"`
|
||||
Status string `json:"status"`
|
||||
Solutions []Solution `json:"solutions"`
|
||||
}
|
||||
|
||||
@@ -8,14 +8,17 @@ import (
|
||||
|
||||
// User represents a user in the system
|
||||
type User struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
TenantID *uuid.UUID `json:"tenant_id,omitempty" db:"tenant_id"`
|
||||
Email string `json:"email" db:"email"`
|
||||
Password string `json:"-" db:"password_hash"`
|
||||
Name string `json:"name" db:"first_name"`
|
||||
Role string `json:"role" db:"role"` // SUPERADMIN, ADMIN_AGENCIA, CLIENTE
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
TenantID *uuid.UUID `json:"tenant_id,omitempty" db:"tenant_id"`
|
||||
Email string `json:"email" db:"email"`
|
||||
Password string `json:"-" db:"password_hash"`
|
||||
Name string `json:"name" db:"first_name"`
|
||||
Role string `json:"role" db:"role"` // SUPERADMIN, ADMIN_AGENCIA, CLIENTE
|
||||
AgencyRole string `json:"agency_role" db:"agency_role"` // owner or collaborator (only for ADMIN_AGENCIA)
|
||||
CreatedBy *uuid.UUID `json:"created_by,omitempty" db:"created_by"` // Which owner created this collaborator
|
||||
CollaboratorCreatedAt *time.Time `json:"collaborator_created_at,omitempty" db:"collaborator_created_at"` // When collaborator was added
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
// CreateUserRequest represents the request to create a new user
|
||||
|
||||
1159
backend/internal/repository/crm_repository.go
Normal file
1159
backend/internal/repository/crm_repository.go
Normal file
File diff suppressed because it is too large
Load Diff
283
backend/internal/repository/plan_repository.go
Normal file
283
backend/internal/repository/plan_repository.go
Normal file
@@ -0,0 +1,283 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"aggios-app/backend/internal/domain"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// PlanRepository handles database operations for plans
|
||||
type PlanRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewPlanRepository creates a new plan repository
|
||||
func NewPlanRepository(db *sql.DB) *PlanRepository {
|
||||
return &PlanRepository{db: db}
|
||||
}
|
||||
|
||||
// Create creates a new plan
|
||||
func (r *PlanRepository) Create(plan *domain.Plan) error {
|
||||
query := `
|
||||
INSERT INTO plans (id, name, slug, description, min_users, max_users, monthly_price, annual_price, features, differentiators, storage_gb, is_active, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING id, created_at, updated_at
|
||||
`
|
||||
|
||||
now := time.Now()
|
||||
plan.ID = uuid.New()
|
||||
plan.CreatedAt = now
|
||||
plan.UpdatedAt = now
|
||||
|
||||
features := pq.Array(plan.Features)
|
||||
differentiators := pq.Array(plan.Differentiators)
|
||||
|
||||
return r.db.QueryRow(
|
||||
query,
|
||||
plan.ID,
|
||||
plan.Name,
|
||||
plan.Slug,
|
||||
plan.Description,
|
||||
plan.MinUsers,
|
||||
plan.MaxUsers,
|
||||
plan.MonthlyPrice,
|
||||
plan.AnnualPrice,
|
||||
features,
|
||||
differentiators,
|
||||
plan.StorageGB,
|
||||
plan.IsActive,
|
||||
plan.CreatedAt,
|
||||
plan.UpdatedAt,
|
||||
).Scan(&plan.ID, &plan.CreatedAt, &plan.UpdatedAt)
|
||||
}
|
||||
|
||||
// GetByID retrieves a plan by ID
|
||||
func (r *PlanRepository) GetByID(id uuid.UUID) (*domain.Plan, error) {
|
||||
query := `
|
||||
SELECT id, name, slug, description, min_users, max_users, monthly_price, annual_price, features, differentiators, storage_gb, is_active, created_at, updated_at
|
||||
FROM plans
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
plan := &domain.Plan{}
|
||||
var features, differentiators pq.StringArray
|
||||
|
||||
err := r.db.QueryRow(query, id).Scan(
|
||||
&plan.ID,
|
||||
&plan.Name,
|
||||
&plan.Slug,
|
||||
&plan.Description,
|
||||
&plan.MinUsers,
|
||||
&plan.MaxUsers,
|
||||
&plan.MonthlyPrice,
|
||||
&plan.AnnualPrice,
|
||||
&features,
|
||||
&differentiators,
|
||||
&plan.StorageGB,
|
||||
&plan.IsActive,
|
||||
&plan.CreatedAt,
|
||||
&plan.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
plan.Features = []string(features)
|
||||
plan.Differentiators = []string(differentiators)
|
||||
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// GetBySlug retrieves a plan by slug
|
||||
func (r *PlanRepository) GetBySlug(slug string) (*domain.Plan, error) {
|
||||
query := `
|
||||
SELECT id, name, slug, description, min_users, max_users, monthly_price, annual_price, features, differentiators, storage_gb, is_active, created_at, updated_at
|
||||
FROM plans
|
||||
WHERE slug = $1
|
||||
`
|
||||
|
||||
plan := &domain.Plan{}
|
||||
var features, differentiators pq.StringArray
|
||||
|
||||
err := r.db.QueryRow(query, slug).Scan(
|
||||
&plan.ID,
|
||||
&plan.Name,
|
||||
&plan.Slug,
|
||||
&plan.Description,
|
||||
&plan.MinUsers,
|
||||
&plan.MaxUsers,
|
||||
&plan.MonthlyPrice,
|
||||
&plan.AnnualPrice,
|
||||
&features,
|
||||
&differentiators,
|
||||
&plan.StorageGB,
|
||||
&plan.IsActive,
|
||||
&plan.CreatedAt,
|
||||
&plan.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
plan.Features = []string(features)
|
||||
plan.Differentiators = []string(differentiators)
|
||||
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// ListAll retrieves all plans
|
||||
func (r *PlanRepository) ListAll() ([]*domain.Plan, error) {
|
||||
query := `
|
||||
SELECT id, name, slug, description, min_users, max_users, monthly_price, annual_price, features, differentiators, storage_gb, is_active, created_at, updated_at
|
||||
FROM plans
|
||||
ORDER BY min_users ASC
|
||||
`
|
||||
|
||||
rows, err := r.db.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var plans []*domain.Plan
|
||||
|
||||
for rows.Next() {
|
||||
plan := &domain.Plan{}
|
||||
var features, differentiators pq.StringArray
|
||||
|
||||
err := rows.Scan(
|
||||
&plan.ID,
|
||||
&plan.Name,
|
||||
&plan.Slug,
|
||||
&plan.Description,
|
||||
&plan.MinUsers,
|
||||
&plan.MaxUsers,
|
||||
&plan.MonthlyPrice,
|
||||
&plan.AnnualPrice,
|
||||
&features,
|
||||
&differentiators,
|
||||
&plan.StorageGB,
|
||||
&plan.IsActive,
|
||||
&plan.CreatedAt,
|
||||
&plan.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
plan.Features = []string(features)
|
||||
plan.Differentiators = []string(differentiators)
|
||||
plans = append(plans, plan)
|
||||
}
|
||||
|
||||
return plans, rows.Err()
|
||||
}
|
||||
|
||||
// ListActive retrieves all active plans
|
||||
func (r *PlanRepository) ListActive() ([]*domain.Plan, error) {
|
||||
query := `
|
||||
SELECT id, name, slug, description, min_users, max_users, monthly_price, annual_price, features, differentiators, storage_gb, is_active, created_at, updated_at
|
||||
FROM plans
|
||||
WHERE is_active = true
|
||||
ORDER BY min_users ASC
|
||||
`
|
||||
|
||||
rows, err := r.db.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var plans []*domain.Plan
|
||||
|
||||
for rows.Next() {
|
||||
plan := &domain.Plan{}
|
||||
var features, differentiators pq.StringArray
|
||||
|
||||
err := rows.Scan(
|
||||
&plan.ID,
|
||||
&plan.Name,
|
||||
&plan.Slug,
|
||||
&plan.Description,
|
||||
&plan.MinUsers,
|
||||
&plan.MaxUsers,
|
||||
&plan.MonthlyPrice,
|
||||
&plan.AnnualPrice,
|
||||
&features,
|
||||
&differentiators,
|
||||
&plan.StorageGB,
|
||||
&plan.IsActive,
|
||||
&plan.CreatedAt,
|
||||
&plan.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
plan.Features = []string(features)
|
||||
plan.Differentiators = []string(differentiators)
|
||||
plans = append(plans, plan)
|
||||
}
|
||||
|
||||
return plans, rows.Err()
|
||||
}
|
||||
|
||||
// Update updates a plan
|
||||
func (r *PlanRepository) Update(plan *domain.Plan) error {
|
||||
query := `
|
||||
UPDATE plans
|
||||
SET name = $2, slug = $3, description = $4, min_users = $5, max_users = $6, monthly_price = $7, annual_price = $8, features = $9, differentiators = $10, storage_gb = $11, is_active = $12, updated_at = $13
|
||||
WHERE id = $1
|
||||
RETURNING updated_at
|
||||
`
|
||||
|
||||
plan.UpdatedAt = time.Now()
|
||||
|
||||
features := pq.Array(plan.Features)
|
||||
differentiators := pq.Array(plan.Differentiators)
|
||||
|
||||
return r.db.QueryRow(
|
||||
query,
|
||||
plan.ID,
|
||||
plan.Name,
|
||||
plan.Slug,
|
||||
plan.Description,
|
||||
plan.MinUsers,
|
||||
plan.MaxUsers,
|
||||
plan.MonthlyPrice,
|
||||
plan.AnnualPrice,
|
||||
features,
|
||||
differentiators,
|
||||
plan.StorageGB,
|
||||
plan.IsActive,
|
||||
plan.UpdatedAt,
|
||||
).Scan(&plan.UpdatedAt)
|
||||
}
|
||||
|
||||
// Delete deletes a plan
|
||||
func (r *PlanRepository) Delete(id uuid.UUID) error {
|
||||
query := `DELETE FROM plans WHERE id = $1`
|
||||
result, err := r.db.Exec(query, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
300
backend/internal/repository/solution_repository.go
Normal file
300
backend/internal/repository/solution_repository.go
Normal file
@@ -0,0 +1,300 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"aggios-app/backend/internal/domain"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type SolutionRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewSolutionRepository(db *sql.DB) *SolutionRepository {
|
||||
return &SolutionRepository{db: db}
|
||||
}
|
||||
|
||||
// ==================== SOLUTIONS ====================
|
||||
|
||||
func (r *SolutionRepository) CreateSolution(solution *domain.Solution) error {
|
||||
query := `
|
||||
INSERT INTO solutions (id, name, slug, icon, description, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING created_at, updated_at
|
||||
`
|
||||
|
||||
return r.db.QueryRow(
|
||||
query,
|
||||
solution.ID, solution.Name, solution.Slug, solution.Icon,
|
||||
solution.Description, solution.IsActive,
|
||||
).Scan(&solution.CreatedAt, &solution.UpdatedAt)
|
||||
}
|
||||
|
||||
func (r *SolutionRepository) GetAllSolutions() ([]domain.Solution, error) {
|
||||
query := `
|
||||
SELECT id, name, slug, icon, description, is_active, created_at, updated_at
|
||||
FROM solutions
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
rows, err := r.db.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var solutions []domain.Solution
|
||||
for rows.Next() {
|
||||
var s domain.Solution
|
||||
err := rows.Scan(
|
||||
&s.ID, &s.Name, &s.Slug, &s.Icon, &s.Description,
|
||||
&s.IsActive, &s.CreatedAt, &s.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
solutions = append(solutions, s)
|
||||
}
|
||||
|
||||
return solutions, nil
|
||||
}
|
||||
|
||||
func (r *SolutionRepository) GetActiveSolutions() ([]domain.Solution, error) {
|
||||
query := `
|
||||
SELECT id, name, slug, icon, description, is_active, created_at, updated_at
|
||||
FROM solutions
|
||||
WHERE is_active = true
|
||||
ORDER BY name
|
||||
`
|
||||
|
||||
rows, err := r.db.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var solutions []domain.Solution
|
||||
for rows.Next() {
|
||||
var s domain.Solution
|
||||
err := rows.Scan(
|
||||
&s.ID, &s.Name, &s.Slug, &s.Icon, &s.Description,
|
||||
&s.IsActive, &s.CreatedAt, &s.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
solutions = append(solutions, s)
|
||||
}
|
||||
|
||||
return solutions, nil
|
||||
}
|
||||
|
||||
func (r *SolutionRepository) GetSolutionByID(id string) (*domain.Solution, error) {
|
||||
query := `
|
||||
SELECT id, name, slug, icon, description, is_active, created_at, updated_at
|
||||
FROM solutions
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
var s domain.Solution
|
||||
err := r.db.QueryRow(query, id).Scan(
|
||||
&s.ID, &s.Name, &s.Slug, &s.Icon, &s.Description,
|
||||
&s.IsActive, &s.CreatedAt, &s.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (r *SolutionRepository) GetSolutionBySlug(slug string) (*domain.Solution, error) {
|
||||
query := `
|
||||
SELECT id, name, slug, icon, description, is_active, created_at, updated_at
|
||||
FROM solutions
|
||||
WHERE slug = $1
|
||||
`
|
||||
|
||||
var s domain.Solution
|
||||
err := r.db.QueryRow(query, slug).Scan(
|
||||
&s.ID, &s.Name, &s.Slug, &s.Icon, &s.Description,
|
||||
&s.IsActive, &s.CreatedAt, &s.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (r *SolutionRepository) UpdateSolution(solution *domain.Solution) error {
|
||||
query := `
|
||||
UPDATE solutions SET
|
||||
name = $1, slug = $2, icon = $3, description = $4, is_active = $5, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $6
|
||||
`
|
||||
|
||||
result, err := r.db.Exec(
|
||||
query,
|
||||
solution.Name, solution.Slug, solution.Icon, solution.Description,
|
||||
solution.IsActive, solution.ID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("solution not found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SolutionRepository) DeleteSolution(id string) error {
|
||||
query := `DELETE FROM solutions WHERE id = $1`
|
||||
|
||||
result, err := r.db.Exec(query, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("solution not found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ==================== PLAN <-> SOLUTION ====================
|
||||
|
||||
func (r *SolutionRepository) AddSolutionToPlan(planID, solutionID string) error {
|
||||
query := `
|
||||
INSERT INTO plan_solutions (plan_id, solution_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (plan_id, solution_id) DO NOTHING
|
||||
`
|
||||
|
||||
_, err := r.db.Exec(query, planID, solutionID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *SolutionRepository) RemoveSolutionFromPlan(planID, solutionID string) error {
|
||||
query := `DELETE FROM plan_solutions WHERE plan_id = $1 AND solution_id = $2`
|
||||
|
||||
_, err := r.db.Exec(query, planID, solutionID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *SolutionRepository) GetPlanSolutions(planID string) ([]domain.Solution, error) {
|
||||
query := `
|
||||
SELECT s.id, s.name, s.slug, s.icon, s.description, s.is_active, s.created_at, s.updated_at
|
||||
FROM solutions s
|
||||
INNER JOIN plan_solutions ps ON s.id = ps.solution_id
|
||||
WHERE ps.plan_id = $1
|
||||
ORDER BY s.name
|
||||
`
|
||||
|
||||
rows, err := r.db.Query(query, planID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var solutions []domain.Solution
|
||||
for rows.Next() {
|
||||
var s domain.Solution
|
||||
err := rows.Scan(
|
||||
&s.ID, &s.Name, &s.Slug, &s.Icon, &s.Description,
|
||||
&s.IsActive, &s.CreatedAt, &s.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
solutions = append(solutions, s)
|
||||
}
|
||||
|
||||
return solutions, nil
|
||||
}
|
||||
|
||||
func (r *SolutionRepository) SetPlanSolutions(planID string, solutionIDs []string) error {
|
||||
// Inicia transação
|
||||
tx, err := r.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove todas as soluções antigas do plano
|
||||
_, err = tx.Exec(`DELETE FROM plan_solutions WHERE plan_id = $1`, planID)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
// Adiciona as novas soluções
|
||||
stmt, err := tx.Prepare(`INSERT INTO plan_solutions (plan_id, solution_id) VALUES ($1, $2)`)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, solutionID := range solutionIDs {
|
||||
_, err = stmt.Exec(planID, solutionID)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (r *SolutionRepository) GetTenantSolutions(tenantID string) ([]domain.Solution, error) {
|
||||
query := `
|
||||
SELECT DISTINCT s.id, s.name, s.slug, s.icon, s.description, s.is_active, s.created_at, s.updated_at
|
||||
FROM solutions s
|
||||
INNER JOIN plan_solutions ps ON s.id = ps.solution_id
|
||||
INNER JOIN agency_subscriptions asub ON ps.plan_id = asub.plan_id
|
||||
WHERE asub.agency_id = $1 AND s.is_active = true AND asub.status = 'active'
|
||||
ORDER BY s.name
|
||||
`
|
||||
|
||||
rows, err := r.db.Query(query, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var solutions []domain.Solution
|
||||
for rows.Next() {
|
||||
var s domain.Solution
|
||||
err := rows.Scan(
|
||||
&s.ID, &s.Name, &s.Slug, &s.Icon, &s.Description,
|
||||
&s.IsActive, &s.CreatedAt, &s.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
solutions = append(solutions, s)
|
||||
}
|
||||
|
||||
// Se não encontrou via subscription, retorna array vazio
|
||||
if solutions == nil {
|
||||
solutions = []domain.Solution{}
|
||||
}
|
||||
|
||||
return solutions, nil
|
||||
}
|
||||
203
backend/internal/repository/subscription_repository.go
Normal file
203
backend/internal/repository/subscription_repository.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"aggios-app/backend/internal/domain"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// SubscriptionRepository handles database operations for subscriptions
|
||||
type SubscriptionRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewSubscriptionRepository creates a new subscription repository
|
||||
func NewSubscriptionRepository(db *sql.DB) *SubscriptionRepository {
|
||||
return &SubscriptionRepository{db: db}
|
||||
}
|
||||
|
||||
// Create creates a new subscription
|
||||
func (r *SubscriptionRepository) Create(subscription *domain.Subscription) error {
|
||||
query := `
|
||||
INSERT INTO agency_subscriptions (id, agency_id, plan_id, billing_type, current_users, status, start_date, renewal_date, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id, created_at, updated_at
|
||||
`
|
||||
|
||||
now := time.Now()
|
||||
subscription.ID = uuid.New()
|
||||
subscription.CreatedAt = now
|
||||
subscription.UpdatedAt = now
|
||||
subscription.StartDate = now
|
||||
|
||||
// Set renewal date based on billing type
|
||||
if subscription.BillingType == "annual" {
|
||||
subscription.RenewalDate = now.AddDate(1, 0, 0)
|
||||
} else {
|
||||
subscription.RenewalDate = now.AddDate(0, 1, 0)
|
||||
}
|
||||
|
||||
return r.db.QueryRow(
|
||||
query,
|
||||
subscription.ID,
|
||||
subscription.AgencyID,
|
||||
subscription.PlanID,
|
||||
subscription.BillingType,
|
||||
subscription.CurrentUsers,
|
||||
subscription.Status,
|
||||
subscription.StartDate,
|
||||
subscription.RenewalDate,
|
||||
subscription.CreatedAt,
|
||||
subscription.UpdatedAt,
|
||||
).Scan(&subscription.ID, &subscription.CreatedAt, &subscription.UpdatedAt)
|
||||
}
|
||||
|
||||
// GetByID retrieves a subscription by ID
|
||||
func (r *SubscriptionRepository) GetByID(id uuid.UUID) (*domain.Subscription, error) {
|
||||
query := `
|
||||
SELECT id, agency_id, plan_id, billing_type, current_users, status, start_date, renewal_date, created_at, updated_at
|
||||
FROM agency_subscriptions
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
subscription := &domain.Subscription{}
|
||||
err := r.db.QueryRow(query, id).Scan(
|
||||
&subscription.ID,
|
||||
&subscription.AgencyID,
|
||||
&subscription.PlanID,
|
||||
&subscription.BillingType,
|
||||
&subscription.CurrentUsers,
|
||||
&subscription.Status,
|
||||
&subscription.StartDate,
|
||||
&subscription.RenewalDate,
|
||||
&subscription.CreatedAt,
|
||||
&subscription.UpdatedAt,
|
||||
)
|
||||
|
||||
return subscription, err
|
||||
}
|
||||
|
||||
// GetByAgencyID retrieves a subscription by agency ID
|
||||
func (r *SubscriptionRepository) GetByAgencyID(agencyID uuid.UUID) (*domain.Subscription, error) {
|
||||
query := `
|
||||
SELECT id, agency_id, plan_id, billing_type, current_users, status, start_date, renewal_date, created_at, updated_at
|
||||
FROM agency_subscriptions
|
||||
WHERE agency_id = $1 AND status = 'active'
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
subscription := &domain.Subscription{}
|
||||
err := r.db.QueryRow(query, agencyID).Scan(
|
||||
&subscription.ID,
|
||||
&subscription.AgencyID,
|
||||
&subscription.PlanID,
|
||||
&subscription.BillingType,
|
||||
&subscription.CurrentUsers,
|
||||
&subscription.Status,
|
||||
&subscription.StartDate,
|
||||
&subscription.RenewalDate,
|
||||
&subscription.CreatedAt,
|
||||
&subscription.UpdatedAt,
|
||||
)
|
||||
|
||||
return subscription, err
|
||||
}
|
||||
|
||||
// ListAll retrieves all subscriptions
|
||||
func (r *SubscriptionRepository) ListAll() ([]*domain.Subscription, error) {
|
||||
query := `
|
||||
SELECT id, agency_id, plan_id, billing_type, current_users, status, start_date, renewal_date, created_at, updated_at
|
||||
FROM agency_subscriptions
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
rows, err := r.db.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var subscriptions []*domain.Subscription
|
||||
|
||||
for rows.Next() {
|
||||
subscription := &domain.Subscription{}
|
||||
err := rows.Scan(
|
||||
&subscription.ID,
|
||||
&subscription.AgencyID,
|
||||
&subscription.PlanID,
|
||||
&subscription.BillingType,
|
||||
&subscription.CurrentUsers,
|
||||
&subscription.Status,
|
||||
&subscription.StartDate,
|
||||
&subscription.RenewalDate,
|
||||
&subscription.CreatedAt,
|
||||
&subscription.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
subscriptions = append(subscriptions, subscription)
|
||||
}
|
||||
|
||||
return subscriptions, rows.Err()
|
||||
}
|
||||
|
||||
// Update updates a subscription
|
||||
func (r *SubscriptionRepository) Update(subscription *domain.Subscription) error {
|
||||
query := `
|
||||
UPDATE agency_subscriptions
|
||||
SET plan_id = $2, billing_type = $3, current_users = $4, status = $5, renewal_date = $6, updated_at = $7
|
||||
WHERE id = $1
|
||||
RETURNING updated_at
|
||||
`
|
||||
|
||||
subscription.UpdatedAt = time.Now()
|
||||
|
||||
return r.db.QueryRow(
|
||||
query,
|
||||
subscription.ID,
|
||||
subscription.PlanID,
|
||||
subscription.BillingType,
|
||||
subscription.CurrentUsers,
|
||||
subscription.Status,
|
||||
subscription.RenewalDate,
|
||||
subscription.UpdatedAt,
|
||||
).Scan(&subscription.UpdatedAt)
|
||||
}
|
||||
|
||||
// Delete deletes a subscription
|
||||
func (r *SubscriptionRepository) Delete(id uuid.UUID) error {
|
||||
query := `DELETE FROM agency_subscriptions WHERE id = $1`
|
||||
result, err := r.db.Exec(query, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateUserCount updates the current user count for a subscription
|
||||
func (r *SubscriptionRepository) UpdateUserCount(agencyID uuid.UUID, userCount int) error {
|
||||
query := `
|
||||
UPDATE agency_subscriptions
|
||||
SET current_users = $2, updated_at = $3
|
||||
WHERE agency_id = $1 AND status = 'active'
|
||||
`
|
||||
|
||||
_, err := r.db.Exec(query, agencyID, userCount, time.Now())
|
||||
return err
|
||||
}
|
||||
@@ -188,17 +188,23 @@ func (r *TenantRepository) FindByID(id uuid.UUID) (*domain.Tenant, error) {
|
||||
// FindBySubdomain finds a tenant by subdomain
|
||||
func (r *TenantRepository) FindBySubdomain(subdomain string) (*domain.Tenant, error) {
|
||||
query := `
|
||||
SELECT id, name, domain, subdomain, created_at, updated_at
|
||||
SELECT id, name, domain, subdomain, primary_color, secondary_color, logo_url, logo_horizontal_url, created_at, updated_at
|
||||
FROM tenants
|
||||
WHERE subdomain = $1
|
||||
`
|
||||
|
||||
tenant := &domain.Tenant{}
|
||||
var primaryColor, secondaryColor, logoURL, logoHorizontalURL sql.NullString
|
||||
|
||||
err := r.db.QueryRow(query, subdomain).Scan(
|
||||
&tenant.ID,
|
||||
&tenant.Name,
|
||||
&tenant.Domain,
|
||||
&tenant.Subdomain,
|
||||
&primaryColor,
|
||||
&secondaryColor,
|
||||
&logoURL,
|
||||
&logoHorizontalURL,
|
||||
&tenant.CreatedAt,
|
||||
&tenant.UpdatedAt,
|
||||
)
|
||||
@@ -207,7 +213,24 @@ func (r *TenantRepository) FindBySubdomain(subdomain string) (*domain.Tenant, er
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return tenant, err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if primaryColor.Valid {
|
||||
tenant.PrimaryColor = primaryColor.String
|
||||
}
|
||||
if secondaryColor.Valid {
|
||||
tenant.SecondaryColor = secondaryColor.String
|
||||
}
|
||||
if logoURL.Valid {
|
||||
tenant.LogoURL = logoURL.String
|
||||
}
|
||||
if logoHorizontalURL.Valid {
|
||||
tenant.LogoHorizontalURL = logoHorizontalURL.String
|
||||
}
|
||||
|
||||
return tenant, nil
|
||||
}
|
||||
|
||||
// SubdomainExists checks if a subdomain is already taken
|
||||
|
||||
@@ -161,3 +161,73 @@ func (r *UserRepository) FindAdminByTenantID(tenantID uuid.UUID) (*domain.User,
|
||||
|
||||
return user, nil
|
||||
}
|
||||
// ListByTenantID returns all users for a tenant (excluding the tenant admin)
|
||||
func (r *UserRepository) ListByTenantID(tenantID uuid.UUID) ([]domain.User, error) {
|
||||
query := `
|
||||
SELECT id, tenant_id, email, password_hash, first_name, role, created_at, updated_at,
|
||||
agency_role, created_by, collaborator_created_at
|
||||
FROM users
|
||||
WHERE tenant_id = $1 AND is_active = true AND role != 'SUPERADMIN'
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
rows, err := r.db.Query(query, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var users []domain.User
|
||||
for rows.Next() {
|
||||
user := domain.User{}
|
||||
err := rows.Scan(
|
||||
&user.ID,
|
||||
&user.TenantID,
|
||||
&user.Email,
|
||||
&user.Password,
|
||||
&user.Name,
|
||||
&user.Role,
|
||||
&user.CreatedAt,
|
||||
&user.UpdatedAt,
|
||||
&user.AgencyRole,
|
||||
&user.CreatedBy,
|
||||
&user.CollaboratorCreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
// GetByID returns a user by ID
|
||||
func (r *UserRepository) GetByID(id uuid.UUID) (*domain.User, error) {
|
||||
return r.FindByID(id)
|
||||
}
|
||||
|
||||
// Delete marks a user as inactive
|
||||
func (r *UserRepository) Delete(id uuid.UUID) error {
|
||||
query := `
|
||||
UPDATE users
|
||||
SET is_active = false, updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
result, err := r.db.Exec(query, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"aggios-app/backend/internal/config"
|
||||
"aggios-app/backend/internal/domain"
|
||||
"aggios-app/backend/internal/repository"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -15,14 +16,16 @@ type AgencyService struct {
|
||||
userRepo *repository.UserRepository
|
||||
tenantRepo *repository.TenantRepository
|
||||
cfg *config.Config
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewAgencyService creates a new agency service
|
||||
func NewAgencyService(userRepo *repository.UserRepository, tenantRepo *repository.TenantRepository, cfg *config.Config) *AgencyService {
|
||||
func NewAgencyService(userRepo *repository.UserRepository, tenantRepo *repository.TenantRepository, cfg *config.Config, db *sql.DB) *AgencyService {
|
||||
return &AgencyService{
|
||||
userRepo: userRepo,
|
||||
tenantRepo: tenantRepo,
|
||||
cfg: cfg,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +183,43 @@ func (s *AgencyService) GetAgencyDetails(id uuid.UUID) (*domain.AgencyDetails, e
|
||||
details.Admin = admin
|
||||
}
|
||||
|
||||
// Buscar subscription e soluções
|
||||
var subscription domain.AgencySubscriptionInfo
|
||||
query := `
|
||||
SELECT
|
||||
s.plan_id,
|
||||
p.name as plan_name,
|
||||
s.status
|
||||
FROM agency_subscriptions s
|
||||
JOIN plans p ON s.plan_id = p.id
|
||||
WHERE s.agency_id = $1
|
||||
LIMIT 1
|
||||
`
|
||||
err = s.db.QueryRow(query, id).Scan(&subscription.PlanID, &subscription.PlanName, &subscription.Status)
|
||||
if err == nil {
|
||||
// Buscar soluções do plano
|
||||
solutionsQuery := `
|
||||
SELECT sol.id, sol.name, sol.slug, sol.icon
|
||||
FROM solutions sol
|
||||
JOIN plan_solutions ps ON sol.id = ps.solution_id
|
||||
WHERE ps.plan_id = $1
|
||||
ORDER BY sol.name
|
||||
`
|
||||
rows, err := s.db.Query(solutionsQuery, subscription.PlanID)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
var solutions []domain.Solution
|
||||
for rows.Next() {
|
||||
var solution domain.Solution
|
||||
if err := rows.Scan(&solution.ID, &solution.Name, &solution.Slug, &solution.Icon); err == nil {
|
||||
solutions = append(solutions, solution)
|
||||
}
|
||||
}
|
||||
subscription.Solutions = solutions
|
||||
details.Subscription = &subscription
|
||||
}
|
||||
}
|
||||
|
||||
return details, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -24,17 +24,19 @@ var (
|
||||
|
||||
// AuthService handles authentication business logic
|
||||
type AuthService struct {
|
||||
userRepo *repository.UserRepository
|
||||
tenantRepo *repository.TenantRepository
|
||||
cfg *config.Config
|
||||
userRepo *repository.UserRepository
|
||||
tenantRepo *repository.TenantRepository
|
||||
crmRepo *repository.CRMRepository
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// NewAuthService creates a new auth service
|
||||
func NewAuthService(userRepo *repository.UserRepository, tenantRepo *repository.TenantRepository, cfg *config.Config) *AuthService {
|
||||
func NewAuthService(userRepo *repository.UserRepository, tenantRepo *repository.TenantRepository, crmRepo *repository.CRMRepository, cfg *config.Config) *AuthService {
|
||||
return &AuthService{
|
||||
userRepo: userRepo,
|
||||
tenantRepo: tenantRepo,
|
||||
cfg: cfg,
|
||||
userRepo: userRepo,
|
||||
tenantRepo: tenantRepo,
|
||||
crmRepo: crmRepo,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,3 +177,158 @@ func (s *AuthService) ChangePassword(userID string, currentPassword, newPassword
|
||||
func parseUUID(s string) (uuid.UUID, error) {
|
||||
return uuid.Parse(s)
|
||||
}
|
||||
|
||||
// GenerateCustomerToken gera um token JWT para um cliente do CRM
|
||||
func (s *AuthService) GenerateCustomerToken(customerID, tenantID, email string) (string, error) {
|
||||
claims := jwt.MapClaims{
|
||||
"customer_id": customerID,
|
||||
"tenant_id": tenantID,
|
||||
"email": email,
|
||||
"type": "customer_portal",
|
||||
"exp": time.Now().Add(time.Hour * 24 * 30).Unix(), // 30 dias
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(s.cfg.JWT.Secret))
|
||||
}
|
||||
|
||||
// UnifiedLogin autentica qualquer tipo de usuário (agência ou cliente) e retorna token unificado
|
||||
func (s *AuthService) UnifiedLogin(req domain.UnifiedLoginRequest) (*domain.UnifiedLoginResponse, error) {
|
||||
email := req.Email
|
||||
password := req.Password
|
||||
|
||||
// TENTATIVA 1: Buscar em users (agência)
|
||||
user, err := s.userRepo.FindByEmail(email)
|
||||
if err == nil && user != nil {
|
||||
// Verificar senha
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
|
||||
log.Printf("❌ Password mismatch for agency user %s", email)
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
// SUPERADMIN usa login próprio em outro domínio, não deve usar esta rota
|
||||
if user.Role == "SUPERADMIN" {
|
||||
log.Printf("🚫 SUPERADMIN attempted unified login - redirecting to proper endpoint")
|
||||
return nil, errors.New("superadmins devem usar o painel administrativo")
|
||||
}
|
||||
|
||||
// Gerar token unificado para agency_user
|
||||
token, err := s.generateUnifiedToken(user.ID.String(), domain.UserTypeAgency, email, user.Role, user.AgencyRole, user.TenantID)
|
||||
if err != nil {
|
||||
log.Printf("❌ Error generating unified token: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Buscar subdomain se tiver tenant
|
||||
subdomain := ""
|
||||
tenantID := ""
|
||||
if user.TenantID != nil {
|
||||
tenantID = user.TenantID.String()
|
||||
tenant, err := s.tenantRepo.FindByID(*user.TenantID)
|
||||
if err == nil && tenant != nil {
|
||||
subdomain = tenant.Subdomain
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("✅ Agency user logged in: %s (type=agency_user, role=%s, agency_role=%s)", email, user.Role, user.AgencyRole)
|
||||
|
||||
return &domain.UnifiedLoginResponse{
|
||||
Token: token,
|
||||
UserType: domain.UserTypeAgency,
|
||||
UserID: user.ID.String(),
|
||||
Email: email,
|
||||
Name: user.Name,
|
||||
Role: user.Role,
|
||||
AgencyRole: user.AgencyRole,
|
||||
TenantID: tenantID,
|
||||
Subdomain: subdomain,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TENTATIVA 2: Buscar em crm_customers
|
||||
log.Printf("🔍 Attempting to find customer in CRM: %s", email)
|
||||
customer, err := s.crmRepo.GetCustomerByEmail(email)
|
||||
log.Printf("🔍 CRM GetCustomerByEmail result: customer=%v, err=%v", customer != nil, err)
|
||||
if err == nil && customer != nil {
|
||||
// Verificar se tem acesso ao portal
|
||||
if !customer.HasPortalAccess {
|
||||
log.Printf("🚫 Customer %s has no portal access", email)
|
||||
return nil, errors.New("acesso ao portal não autorizado. Entre em contato com o administrador")
|
||||
}
|
||||
|
||||
// Verificar senha
|
||||
if customer.PasswordHash == "" {
|
||||
log.Printf("❌ Customer %s has no password set", email)
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(customer.PasswordHash), []byte(password)); err != nil {
|
||||
log.Printf("❌ Password mismatch for customer %s", email)
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
// Atualizar último login
|
||||
if err := s.crmRepo.UpdateCustomerLastLogin(customer.ID); err != nil {
|
||||
log.Printf("⚠️ Warning: Failed to update last login for customer %s: %v", customer.ID, err)
|
||||
}
|
||||
|
||||
// Gerar token unificado
|
||||
tenantUUID, _ := uuid.Parse(customer.TenantID)
|
||||
token, err := s.generateUnifiedToken(customer.ID, domain.UserTypeCustomer, email, "", "", &tenantUUID)
|
||||
if err != nil {
|
||||
log.Printf("❌ Error generating unified token: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Buscar subdomain do tenant
|
||||
subdomain := ""
|
||||
if tenantUUID != uuid.Nil {
|
||||
tenant, err := s.tenantRepo.FindByID(tenantUUID)
|
||||
if err == nil && tenant != nil {
|
||||
subdomain = tenant.Subdomain
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("✅ Customer logged in: %s (tenant=%s)", email, customer.TenantID)
|
||||
|
||||
return &domain.UnifiedLoginResponse{
|
||||
Token: token,
|
||||
UserType: domain.UserTypeCustomer,
|
||||
UserID: customer.ID,
|
||||
Email: email,
|
||||
Name: customer.Name,
|
||||
TenantID: customer.TenantID,
|
||||
Subdomain: subdomain,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Não encontrou em nenhuma tabela
|
||||
log.Printf("❌ User not found: %s", email)
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
// generateUnifiedToken cria um JWT com claims unificadas
|
||||
func (s *AuthService) generateUnifiedToken(userID string, userType domain.UserType, email, role, agencyRole string, tenantID *uuid.UUID) (string, error) {
|
||||
tenantIDStr := ""
|
||||
if tenantID != nil {
|
||||
tenantIDStr = tenantID.String()
|
||||
}
|
||||
|
||||
claims := domain.UnifiedClaims{
|
||||
UserID: userID,
|
||||
UserType: userType,
|
||||
TenantID: tenantIDStr,
|
||||
Email: email,
|
||||
Role: role,
|
||||
AgencyRole: agencyRole,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24 * 30)), // 30 dias
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(s.cfg.JWT.Secret))
|
||||
}
|
||||
|
||||
|
||||
|
||||
286
backend/internal/service/plan_service.go
Normal file
286
backend/internal/service/plan_service.go
Normal file
@@ -0,0 +1,286 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"aggios-app/backend/internal/domain"
|
||||
"aggios-app/backend/internal/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPlanNotFound = errors.New("plan not found")
|
||||
ErrPlanSlugTaken = errors.New("plan slug already exists")
|
||||
ErrInvalidUserRange = errors.New("invalid user range: min_users must be less than or equal to max_users")
|
||||
ErrSubscriptionNotFound = errors.New("subscription not found")
|
||||
ErrUserLimitExceeded = errors.New("user limit exceeded for this plan")
|
||||
ErrSubscriptionExists = errors.New("agency already has an active subscription")
|
||||
)
|
||||
|
||||
// PlanService handles plan business logic
|
||||
type PlanService struct {
|
||||
planRepo *repository.PlanRepository
|
||||
subscriptionRepo *repository.SubscriptionRepository
|
||||
}
|
||||
|
||||
// NewPlanService creates a new plan service
|
||||
func NewPlanService(planRepo *repository.PlanRepository, subscriptionRepo *repository.SubscriptionRepository) *PlanService {
|
||||
return &PlanService{
|
||||
planRepo: planRepo,
|
||||
subscriptionRepo: subscriptionRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// CreatePlan creates a new plan
|
||||
func (s *PlanService) CreatePlan(req *domain.CreatePlanRequest) (*domain.Plan, error) {
|
||||
// Validate user range
|
||||
if req.MinUsers > req.MaxUsers && req.MaxUsers != -1 {
|
||||
return nil, ErrInvalidUserRange
|
||||
}
|
||||
|
||||
// Check if slug is unique
|
||||
existing, _ := s.planRepo.GetBySlug(req.Slug)
|
||||
if existing != nil {
|
||||
return nil, ErrPlanSlugTaken
|
||||
}
|
||||
|
||||
plan := &domain.Plan{
|
||||
Name: req.Name,
|
||||
Slug: req.Slug,
|
||||
Description: req.Description,
|
||||
MinUsers: req.MinUsers,
|
||||
MaxUsers: req.MaxUsers,
|
||||
Features: req.Features,
|
||||
Differentiators: req.Differentiators,
|
||||
StorageGB: req.StorageGB,
|
||||
IsActive: req.IsActive,
|
||||
}
|
||||
|
||||
// Convert prices if provided
|
||||
if req.MonthlyPrice != nil {
|
||||
price := decimal.NewFromFloat(*req.MonthlyPrice)
|
||||
plan.MonthlyPrice = &price
|
||||
}
|
||||
if req.AnnualPrice != nil {
|
||||
price := decimal.NewFromFloat(*req.AnnualPrice)
|
||||
plan.AnnualPrice = &price
|
||||
}
|
||||
|
||||
if err := s.planRepo.Create(plan); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// GetPlan retrieves a plan by ID
|
||||
func (s *PlanService) GetPlan(id uuid.UUID) (*domain.Plan, error) {
|
||||
plan, err := s.planRepo.GetByID(id)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrPlanNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// ListPlans retrieves all plans
|
||||
func (s *PlanService) ListPlans() ([]*domain.Plan, error) {
|
||||
return s.planRepo.ListAll()
|
||||
}
|
||||
|
||||
// ListActivePlans retrieves all active plans
|
||||
func (s *PlanService) ListActivePlans() ([]*domain.Plan, error) {
|
||||
return s.planRepo.ListActive()
|
||||
}
|
||||
|
||||
// UpdatePlan updates a plan
|
||||
func (s *PlanService) UpdatePlan(id uuid.UUID, req *domain.UpdatePlanRequest) (*domain.Plan, error) {
|
||||
plan, err := s.planRepo.GetByID(id)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrPlanNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Update fields if provided
|
||||
if req.Name != nil {
|
||||
plan.Name = *req.Name
|
||||
}
|
||||
if req.Slug != nil {
|
||||
// Check if new slug is unique
|
||||
existing, _ := s.planRepo.GetBySlug(*req.Slug)
|
||||
if existing != nil && existing.ID != plan.ID {
|
||||
return nil, ErrPlanSlugTaken
|
||||
}
|
||||
plan.Slug = *req.Slug
|
||||
}
|
||||
if req.Description != nil {
|
||||
plan.Description = *req.Description
|
||||
}
|
||||
if req.MinUsers != nil {
|
||||
plan.MinUsers = *req.MinUsers
|
||||
}
|
||||
if req.MaxUsers != nil {
|
||||
plan.MaxUsers = *req.MaxUsers
|
||||
}
|
||||
if req.MonthlyPrice != nil {
|
||||
price := decimal.NewFromFloat(*req.MonthlyPrice)
|
||||
plan.MonthlyPrice = &price
|
||||
}
|
||||
if req.AnnualPrice != nil {
|
||||
price := decimal.NewFromFloat(*req.AnnualPrice)
|
||||
plan.AnnualPrice = &price
|
||||
}
|
||||
if req.Features != nil {
|
||||
plan.Features = req.Features
|
||||
}
|
||||
if req.Differentiators != nil {
|
||||
plan.Differentiators = req.Differentiators
|
||||
}
|
||||
if req.StorageGB != nil {
|
||||
plan.StorageGB = *req.StorageGB
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
plan.IsActive = *req.IsActive
|
||||
}
|
||||
|
||||
// Validate user range
|
||||
if plan.MinUsers > plan.MaxUsers && plan.MaxUsers != -1 {
|
||||
return nil, ErrInvalidUserRange
|
||||
}
|
||||
|
||||
if err := s.planRepo.Update(plan); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// DeletePlan deletes a plan
|
||||
func (s *PlanService) DeletePlan(id uuid.UUID) error {
|
||||
// Check if plan exists
|
||||
if _, err := s.planRepo.GetByID(id); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return ErrPlanNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return s.planRepo.Delete(id)
|
||||
}
|
||||
|
||||
// CreateSubscription creates a new subscription for an agency
|
||||
func (s *PlanService) CreateSubscription(req *domain.CreateSubscriptionRequest) (*domain.Subscription, error) {
|
||||
// Check if plan exists
|
||||
plan, err := s.planRepo.GetByID(req.PlanID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrPlanNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if agency already has active subscription
|
||||
existing, err := s.subscriptionRepo.GetByAgencyID(req.AgencyID)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil {
|
||||
return nil, ErrSubscriptionExists
|
||||
}
|
||||
|
||||
subscription := &domain.Subscription{
|
||||
AgencyID: req.AgencyID,
|
||||
PlanID: req.PlanID,
|
||||
BillingType: req.BillingType,
|
||||
Status: "active",
|
||||
CurrentUsers: 0,
|
||||
}
|
||||
|
||||
if err := s.subscriptionRepo.Create(subscription); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Load plan details
|
||||
subscription.PlanID = plan.ID
|
||||
|
||||
return subscription, nil
|
||||
}
|
||||
|
||||
// GetSubscription retrieves a subscription by ID
|
||||
func (s *PlanService) GetSubscription(id uuid.UUID) (*domain.Subscription, error) {
|
||||
subscription, err := s.subscriptionRepo.GetByID(id)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrSubscriptionNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return subscription, nil
|
||||
}
|
||||
|
||||
// GetAgencySubscription retrieves an agency's active subscription
|
||||
func (s *PlanService) GetAgencySubscription(agencyID uuid.UUID) (*domain.Subscription, error) {
|
||||
subscription, err := s.subscriptionRepo.GetByAgencyID(agencyID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrSubscriptionNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return subscription, nil
|
||||
}
|
||||
|
||||
// ListSubscriptions retrieves all subscriptions
|
||||
func (s *PlanService) ListSubscriptions() ([]*domain.Subscription, error) {
|
||||
return s.subscriptionRepo.ListAll()
|
||||
}
|
||||
|
||||
// ValidateUserLimit checks if adding a user would exceed plan limit
|
||||
func (s *PlanService) ValidateUserLimit(agencyID uuid.UUID, newUserCount int) error {
|
||||
subscription, err := s.subscriptionRepo.GetByAgencyID(agencyID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return ErrSubscriptionNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
plan, err := s.planRepo.GetByID(subscription.PlanID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return ErrPlanNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if plan.MaxUsers != -1 && newUserCount > plan.MaxUsers {
|
||||
return fmt.Errorf("%w (limit: %d, requested: %d)", ErrUserLimitExceeded, plan.MaxUsers, newUserCount)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPlanByUserCount returns the appropriate plan for a given user count
|
||||
func (s *PlanService) GetPlanByUserCount(userCount int) (*domain.Plan, error) {
|
||||
plans, err := s.planRepo.ListActive()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Find the plan that fits the user count
|
||||
for _, plan := range plans {
|
||||
if userCount >= plan.MinUsers && (plan.MaxUsers == -1 || userCount <= plan.MaxUsers) {
|
||||
return plan, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no plan found for user count: %d", userCount)
|
||||
}
|
||||
@@ -17,12 +17,14 @@ var (
|
||||
// TenantService handles tenant business logic
|
||||
type TenantService struct {
|
||||
tenantRepo *repository.TenantRepository
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewTenantService creates a new tenant service
|
||||
func NewTenantService(tenantRepo *repository.TenantRepository) *TenantService {
|
||||
func NewTenantService(tenantRepo *repository.TenantRepository, db *sql.DB) *TenantService {
|
||||
return &TenantService{
|
||||
tenantRepo: tenantRepo,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +81,84 @@ func (s *TenantService) ListAll() ([]*domain.Tenant, error) {
|
||||
return s.tenantRepo.FindAll()
|
||||
}
|
||||
|
||||
// ListAllWithDetails retrieves all tenants with their plan and solutions information
|
||||
func (s *TenantService) ListAllWithDetails() ([]map[string]interface{}, error) {
|
||||
tenants, err := s.tenantRepo.FindAll()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []map[string]interface{}
|
||||
for _, tenant := range tenants {
|
||||
tenantData := map[string]interface{}{
|
||||
"id": tenant.ID,
|
||||
"name": tenant.Name,
|
||||
"subdomain": tenant.Subdomain,
|
||||
"domain": tenant.Domain,
|
||||
"email": tenant.Email,
|
||||
"phone": tenant.Phone,
|
||||
"cnpj": tenant.CNPJ,
|
||||
"is_active": tenant.IsActive,
|
||||
"created_at": tenant.CreatedAt,
|
||||
"logo_url": tenant.LogoURL,
|
||||
"logo_horizontal_url": tenant.LogoHorizontalURL,
|
||||
"primary_color": tenant.PrimaryColor,
|
||||
"secondary_color": tenant.SecondaryColor,
|
||||
}
|
||||
|
||||
// Buscar subscription e soluções
|
||||
var planName sql.NullString
|
||||
var planID string
|
||||
query := `
|
||||
SELECT
|
||||
s.plan_id,
|
||||
p.name as plan_name
|
||||
FROM agency_subscriptions s
|
||||
JOIN plans p ON s.plan_id = p.id
|
||||
WHERE s.agency_id = $1 AND s.status = 'active'
|
||||
LIMIT 1
|
||||
`
|
||||
err = s.db.QueryRow(query, tenant.ID).Scan(&planID, &planName)
|
||||
if err == nil && planName.Valid {
|
||||
tenantData["plan_name"] = planName.String
|
||||
|
||||
// Buscar soluções do plano
|
||||
solutionsQuery := `
|
||||
SELECT sol.id, sol.name, sol.slug, sol.icon
|
||||
FROM solutions sol
|
||||
JOIN plan_solutions ps ON sol.id = ps.solution_id
|
||||
WHERE ps.plan_id = $1
|
||||
ORDER BY sol.name
|
||||
`
|
||||
rows, err := s.db.Query(solutionsQuery, planID)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
var solutions []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var id, name, slug string
|
||||
var icon sql.NullString
|
||||
if err := rows.Scan(&id, &name, &slug, &icon); err == nil {
|
||||
solution := map[string]interface{}{
|
||||
"id": id,
|
||||
"name": name,
|
||||
"slug": slug,
|
||||
}
|
||||
if icon.Valid {
|
||||
solution["icon"] = icon.String
|
||||
}
|
||||
solutions = append(solutions, solution)
|
||||
}
|
||||
}
|
||||
tenantData["solutions"] = solutions
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, tenantData)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Delete removes a tenant by ID
|
||||
func (s *TenantService) Delete(id uuid.UUID) error {
|
||||
if err := s.tenantRepo.Delete(id); err != nil {
|
||||
|
||||
BIN
backups/.superadmin_password.txt
Normal file
BIN
backups/.superadmin_password.txt
Normal file
Binary file not shown.
BIN
backups/aggios_backup_2025-12-13_19-56-18.sql
Normal file
BIN
backups/aggios_backup_2025-12-13_19-56-18.sql
Normal file
Binary file not shown.
BIN
backups/aggios_backup_2025-12-13_20-12-49.sql
Normal file
BIN
backups/aggios_backup_2025-12-13_20-12-49.sql
Normal file
Binary file not shown.
BIN
backups/aggios_backup_2025-12-13_20-17-59.sql
Normal file
BIN
backups/aggios_backup_2025-12-13_20-17-59.sql
Normal file
Binary file not shown.
BIN
backups/aggios_backup_2025-12-13_20-23-08.sql
Normal file
BIN
backups/aggios_backup_2025-12-13_20-23-08.sql
Normal file
Binary file not shown.
343
backups/aggios_backup_2025-12-14_02-42-03.sql
Normal file
343
backups/aggios_backup_2025-12-14_02-42-03.sql
Normal file
@@ -0,0 +1,343 @@
|
||||
--
|
||||
-- PostgreSQL database dump
|
||||
--
|
||||
|
||||
\restrict mUKTWCYeXvRf2SKhMr352J1jYiouAP5fsYPxvQjxn9xhEgk8BrOSEtYCYQoFicQ
|
||||
|
||||
-- Dumped from database version 16.11
|
||||
-- Dumped by pg_dump version 18.1
|
||||
|
||||
SET statement_timeout = 0;
|
||||
SET lock_timeout = 0;
|
||||
SET idle_in_transaction_session_timeout = 0;
|
||||
SET transaction_timeout = 0;
|
||||
SET client_encoding = 'UTF8';
|
||||
SET standard_conforming_strings = on;
|
||||
SELECT pg_catalog.set_config('search_path', '', false);
|
||||
SET check_function_bodies = false;
|
||||
SET xmloption = content;
|
||||
SET client_min_messages = warning;
|
||||
SET row_security = off;
|
||||
|
||||
--
|
||||
-- Name: pgcrypto; Type: EXTENSION; Schema: -; Owner: -
|
||||
--
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public;
|
||||
|
||||
|
||||
--
|
||||
-- Name: EXTENSION pgcrypto; Type: COMMENT; Schema: -; Owner:
|
||||
--
|
||||
|
||||
COMMENT ON EXTENSION pgcrypto IS 'cryptographic functions';
|
||||
|
||||
|
||||
--
|
||||
-- Name: uuid-ossp; Type: EXTENSION; Schema: -; Owner: -
|
||||
--
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA public;
|
||||
|
||||
|
||||
--
|
||||
-- Name: EXTENSION "uuid-ossp"; Type: COMMENT; Schema: -; Owner:
|
||||
--
|
||||
|
||||
COMMENT ON EXTENSION "uuid-ossp" IS 'generate universally unique identifiers (UUIDs)';
|
||||
|
||||
|
||||
SET default_tablespace = '';
|
||||
|
||||
SET default_table_access_method = heap;
|
||||
|
||||
--
|
||||
-- Name: companies; Type: TABLE; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
CREATE TABLE public.companies (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
cnpj character varying(18) NOT NULL,
|
||||
razao_social character varying(255) NOT NULL,
|
||||
nome_fantasia character varying(255),
|
||||
email character varying(255),
|
||||
telefone character varying(20),
|
||||
status character varying(50) DEFAULT 'active'::character varying,
|
||||
created_by_user_id uuid,
|
||||
created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.companies OWNER TO aggios;
|
||||
|
||||
--
|
||||
-- Name: refresh_tokens; Type: TABLE; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
CREATE TABLE public.refresh_tokens (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
token_hash character varying(255) NOT NULL,
|
||||
expires_at timestamp with time zone NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.refresh_tokens OWNER TO aggios;
|
||||
|
||||
--
|
||||
-- Name: tenants; Type: TABLE; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
CREATE TABLE public.tenants (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
name character varying(255) NOT NULL,
|
||||
domain character varying(255) NOT NULL,
|
||||
subdomain character varying(63) NOT NULL,
|
||||
cnpj character varying(18),
|
||||
razao_social character varying(255),
|
||||
email character varying(255),
|
||||
phone character varying(20),
|
||||
website character varying(255),
|
||||
address text,
|
||||
city character varying(100),
|
||||
state character varying(2),
|
||||
zip character varying(10),
|
||||
description text,
|
||||
industry character varying(100),
|
||||
is_active boolean DEFAULT true,
|
||||
created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
|
||||
neighborhood character varying(100),
|
||||
street character varying(100),
|
||||
number character varying(20),
|
||||
complement character varying(100),
|
||||
team_size character varying(20),
|
||||
primary_color character varying(7),
|
||||
secondary_color character varying(7),
|
||||
logo_url text,
|
||||
logo_horizontal_url text
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.tenants OWNER TO aggios;
|
||||
|
||||
--
|
||||
-- Name: users; Type: TABLE; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
CREATE TABLE public.users (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid,
|
||||
email character varying(255) NOT NULL,
|
||||
password_hash character varying(255) NOT NULL,
|
||||
first_name character varying(128),
|
||||
last_name character varying(128),
|
||||
role character varying(50) DEFAULT 'CLIENTE'::character varying,
|
||||
is_active boolean DEFAULT true,
|
||||
created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT users_role_check CHECK (((role)::text = ANY ((ARRAY['SUPERADMIN'::character varying, 'ADMIN_AGENCIA'::character varying, 'CLIENTE'::character varying])::text[])))
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.users OWNER TO aggios;
|
||||
|
||||
--
|
||||
-- Data for Name: companies; Type: TABLE DATA; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
COPY public.companies (id, tenant_id, cnpj, razao_social, nome_fantasia, email, telefone, status, created_by_user_id, created_at, updated_at) FROM stdin;
|
||||
\.
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: refresh_tokens; Type: TABLE DATA; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
COPY public.refresh_tokens (id, user_id, token_hash, expires_at, created_at) FROM stdin;
|
||||
\.
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: tenants; Type: TABLE DATA; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
COPY public.tenants (id, name, domain, subdomain, cnpj, razao_social, email, phone, website, address, city, state, zip, description, industry, is_active, created_at, updated_at, neighborhood, street, number, complement, team_size, primary_color, secondary_color, logo_url, logo_horizontal_url) FROM stdin;
|
||||
d351e725-1428-45f3-b2e3-ca767e9b952c Agência Teste agencia-teste.aggios.app agencia-teste \N \N \N \N \N \N \N \N \N \N \N t 2025-12-13 22:31:35.818953+00 2025-12-13 22:31:35.818953+00 \N \N \N \N \N \N \N \N \N
|
||||
13d32cc3-0490-4557-96a3-7a38da194185 Empresa Teste teste-empresa.localhost teste-empresa 12.345.678/0001-90 EMPRESA TESTE LTDA teste@teste.com (11) 99999-9999 teste.com.br Avenida Paulista, 1000 - Andar 10 S<EFBFBD>o Paulo SP 01310-100 Empresa de teste tecnologia t 2025-12-13 23:22:58.406376+00 2025-12-13 23:22:58.406376+00 Bela Vista \N 1000 Andar 10 1-10 #8B5CF6 #A78BFA
|
||||
ae271be0-a63c-407f-9cf6-a4a8c8a0a4dc IdealPages idealpages.localhost idealpages 31.091.190/0001-23 ERIK DA SILVA SANTOS 36615318830 erik@idealpages.com.br (13) 92000-4392 idealpages.com.br Rua Quatorze, 150 - Casa Guarujá SP 11436-575 Empresa de contrucao de marca e desenvolvimento de software agencia-digital t 2025-12-13 23:23:35.508285+00 2025-12-13 23:26:40.947714+00 Vila Zilda \N 150 Casa 1-10 #8B5CF6 #A78BFA http://api.localhost/api/files/aggios-logos/tenants/ae271be0-a63c-407f-9cf6-a4a8c8a0a4dc/logo-1765668400.png
|
||||
\.
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: users; Type: TABLE DATA; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
COPY public.users (id, tenant_id, email, password_hash, first_name, last_name, role, is_active, created_at, updated_at) FROM stdin;
|
||||
7b51ae6e-6fb0-42c4-8473-a98cbfcda6a4 \N admin@aggios.app $2a$10$yhCREFqXL7FA4zveCFcl4eYODNTSyt/swuYjS0nXkEq8pzqJo.BwO Super Admin SUPERADMIN t 2025-12-13 23:02:33.124444+00 2025-12-13 23:02:33.124444+00
|
||||
488351e7-4ddc-41a4-9cd3-5c3dec833c44 13d32cc3-0490-4557-96a3-7a38da194185 teste@teste.com $2a$10$fx3bQqL01A9UqJwSwKpdLuVCq8M/1L9CvcQhx5tTkdinsvCpPsh4a Teste Silva \N ADMIN_AGENCIA t 2025-12-13 23:22:58.446011+00 2025-12-13 23:22:58.446011+00
|
||||
8742c1a1-5f1a-4df3-aa53-dcf94a2a2591 ae271be0-a63c-407f-9cf6-a4a8c8a0a4dc erik@idealpages.com.br $2a$10$tD8Kq/ZW0fbmW3Ga5JsKbOUy0nzsIZwkXJKaf43gFDVnRxjaf63Em Erik da Silva Santos \N ADMIN_AGENCIA t 2025-12-13 23:23:35.551192+00 2025-12-13 23:23:35.551192+00
|
||||
\.
|
||||
|
||||
|
||||
--
|
||||
-- Name: companies companies_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.companies
|
||||
ADD CONSTRAINT companies_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: companies companies_tenant_id_cnpj_key; Type: CONSTRAINT; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.companies
|
||||
ADD CONSTRAINT companies_tenant_id_cnpj_key UNIQUE (tenant_id, cnpj);
|
||||
|
||||
|
||||
--
|
||||
-- Name: refresh_tokens refresh_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.refresh_tokens
|
||||
ADD CONSTRAINT refresh_tokens_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: tenants tenants_domain_key; Type: CONSTRAINT; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.tenants
|
||||
ADD CONSTRAINT tenants_domain_key UNIQUE (domain);
|
||||
|
||||
|
||||
--
|
||||
-- Name: tenants tenants_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.tenants
|
||||
ADD CONSTRAINT tenants_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: tenants tenants_subdomain_key; Type: CONSTRAINT; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.tenants
|
||||
ADD CONSTRAINT tenants_subdomain_key UNIQUE (subdomain);
|
||||
|
||||
|
||||
--
|
||||
-- Name: users users_email_key; Type: CONSTRAINT; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.users
|
||||
ADD CONSTRAINT users_email_key UNIQUE (email);
|
||||
|
||||
|
||||
--
|
||||
-- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.users
|
||||
ADD CONSTRAINT users_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_companies_cnpj; Type: INDEX; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
CREATE INDEX idx_companies_cnpj ON public.companies USING btree (cnpj);
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_companies_tenant_id; Type: INDEX; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
CREATE INDEX idx_companies_tenant_id ON public.companies USING btree (tenant_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_refresh_tokens_expires_at; Type: INDEX; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
CREATE INDEX idx_refresh_tokens_expires_at ON public.refresh_tokens USING btree (expires_at);
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_refresh_tokens_user_id; Type: INDEX; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
CREATE INDEX idx_refresh_tokens_user_id ON public.refresh_tokens USING btree (user_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_tenants_domain; Type: INDEX; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
CREATE INDEX idx_tenants_domain ON public.tenants USING btree (domain);
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_tenants_subdomain; Type: INDEX; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
CREATE INDEX idx_tenants_subdomain ON public.tenants USING btree (subdomain);
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_users_email; Type: INDEX; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
CREATE INDEX idx_users_email ON public.users USING btree (email);
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_users_tenant_id; Type: INDEX; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
CREATE INDEX idx_users_tenant_id ON public.users USING btree (tenant_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: companies companies_created_by_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.companies
|
||||
ADD CONSTRAINT companies_created_by_user_id_fkey FOREIGN KEY (created_by_user_id) REFERENCES public.users(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: companies companies_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.companies
|
||||
ADD CONSTRAINT companies_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
|
||||
|
||||
|
||||
--
|
||||
-- Name: refresh_tokens refresh_tokens_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.refresh_tokens
|
||||
ADD CONSTRAINT refresh_tokens_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
|
||||
|
||||
|
||||
--
|
||||
-- Name: users users_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.users
|
||||
ADD CONSTRAINT users_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
|
||||
|
||||
|
||||
--
|
||||
-- PostgreSQL database dump complete
|
||||
--
|
||||
|
||||
\unrestrict mUKTWCYeXvRf2SKhMr352J1jYiouAP5fsYPxvQjxn9xhEgk8BrOSEtYCYQoFicQ
|
||||
|
||||
343
backups/aggios_backup_2025-12-14_03-42-29.sql
Normal file
343
backups/aggios_backup_2025-12-14_03-42-29.sql
Normal file
@@ -0,0 +1,343 @@
|
||||
--
|
||||
-- PostgreSQL database dump
|
||||
--
|
||||
|
||||
\restrict ZSl79LbDN89EVihiEgzYdjR8EV38YLVYgKFBBZX4jKNuTBgFyc2DCZ8bFM5F42n
|
||||
|
||||
-- Dumped from database version 16.11
|
||||
-- Dumped by pg_dump version 18.1
|
||||
|
||||
SET statement_timeout = 0;
|
||||
SET lock_timeout = 0;
|
||||
SET idle_in_transaction_session_timeout = 0;
|
||||
SET transaction_timeout = 0;
|
||||
SET client_encoding = 'UTF8';
|
||||
SET standard_conforming_strings = on;
|
||||
SELECT pg_catalog.set_config('search_path', '', false);
|
||||
SET check_function_bodies = false;
|
||||
SET xmloption = content;
|
||||
SET client_min_messages = warning;
|
||||
SET row_security = off;
|
||||
|
||||
--
|
||||
-- Name: pgcrypto; Type: EXTENSION; Schema: -; Owner: -
|
||||
--
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public;
|
||||
|
||||
|
||||
--
|
||||
-- Name: EXTENSION pgcrypto; Type: COMMENT; Schema: -; Owner:
|
||||
--
|
||||
|
||||
COMMENT ON EXTENSION pgcrypto IS 'cryptographic functions';
|
||||
|
||||
|
||||
--
|
||||
-- Name: uuid-ossp; Type: EXTENSION; Schema: -; Owner: -
|
||||
--
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA public;
|
||||
|
||||
|
||||
--
|
||||
-- Name: EXTENSION "uuid-ossp"; Type: COMMENT; Schema: -; Owner:
|
||||
--
|
||||
|
||||
COMMENT ON EXTENSION "uuid-ossp" IS 'generate universally unique identifiers (UUIDs)';
|
||||
|
||||
|
||||
SET default_tablespace = '';
|
||||
|
||||
SET default_table_access_method = heap;
|
||||
|
||||
--
|
||||
-- Name: companies; Type: TABLE; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
CREATE TABLE public.companies (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
cnpj character varying(18) NOT NULL,
|
||||
razao_social character varying(255) NOT NULL,
|
||||
nome_fantasia character varying(255),
|
||||
email character varying(255),
|
||||
telefone character varying(20),
|
||||
status character varying(50) DEFAULT 'active'::character varying,
|
||||
created_by_user_id uuid,
|
||||
created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.companies OWNER TO aggios;
|
||||
|
||||
--
|
||||
-- Name: refresh_tokens; Type: TABLE; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
CREATE TABLE public.refresh_tokens (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
token_hash character varying(255) NOT NULL,
|
||||
expires_at timestamp with time zone NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.refresh_tokens OWNER TO aggios;
|
||||
|
||||
--
|
||||
-- Name: tenants; Type: TABLE; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
CREATE TABLE public.tenants (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
name character varying(255) NOT NULL,
|
||||
domain character varying(255) NOT NULL,
|
||||
subdomain character varying(63) NOT NULL,
|
||||
cnpj character varying(18),
|
||||
razao_social character varying(255),
|
||||
email character varying(255),
|
||||
phone character varying(20),
|
||||
website character varying(255),
|
||||
address text,
|
||||
city character varying(100),
|
||||
state character varying(2),
|
||||
zip character varying(10),
|
||||
description text,
|
||||
industry character varying(100),
|
||||
is_active boolean DEFAULT true,
|
||||
created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
|
||||
neighborhood character varying(100),
|
||||
street character varying(100),
|
||||
number character varying(20),
|
||||
complement character varying(100),
|
||||
team_size character varying(20),
|
||||
primary_color character varying(7),
|
||||
secondary_color character varying(7),
|
||||
logo_url text,
|
||||
logo_horizontal_url text
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.tenants OWNER TO aggios;
|
||||
|
||||
--
|
||||
-- Name: users; Type: TABLE; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
CREATE TABLE public.users (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid,
|
||||
email character varying(255) NOT NULL,
|
||||
password_hash character varying(255) NOT NULL,
|
||||
first_name character varying(128),
|
||||
last_name character varying(128),
|
||||
role character varying(50) DEFAULT 'CLIENTE'::character varying,
|
||||
is_active boolean DEFAULT true,
|
||||
created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT users_role_check CHECK (((role)::text = ANY ((ARRAY['SUPERADMIN'::character varying, 'ADMIN_AGENCIA'::character varying, 'CLIENTE'::character varying])::text[])))
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.users OWNER TO aggios;
|
||||
|
||||
--
|
||||
-- Data for Name: companies; Type: TABLE DATA; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
COPY public.companies (id, tenant_id, cnpj, razao_social, nome_fantasia, email, telefone, status, created_by_user_id, created_at, updated_at) FROM stdin;
|
||||
\.
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: refresh_tokens; Type: TABLE DATA; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
COPY public.refresh_tokens (id, user_id, token_hash, expires_at, created_at) FROM stdin;
|
||||
\.
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: tenants; Type: TABLE DATA; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
COPY public.tenants (id, name, domain, subdomain, cnpj, razao_social, email, phone, website, address, city, state, zip, description, industry, is_active, created_at, updated_at, neighborhood, street, number, complement, team_size, primary_color, secondary_color, logo_url, logo_horizontal_url) FROM stdin;
|
||||
d351e725-1428-45f3-b2e3-ca767e9b952c Agência Teste agencia-teste.aggios.app agencia-teste \N \N \N \N \N \N \N \N \N \N \N t 2025-12-13 22:31:35.818953+00 2025-12-13 22:31:35.818953+00 \N \N \N \N \N \N \N \N \N
|
||||
13d32cc3-0490-4557-96a3-7a38da194185 Empresa Teste teste-empresa.localhost teste-empresa 12.345.678/0001-90 EMPRESA TESTE LTDA teste@teste.com (11) 99999-9999 teste.com.br Avenida Paulista, 1000 - Andar 10 S<EFBFBD>o Paulo SP 01310-100 Empresa de teste tecnologia t 2025-12-13 23:22:58.406376+00 2025-12-13 23:22:58.406376+00 Bela Vista \N 1000 Andar 10 1-10 #8B5CF6 #A78BFA
|
||||
ae271be0-a63c-407f-9cf6-a4a8c8a0a4dc IdealPages idealpages.localhost idealpages 31.091.190/0001-23 ERIK DA SILVA SANTOS 36615318830 erik@idealpages.com.br (13) 92000-4392 idealpages.com.br Rua Quatorze, 150 - Casa Guarujá SP 11436-575 Empresa de contrucao de marca e desenvolvimento de software agencia-digital t 2025-12-13 23:23:35.508285+00 2025-12-13 23:26:40.947714+00 Vila Zilda \N 150 Casa 1-10 #8B5CF6 #A78BFA http://api.localhost/api/files/aggios-logos/tenants/ae271be0-a63c-407f-9cf6-a4a8c8a0a4dc/logo-1765668400.png
|
||||
\.
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: users; Type: TABLE DATA; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
COPY public.users (id, tenant_id, email, password_hash, first_name, last_name, role, is_active, created_at, updated_at) FROM stdin;
|
||||
7b51ae6e-6fb0-42c4-8473-a98cbfcda6a4 \N admin@aggios.app $2a$10$yhCREFqXL7FA4zveCFcl4eYODNTSyt/swuYjS0nXkEq8pzqJo.BwO Super Admin SUPERADMIN t 2025-12-13 23:02:33.124444+00 2025-12-13 23:02:33.124444+00
|
||||
488351e7-4ddc-41a4-9cd3-5c3dec833c44 13d32cc3-0490-4557-96a3-7a38da194185 teste@teste.com $2a$10$fx3bQqL01A9UqJwSwKpdLuVCq8M/1L9CvcQhx5tTkdinsvCpPsh4a Teste Silva \N ADMIN_AGENCIA t 2025-12-13 23:22:58.446011+00 2025-12-13 23:22:58.446011+00
|
||||
8742c1a1-5f1a-4df3-aa53-dcf94a2a2591 ae271be0-a63c-407f-9cf6-a4a8c8a0a4dc erik@idealpages.com.br $2a$10$tD8Kq/ZW0fbmW3Ga5JsKbOUy0nzsIZwkXJKaf43gFDVnRxjaf63Em Erik da Silva Santos \N ADMIN_AGENCIA t 2025-12-13 23:23:35.551192+00 2025-12-13 23:23:35.551192+00
|
||||
\.
|
||||
|
||||
|
||||
--
|
||||
-- Name: companies companies_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.companies
|
||||
ADD CONSTRAINT companies_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: companies companies_tenant_id_cnpj_key; Type: CONSTRAINT; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.companies
|
||||
ADD CONSTRAINT companies_tenant_id_cnpj_key UNIQUE (tenant_id, cnpj);
|
||||
|
||||
|
||||
--
|
||||
-- Name: refresh_tokens refresh_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.refresh_tokens
|
||||
ADD CONSTRAINT refresh_tokens_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: tenants tenants_domain_key; Type: CONSTRAINT; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.tenants
|
||||
ADD CONSTRAINT tenants_domain_key UNIQUE (domain);
|
||||
|
||||
|
||||
--
|
||||
-- Name: tenants tenants_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.tenants
|
||||
ADD CONSTRAINT tenants_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: tenants tenants_subdomain_key; Type: CONSTRAINT; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.tenants
|
||||
ADD CONSTRAINT tenants_subdomain_key UNIQUE (subdomain);
|
||||
|
||||
|
||||
--
|
||||
-- Name: users users_email_key; Type: CONSTRAINT; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.users
|
||||
ADD CONSTRAINT users_email_key UNIQUE (email);
|
||||
|
||||
|
||||
--
|
||||
-- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.users
|
||||
ADD CONSTRAINT users_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_companies_cnpj; Type: INDEX; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
CREATE INDEX idx_companies_cnpj ON public.companies USING btree (cnpj);
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_companies_tenant_id; Type: INDEX; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
CREATE INDEX idx_companies_tenant_id ON public.companies USING btree (tenant_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_refresh_tokens_expires_at; Type: INDEX; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
CREATE INDEX idx_refresh_tokens_expires_at ON public.refresh_tokens USING btree (expires_at);
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_refresh_tokens_user_id; Type: INDEX; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
CREATE INDEX idx_refresh_tokens_user_id ON public.refresh_tokens USING btree (user_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_tenants_domain; Type: INDEX; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
CREATE INDEX idx_tenants_domain ON public.tenants USING btree (domain);
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_tenants_subdomain; Type: INDEX; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
CREATE INDEX idx_tenants_subdomain ON public.tenants USING btree (subdomain);
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_users_email; Type: INDEX; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
CREATE INDEX idx_users_email ON public.users USING btree (email);
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_users_tenant_id; Type: INDEX; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
CREATE INDEX idx_users_tenant_id ON public.users USING btree (tenant_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: companies companies_created_by_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.companies
|
||||
ADD CONSTRAINT companies_created_by_user_id_fkey FOREIGN KEY (created_by_user_id) REFERENCES public.users(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: companies companies_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.companies
|
||||
ADD CONSTRAINT companies_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
|
||||
|
||||
|
||||
--
|
||||
-- Name: refresh_tokens refresh_tokens_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.refresh_tokens
|
||||
ADD CONSTRAINT refresh_tokens_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
|
||||
|
||||
|
||||
--
|
||||
-- Name: users users_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.users
|
||||
ADD CONSTRAINT users_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
|
||||
|
||||
|
||||
--
|
||||
-- PostgreSQL database dump complete
|
||||
--
|
||||
|
||||
\unrestrict ZSl79LbDN89EVihiEgzYdjR8EV38YLVYgKFBBZX4jKNuTBgFyc2DCZ8bFM5F42n
|
||||
|
||||
1091
backups/aggios_backup_2025-12-16_15-37-28.sql
Normal file
1091
backups/aggios_backup_2025-12-16_15-37-28.sql
Normal file
File diff suppressed because it is too large
Load Diff
1094
backups/aggios_backup_2025-12-17_13-26-04.sql
Normal file
1094
backups/aggios_backup_2025-12-17_13-26-04.sql
Normal file
File diff suppressed because one or more lines are too long
@@ -65,9 +65,24 @@ services:
|
||||
container_name: aggios-minio
|
||||
restart: unless-stopped
|
||||
command: server /data --console-address ":9001"
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# Router para acesso aos arquivos (API S3)
|
||||
- "traefik.http.routers.minio.rule=Host(`files.aggios.local`) || Host(`files.localhost`)"
|
||||
- "traefik.http.routers.minio.entrypoints=web"
|
||||
- "traefik.http.routers.minio.priority=100" # Prioridade alta para evitar captura pelo wildcard
|
||||
- "traefik.http.services.minio.loadbalancer.server.port=9000"
|
||||
- "traefik.http.services.minio.loadbalancer.passhostheader=true"
|
||||
# Router para o Console do MinIO
|
||||
- "traefik.http.routers.minio-console.rule=Host(`minio.aggios.local`) || Host(`minio.localhost`)"
|
||||
- "traefik.http.routers.minio-console.entrypoints=web"
|
||||
- "traefik.http.routers.minio-console.priority=100"
|
||||
- "traefik.http.services.minio-console.loadbalancer.server.port=9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD:-M1n10_S3cur3_P@ss_2025!}
|
||||
MINIO_BROWSER_REDIRECT_URL: http://minio.localhost
|
||||
MINIO_SERVER_URL: http://files.localhost
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
ports:
|
||||
@@ -89,12 +104,15 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
container_name: aggios-backend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8085:8080"
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.backend.rule=Host(`api.aggios.local`) || Host(`api.localhost`)"
|
||||
- "traefik.http.routers.backend.entrypoints=web"
|
||||
- "traefik.http.services.backend.loadbalancer.server.port=8080"
|
||||
environment:
|
||||
TZ: America/Sao_Paulo
|
||||
SERVER_HOST: 0.0.0.0
|
||||
SERVER_PORT: 8080
|
||||
JWT_SECRET: ${JWT_SECRET:-Th1s_1s_A_V3ry_S3cur3_JWT_S3cr3t_K3y_2025_Ch@ng3_In_Pr0d!}
|
||||
@@ -107,8 +125,11 @@ services:
|
||||
REDIS_PORT: 6379
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-R3d1s_S3cur3_P@ss_2025!}
|
||||
MINIO_ENDPOINT: minio:9000
|
||||
MINIO_PUBLIC_URL: http://files.localhost
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD:-M1n10_S3cur3_P@ss_2025!}
|
||||
volumes:
|
||||
- ./backups:/backups
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -178,6 +199,7 @@ services:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.agency.rule=Host(`agency.aggios.local`) || Host(`agency.localhost`) || HostRegexp(`^.+\\.localhost$`)"
|
||||
- "traefik.http.routers.agency.entrypoints=web"
|
||||
- "traefik.http.routers.agency.priority=1" # Prioridade baixa para não conflitar com files/minio
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- NEXT_PUBLIC_API_URL=http://api.localhost
|
||||
|
||||
159
docs/COLABORADORES_SETUP.md
Normal file
159
docs/COLABORADORES_SETUP.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# Sistema de Hierarquia de Usuários - Guia de Configuração
|
||||
|
||||
## Visão Geral
|
||||
|
||||
O sistema implementa dois tipos de usuários para agências:
|
||||
|
||||
1. **Dono da Agência (owner)** - Acesso total
|
||||
- Pode convidar colaboradores
|
||||
- Pode remover colaboradores
|
||||
- Tem acesso completo ao CRM
|
||||
|
||||
2. **Colaborador (collaborator)** - Acesso Restrito
|
||||
- Pode VER leads e clientes
|
||||
- **NÃO pode** editar ou remover dados
|
||||
- Acesso somente leitura (read-only)
|
||||
|
||||
## Configuração Inicial
|
||||
|
||||
### Passo 1: Configurar o primeiro usuário como "owner"
|
||||
|
||||
Após criar a primeira agência e seu usuário admin, execute o script SQL:
|
||||
|
||||
```bash
|
||||
docker exec aggios-postgres psql -U postgres -d aggios < /docker-entrypoint-initdb.d/../setup_owner_role.sql
|
||||
```
|
||||
|
||||
Ou manualmente:
|
||||
|
||||
```sql
|
||||
UPDATE users
|
||||
SET agency_role = 'owner'
|
||||
WHERE email = 'seu-email@exemplo.com' AND role = 'ADMIN_AGENCIA';
|
||||
```
|
||||
|
||||
### Passo 2: Login e acessar o gerenciamento de colaboradores
|
||||
|
||||
1. Faça login com o usuário owner
|
||||
2. Vá em **Configurações > Equipe**
|
||||
3. Clique em "Convidar Colaborador"
|
||||
|
||||
### Passo 3: Convidar um colaborador
|
||||
|
||||
- Preencha Nome e Email
|
||||
- Clique em "Convidar"
|
||||
- Copie a senha temporária (16 caracteres)
|
||||
- Compartilhe com o colaborador
|
||||
|
||||
## Fluxo de Funcionamento
|
||||
|
||||
### Quando um Colaborador é Convidado
|
||||
|
||||
1. Novo usuário é criado com `agency_role = 'collaborator'`
|
||||
2. Recebe uma **senha temporária aleatória**
|
||||
3. Email é adicionado à agência do owner
|
||||
|
||||
### Quando um Colaborador Faz Login
|
||||
|
||||
1. JWT contém `"agency_role": "collaborator"`
|
||||
2. Frontend detecta a restrição
|
||||
- Botões de editar/deletar desabilitados
|
||||
- Mensagens de acesso restrito
|
||||
3. Backend bloqueia POST/PUT/DELETE em `/api/crm/*`
|
||||
- Retorna 403 Forbidden se tentar
|
||||
|
||||
### Dados no JWT
|
||||
|
||||
```json
|
||||
{
|
||||
"user_id": "uuid",
|
||||
"user_type": "agency_user",
|
||||
"agency_role": "owner", // ou "collaborator"
|
||||
"email": "usuario@exemplo.com",
|
||||
"role": "ADMIN_AGENCIA",
|
||||
"tenant_id": "uuid",
|
||||
"exp": 1234567890
|
||||
}
|
||||
```
|
||||
|
||||
## Banco de Dados
|
||||
|
||||
### Novos Campos na Tabela `users`
|
||||
|
||||
```sql
|
||||
- agency_role VARCHAR(50) -- 'owner' ou 'collaborator'
|
||||
- created_by UUID REFERENCES users -- Quem criou este colaborador
|
||||
- collaborator_created_at TIMESTAMP -- Quando foi adicionado
|
||||
```
|
||||
|
||||
## Endpoints da API
|
||||
|
||||
### Listar Colaboradores
|
||||
```
|
||||
GET /api/agency/collaborators
|
||||
Headers: Authorization: Bearer <token>
|
||||
Resposta: Array de Collaborators
|
||||
Restrição: Apenas owner pode usar
|
||||
```
|
||||
|
||||
### Convidar Colaborador
|
||||
```
|
||||
POST /api/agency/collaborators/invite
|
||||
Body: { "email": "...", "name": "..." }
|
||||
Resposta: { "temporary_password": "..." }
|
||||
Restrição: Apenas owner pode usar
|
||||
```
|
||||
|
||||
### Remover Colaborador
|
||||
```
|
||||
DELETE /api/agency/collaborators/{id}
|
||||
Restrição: Apenas owner pode usar
|
||||
```
|
||||
|
||||
## Página de Interface
|
||||
|
||||
**Localização:** `/configuracoes` → Aba "Equipe"
|
||||
|
||||
### Funcionalidades
|
||||
- ✅ Ver lista de colaboradores (dono apenas)
|
||||
- ✅ Convidar novo colaborador
|
||||
- ✅ Copiar senha temporária
|
||||
- ✅ Remover colaborador (com confirmação)
|
||||
- ✅ Ver data de adição de cada colaborador
|
||||
- ✅ Indicador visual (badge) do tipo de usuário
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Apenas o dono da agência pode gerenciar colaboradores"
|
||||
|
||||
**Causa:** O usuário não tem `agency_role = 'owner'`
|
||||
|
||||
**Solução:**
|
||||
```sql
|
||||
UPDATE users
|
||||
SET agency_role = 'owner'
|
||||
WHERE id = 'seu-user-id';
|
||||
```
|
||||
|
||||
### Colaborador consegue editar dados (bug)
|
||||
|
||||
**Causa:** A middleware de read-only não está ativa
|
||||
|
||||
**Status:** Implementada em `backend/internal/api/middleware/collaborator_readonly.go`
|
||||
|
||||
**Para ativar:** Descomente a linha em `main.go` que aplica `CheckCollaboratorReadOnly`
|
||||
|
||||
### Senha temporária não aparece
|
||||
|
||||
**Verificar:**
|
||||
1. API `/api/agency/collaborators/invite` retorna 200?
|
||||
2. Response JSON tem o campo `temporary_password`?
|
||||
3. Verificar logs do backend para erros
|
||||
|
||||
## Próximas Melhorias
|
||||
|
||||
- [ ] Permitir editar nome/email do colaborador
|
||||
- [ ] Definir permissões granulares por colaborador
|
||||
- [ ] Histórico de ações feitas por cada colaborador
|
||||
- [ ] 2FA para owners
|
||||
- [ ] Auditoria de quem removeu quem
|
||||
186
docs/backup-system.md
Normal file
186
docs/backup-system.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# 📦 Sistema de Backup & Restore - Aggios
|
||||
|
||||
## 🎯 Funcionalidades Implementadas
|
||||
|
||||
### Interface Web (Superadmin)
|
||||
**URL:** `http://dash.localhost/superadmin/backup`
|
||||
|
||||
Disponível apenas para usuários com role `superadmin`.
|
||||
|
||||
#### Recursos:
|
||||
1. **Criar Backup**
|
||||
- Botão para criar novo backup instantâneo
|
||||
- Mostra nome do arquivo e tamanho
|
||||
- Mantém automaticamente apenas os últimos 10 backups
|
||||
|
||||
2. **Listar Backups**
|
||||
- Exibe todos os backups disponíveis
|
||||
- Informações: nome, data, tamanho
|
||||
- Seleção visual do backup ativo
|
||||
|
||||
3. **Restaurar Backup**
|
||||
- Seleção de backup na lista
|
||||
- Confirmação de segurança (alerta de sobrescrita)
|
||||
- Recarrega a página após restauração
|
||||
|
||||
4. **Download de Backup**
|
||||
- Botão de download em cada backup
|
||||
- Download direto do arquivo .sql
|
||||
|
||||
### API Endpoints
|
||||
|
||||
#### 1. Listar Backups
|
||||
```
|
||||
GET /api/superadmin/backups
|
||||
Authorization: Bearer {token}
|
||||
```
|
||||
|
||||
**Resposta:**
|
||||
```json
|
||||
{
|
||||
"backups": [
|
||||
{
|
||||
"filename": "aggios_backup_2025-12-13_20-23-08.sql",
|
||||
"size": "20.49 KB",
|
||||
"date": "13/12/2025 20:23:08",
|
||||
"timestamp": "2025-12-13_20-23-08"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Criar Backup
|
||||
```
|
||||
POST /api/superadmin/backup/create
|
||||
Authorization: Bearer {token}
|
||||
```
|
||||
|
||||
**Resposta:**
|
||||
```json
|
||||
{
|
||||
"message": "Backup created successfully",
|
||||
"filename": "aggios_backup_2025-12-13_20-30-15.sql",
|
||||
"size": "20.52 KB"
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Restaurar Backup
|
||||
```
|
||||
POST /api/superadmin/backup/restore
|
||||
Authorization: Bearer {token}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"filename": "aggios_backup_2025-12-13_20-23-08.sql"
|
||||
}
|
||||
```
|
||||
|
||||
**Resposta:**
|
||||
```json
|
||||
{
|
||||
"message": "Backup restored successfully"
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Download de Backup
|
||||
```
|
||||
GET /api/superadmin/backup/download/{filename}
|
||||
Authorization: Bearer {token}
|
||||
```
|
||||
|
||||
**Resposta:** Arquivo .sql para download
|
||||
|
||||
## 📂 Estrutura de Arquivos
|
||||
|
||||
```
|
||||
backups/
|
||||
├── aggios_backup_2025-12-13_19-56-18.sql
|
||||
├── aggios_backup_2025-12-13_20-12-49.sql
|
||||
├── aggios_backup_2025-12-13_20-17-59.sql
|
||||
└── aggios_backup_2025-12-13_20-23-08.sql (mais recente)
|
||||
```
|
||||
|
||||
## ⚙️ Scripts PowerShell (ainda funcionam!)
|
||||
|
||||
### Backup Manual
|
||||
```powershell
|
||||
cd g:\Projetos\aggios-app\scripts
|
||||
.\backup-db.ps1
|
||||
```
|
||||
|
||||
### Restaurar Último Backup
|
||||
```powershell
|
||||
cd g:\Projetos\aggios-app\scripts
|
||||
.\restore-db.ps1
|
||||
```
|
||||
|
||||
## 🔒 Segurança
|
||||
|
||||
1. ✅ Apenas superadmins podem acessar
|
||||
2. ✅ Validação de arquivos (apenas .sql na pasta backups/)
|
||||
3. ✅ Proteção contra path traversal
|
||||
4. ✅ Autenticação JWT obrigatória
|
||||
5. ✅ Confirmação dupla antes de restaurar
|
||||
|
||||
## ⚠️ Avisos Importantes
|
||||
|
||||
1. **Backup Automático:**
|
||||
- Ainda não configurado
|
||||
- Por enquanto, fazer backups manuais antes de `docker-compose down -v`
|
||||
|
||||
2. **Limite de Backups:**
|
||||
- Sistema mantém apenas os **últimos 10 backups**
|
||||
- Backups antigos são deletados automaticamente
|
||||
|
||||
3. **Restauração:**
|
||||
- ⚠️ **SOBRESCREVE TODOS OS DADOS ATUAIS**
|
||||
- Sempre peça confirmação dupla
|
||||
- Cria um backup automático antes? (implementar depois)
|
||||
|
||||
## 🚀 Como Usar
|
||||
|
||||
1. **Acesse o Superadmin:**
|
||||
- Login: admin@aggios.app
|
||||
- Senha: Ag@}O%}Z;if)97o*JOgNMbP2025!
|
||||
|
||||
2. **No Menu Lateral:**
|
||||
- Clique em "Backup & Restore" (ícone de servidor)
|
||||
|
||||
3. **Criar Backup:**
|
||||
- Clique em "Criar Novo Backup"
|
||||
- Aguarde confirmação
|
||||
|
||||
4. **Restaurar:**
|
||||
- Selecione o backup desejado na lista
|
||||
- Clique em "Restaurar Backup"
|
||||
- Confirme o alerta
|
||||
- Aguarde reload da página
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Erro ao criar backup
|
||||
```bash
|
||||
# Verificar se o container está rodando
|
||||
docker ps | grep aggios-postgres
|
||||
|
||||
# Verificar logs
|
||||
docker logs aggios-backend --tail 50
|
||||
```
|
||||
|
||||
### Erro ao restaurar
|
||||
```bash
|
||||
# Verificar permissões
|
||||
ls -la g:\Projetos\aggios-app\backups\
|
||||
|
||||
# Testar manualmente
|
||||
docker exec -i aggios-postgres psql -U aggios aggios_db < backup.sql
|
||||
```
|
||||
|
||||
## 📝 TODO Futuro
|
||||
|
||||
- [ ] Backup automático agendado (diário)
|
||||
- [ ] Backup antes de restaurar (safety)
|
||||
- [ ] Upload de backup externo
|
||||
- [ ] Exportar/importar apenas tabelas específicas
|
||||
- [ ] Histórico de restaurações
|
||||
- [ ] Notificações por email
|
||||
@@ -30,6 +30,12 @@ RUN npm ci --omit=dev
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Create uploads directory
|
||||
RUN mkdir -p ./public/uploads/logos && chown -R node:node ./public/uploads
|
||||
|
||||
# Switch to node user
|
||||
USER node
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
|
||||
105
front-end-agency/app/(agency)/AgencyLayoutClient.tsx
Normal file
105
front-end-agency/app/(agency)/AgencyLayoutClient.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import { DashboardLayout } from '@/components/layout/DashboardLayout';
|
||||
import { AgencyBranding } from '@/components/layout/AgencyBranding';
|
||||
import AuthGuard from '@/components/auth/AuthGuard';
|
||||
import { CRMFilterProvider } from '@/contexts/CRMFilterContext';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
HomeIcon,
|
||||
RocketLaunchIcon,
|
||||
UserPlusIcon,
|
||||
RectangleStackIcon,
|
||||
UsersIcon,
|
||||
MegaphoneIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
const AGENCY_MENU_ITEMS = [
|
||||
{ id: 'dashboard', label: 'Dashboard', href: '/dashboard', icon: HomeIcon },
|
||||
{
|
||||
id: 'crm',
|
||||
label: 'CRM',
|
||||
href: '/crm',
|
||||
icon: RocketLaunchIcon,
|
||||
requiredSolution: 'crm',
|
||||
subItems: [
|
||||
{ label: 'Visão Geral', href: '/crm', icon: HomeIcon },
|
||||
{ label: 'Funis de Vendas', href: '/crm/funis', icon: RectangleStackIcon },
|
||||
{ label: 'Clientes', href: '/crm/clientes', icon: UsersIcon },
|
||||
{ label: 'Campanhas', href: '/crm/campanhas', icon: MegaphoneIcon },
|
||||
{ label: 'Leads', href: '/crm/leads', icon: UserPlusIcon },
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
interface AgencyLayoutClientProps {
|
||||
children: React.ReactNode;
|
||||
colors?: {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function AgencyLayoutClient({ children, colors }: AgencyLayoutClientProps) {
|
||||
const [filteredMenuItems, setFilteredMenuItems] = useState(AGENCY_MENU_ITEMS);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTenantSolutions = async () => {
|
||||
try {
|
||||
console.log('🔍 Buscando soluções do tenant...');
|
||||
const response = await fetch('/api/tenant/solutions', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('📡 Response status:', response.status);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log('📦 Dados recebidos:', data);
|
||||
const solutions = data.solutions || [];
|
||||
console.log('✅ Soluções:', solutions);
|
||||
|
||||
// Mapear slugs de solutions para IDs de menu
|
||||
const solutionSlugs = solutions.map((s: any) => s.slug.toLowerCase());
|
||||
console.log('🏷️ Slugs das soluções:', solutionSlugs);
|
||||
|
||||
// Sempre mostrar dashboard + soluções disponíveis
|
||||
const filtered = AGENCY_MENU_ITEMS.filter(item => {
|
||||
if (item.id === 'dashboard') return true;
|
||||
const requiredSolution = (item as any).requiredSolution;
|
||||
return solutionSlugs.includes((requiredSolution || item.id).toLowerCase());
|
||||
});
|
||||
|
||||
console.log('📋 Menu filtrado:', filtered.map(i => i.id));
|
||||
setFilteredMenuItems(filtered);
|
||||
} else {
|
||||
console.error('❌ Erro na resposta:', response.status);
|
||||
// Em caso de erro, mostrar todos (fallback)
|
||||
setFilteredMenuItems(AGENCY_MENU_ITEMS);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error fetching solutions:', error);
|
||||
// Em caso de erro, mostrar todos (fallback)
|
||||
setFilteredMenuItems(AGENCY_MENU_ITEMS);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTenantSolutions();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthGuard allowedTypes={['agency_user']}>
|
||||
<CRMFilterProvider>
|
||||
<AgencyBranding colors={colors} />
|
||||
<DashboardLayout menuItems={loading ? [AGENCY_MENU_ITEMS[0]] : filteredMenuItems}>
|
||||
{children}
|
||||
</DashboardLayout>
|
||||
</CRMFilterProvider>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
"use client";
|
||||
|
||||
export default function ClientesPage() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">Clientes</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">Gerencie sua carteira de clientes</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-12 border border-gray-200 dark:border-gray-700 text-center">
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="w-24 h-24 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<i className="ri-user-line text-5xl text-blue-600 dark:text-blue-400"></i>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">
|
||||
Módulo CRM em Desenvolvimento
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Em breve você poderá gerenciar seus clientes com recursos avançados de CRM.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Tab } from '@headlessui/react';
|
||||
import { Button, Dialog } from '@/components/ui';
|
||||
import { Button, Dialog, Input } from '@/components/ui';
|
||||
import { Toaster, toast } from 'react-hot-toast';
|
||||
import TeamManagement from '@/components/team/TeamManagement';
|
||||
import {
|
||||
BuildingOfficeIcon,
|
||||
PhotoIcon,
|
||||
@@ -44,6 +45,7 @@ export default function ConfiguracoesPage() {
|
||||
const [showSupportDialog, setShowSupportDialog] = useState(false);
|
||||
const [supportMessage, setSupportMessage] = useState('Para alterar estes dados, contate o suporte.');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loadingCep, setLoadingCep] = useState(false);
|
||||
const [uploadingLogo, setUploadingLogo] = useState(false);
|
||||
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
||||
@@ -70,8 +72,32 @@ export default function ConfiguracoesPage() {
|
||||
teamSize: '',
|
||||
logoUrl: '',
|
||||
logoHorizontalUrl: '',
|
||||
primaryColor: '#ff3a05',
|
||||
secondaryColor: '#ff0080',
|
||||
});
|
||||
|
||||
// Live Preview da Cor Primária
|
||||
useEffect(() => {
|
||||
if (agencyData.primaryColor) {
|
||||
const root = document.documentElement;
|
||||
const hexToRgb = (hex: string) => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result ? `${parseInt(result[1], 16)} ${parseInt(result[2], 16)} ${parseInt(result[3], 16)}` : null;
|
||||
};
|
||||
|
||||
const primaryRgb = hexToRgb(agencyData.primaryColor);
|
||||
|
||||
if (primaryRgb) {
|
||||
root.style.setProperty('--brand-rgb', primaryRgb);
|
||||
root.style.setProperty('--brand-strong-rgb', primaryRgb);
|
||||
root.style.setProperty('--brand-hover-rgb', primaryRgb);
|
||||
}
|
||||
|
||||
root.style.setProperty('--brand-color', agencyData.primaryColor);
|
||||
root.style.setProperty('--gradient', `linear-gradient(135deg, ${agencyData.primaryColor}, ${agencyData.primaryColor})`);
|
||||
}
|
||||
}, [agencyData.primaryColor]);
|
||||
|
||||
// Dados para alteração de senha
|
||||
const [passwordData, setPasswordData] = useState({
|
||||
currentPassword: '',
|
||||
@@ -102,9 +128,6 @@ export default function ConfiguracoesPage() {
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log('DEBUG: API response data:', data);
|
||||
console.log('DEBUG: logo_url:', data.logo_url);
|
||||
console.log('DEBUG: logo_horizontal_url:', data.logo_horizontal_url);
|
||||
|
||||
const parsedAddress = parseAddressParts(data.address || '');
|
||||
setAgencyData({
|
||||
@@ -125,19 +148,26 @@ export default function ConfiguracoesPage() {
|
||||
description: data.description || '',
|
||||
industry: data.industry || '',
|
||||
teamSize: data.team_size || '',
|
||||
logoUrl: data.logo_url || '',
|
||||
logoHorizontalUrl: data.logo_horizontal_url || '',
|
||||
logoUrl: data.logo_url || localStorage.getItem('agency-logo-url') || '',
|
||||
logoHorizontalUrl: data.logo_horizontal_url || localStorage.getItem('agency-logo-horizontal-url') || '',
|
||||
primaryColor: data.primary_color || '#ff3a05',
|
||||
secondaryColor: data.secondary_color || '#ff0080',
|
||||
});
|
||||
|
||||
// Set logo previews
|
||||
console.log('DEBUG: Setting previews...');
|
||||
if (data.logo_url) {
|
||||
console.log('DEBUG: Setting logoPreview to:', data.logo_url);
|
||||
setLogoPreview(data.logo_url);
|
||||
// Set logo previews - usar localStorage como fallback se API não retornar
|
||||
const cachedLogo = localStorage.getItem('agency-logo-url');
|
||||
const cachedHorizontal = localStorage.getItem('agency-logo-horizontal-url');
|
||||
|
||||
const finalLogoUrl = data.logo_url || cachedLogo;
|
||||
const finalHorizontalUrl = data.logo_horizontal_url || cachedHorizontal;
|
||||
|
||||
if (finalLogoUrl) {
|
||||
setLogoPreview(finalLogoUrl);
|
||||
localStorage.setItem('agency-logo-url', finalLogoUrl);
|
||||
}
|
||||
if (data.logo_horizontal_url) {
|
||||
console.log('DEBUG: Setting logoHorizontalPreview to:', data.logo_horizontal_url);
|
||||
setLogoHorizontalPreview(data.logo_horizontal_url);
|
||||
if (finalHorizontalUrl) {
|
||||
setLogoHorizontalPreview(finalHorizontalUrl);
|
||||
localStorage.setItem('agency-logo-horizontal-url', finalHorizontalUrl);
|
||||
}
|
||||
} else {
|
||||
console.error('Erro ao buscar dados:', response.status);
|
||||
@@ -166,6 +196,8 @@ export default function ConfiguracoesPage() {
|
||||
teamSize: data.formData?.teamSize || '',
|
||||
logoUrl: '',
|
||||
logoHorizontalUrl: '',
|
||||
primaryColor: '#ff3a05',
|
||||
secondaryColor: '#ff0080',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -223,11 +255,18 @@ export default function ConfiguracoesPage() {
|
||||
if (type === 'logo') {
|
||||
setAgencyData(prev => ({ ...prev, logoUrl }));
|
||||
setLogoPreview(logoUrl);
|
||||
// Salvar no localStorage para uso do favicon
|
||||
localStorage.setItem('agency-logo-url', logoUrl);
|
||||
} else {
|
||||
setAgencyData(prev => ({ ...prev, logoHorizontalUrl: logoUrl }));
|
||||
setLogoHorizontalPreview(logoUrl);
|
||||
}
|
||||
|
||||
// Disparar evento para atualizar branding em tempo real
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new Event('branding-update'));
|
||||
}
|
||||
|
||||
toast.success('Logo enviado com sucesso!');
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
@@ -319,11 +358,13 @@ export default function ConfiguracoesPage() {
|
||||
};
|
||||
|
||||
const handleSaveAgency = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
setSuccessMessage('Você precisa estar autenticado.');
|
||||
setShowSuccessDialog(true);
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -354,17 +395,47 @@ export default function ConfiguracoesPage() {
|
||||
description: agencyData.description,
|
||||
industry: agencyData.industry,
|
||||
team_size: agencyData.teamSize,
|
||||
primary_color: agencyData.primaryColor,
|
||||
secondary_color: agencyData.secondaryColor,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setSuccessMessage('Dados da agência salvos com sucesso!');
|
||||
|
||||
// Atualiza localStorage imediatamente para persistência instantânea
|
||||
localStorage.setItem('agency-primary-color', agencyData.primaryColor);
|
||||
localStorage.setItem('agency-secondary-color', agencyData.secondaryColor);
|
||||
|
||||
// Preservar logos no localStorage (não sobrescrever com valores vazios)
|
||||
// Logos são gerenciados separadamente via upload
|
||||
const currentLogoCache = localStorage.getItem('agency-logo-url');
|
||||
const currentHorizontalCache = localStorage.getItem('agency-logo-horizontal-url');
|
||||
|
||||
// Só atualizar se temos valores novos no estado
|
||||
if (agencyData.logoUrl) {
|
||||
localStorage.setItem('agency-logo-url', agencyData.logoUrl);
|
||||
} else if (!currentLogoCache && logoPreview) {
|
||||
// Se não tem cache mas tem preview, usar o preview
|
||||
localStorage.setItem('agency-logo-url', logoPreview);
|
||||
}
|
||||
|
||||
if (agencyData.logoHorizontalUrl) {
|
||||
localStorage.setItem('agency-logo-horizontal-url', agencyData.logoHorizontalUrl);
|
||||
} else if (!currentHorizontalCache && logoHorizontalPreview) {
|
||||
localStorage.setItem('agency-logo-horizontal-url', logoHorizontalPreview);
|
||||
}
|
||||
|
||||
// Disparar evento para atualizar o tema em tempo real
|
||||
window.dispatchEvent(new Event('branding-update'));
|
||||
} else {
|
||||
setSuccessMessage('Erro ao salvar dados. Tente novamente.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao salvar:', error);
|
||||
setSuccessMessage('Erro ao salvar dados. Verifique sua conexão.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
setShowSuccessDialog(true);
|
||||
};
|
||||
@@ -437,7 +508,7 @@ export default function ConfiguracoesPage() {
|
||||
{/* Loading State */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900 dark:border-gray-100"></div>
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b border-gray-900 dark:border-gray-100"></div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -475,52 +546,40 @@ export default function ConfiguracoesPage() {
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Nome da Agência
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
<Input
|
||||
label="Nome da Agência"
|
||||
value={agencyData.name}
|
||||
onChange={(e) => setAgencyData({ ...agencyData, name: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none"
|
||||
placeholder="Ex: Minha Agência"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Razão Social
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
<Input
|
||||
label="Razão Social"
|
||||
value={agencyData.razaoSocial}
|
||||
onChange={(e) => setAgencyData({ ...agencyData, razaoSocial: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none"
|
||||
placeholder="Razão Social Ltda"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center justify-between">
|
||||
<span>CNPJ</span>
|
||||
<span className="text-xs text-gray-500">Alteração via suporte</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
<Input
|
||||
label="CNPJ"
|
||||
value={agencyData.cnpj}
|
||||
readOnly
|
||||
onClick={() => {
|
||||
setSupportMessage('Para alterar CNPJ, contate o suporte.');
|
||||
setShowSupportDialog(true);
|
||||
}}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none cursor-pointer"
|
||||
className="cursor-pointer bg-gray-50 dark:bg-gray-800"
|
||||
helperText="Alteração via suporte"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center justify-between">
|
||||
<span>E-mail (acesso)</span>
|
||||
<span className="text-xs text-gray-500">Alteração via suporte</span>
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
label="E-mail (acesso)"
|
||||
type="email"
|
||||
value={agencyData.email}
|
||||
readOnly
|
||||
@@ -528,55 +587,47 @@ export default function ConfiguracoesPage() {
|
||||
setSupportMessage('Para alterar o e-mail de acesso, contate o suporte.');
|
||||
setShowSupportDialog(true);
|
||||
}}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none cursor-pointer"
|
||||
className="cursor-pointer bg-gray-50 dark:bg-gray-800"
|
||||
helperText="Alteração via suporte"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Telefone / WhatsApp
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
label="Telefone / WhatsApp"
|
||||
type="tel"
|
||||
value={agencyData.phone}
|
||||
onChange={(e) => setAgencyData({ ...agencyData, phone: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none"
|
||||
placeholder="(00) 00000-0000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Website
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
label="Website"
|
||||
type="url"
|
||||
value={agencyData.website}
|
||||
onChange={(e) => setAgencyData({ ...agencyData, website: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none"
|
||||
placeholder="https://www.suaagencia.com.br"
|
||||
leftIcon="ri-global-line"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Segmento / Indústria
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
<Input
|
||||
label="Segmento / Indústria"
|
||||
value={agencyData.industry}
|
||||
onChange={(e) => setAgencyData({ ...agencyData, industry: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none"
|
||||
placeholder="Ex: Marketing Digital"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Tamanho da Equipe
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
<Input
|
||||
label="Tamanho da Equipe"
|
||||
value={agencyData.teamSize}
|
||||
onChange={(e) => setAgencyData({ ...agencyData, teamSize: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none"
|
||||
placeholder="Ex: 10-50 funcionários"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -591,11 +642,8 @@ export default function ConfiguracoesPage() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
CEP
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
<Input
|
||||
label="CEP"
|
||||
value={agencyData.zip}
|
||||
onChange={(e) => {
|
||||
const formatted = formatCep(e.target.value);
|
||||
@@ -617,85 +665,74 @@ export default function ConfiguracoesPage() {
|
||||
}));
|
||||
}
|
||||
}}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none"
|
||||
placeholder="00000-000"
|
||||
rightIcon={loadingCep ? "ri-loader-4-line animate-spin" : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Estado
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
<Input
|
||||
label="Estado"
|
||||
value={agencyData.state}
|
||||
readOnly
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none cursor-not-allowed"
|
||||
className="bg-gray-50 dark:bg-gray-800 cursor-not-allowed"
|
||||
/>
|
||||
</div> <div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Cidade
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
label="Cidade"
|
||||
value={agencyData.city}
|
||||
readOnly
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none cursor-not-allowed"
|
||||
className="bg-gray-50 dark:bg-gray-800 cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Bairro
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
<Input
|
||||
label="Bairro"
|
||||
value={agencyData.neighborhood}
|
||||
readOnly
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none cursor-not-allowed"
|
||||
className="bg-gray-50 dark:bg-gray-800 cursor-not-allowed"
|
||||
/>
|
||||
</div> <div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Rua/Avenida
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<Input
|
||||
label="Rua/Avenida"
|
||||
value={agencyData.street}
|
||||
readOnly
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none cursor-not-allowed"
|
||||
/>
|
||||
</div> <div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Número
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={agencyData.number}
|
||||
onChange={(e) => setAgencyData({ ...agencyData, number: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none"
|
||||
className="bg-gray-50 dark:bg-gray-800 cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Complemento (opcional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
<Input
|
||||
label="Número"
|
||||
value={agencyData.number}
|
||||
onChange={(e) => setAgencyData({ ...agencyData, number: e.target.value })}
|
||||
placeholder="123"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
label="Complemento (opcional)"
|
||||
value={agencyData.complement}
|
||||
onChange={(e) => setAgencyData({ ...agencyData, complement: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none"
|
||||
placeholder="Apto 101, Bloco B"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
<Button
|
||||
onClick={handleSaveAgency}
|
||||
className="px-6 py-2 rounded-lg text-white font-medium transition-all hover:scale-105"
|
||||
style={{ background: 'var(--gradient-primary)' }}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
>
|
||||
Salvar Alterações
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
@@ -789,7 +826,7 @@ export default function ConfiguracoesPage() {
|
||||
className="hidden"
|
||||
disabled={uploadingLogo}
|
||||
/>
|
||||
<div className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl p-8 text-center hover:border-gray-400 dark:hover:border-gray-500 transition-colors bg-gray-50/50 dark:bg-gray-900/50">
|
||||
<div className="border-2 border-dashed border-gray-200 dark:border-gray-600 rounded-xl p-8 text-center hover:border-gray-400 dark:hover:border-gray-500 transition-colors bg-gray-50/50 dark:bg-gray-900/50">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-4">
|
||||
<PhotoIcon className="w-8 h-8 text-gray-400 dark:text-gray-500" />
|
||||
@@ -897,7 +934,7 @@ export default function ConfiguracoesPage() {
|
||||
className="hidden"
|
||||
disabled={uploadingLogo}
|
||||
/>
|
||||
<div className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl p-8 text-center hover:border-gray-400 dark:hover:border-gray-500 transition-colors bg-gray-50/50 dark:bg-gray-900/50">
|
||||
<div className="border-2 border-dashed border-gray-200 dark:border-gray-600 rounded-xl p-8 text-center hover:border-gray-400 dark:hover:border-gray-500 transition-colors bg-gray-50/50 dark:bg-gray-900/50">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-4">
|
||||
<PhotoIcon className="w-8 h-8 text-gray-400 dark:text-gray-500" />
|
||||
@@ -928,6 +965,69 @@ export default function ConfiguracoesPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cores da Marca */}
|
||||
<div className="pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Personalização de Cores
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="relative w-[50px] h-[42px] rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700 shadow-sm shrink-0 mb-[2px]">
|
||||
<input
|
||||
type="color"
|
||||
value={agencyData.primaryColor}
|
||||
onChange={(e) => setAgencyData({ ...agencyData, primaryColor: e.target.value })}
|
||||
className="absolute -top-2 -left-2 w-24 h-24 cursor-pointer p-0 border-0"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
label="Cor Primária"
|
||||
type="text"
|
||||
value={agencyData.primaryColor}
|
||||
onChange={(e) => setAgencyData({ ...agencyData, primaryColor: e.target.value })}
|
||||
className="uppercase"
|
||||
maxLength={7}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="relative w-[50px] h-[42px] rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700 shadow-sm shrink-0 mb-[2px]">
|
||||
<input
|
||||
type="color"
|
||||
value={agencyData.secondaryColor}
|
||||
onChange={(e) => setAgencyData({ ...agencyData, secondaryColor: e.target.value })}
|
||||
className="absolute -top-2 -left-2 w-24 h-24 cursor-pointer p-0 border-0"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
label="Cor Secundária"
|
||||
type="text"
|
||||
value={agencyData.secondaryColor}
|
||||
onChange={(e) => setAgencyData({ ...agencyData, secondaryColor: e.target.value })}
|
||||
className="uppercase"
|
||||
maxLength={7}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button
|
||||
onClick={handleSaveAgency}
|
||||
variant="primary"
|
||||
isLoading={saving}
|
||||
style={{ backgroundColor: agencyData.primaryColor }}
|
||||
>
|
||||
Salvar Cores
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info adicional */}
|
||||
@@ -941,19 +1041,7 @@ export default function ConfiguracoesPage() {
|
||||
|
||||
{/* Tab 3: Equipe */}
|
||||
<Tab.Panel className="rounded-xl bg-white dark:bg-gray-800 p-6 border border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">
|
||||
Gerenciamento de Equipe
|
||||
</h2>
|
||||
|
||||
<div className="text-center py-12">
|
||||
<UserGroupIcon className="w-16 h-16 mx-auto text-gray-300 dark:text-gray-600 mb-4" />
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Em breve: gerenciamento completo de usuários e permissões
|
||||
</p>
|
||||
<button className="px-6 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-lg font-medium hover:scale-105 transition-all">
|
||||
Convidar Membro
|
||||
</button>
|
||||
</div>
|
||||
<TeamManagement />
|
||||
</Tab.Panel>
|
||||
|
||||
{/* Tab 3: Segurança */}
|
||||
@@ -970,52 +1058,42 @@ export default function ConfiguracoesPage() {
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Senha Atual
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
label="Senha Atual"
|
||||
type="password"
|
||||
value={passwordData.currentPassword}
|
||||
onChange={(e) => setPasswordData({ ...passwordData, currentPassword: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none"
|
||||
placeholder="Digite sua senha atual"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Nova Senha
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
label="Nova Senha"
|
||||
type="password"
|
||||
value={passwordData.newPassword}
|
||||
onChange={(e) => setPasswordData({ ...passwordData, newPassword: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none"
|
||||
placeholder="Digite a nova senha (mínimo 8 caracteres)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Confirmar Nova Senha
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
label="Confirmar Nova Senha"
|
||||
type="password"
|
||||
value={passwordData.confirmPassword}
|
||||
onChange={(e) => setPasswordData({ ...passwordData, confirmPassword: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none"
|
||||
placeholder="Digite a nova senha novamente"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<button
|
||||
<Button
|
||||
onClick={handleChangePassword}
|
||||
className="px-6 py-2 rounded-lg text-white font-medium transition-all hover:scale-105"
|
||||
style={{ background: 'var(--gradient-primary)' }}
|
||||
variant="primary"
|
||||
>
|
||||
Alterar Senha
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1071,13 +1149,12 @@ export default function ConfiguracoesPage() {
|
||||
<p className="text-center py-4">{successMessage}</p>
|
||||
</Dialog.Body>
|
||||
<Dialog.Footer>
|
||||
<button
|
||||
<Button
|
||||
onClick={() => setShowSuccessDialog(false)}
|
||||
className="px-6 py-2 rounded-lg text-white font-medium transition-all hover:scale-105"
|
||||
style={{ background: 'var(--gradient-primary)' }}
|
||||
variant="primary"
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog>
|
||||
|
||||
@@ -1092,12 +1169,12 @@ export default function ConfiguracoesPage() {
|
||||
<p className="mt-3 text-sm text-gray-500">Envie um e-mail para suporte@aggios.app ou abra um chamado para ajuste desses dados.</p>
|
||||
</Dialog.Body>
|
||||
<Dialog.Footer>
|
||||
<button
|
||||
<Button
|
||||
onClick={() => setShowSupportDialog(false)}
|
||||
className="px-4 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-lg font-medium hover:scale-105 transition-all"
|
||||
variant="primary"
|
||||
>
|
||||
Fechar
|
||||
</button>
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
16
front-end-agency/app/(agency)/contratos/page.tsx
Normal file
16
front-end-agency/app/(agency)/contratos/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { SolutionGuard } from '@/components/auth/SolutionGuard';
|
||||
|
||||
export default function ContratosPage() {
|
||||
return (
|
||||
<SolutionGuard requiredSolution="contratos">
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">Contratos</h1>
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-8 text-center">
|
||||
<p className="text-gray-500">Gestão de Contratos e Assinaturas em breve</p>
|
||||
</div>
|
||||
</div>
|
||||
</SolutionGuard>
|
||||
);
|
||||
}
|
||||
624
front-end-agency/app/(agency)/crm/campanhas/[id]/page.tsx
Normal file
624
front-end-agency/app/(agency)/crm/campanhas/[id]/page.tsx
Normal file
@@ -0,0 +1,624 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment, useEffect, useState, use } from 'react';
|
||||
import { Tab, Menu, Transition } from '@headlessui/react';
|
||||
import {
|
||||
UserGroupIcon,
|
||||
InformationCircleIcon,
|
||||
CreditCardIcon,
|
||||
ArrowLeftIcon,
|
||||
PlusIcon,
|
||||
MagnifyingGlassIcon,
|
||||
FunnelIcon,
|
||||
EllipsisVerticalIcon,
|
||||
PencilIcon,
|
||||
TrashIcon,
|
||||
EnvelopeIcon,
|
||||
PhoneIcon,
|
||||
TagIcon,
|
||||
CalendarIcon,
|
||||
UserIcon,
|
||||
ArrowDownTrayIcon,
|
||||
BriefcaseIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import Link from 'next/link';
|
||||
import { useToast } from '@/components/layout/ToastContext';
|
||||
import KanbanBoard from '@/components/crm/KanbanBoard';
|
||||
|
||||
interface Lead {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
interface Campaign {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
color: string;
|
||||
customer_id: string;
|
||||
customer_name: string;
|
||||
lead_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'novo', label: 'Novo', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' },
|
||||
{ value: 'qualificado', label: 'Qualificado', color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' },
|
||||
{ value: 'negociacao', label: 'Em Negociação', color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' },
|
||||
{ value: 'convertido', label: 'Convertido', color: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200' },
|
||||
{ value: 'perdido', label: 'Perdido', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' },
|
||||
];
|
||||
|
||||
function classNames(...classes: string[]) {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export default function CampaignDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params);
|
||||
const toast = useToast();
|
||||
const [campaign, setCampaign] = useState<Campaign | null>(null);
|
||||
const [leads, setLeads] = useState<Lead[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [funnels, setFunnels] = useState<any[]>([]);
|
||||
const [selectedFunnelId, setSelectedFunnelId] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchCampaignDetails();
|
||||
fetchCampaignLeads();
|
||||
fetchFunnels();
|
||||
}, [id]);
|
||||
|
||||
const fetchFunnels = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/crm/funnels', {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setFunnels(data.funnels || []);
|
||||
if (data.funnels?.length > 0) {
|
||||
setSelectedFunnelId(data.funnels[0].id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching funnels:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCampaignDetails = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/crm/lists`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const found = data.lists?.find((l: Campaign) => l.id === id);
|
||||
if (found) {
|
||||
setCampaign(found);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching campaign details:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCampaignLeads = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/crm/lists/${id}/leads`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setLeads(data.leads || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching leads:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredLeads = leads.filter(lead =>
|
||||
(lead.name?.toLowerCase() || '').includes(searchTerm.toLowerCase()) ||
|
||||
(lead.email?.toLowerCase() || '').includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const handleExport = async (format: 'csv' | 'xlsx' | 'json') => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`/api/crm/leads/export?format=${format}&campaign_id=${id}`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `leads-${campaign?.name || 'campaign'}.${format === 'xlsx' ? 'xlsx' : format}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
toast.success('Exportado com sucesso!');
|
||||
} else {
|
||||
toast.error('Erro ao exportar leads');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Export error:', error);
|
||||
toast.error('Erro ao exportar');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && !campaign) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!campaign) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
<h2 className="text-2xl font-bold text-zinc-900 dark:text-white">Campanha não encontrada</h2>
|
||||
<Link href="/crm/campanhas" className="mt-4 inline-flex items-center text-brand-500 hover:underline">
|
||||
<ArrowLeftIcon className="w-4 h-4 mr-2" />
|
||||
Voltar para Campanhas
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-[1600px] mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<Link
|
||||
href="/crm/campanhas"
|
||||
className="inline-flex items-center text-sm text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4 mr-2" />
|
||||
Voltar para Campanhas
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className="w-14 h-14 rounded-2xl flex items-center justify-center text-white shadow-lg"
|
||||
style={{ backgroundColor: campaign.color }}
|
||||
>
|
||||
<UserGroupIcon className="w-8 h-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white tracking-tight">
|
||||
{campaign.name}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{campaign.customer_name ? (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider bg-brand-50 text-brand-700 dark:bg-brand-900/20 dark:text-brand-400 border border-brand-100 dark:border-brand-800/50">
|
||||
{campaign.customer_name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400 border border-zinc-200 dark:border-zinc-700">
|
||||
Geral
|
||||
</span>
|
||||
)}
|
||||
<span className="text-zinc-400 text-xs">•</span>
|
||||
<span className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{leads.length} leads vinculados
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative inline-block text-left">
|
||||
<Menu>
|
||||
<Menu.Button className="inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-zinc-700 dark:text-zinc-300 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors">
|
||||
<ArrowDownTrayIcon className="w-4 h-4" />
|
||||
Exportar
|
||||
</Menu.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items className="absolute right-0 mt-2 w-48 origin-top-right divide-y divide-zinc-100 dark:divide-zinc-800 rounded-xl bg-white dark:bg-zinc-900 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none z-50 border border-zinc-200 dark:border-zinc-800">
|
||||
<div className="px-1 py-1">
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={() => handleExport('csv')}
|
||||
className={`${active ? 'bg-zinc-50 dark:bg-zinc-800' : ''} group flex w-full items-center rounded-lg px-3 py-2 text-sm text-zinc-700 dark:text-zinc-300`}
|
||||
>
|
||||
Exportar como CSV
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={() => handleExport('xlsx')}
|
||||
className={`${active ? 'bg-zinc-50 dark:bg-zinc-800' : ''} group flex w-full items-center rounded-lg px-3 py-2 text-sm text-zinc-700 dark:text-zinc-300`}
|
||||
>
|
||||
Exportar como Excel
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={() => handleExport('json')}
|
||||
className={`${active ? 'bg-zinc-50 dark:bg-zinc-800' : ''} group flex w-full items-center rounded-lg px-3 py-2 text-sm text-zinc-700 dark:text-zinc-300`}
|
||||
>
|
||||
Exportar como JSON
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
<button className="px-4 py-2 text-sm font-medium text-zinc-700 dark:text-zinc-300 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors">
|
||||
Editar Campanha
|
||||
</button>
|
||||
<Link
|
||||
href={`/crm/leads/importar?campaign=${campaign.id}`}
|
||||
className="inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-white rounded-lg hover:opacity-90 transition-opacity"
|
||||
style={{ background: 'var(--gradient)' }}
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
Importar Leads
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tab.Group>
|
||||
<Tab.List className="flex space-x-1 rounded-xl bg-zinc-100 dark:bg-zinc-800/50 p-1 max-w-lg">
|
||||
<Tab className={({ selected }) =>
|
||||
classNames(
|
||||
'w-full rounded-lg py-2.5 text-sm font-medium leading-5 transition-all duration-200',
|
||||
'ring-white ring-opacity-60 ring-offset-2 ring-offset-brand-400 focus:outline-none',
|
||||
selected
|
||||
? 'bg-white dark:bg-zinc-900 text-brand-600 dark:text-brand-400 shadow-sm'
|
||||
: 'text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200 hover:bg-white/[0.12]'
|
||||
)
|
||||
}>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<FunnelIcon className="w-4 h-4" />
|
||||
Monitoramento
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab className={({ selected }) =>
|
||||
classNames(
|
||||
'w-full rounded-lg py-2.5 text-sm font-medium leading-5 transition-all duration-200',
|
||||
'ring-white ring-opacity-60 ring-offset-2 ring-offset-brand-400 focus:outline-none',
|
||||
selected
|
||||
? 'bg-white dark:bg-zinc-900 text-brand-600 dark:text-brand-400 shadow-sm'
|
||||
: 'text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200 hover:bg-white/[0.12]'
|
||||
)
|
||||
}>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<UserGroupIcon className="w-4 h-4" />
|
||||
Leads
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab className={({ selected }) =>
|
||||
classNames(
|
||||
'w-full rounded-lg py-2.5 text-sm font-medium leading-5 transition-all duration-200',
|
||||
'ring-white ring-opacity-60 ring-offset-2 ring-offset-brand-400 focus:outline-none',
|
||||
selected
|
||||
? 'bg-white dark:bg-zinc-900 text-brand-600 dark:text-brand-400 shadow-sm'
|
||||
: 'text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200 hover:bg-white/[0.12]'
|
||||
)
|
||||
}>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<InformationCircleIcon className="w-4 h-4" />
|
||||
Informações
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab className={({ selected }) =>
|
||||
classNames(
|
||||
'w-full rounded-lg py-2.5 text-sm font-medium leading-5 transition-all duration-200',
|
||||
'ring-white ring-opacity-60 ring-offset-2 ring-offset-brand-400 focus:outline-none',
|
||||
selected
|
||||
? 'bg-white dark:bg-zinc-900 text-brand-600 dark:text-brand-400 shadow-sm'
|
||||
: 'text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200 hover:bg-white/[0.12]'
|
||||
)
|
||||
}>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<CreditCardIcon className="w-4 h-4" />
|
||||
Pagamentos
|
||||
</div>
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
|
||||
<Tab.Panels className="mt-6">
|
||||
{/* Monitoramento Panel */}
|
||||
<Tab.Panel className="space-y-6">
|
||||
{funnels.length > 0 ? (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 bg-brand-50 dark:bg-brand-900/20 rounded-lg">
|
||||
<FunnelIcon className="h-5 w-5 text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-zinc-900 dark:text-white uppercase tracking-wider">Monitoramento de Leads</h3>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400">Acompanhe o progresso dos leads desta campanha no funil.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-xs font-bold text-zinc-500 uppercase">Funil:</label>
|
||||
<select
|
||||
value={selectedFunnelId}
|
||||
onChange={(e) => setSelectedFunnelId(e.target.value)}
|
||||
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg px-3 py-1.5 text-sm font-medium focus:ring-2 focus:ring-brand-500/20 outline-none"
|
||||
>
|
||||
{funnels.map(f => (
|
||||
<option key={f.id} value={f.id}>{f.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-[600px]">
|
||||
<KanbanBoard funnelId={selectedFunnelId} campaignId={id} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-64 bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 text-center p-8">
|
||||
<div className="w-16 h-16 bg-zinc-50 dark:bg-zinc-800 rounded-full flex items-center justify-center mb-4">
|
||||
<FunnelIcon className="w-8 h-8 text-zinc-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-zinc-900 dark:text-white mb-1">
|
||||
Nenhum funil configurado
|
||||
</h3>
|
||||
<p className="text-zinc-500 dark:text-zinc-400 max-w-sm mx-auto">
|
||||
Configure um funil de vendas para começar a monitorar os leads desta campanha.
|
||||
</p>
|
||||
<Link href="/crm/funis" className="mt-4 text-brand-600 font-medium hover:underline">
|
||||
Configurar Funis
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</Tab.Panel>
|
||||
|
||||
{/* Leads Panel */}
|
||||
<Tab.Panel className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="relative w-full lg:w-96">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<MagnifyingGlassIcon className="h-5 w-5 text-zinc-400" aria-hidden="true" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="block w-full pl-10 pr-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg leading-5 bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:outline-none focus:ring-1 focus:ring-[var(--brand-color)] focus:border-[var(--brand-color)] sm:text-sm transition duration-150 ease-in-out"
|
||||
placeholder="Buscar leads nesta campanha..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-zinc-700 dark:text-zinc-300 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors">
|
||||
<FunnelIcon className="w-4 h-4" />
|
||||
Filtros
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredLeads.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 text-center p-8">
|
||||
<div className="w-16 h-16 bg-zinc-50 dark:bg-zinc-800 rounded-full flex items-center justify-center mb-4">
|
||||
<UserGroupIcon className="w-8 h-8 text-zinc-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-zinc-900 dark:text-white mb-1">
|
||||
Nenhum lead encontrado
|
||||
</h3>
|
||||
<p className="text-zinc-500 dark:text-zinc-400 max-w-sm mx-auto">
|
||||
{searchTerm ? 'Nenhum lead corresponde à sua busca.' : 'Esta campanha ainda não possui leads vinculados.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredLeads.map((lead) => (
|
||||
<div key={lead.id} className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 p-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-zinc-900 dark:text-white truncate">
|
||||
{lead.name || 'Sem nome'}
|
||||
</h3>
|
||||
<span className={classNames(
|
||||
'inline-block px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider rounded-full mt-1',
|
||||
STATUS_OPTIONS.find(s => s.value === lead.status)?.color || 'bg-zinc-100 text-zinc-800'
|
||||
)}>
|
||||
{STATUS_OPTIONS.find(s => s.value === lead.status)?.label || lead.status}
|
||||
</span>
|
||||
</div>
|
||||
<button className="p-1 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded text-zinc-400">
|
||||
<EllipsisVerticalIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
{lead.email && (
|
||||
<div className="flex items-center gap-2 text-zinc-600 dark:text-zinc-400">
|
||||
<EnvelopeIcon className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="truncate">{lead.email}</span>
|
||||
</div>
|
||||
)}
|
||||
{lead.phone && (
|
||||
<div className="flex items-center gap-2 text-zinc-600 dark:text-zinc-400">
|
||||
<PhoneIcon className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{lead.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-zinc-100 dark:border-zinc-800 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 text-[10px] text-zinc-400 uppercase font-bold tracking-widest">
|
||||
<CalendarIcon className="w-3 h-3" />
|
||||
{new Date(lead.created_at).toLocaleDateString('pt-BR')}
|
||||
</div>
|
||||
<button className="text-xs font-semibold text-brand-600 dark:text-brand-400 hover:underline">
|
||||
Ver Detalhes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Tab.Panel>
|
||||
|
||||
{/* Info Panel */}
|
||||
<Tab.Panel>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-200 dark:border-zinc-800 overflow-hidden">
|
||||
<div className="p-6 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<h3 className="text-lg font-bold text-zinc-900 dark:text-white">Detalhes da Campanha</h3>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-zinc-400 uppercase tracking-widest mb-2">Descrição</label>
|
||||
<p className="text-zinc-600 dark:text-zinc-400">
|
||||
{campaign.description || 'Nenhuma descrição fornecida para esta campanha.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-zinc-400 uppercase tracking-widest mb-2">Data de Criação</label>
|
||||
<div className="flex items-center gap-2 text-zinc-900 dark:text-white">
|
||||
<CalendarIcon className="w-5 h-5 text-zinc-400" />
|
||||
{new Date(campaign.created_at).toLocaleDateString('pt-BR', { day: '2-digit', month: 'long', year: 'numeric' })}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-zinc-400 uppercase tracking-widest mb-2">Cor de Identificação</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-full shadow-sm" style={{ backgroundColor: campaign.color }}></div>
|
||||
<span className="text-zinc-900 dark:text-white font-medium">{campaign.color}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-200 dark:border-zinc-800 overflow-hidden">
|
||||
<div className="p-6 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<h3 className="text-lg font-bold text-zinc-900 dark:text-white">Configurações de Integração</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="bg-zinc-50 dark:bg-zinc-800/50 rounded-xl p-4 border border-zinc-200 dark:border-zinc-700">
|
||||
<div className="flex items-start gap-3">
|
||||
<InformationCircleIcon className="w-5 h-5 text-brand-500 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-zinc-900 dark:text-white">Webhook de Entrada</h4>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-1">
|
||||
Use este endpoint para enviar leads automaticamente de outras plataformas (Typeform, Elementor, etc).
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<code className="flex-1 block p-2 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded text-[10px] text-zinc-600 dark:text-zinc-400 overflow-x-auto">
|
||||
https://api.aggios.app/v1/webhooks/leads/{campaign.id}
|
||||
</code>
|
||||
<button className="p-2 text-zinc-400 hover:text-brand-500 transition-colors">
|
||||
<TagIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-200 dark:border-zinc-800 p-6">
|
||||
<h3 className="text-lg font-bold text-zinc-900 dark:text-white mb-4">Cliente Responsável</h3>
|
||||
{campaign.customer_id ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center text-brand-600 dark:text-brand-400">
|
||||
<UserIcon className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-zinc-900 dark:text-white">{campaign.customer_name}</p>
|
||||
<p className="text-xs text-zinc-500">Cliente Ativo</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href={`/crm/clientes?id=${campaign.customer_id}`}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-zinc-50 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 text-xs font-bold rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
<BriefcaseIcon className="w-4 h-4" />
|
||||
Ver Perfil do Cliente
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-sm text-zinc-500">Esta é uma campanha geral da agência.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-brand-500 to-brand-600 rounded-2xl p-6 text-white shadow-lg">
|
||||
<h3 className="text-lg font-bold mb-2">Resumo de Performance</h3>
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex justify-between items-end">
|
||||
<span className="text-xs text-brand-100">Total de Leads</span>
|
||||
<span className="text-2xl font-bold">{leads.length}</span>
|
||||
</div>
|
||||
<div className="w-full bg-white/20 rounded-full h-1.5">
|
||||
<div className="bg-white h-1.5 rounded-full" style={{ width: '65%' }}></div>
|
||||
</div>
|
||||
<p className="text-[10px] text-brand-100">
|
||||
+12% em relação ao mês passado
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
|
||||
{/* Payments Panel */}
|
||||
<Tab.Panel>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-200 dark:border-zinc-800 overflow-hidden">
|
||||
<div className="p-12 text-center">
|
||||
<div className="w-20 h-20 bg-zinc-50 dark:bg-zinc-800 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<CreditCardIcon className="w-10 h-10 text-zinc-400" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-zinc-900 dark:text-white mb-2">Módulo de Pagamentos</h3>
|
||||
<p className="text-zinc-500 dark:text-zinc-400 max-w-md mx-auto mb-8">
|
||||
Em breve você poderá gerenciar orçamentos, faturas e pagamentos vinculados diretamente a esta campanha.
|
||||
</p>
|
||||
<button className="px-6 py-3 bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 font-bold rounded-xl hover:opacity-90 transition-opacity">
|
||||
Solicitar Acesso Antecipado
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
622
front-end-agency/app/(agency)/crm/campanhas/page.tsx
Normal file
622
front-end-agency/app/(agency)/crm/campanhas/page.tsx
Normal file
@@ -0,0 +1,622 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Menu, Transition } from '@headlessui/react';
|
||||
import ConfirmDialog from '@/components/layout/ConfirmDialog';
|
||||
import { useToast } from '@/components/layout/ToastContext';
|
||||
import Pagination from '@/components/layout/Pagination';
|
||||
import { useCRMFilter } from '@/contexts/CRMFilterContext';
|
||||
import { SolutionGuard } from '@/components/auth/SolutionGuard';
|
||||
import SearchableSelect from '@/components/form/SearchableSelect';
|
||||
import {
|
||||
ListBulletIcon,
|
||||
TrashIcon,
|
||||
PencilIcon,
|
||||
EllipsisVerticalIcon,
|
||||
MagnifyingGlassIcon,
|
||||
PlusIcon,
|
||||
XMarkIcon,
|
||||
UserGroupIcon,
|
||||
EyeIcon,
|
||||
CalendarIcon,
|
||||
RectangleStackIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
interface List {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
customer_id: string;
|
||||
customer_name: string;
|
||||
funnel_id?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
color: string;
|
||||
customer_count: number;
|
||||
lead_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface Funnel {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Customer {
|
||||
id: string;
|
||||
name: string;
|
||||
company: string;
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
{ name: 'Azul', value: '#3B82F6' },
|
||||
{ name: 'Verde', value: '#10B981' },
|
||||
{ name: 'Roxo', value: '#8B5CF6' },
|
||||
{ name: 'Rosa', value: '#EC4899' },
|
||||
{ name: 'Laranja', value: '#F97316' },
|
||||
{ name: 'Amarelo', value: '#EAB308' },
|
||||
{ name: 'Vermelho', value: '#EF4444' },
|
||||
{ name: 'Cinza', value: '#6B7280' },
|
||||
];
|
||||
|
||||
function CampaignsContent() {
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const { selectedCustomerId } = useCRMFilter();
|
||||
console.log('📢 CampaignsPage render, selectedCustomerId:', selectedCustomerId);
|
||||
|
||||
const [lists, setLists] = useState<List[]>([]);
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [funnels, setFunnels] = useState<Funnel[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingList, setEditingList] = useState<List | null>(null);
|
||||
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [listToDelete, setListToDelete] = useState<string | null>(null);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 10;
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
color: COLORS[0].value,
|
||||
customer_id: '',
|
||||
funnel_id: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
console.log('🔄 CampaignsPage useEffect triggered by selectedCustomerId:', selectedCustomerId);
|
||||
fetchLists();
|
||||
fetchCustomers();
|
||||
fetchFunnels();
|
||||
}, [selectedCustomerId]);
|
||||
|
||||
const fetchFunnels = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/crm/funnels', {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setFunnels(data.funnels || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching funnels:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCustomers = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/crm/customers', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCustomers(data.customers || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching customers:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchLists = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const url = selectedCustomerId
|
||||
? `/api/crm/lists?customer_id=${selectedCustomerId}`
|
||||
: '/api/crm/lists';
|
||||
|
||||
console.log(`📊 Fetching campaigns from: ${url}`);
|
||||
|
||||
const response = await fetch(url, {
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log('📊 Campaigns data received:', data);
|
||||
setLists(data.lists || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching campaigns:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const url = editingList
|
||||
? `/api/crm/lists/${editingList.id}`
|
||||
: '/api/crm/lists';
|
||||
|
||||
const method = editingList ? 'PUT' : 'POST';
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success(
|
||||
editingList ? 'Campanha atualizada' : 'Campanha criada',
|
||||
editingList ? 'A campanha foi atualizada com sucesso.' : 'A nova campanha foi criada com sucesso.'
|
||||
);
|
||||
fetchLists();
|
||||
handleCloseModal();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
toast.error('Erro', error.message || 'Não foi possível salvar a campanha.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving campaign:', error);
|
||||
toast.error('Erro', 'Ocorreu um erro ao salvar a campanha.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewCampaign = () => {
|
||||
setEditingList(null);
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
color: COLORS[0].value,
|
||||
customer_id: selectedCustomerId || '',
|
||||
funnel_id: '',
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (list: List) => {
|
||||
setEditingList(list);
|
||||
setFormData({
|
||||
name: list.name,
|
||||
description: list.description,
|
||||
color: list.color,
|
||||
customer_id: list.customer_id || '',
|
||||
funnel_id: list.funnel_id || '',
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (id: string) => {
|
||||
setListToDelete(id);
|
||||
setConfirmOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!listToDelete) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/crm/lists/${listToDelete}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setLists(lists.filter(l => l.id !== listToDelete));
|
||||
toast.success('Campanha excluída', 'A campanha foi excluída com sucesso.');
|
||||
} else {
|
||||
toast.error('Erro ao excluir', 'Não foi possível excluir a campanha.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting campaign:', error);
|
||||
toast.error('Erro ao excluir', 'Ocorreu um erro ao excluir a campanha.');
|
||||
} finally {
|
||||
setConfirmOpen(false);
|
||||
setListToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setEditingList(null);
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
color: COLORS[0].value,
|
||||
customer_id: '',
|
||||
funnel_id: '',
|
||||
});
|
||||
};
|
||||
|
||||
const filteredLists = lists.filter((list) => {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
return (
|
||||
(list.name?.toLowerCase() || '').includes(searchLower) ||
|
||||
(list.description?.toLowerCase() || '').includes(searchLower)
|
||||
);
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(filteredLists.length / itemsPerPage);
|
||||
const paginatedLists = filteredLists.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-[1600px] mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white tracking-tight">Campanhas</h1>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
|
||||
Organize seus leads e rastreie a origem de cada um
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleNewCampaign}
|
||||
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium text-white rounded-lg hover:opacity-90 transition-opacity"
|
||||
style={{ background: 'var(--gradient)' }}
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
Nova Campanha
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative w-full lg:w-96">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<MagnifyingGlassIcon className="h-5 w-5 text-zinc-400" aria-hidden="true" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="block w-full pl-10 pr-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg leading-5 bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:outline-none focus:ring-1 focus:ring-[var(--brand-color)] focus:border-[var(--brand-color)] sm:text-sm transition duration-150 ease-in-out"
|
||||
placeholder="Buscar campanhas..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64 bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--brand-color)]"></div>
|
||||
</div>
|
||||
) : filteredLists.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 text-center p-8">
|
||||
<div className="w-16 h-16 bg-zinc-50 dark:bg-zinc-800 rounded-full flex items-center justify-center mb-4">
|
||||
<ListBulletIcon className="w-8 h-8 text-zinc-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-zinc-900 dark:text-white mb-1">
|
||||
Nenhuma campanha encontrada
|
||||
</h3>
|
||||
<p className="text-zinc-500 dark:text-zinc-400 max-w-sm mx-auto">
|
||||
{searchTerm ? 'Nenhuma campanha corresponde à sua busca.' : 'Comece criando sua primeira campanha.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-zinc-50/50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-800">
|
||||
<th className="px-6 py-4 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Campanha</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Cliente Vinculado</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Leads</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Criada em</th>
|
||||
<th className="px-6 py-4 text-right text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
{paginatedLists.map((list) => (
|
||||
<tr
|
||||
key={list.id}
|
||||
onClick={() => router.push(`/crm/campanhas/${list.id}`)}
|
||||
className="group hover:bg-zinc-50 dark:hover:bg-zinc-800/50 transition-colors cursor-pointer"
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center text-white shadow-sm"
|
||||
style={{ backgroundColor: list.color }}
|
||||
>
|
||||
<ListBulletIcon className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-zinc-900 dark:text-white">
|
||||
{list.name}
|
||||
</div>
|
||||
{list.description && (
|
||||
<div className="text-xs text-zinc-500 dark:text-zinc-400 truncate max-w-[200px]">
|
||||
{list.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{list.customer_name ? (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-brand-50 text-brand-700 dark:bg-brand-900/20 dark:text-brand-400 border border-brand-100 dark:border-brand-800/50">
|
||||
{list.customer_name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400 border border-zinc-200 dark:border-zinc-700">
|
||||
Geral
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<UserGroupIcon className="w-4 h-4 text-zinc-400" />
|
||||
<span className="text-sm font-bold text-zinc-900 dark:text-white">{list.lead_count || 0}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CalendarIcon className="w-4 h-4 text-zinc-400" />
|
||||
{new Date(list.created_at).toLocaleDateString('pt-BR')}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div className="flex items-center justify-end gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => router.push(`/crm/campanhas/${list.id}`)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold text-brand-600 dark:text-brand-400 bg-brand-50 dark:bg-brand-900/20 rounded-lg hover:bg-brand-100 dark:hover:bg-brand-900/40 transition-all"
|
||||
title="Monitorar Leads"
|
||||
>
|
||||
<RectangleStackIcon className="w-4 h-4" />
|
||||
MONITORAR
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push(`/crm/campanhas/${list.id}`)}
|
||||
className="p-2 text-zinc-400 hover:text-brand-500 dark:hover:text-brand-400 transition-colors"
|
||||
title="Ver Detalhes"
|
||||
>
|
||||
<EyeIcon className="w-5 h-5" />
|
||||
</button>
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<Menu.Button className="p-2 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 transition-colors">
|
||||
<EllipsisVerticalIcon className="w-5 h-5" />
|
||||
</Menu.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items className="absolute right-0 mt-2 w-48 origin-top-right divide-y divide-zinc-100 dark:divide-zinc-800 rounded-xl bg-white dark:bg-zinc-900 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none z-50 border border-zinc-200 dark:border-zinc-800">
|
||||
<div className="px-1 py-1">
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={() => handleEdit(list)}
|
||||
className={`${active ? 'bg-zinc-50 dark:bg-zinc-800' : ''
|
||||
} group flex w-full items-center rounded-lg px-3 py-2 text-sm text-zinc-700 dark:text-zinc-300`}
|
||||
>
|
||||
<PencilIcon className="mr-2 h-4 w-4 text-zinc-400" />
|
||||
Editar
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</div>
|
||||
<div className="px-1 py-1">
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={() => handleDeleteClick(list.id)}
|
||||
className={`${active ? 'bg-red-50 dark:bg-red-900/20' : ''
|
||||
} group flex w-full items-center rounded-lg px-3 py-2 text-sm text-red-600 dark:text-red-400`}
|
||||
>
|
||||
<TrashIcon className="mr-2 h-4 w-4" />
|
||||
Excluir
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={filteredLists.length}
|
||||
itemsPerPage={itemsPerPage}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<div className="fixed inset-0 bg-zinc-900/40 backdrop-blur-sm transition-opacity" onClick={handleCloseModal}></div>
|
||||
|
||||
<div className="relative transform overflow-hidden rounded-2xl bg-white dark:bg-zinc-900 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg border border-zinc-200 dark:border-zinc-800">
|
||||
<div className="absolute right-0 top-0 pr-6 pt-6">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg p-1.5 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
|
||||
onClick={handleCloseModal}
|
||||
>
|
||||
<XMarkIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 sm:p-8">
|
||||
<div className="flex items-start gap-4 mb-6">
|
||||
<div
|
||||
className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl shadow-lg"
|
||||
style={{ backgroundColor: formData.color }}
|
||||
>
|
||||
<ListBulletIcon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-zinc-900 dark:text-white">
|
||||
{editingList ? 'Editar Campanha' : 'Nova Campanha'}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{editingList ? 'Atualize as informações da campanha.' : 'Crie uma nova campanha para organizar seus leads.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<SearchableSelect
|
||||
label="Cliente Vinculado"
|
||||
options={customers.map(c => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
subtitle: c.company || undefined
|
||||
}))}
|
||||
value={formData.customer_id}
|
||||
onChange={(value) => setFormData({ ...formData, customer_id: value || '' })}
|
||||
placeholder="Nenhum cliente (Geral)"
|
||||
emptyText="Nenhum cliente encontrado"
|
||||
helperText="Vincule esta campanha a um cliente específico para melhor organização."
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
|
||||
Nome da Campanha *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="Ex: Black Friday 2025"
|
||||
required
|
||||
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
|
||||
Descrição
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Descreva o propósito desta campanha"
|
||||
rows={3}
|
||||
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent resize-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-3">
|
||||
Cor
|
||||
</label>
|
||||
<div className="grid grid-cols-8 gap-2">
|
||||
{COLORS.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, color: color.value })}
|
||||
className={`w-10 h-10 rounded-lg transition-all ${formData.color === color.value
|
||||
? 'ring-2 ring-offset-2 ring-zinc-400 dark:ring-zinc-600 scale-110'
|
||||
: 'hover:scale-105'
|
||||
}`}
|
||||
style={{ backgroundColor: color.value }}
|
||||
title={color.name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchableSelect
|
||||
label="Funil de Vendas"
|
||||
options={funnels.map(f => ({
|
||||
id: f.id,
|
||||
name: f.name
|
||||
}))}
|
||||
value={formData.funnel_id}
|
||||
onChange={(value) => setFormData({ ...formData, funnel_id: value || '' })}
|
||||
placeholder="Nenhum funil selecionado"
|
||||
emptyText="Nenhum funil encontrado. Crie um funil primeiro."
|
||||
helperText="Leads desta campanha seguirão as etapas do funil selecionado."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-zinc-200 dark:border-zinc-700 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCloseModal}
|
||||
className="flex-1 px-4 py-2.5 border border-zinc-200 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 font-medium rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-4 py-2.5 text-white font-medium rounded-lg transition-all shadow-lg hover:shadow-xl"
|
||||
style={{ background: 'var(--gradient)' }}
|
||||
>
|
||||
{editingList ? 'Atualizar' : 'Criar Campanha'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={confirmOpen}
|
||||
onClose={() => {
|
||||
setConfirmOpen(false);
|
||||
setListToDelete(null);
|
||||
}}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="Excluir Campanha"
|
||||
message="Tem certeza que deseja excluir esta campanha? Os leads não serão excluídos, apenas removidos da campanha."
|
||||
confirmText="Excluir"
|
||||
cancelText="Cancelar"
|
||||
variant="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CampaignsPage() {
|
||||
return (
|
||||
<SolutionGuard requiredSolution="crm">
|
||||
<CampaignsContent />
|
||||
</SolutionGuard>
|
||||
);
|
||||
}
|
||||
1369
front-end-agency/app/(agency)/crm/clientes/page.tsx
Normal file
1369
front-end-agency/app/(agency)/crm/clientes/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
426
front-end-agency/app/(agency)/crm/funis/[id]/page.tsx
Normal file
426
front-end-agency/app/(agency)/crm/funis/[id]/page.tsx
Normal file
@@ -0,0 +1,426 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { FunnelIcon, Cog6ToothIcon, TrashIcon, PencilIcon, CheckIcon, ChevronUpIcon, ChevronDownIcon, RectangleStackIcon, ArrowLeftIcon } from '@heroicons/react/24/outline';
|
||||
import KanbanBoard from '@/components/crm/KanbanBoard';
|
||||
import { useToast } from '@/components/layout/ToastContext';
|
||||
import Modal from '@/components/layout/Modal';
|
||||
import ConfirmDialog from '@/components/layout/ConfirmDialog';
|
||||
|
||||
interface Stage {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
order_index: number;
|
||||
}
|
||||
|
||||
interface Funnel {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
export default function FunnelDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const funnelId = params.id as string;
|
||||
const [funnel, setFunnel] = useState<Funnel | null>(null);
|
||||
const [stages, setStages] = useState<Stage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [editingStageId, setEditingStageId] = useState<string | null>(null);
|
||||
const [confirmStageOpen, setConfirmStageOpen] = useState(false);
|
||||
const [stageToDelete, setStageToDelete] = useState<string | null>(null);
|
||||
const [newStageForm, setNewStageForm] = useState({ name: '', color: '#3b82f6' });
|
||||
const [editStageForm, setEditStageForm] = useState<{ id: string; name: string; color: string }>({ id: '', name: '', color: '' });
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
fetchFunnel();
|
||||
fetchStages();
|
||||
}, [funnelId]);
|
||||
|
||||
const fetchFunnel = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/crm/funnels/${funnelId}`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setFunnel(data.funnel);
|
||||
} else {
|
||||
toast.error('Funil não encontrado');
|
||||
router.push('/crm/funis');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching funnel:', error);
|
||||
toast.error('Erro ao carregar funil');
|
||||
router.push('/crm/funis');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchStages = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/crm/funnels/${funnelId}/stages`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setStages((data.stages || []).sort((a: Stage, b: Stage) => a.order_index - b.order_index));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching stages:', error);
|
||||
toast.error('Erro ao carregar etapas');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddStage = async () => {
|
||||
if (!newStageForm.name.trim()) {
|
||||
toast.error('Digite o nome da etapa');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`/api/crm/funnels/${funnelId}/stages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: newStageForm.name,
|
||||
color: newStageForm.color,
|
||||
order_index: stages.length
|
||||
})
|
||||
});
|
||||
if (response.ok) {
|
||||
toast.success('Etapa criada');
|
||||
setNewStageForm({ name: '', color: '#3b82f6' });
|
||||
fetchStages();
|
||||
// Notificar o KanbanBoard para refetch
|
||||
window.dispatchEvent(new Event('kanban-refresh'));
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Erro ao criar etapa');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateStage = async () => {
|
||||
if (!editStageForm.name.trim()) {
|
||||
toast.error('Nome não pode estar vazio');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`/api/crm/funnels/${funnelId}/stages/${editStageForm.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: editStageForm.name,
|
||||
color: editStageForm.color,
|
||||
order_index: stages.find(s => s.id === editStageForm.id)?.order_index || 0
|
||||
})
|
||||
});
|
||||
if (response.ok) {
|
||||
toast.success('Etapa atualizada');
|
||||
setEditingStageId(null);
|
||||
fetchStages();
|
||||
window.dispatchEvent(new Event('kanban-refresh'));
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Erro ao atualizar etapa');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteStage = async () => {
|
||||
if (!stageToDelete) return;
|
||||
try {
|
||||
const response = await fetch(`/api/crm/funnels/${funnelId}/stages/${stageToDelete}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
if (response.ok) {
|
||||
toast.success('Etapa excluída');
|
||||
fetchStages();
|
||||
window.dispatchEvent(new Event('kanban-refresh'));
|
||||
} else {
|
||||
toast.error('Erro ao excluir etapa');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Erro ao excluir etapa');
|
||||
} finally {
|
||||
setConfirmStageOpen(false);
|
||||
setStageToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveStage = async (stageId: string, direction: 'up' | 'down') => {
|
||||
const idx = stages.findIndex(s => s.id === stageId);
|
||||
if (idx === -1) return;
|
||||
if (direction === 'up' && idx === 0) return;
|
||||
if (direction === 'down' && idx === stages.length - 1) return;
|
||||
|
||||
const newStages = [...stages];
|
||||
const targetIdx = direction === 'up' ? idx - 1 : idx + 1;
|
||||
[newStages[idx], newStages[targetIdx]] = [newStages[targetIdx], newStages[idx]];
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
newStages.map((s, i) =>
|
||||
fetch(`/api/crm/funnels/${funnelId}/stages/${s.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify({ ...s, order_index: i })
|
||||
})
|
||||
)
|
||||
);
|
||||
fetchStages();
|
||||
window.dispatchEvent(new Event('kanban-refresh'));
|
||||
} catch (error) {
|
||||
toast.error('Erro ao reordenar etapas');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--brand-color)]"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!funnel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-[1600px] mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => router.push('/crm/funis')}
|
||||
className="p-2 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg transition-colors"
|
||||
title="Voltar"
|
||||
>
|
||||
<ArrowLeftIcon className="w-5 h-5 text-zinc-700 dark:text-zinc-300" />
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg flex items-center justify-center text-white shadow-sm bg-gradient-to-br from-brand-500 to-brand-600">
|
||||
<FunnelIcon className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white tracking-tight flex items-center gap-2">
|
||||
{funnel.name}
|
||||
{funnel.is_default && (
|
||||
<span className="inline-block px-2 py-0.5 text-xs font-bold text-brand-600 bg-brand-50 dark:bg-brand-900/30 rounded">
|
||||
PADRÃO
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
{funnel.description && (
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-0.5">
|
||||
{funnel.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsSettingsModalOpen(true)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium text-zinc-700 dark:text-zinc-300 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
<Cog6ToothIcon className="w-4 h-4" />
|
||||
Configurar Etapas
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Kanban */}
|
||||
{stages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 text-center p-8">
|
||||
<div className="w-16 h-16 bg-zinc-50 dark:bg-zinc-800 rounded-full flex items-center justify-center mb-4">
|
||||
<RectangleStackIcon className="w-8 h-8 text-zinc-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-zinc-900 dark:text-white mb-1">
|
||||
Nenhuma etapa configurada
|
||||
</h3>
|
||||
<p className="text-zinc-500 dark:text-zinc-400 max-w-sm mx-auto mb-4">
|
||||
Configure as etapas do funil para começar a gerenciar seus leads.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setIsSettingsModalOpen(true)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium text-white rounded-lg hover:opacity-90 transition-opacity"
|
||||
style={{ background: 'var(--gradient)' }}
|
||||
>
|
||||
<Cog6ToothIcon className="w-4 h-4" />
|
||||
Configurar Etapas
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<KanbanBoard
|
||||
funnelId={funnelId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Modal Configurações */}
|
||||
<Modal
|
||||
isOpen={isSettingsModalOpen}
|
||||
onClose={() => setIsSettingsModalOpen(false)}
|
||||
title="Configurar Etapas do Funil"
|
||||
maxWidth="2xl"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Nova Etapa */}
|
||||
<div className="bg-zinc-50 dark:bg-zinc-800/50 p-4 rounded-xl space-y-3">
|
||||
<h3 className="text-sm font-bold text-zinc-700 dark:text-zinc-300">Nova Etapa</h3>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Nome da etapa"
|
||||
className="w-full px-4 py-2.5 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-brand-500/20 outline-none"
|
||||
value={newStageForm.name}
|
||||
onChange={e => setNewStageForm({ ...newStageForm, name: e.target.value })}
|
||||
onKeyPress={e => e.key === 'Enter' && handleAddStage()}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={newStageForm.color}
|
||||
onChange={e => setNewStageForm({ ...newStageForm, color: e.target.value })}
|
||||
className="w-12 h-10 rounded-lg cursor-pointer"
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddStage}
|
||||
className="px-4 py-2.5 text-sm font-bold text-white rounded-xl transition-all"
|
||||
style={{ background: 'var(--gradient)' }}
|
||||
>
|
||||
Adicionar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lista de Etapas */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-bold text-zinc-700 dark:text-zinc-300">Etapas Configuradas</h3>
|
||||
{stages.length === 0 ? (
|
||||
<div className="text-center py-8 text-zinc-500 dark:text-zinc-400">
|
||||
Nenhuma etapa configurada. Adicione a primeira etapa acima.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto pr-2 scrollbar-thin">
|
||||
{stages.map((stage, idx) => (
|
||||
<div
|
||||
key={stage.id}
|
||||
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl p-4 flex items-center gap-3"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
onClick={() => handleMoveStage(stage.id, 'up')}
|
||||
disabled={idx === 0}
|
||||
className="p-1 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronUpIcon className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMoveStage(stage.id, 'down')}
|
||||
disabled={idx === stages.length - 1}
|
||||
className="p-1 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronDownIcon className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{editingStageId === stage.id ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 px-3 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg text-sm focus:ring-2 focus:ring-brand-500/20 outline-none"
|
||||
value={editStageForm.name}
|
||||
onChange={e => setEditStageForm({ ...editStageForm, name: e.target.value })}
|
||||
onKeyPress={e => e.key === 'Enter' && handleUpdateStage()}
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
value={editStageForm.color}
|
||||
onChange={e => setEditStageForm({ ...editStageForm, color: e.target.value })}
|
||||
className="w-12 h-10 rounded-lg cursor-pointer"
|
||||
/>
|
||||
<button
|
||||
onClick={handleUpdateStage}
|
||||
className="p-2 text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20 rounded-lg"
|
||||
>
|
||||
<CheckIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="w-6 h-6 rounded-lg shadow-sm"
|
||||
style={{ backgroundColor: stage.color }}
|
||||
></div>
|
||||
<span className="flex-1 font-medium text-zinc-900 dark:text-white">{stage.name}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingStageId(stage.id);
|
||||
setEditStageForm({ id: stage.id, name: stage.name, color: stage.color });
|
||||
}}
|
||||
className="p-2 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg"
|
||||
>
|
||||
<PencilIcon className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setStageToDelete(stage.id);
|
||||
setConfirmStageOpen(true);
|
||||
}}
|
||||
className="p-2 text-zinc-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg"
|
||||
>
|
||||
<TrashIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4 border-t border-zinc-100 dark:border-zinc-800">
|
||||
<button
|
||||
onClick={() => setIsSettingsModalOpen(false)}
|
||||
className="px-6 py-2.5 text-sm font-bold text-white rounded-xl transition-all"
|
||||
style={{ background: 'var(--gradient)' }}
|
||||
>
|
||||
Concluir
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={confirmStageOpen}
|
||||
onClose={() => {
|
||||
setConfirmStageOpen(false);
|
||||
setStageToDelete(null);
|
||||
}}
|
||||
onConfirm={handleDeleteStage}
|
||||
title="Excluir Etapa"
|
||||
message="Tem certeza que deseja excluir esta etapa? Leads nesta etapa permanecerão no funil mas sem uma etapa definida."
|
||||
confirmText="Excluir"
|
||||
cancelText="Cancelar"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
456
front-end-agency/app/(agency)/crm/funis/page.tsx
Normal file
456
front-end-agency/app/(agency)/crm/funis/page.tsx
Normal file
@@ -0,0 +1,456 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { FunnelIcon, PlusIcon, TrashIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useToast } from '@/components/layout/ToastContext';
|
||||
import Modal from '@/components/layout/Modal';
|
||||
import ConfirmDialog from '@/components/layout/ConfirmDialog';
|
||||
|
||||
interface Funnel {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
const FUNNEL_TEMPLATES = [
|
||||
{
|
||||
name: 'Vendas Padrão',
|
||||
description: 'Funil clássico para prospecção e fechamento de negócios.',
|
||||
stages: [
|
||||
{ name: 'Novo Lead', color: '#3b82f6' },
|
||||
{ name: 'Qualificado', color: '#10b981' },
|
||||
{ name: 'Reunião Agendada', color: '#f59e0b' },
|
||||
{ name: 'Proposta Enviada', color: '#6366f1' },
|
||||
{ name: 'Negociação', color: '#8b5cf6' },
|
||||
{ name: 'Fechado / Ganho', color: '#22c55e' },
|
||||
{ name: 'Perdido', color: '#ef4444' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Onboarding de Clientes',
|
||||
description: 'Acompanhamento após a venda até o sucesso do cliente.',
|
||||
stages: [
|
||||
{ name: 'Contrato Assinado', color: '#10b981' },
|
||||
{ name: 'Briefing', color: '#3b82f6' },
|
||||
{ name: 'Setup Inicial', color: '#6366f1' },
|
||||
{ name: 'Treinamento', color: '#f59e0b' },
|
||||
{ name: 'Lançamento', color: '#8b5cf6' },
|
||||
{ name: 'Sucesso', color: '#22c55e' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Suporte / Atendimento',
|
||||
description: 'Gestão de chamados e solicitações de clientes.',
|
||||
stages: [
|
||||
{ name: 'Aberto', color: '#ef4444' },
|
||||
{ name: 'Em Atendimento', color: '#f59e0b' },
|
||||
{ name: 'Aguardando Cliente', color: '#3b82f6' },
|
||||
{ name: 'Resolvido', color: '#10b981' },
|
||||
{ name: 'Fechado', color: '#71717a' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export default function FunisPage() {
|
||||
const router = useRouter();
|
||||
const [funnels, setFunnels] = useState<Funnel[]>([]);
|
||||
const [campaigns, setCampaigns] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isFunnelModalOpen, setIsFunnelModalOpen] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [funnelToDelete, setFunnelToDelete] = useState<string | null>(null);
|
||||
|
||||
const [funnelForm, setFunnelForm] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
template_index: -1,
|
||||
campaign_id: ''
|
||||
});
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
fetchFunnels();
|
||||
fetchCampaigns();
|
||||
}, []);
|
||||
|
||||
const fetchCampaigns = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/crm/lists', {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCampaigns(data.lists || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar campanhas:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchFunnels = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/crm/funnels', {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setFunnels(data.funnels || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching funnels:', error);
|
||||
toast.error('Erro ao carregar funis');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateFunnel = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const response = await fetch('/api/crm/funnels', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: funnelForm.name,
|
||||
description: funnelForm.description,
|
||||
is_default: funnels.length === 0
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const newFunnelId = data.id;
|
||||
|
||||
// Se selecionou uma campanha, vincular o funil a ela
|
||||
if (funnelForm.campaign_id) {
|
||||
const campaign = campaigns.find(c => c.id === funnelForm.campaign_id);
|
||||
if (campaign) {
|
||||
await fetch(`/api/crm/lists/${campaign.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...campaign,
|
||||
funnel_id: newFunnelId
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Se escolheu um template, criar as etapas
|
||||
if (funnelForm.template_index >= 0) {
|
||||
const template = FUNNEL_TEMPLATES[funnelForm.template_index];
|
||||
for (let i = 0; i < template.stages.length; i++) {
|
||||
const s = template.stages[i];
|
||||
await fetch(`/api/crm/funnels/${newFunnelId}/stages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: s.name,
|
||||
color: s.color,
|
||||
order_index: i
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toast.success('Funil criado com sucesso');
|
||||
setIsFunnelModalOpen(false);
|
||||
setFunnelForm({ name: '', description: '', template_index: -1, campaign_id: '' });
|
||||
fetchFunnels();
|
||||
router.push(`/crm/funis/${newFunnelId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Erro ao criar funil');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFunnel = async () => {
|
||||
if (!funnelToDelete) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/crm/funnels/${funnelToDelete}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
if (response.ok) {
|
||||
toast.success('Funil excluído com sucesso');
|
||||
setFunnels(funnels.filter(f => f.id !== funnelToDelete));
|
||||
} else {
|
||||
toast.error('Erro ao excluir funil');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Erro ao excluir funil');
|
||||
} finally {
|
||||
setConfirmOpen(false);
|
||||
setFunnelToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredFunnels = funnels.filter(f =>
|
||||
f.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(f.description || '').toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-[1600px] mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white tracking-tight">Funis de Vendas</h1>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
|
||||
Gerencie seus funis e acompanhe o progresso dos leads
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsFunnelModalOpen(true)}
|
||||
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium text-white rounded-lg hover:opacity-90 transition-opacity"
|
||||
style={{ background: 'var(--gradient)' }}
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
Novo Funil
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative w-full lg:w-96">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<MagnifyingGlassIcon className="h-5 w-5 text-zinc-400" aria-hidden="true" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="block w-full pl-10 pr-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg leading-5 bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:outline-none focus:ring-1 focus:ring-[var(--brand-color)] focus:border-[var(--brand-color)] sm:text-sm transition duration-150 ease-in-out"
|
||||
placeholder="Buscar funis..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64 bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--brand-color)]"></div>
|
||||
</div>
|
||||
) : filteredFunnels.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 text-center p-8">
|
||||
<div className="w-16 h-16 bg-zinc-50 dark:bg-zinc-800 rounded-full flex items-center justify-center mb-4">
|
||||
<FunnelIcon className="w-8 h-8 text-zinc-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-zinc-900 dark:text-white mb-1">
|
||||
Nenhum funil encontrado
|
||||
</h3>
|
||||
<p className="text-zinc-500 dark:text-zinc-400 max-w-sm mx-auto">
|
||||
{searchTerm ? 'Nenhum funil corresponde à sua busca.' : 'Comece criando seu primeiro funil de vendas.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-zinc-50/50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-800">
|
||||
<th className="px-6 py-4 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Funil</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Etapas</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Status</th>
|
||||
<th className="px-6 py-4 text-right text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
{filteredFunnels.map((funnel) => (
|
||||
<tr
|
||||
key={funnel.id}
|
||||
onClick={() => router.push(`/crm/funis/${funnel.id}`)}
|
||||
className="group hover:bg-zinc-50 dark:hover:bg-zinc-800/50 transition-colors cursor-pointer"
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg flex items-center justify-center text-white shadow-sm bg-gradient-to-br from-brand-500 to-brand-600">
|
||||
<FunnelIcon className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-zinc-900 dark:text-white flex items-center gap-2">
|
||||
{funnel.name}
|
||||
{funnel.is_default && (
|
||||
<span className="inline-block px-1.5 py-0.5 text-[10px] font-bold text-brand-600 bg-brand-50 dark:bg-brand-900/30 rounded">
|
||||
PADRÃO
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{funnel.description && (
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400 truncate max-w-md">
|
||||
{funnel.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-zinc-700 dark:text-zinc-300">
|
||||
Clique para ver
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
|
||||
Ativo
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFunnelToDelete(funnel.id);
|
||||
setConfirmOpen(true);
|
||||
}}
|
||||
className="text-zinc-400 hover:text-red-600 transition-colors p-2"
|
||||
title="Excluir"
|
||||
>
|
||||
<TrashIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal Criar Funil */}
|
||||
<Modal
|
||||
isOpen={isFunnelModalOpen}
|
||||
onClose={() => setIsFunnelModalOpen(false)}
|
||||
title="Criar Novo Funil"
|
||||
maxWidth="2xl"
|
||||
>
|
||||
<form onSubmit={handleCreateFunnel} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-bold text-zinc-500 uppercase ml-1">Nome do Funil</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="w-full px-4 py-2.5 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-brand-500/20 outline-none"
|
||||
placeholder="Ex: Vendas High Ticket"
|
||||
value={funnelForm.name}
|
||||
onChange={e => setFunnelForm({ ...funnelForm, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-bold text-zinc-500 uppercase ml-1">Descrição (Opcional)</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
className="w-full px-4 py-2.5 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-brand-500/20 outline-none resize-none"
|
||||
placeholder="Para que serve este funil?"
|
||||
value={funnelForm.description}
|
||||
onChange={e => setFunnelForm({ ...funnelForm, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-bold text-zinc-500 uppercase ml-1">Vincular à Campanha (Opcional)</label>
|
||||
<select
|
||||
className="w-full px-4 py-2.5 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-brand-500/20 outline-none"
|
||||
value={funnelForm.campaign_id}
|
||||
onChange={e => setFunnelForm({ ...funnelForm, campaign_id: e.target.value })}
|
||||
>
|
||||
<option value="">Nenhuma campanha selecionada</option>
|
||||
{campaigns.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="text-xs font-bold text-zinc-500 uppercase ml-1">Escolha um Template</label>
|
||||
<div className="space-y-2 max-h-[250px] overflow-y-auto pr-2 scrollbar-thin">
|
||||
{FUNNEL_TEMPLATES.map((template, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => setFunnelForm({ ...funnelForm, template_index: idx })}
|
||||
className={`w-full p-4 text-left rounded-xl border transition-all ${funnelForm.template_index === idx
|
||||
? 'border-brand-500 bg-brand-50/50 dark:bg-brand-900/10 ring-1 ring-brand-500'
|
||||
: 'border-zinc-200 dark:border-zinc-800 hover:border-zinc-300 dark:hover:border-zinc-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-bold text-sm text-zinc-900 dark:text-white">{template.name}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-zinc-500 dark:text-zinc-400 leading-relaxed">
|
||||
{template.description}
|
||||
</p>
|
||||
<div className="mt-2 flex gap-1">
|
||||
{template.stages.slice(0, 4).map((s, i) => (
|
||||
<div key={i} className="h-1 w-4 rounded-full" style={{ backgroundColor: s.color }}></div>
|
||||
))}
|
||||
{template.stages.length > 4 && <span className="text-[8px] text-zinc-400">+{template.stages.length - 4}</span>}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFunnelForm({ ...funnelForm, template_index: -1 })}
|
||||
className={`w-full p-4 text-left rounded-xl border transition-all ${funnelForm.template_index === -1
|
||||
? 'border-brand-500 bg-brand-50/50 dark:bg-brand-900/10 ring-1 ring-brand-500'
|
||||
: 'border-zinc-200 dark:border-zinc-800 hover:border-zinc-300 dark:hover:border-zinc-700'
|
||||
}`}
|
||||
>
|
||||
<span className="font-bold text-sm text-zinc-900 dark:text-white">Personalizado</span>
|
||||
<p className="text-[10px] text-zinc-500 dark:text-zinc-400">Comece com um funil vazio e crie suas próprias etapas.</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-6 border-t border-zinc-100 dark:border-zinc-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsFunnelModalOpen(false)}
|
||||
className="px-6 py-2.5 text-sm font-bold text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className="px-6 py-2.5 text-sm font-bold text-white rounded-xl transition-all disabled:opacity-50"
|
||||
style={{ background: 'var(--gradient)' }}
|
||||
>
|
||||
{isSaving ? 'Criando...' : 'Criar Funil'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={confirmOpen}
|
||||
onClose={() => {
|
||||
setConfirmOpen(false);
|
||||
setFunnelToDelete(null);
|
||||
}}
|
||||
onConfirm={handleDeleteFunnel}
|
||||
title="Excluir Funil"
|
||||
message="Tem certeza que deseja excluir este funil e todas as suas etapas? Leads vinculados a este funil ficarão órfãos."
|
||||
confirmText="Excluir"
|
||||
cancelText="Cancelar"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
648
front-end-agency/app/(agency)/crm/leads/importar/page.tsx
Normal file
648
front-end-agency/app/(agency)/crm/leads/importar/page.tsx
Normal file
@@ -0,0 +1,648 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, Suspense, useRef } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useToast } from '@/components/layout/ToastContext';
|
||||
import Papa from 'papaparse';
|
||||
import {
|
||||
ArrowUpTrayIcon,
|
||||
DocumentTextIcon,
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
ArrowPathIcon,
|
||||
ChevronLeftIcon,
|
||||
InformationCircleIcon,
|
||||
TableCellsIcon,
|
||||
CommandLineIcon,
|
||||
CpuChipIcon,
|
||||
CloudArrowUpIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
interface Customer {
|
||||
id: string;
|
||||
name: string;
|
||||
company: string;
|
||||
}
|
||||
|
||||
interface Campaign {
|
||||
id: string;
|
||||
name: string;
|
||||
customer_id: string;
|
||||
}
|
||||
|
||||
function ImportLeadsContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const campaignIdFromUrl = searchParams.get('campaign');
|
||||
const customerIdFromUrl = searchParams.get('customer');
|
||||
|
||||
const toast = useToast();
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
|
||||
const [selectedCustomer, setSelectedCustomer] = useState(customerIdFromUrl || '');
|
||||
const [selectedCampaign, setSelectedCampaign] = useState(campaignIdFromUrl || '');
|
||||
const [jsonContent, setJsonContent] = useState('');
|
||||
const [csvFile, setCsvFile] = useState<File | null>(null);
|
||||
const [preview, setPreview] = useState<any[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [importType, setImportType] = useState<'json' | 'csv' | 'typebot' | 'api'>('json');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Mapeamento inteligente de campos
|
||||
const mapLeadData = (data: any[]) => {
|
||||
const fieldMap: Record<string, string[]> = {
|
||||
name: ['nome', 'name', 'full name', 'nome completo', 'cliente', 'contato'],
|
||||
email: ['email', 'e-mail', 'mail', 'correio'],
|
||||
phone: ['phone', 'telefone', 'celular', 'mobile', 'whatsapp', 'zap', 'tel'],
|
||||
source: ['source', 'origem', 'canal', 'campanha', 'midia', 'mídia', 'campaign'],
|
||||
status: ['status', 'fase', 'etapa', 'situação', 'situacao'],
|
||||
notes: ['notes', 'notas', 'observações', 'observacoes', 'obs', 'comentário', 'comentario'],
|
||||
};
|
||||
|
||||
return data.map(item => {
|
||||
const mapped: any = { ...item };
|
||||
const itemKeys = Object.keys(item);
|
||||
|
||||
// Tenta encontrar correspondências para cada campo principal
|
||||
Object.entries(fieldMap).forEach(([targetKey, aliases]) => {
|
||||
const foundKey = itemKeys.find(k =>
|
||||
aliases.includes(k.toLowerCase().trim())
|
||||
);
|
||||
if (foundKey && !mapped[targetKey]) {
|
||||
mapped[targetKey] = item[foundKey];
|
||||
}
|
||||
});
|
||||
|
||||
// Garante que campos básicos existam
|
||||
if (!mapped.name && mapped.Nome) mapped.name = mapped.Nome;
|
||||
if (!mapped.email && mapped.Email) mapped.email = mapped.Email;
|
||||
if (!mapped.phone && (mapped.Celular || mapped.Telefone)) mapped.phone = mapped.Celular || mapped.Telefone;
|
||||
|
||||
return mapped;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (file.type !== 'text/csv' && !file.name.endsWith('.csv')) {
|
||||
toast.error('Erro', 'Por favor, selecione um arquivo CSV válido.');
|
||||
return;
|
||||
}
|
||||
|
||||
setCsvFile(file);
|
||||
setError(null);
|
||||
|
||||
// Tenta ler o arquivo primeiro para detectar onde começam os dados
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const text = event.target?.result as string;
|
||||
const lines = text.split('\n');
|
||||
|
||||
// Procura a linha que parece ser o cabeçalho (contém Nome, Email ou Celular)
|
||||
let headerIndex = 0;
|
||||
for (let i = 0; i < Math.min(lines.length, 10); i++) {
|
||||
const lowerLine = lines[i].toLowerCase();
|
||||
if (lowerLine.includes('nome') || lowerLine.includes('email') || lowerLine.includes('celular')) {
|
||||
headerIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const csvData = lines.slice(headerIndex).join('\n');
|
||||
|
||||
Papa.parse(csvData, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
complete: (results) => {
|
||||
if (results.errors.length > 0 && results.data.length === 0) {
|
||||
setError('Erro ao processar CSV. Verifique a formatação.');
|
||||
setPreview([]);
|
||||
} else {
|
||||
const mappedData = mapLeadData(results.data);
|
||||
setPreview(mappedData.slice(0, 5));
|
||||
}
|
||||
},
|
||||
error: (err: any) => {
|
||||
setError('Falha ao ler o arquivo.');
|
||||
setPreview([]);
|
||||
}
|
||||
});
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [custRes, campRes] = await Promise.all([
|
||||
fetch('/api/crm/customers', {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
}),
|
||||
fetch('/api/crm/lists', {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
})
|
||||
]);
|
||||
|
||||
let fetchedCampaigns: Campaign[] = [];
|
||||
if (campRes.ok) {
|
||||
const data = await campRes.json();
|
||||
fetchedCampaigns = data.lists || [];
|
||||
setCampaigns(fetchedCampaigns);
|
||||
}
|
||||
|
||||
if (custRes.ok) {
|
||||
const data = await custRes.json();
|
||||
setCustomers(data.customers || []);
|
||||
}
|
||||
|
||||
// Se veio da campanha, tenta setar o cliente automaticamente
|
||||
if (campaignIdFromUrl && fetchedCampaigns.length > 0) {
|
||||
const campaign = fetchedCampaigns.find(c => c.id === campaignIdFromUrl);
|
||||
if (campaign && campaign.customer_id) {
|
||||
setSelectedCustomer(campaign.customer_id);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleJsonChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const content = e.target.value;
|
||||
setJsonContent(content);
|
||||
setError(null);
|
||||
|
||||
if (!content.trim()) {
|
||||
setPreview([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
const leads = Array.isArray(parsed) ? parsed : [parsed];
|
||||
const mappedData = mapLeadData(leads);
|
||||
setPreview(mappedData.slice(0, 5));
|
||||
} catch (err) {
|
||||
setError('JSON inválido. Verifique a formatação.');
|
||||
setPreview([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
let leads: any[] = [];
|
||||
|
||||
if (importType === 'json') {
|
||||
if (!jsonContent.trim() || error) {
|
||||
toast.error('Erro', 'Por favor, insira um JSON válido.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(jsonContent);
|
||||
leads = Array.isArray(parsed) ? parsed : [parsed];
|
||||
} catch (err) {
|
||||
toast.error('Erro', 'JSON inválido.');
|
||||
return;
|
||||
}
|
||||
} else if (importType === 'csv') {
|
||||
if (!csvFile || error) {
|
||||
toast.error('Erro', 'Por favor, selecione um arquivo CSV válido.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse CSV again to get all data
|
||||
const results = await new Promise<any[]>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const text = event.target?.result as string;
|
||||
const lines = text.split('\n');
|
||||
let headerIndex = 0;
|
||||
for (let i = 0; i < Math.min(lines.length, 10); i++) {
|
||||
const lowerLine = lines[i].toLowerCase();
|
||||
if (lowerLine.includes('nome') || lowerLine.includes('email') || lowerLine.includes('celular')) {
|
||||
headerIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const csvData = lines.slice(headerIndex).join('\n');
|
||||
Papa.parse(csvData, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
complete: (results: any) => resolve(results.data)
|
||||
});
|
||||
};
|
||||
reader.readAsText(csvFile);
|
||||
});
|
||||
leads = results;
|
||||
}
|
||||
|
||||
if (leads.length === 0) {
|
||||
toast.error('Erro', 'Nenhum lead encontrado para importar.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Aplica o mapeamento inteligente antes de enviar
|
||||
const mappedLeads = mapLeadData(leads);
|
||||
|
||||
setImporting(true);
|
||||
try {
|
||||
const response = await fetch('/api/crm/leads/import', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
customer_id: selectedCustomer,
|
||||
campaign_id: selectedCampaign,
|
||||
leads: mappedLeads
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
toast.success('Sucesso', `${result.count} leads importados com sucesso.`);
|
||||
|
||||
// Se veio de uma campanha, volta para a campanha
|
||||
if (campaignIdFromUrl) {
|
||||
router.push(`/crm/campanhas/${campaignIdFromUrl}`);
|
||||
} else {
|
||||
router.push('/crm/leads');
|
||||
}
|
||||
} else {
|
||||
const errData = await response.json();
|
||||
toast.error('Erro na importação', errData.error || 'Ocorreu um erro ao importar os leads.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Import error:', err);
|
||||
toast.error('Erro', 'Falha ao processar a importação.');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="p-2 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800 text-zinc-500 transition-colors"
|
||||
>
|
||||
<ChevronLeftIcon className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white tracking-tight">Importar Leads</h1>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
|
||||
Selecione o método de importação e organize seus leads
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Import Methods */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<button
|
||||
onClick={() => setImportType('json')}
|
||||
className={`p-4 rounded-xl border transition-all text-left flex flex-col gap-3 ${importType === 'json'
|
||||
? 'bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800 ring-1 ring-blue-500'
|
||||
: 'bg-white border-zinc-200 dark:bg-zinc-900 dark:border-zinc-800 hover:border-zinc-300 dark:hover:border-zinc-700'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${importType === 'json' ? 'bg-blue-500 text-white' : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500'}`}>
|
||||
<DocumentTextIcon className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-zinc-900 dark:text-white">JSON</h3>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400">Importação via código</p>
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
<span className="text-[10px] font-bold uppercase px-1.5 py-0.5 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 rounded">Ativo</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setImportType('csv');
|
||||
setPreview([]);
|
||||
setError(null);
|
||||
}}
|
||||
className={`p-4 rounded-xl border transition-all text-left flex flex-col gap-3 ${importType === 'csv'
|
||||
? 'bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800 ring-1 ring-blue-500'
|
||||
: 'bg-white border-zinc-200 dark:bg-zinc-900 dark:border-zinc-800 hover:border-zinc-300 dark:hover:border-zinc-700'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${importType === 'csv' ? 'bg-blue-500 text-white' : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500'}`}>
|
||||
<TableCellsIcon className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-zinc-900 dark:text-white">CSV / Excel</h3>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400">Planilhas padrão</p>
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
<span className="text-[10px] font-bold uppercase px-1.5 py-0.5 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 rounded">Ativo</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
disabled
|
||||
className="p-4 rounded-xl border bg-zinc-50/50 dark:bg-zinc-900/50 border-zinc-200 dark:border-zinc-800 opacity-60 cursor-not-allowed text-left flex flex-col gap-3"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg flex items-center justify-center bg-zinc-100 dark:bg-zinc-800 text-zinc-400">
|
||||
<CpuChipIcon className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-zinc-400">Typebot</h3>
|
||||
<p className="text-xs text-zinc-400">Integração direta</p>
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
<span className="text-[10px] font-bold uppercase px-1.5 py-0.5 bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-500 rounded">Em breve</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
disabled
|
||||
className="p-4 rounded-xl border bg-zinc-50/50 dark:bg-zinc-900/50 border-zinc-200 dark:border-zinc-800 opacity-60 cursor-not-allowed text-left flex flex-col gap-3"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg flex items-center justify-center bg-zinc-100 dark:bg-zinc-800 text-zinc-400">
|
||||
<CommandLineIcon className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-zinc-400">API / Webhook</h3>
|
||||
<p className="text-xs text-zinc-400">Endpoint externo</p>
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
<span className="text-[10px] font-bold uppercase px-1.5 py-0.5 bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-500 rounded">Em breve</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Config Side */}
|
||||
<div className="lg:col-span-1 space-y-6">
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 p-6 shadow-sm">
|
||||
<h2 className="text-sm font-semibold text-zinc-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<InformationCircleIcon className="w-4 h-4 text-blue-500" />
|
||||
Destino dos Leads
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider mb-1.5">
|
||||
Campanha
|
||||
</label>
|
||||
<select
|
||||
value={selectedCampaign}
|
||||
onChange={(e) => {
|
||||
setSelectedCampaign(e.target.value);
|
||||
const camp = campaigns.find(c => c.id === e.target.value);
|
||||
if (camp?.customer_id) setSelectedCustomer(camp.customer_id);
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all"
|
||||
>
|
||||
<option value="">Nenhuma</option>
|
||||
{campaigns.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{campaignIdFromUrl && (
|
||||
<p className="mt-1.5 text-[10px] text-blue-600 dark:text-blue-400 font-medium">
|
||||
* Campanha pré-selecionada via contexto
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider mb-1.5">
|
||||
Cliente Vinculado
|
||||
</label>
|
||||
<select
|
||||
value={selectedCustomer}
|
||||
onChange={(e) => setSelectedCustomer(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all"
|
||||
>
|
||||
<option value="">Nenhum (Geral)</option>
|
||||
{customers.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-xl border border-blue-100 dark:border-blue-800/30 p-4">
|
||||
<h3 className="text-xs font-bold text-blue-700 dark:text-blue-400 uppercase mb-2">Formato JSON Esperado</h3>
|
||||
<pre className="text-[10px] text-blue-600 dark:text-blue-300 overflow-x-auto">
|
||||
{`[
|
||||
{
|
||||
"name": "João Silva",
|
||||
"email": "joao@email.com",
|
||||
"phone": "11999999999",
|
||||
"source": "facebook",
|
||||
"tags": ["lead-quente"]
|
||||
}
|
||||
]`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor Side */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{importType === 'json' ? (
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-zinc-200 dark:border-zinc-800 flex items-center justify-between bg-zinc-50/50 dark:bg-zinc-800/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<DocumentTextIcon className="w-5 h-5 text-zinc-400" />
|
||||
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Conteúdo JSON</span>
|
||||
</div>
|
||||
{error && (
|
||||
<span className="text-xs text-red-500 flex items-center gap-1">
|
||||
<XCircleIcon className="w-4 h-4" />
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
{!error && preview.length > 0 && (
|
||||
<span className="text-xs text-green-500 flex items-center gap-1">
|
||||
<CheckCircleIcon className="w-4 h-4" />
|
||||
JSON Válido
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
value={jsonContent}
|
||||
onChange={handleJsonChange}
|
||||
placeholder="Cole seu JSON aqui..."
|
||||
className="w-full h-80 p-4 font-mono text-sm bg-transparent border-none focus:ring-0 resize-none text-zinc-800 dark:text-zinc-200"
|
||||
/>
|
||||
<div className="px-6 py-4 bg-zinc-50 dark:bg-zinc-800/50 border-t border-zinc-200 dark:border-zinc-800 flex justify-end">
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={importing || !!error || !jsonContent.trim()}
|
||||
className="inline-flex items-center gap-2 px-6 py-2.5 bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg font-semibold text-sm hover:opacity-90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{importing ? (
|
||||
<ArrowPathIcon className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowUpTrayIcon className="w-4 h-4" />
|
||||
)}
|
||||
{importing ? 'Importando...' : 'Iniciar Importação'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : importType === 'csv' ? (
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-zinc-200 dark:border-zinc-800 flex items-center justify-between bg-zinc-50/50 dark:bg-zinc-800/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<TableCellsIcon className="w-5 h-5 text-zinc-400" />
|
||||
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Upload de Arquivo CSV</span>
|
||||
</div>
|
||||
{error && (
|
||||
<span className="text-xs text-red-500 flex items-center gap-1">
|
||||
<XCircleIcon className="w-4 h-4" />
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
{!error && csvFile && (
|
||||
<span className="text-xs text-green-500 flex items-center gap-1">
|
||||
<CheckCircleIcon className="w-4 h-4" />
|
||||
Arquivo Selecionado
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept=".csv"
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`border-2 border-dashed rounded-2xl p-12 text-center cursor-pointer transition-all ${csvFile
|
||||
? 'border-green-200 bg-green-50/30 dark:border-green-900/30 dark:bg-green-900/10'
|
||||
: 'border-zinc-200 hover:border-blue-400 dark:border-zinc-800 dark:hover:border-blue-500 bg-zinc-50/50 dark:bg-zinc-800/30'
|
||||
}`}
|
||||
>
|
||||
<div className="w-16 h-16 bg-white dark:bg-zinc-800 rounded-2xl shadow-sm flex items-center justify-center mx-auto mb-4">
|
||||
<CloudArrowUpIcon className={`w-8 h-8 ${csvFile ? 'text-green-500' : 'text-zinc-400'}`} />
|
||||
</div>
|
||||
{csvFile ? (
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-zinc-900 dark:text-white">{csvFile.name}</h4>
|
||||
<p className="text-xs text-zinc-500 mt-1">{(csvFile.size / 1024).toFixed(2)} KB</p>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCsvFile(null);
|
||||
setPreview([]);
|
||||
}}
|
||||
className="mt-4 text-xs font-semibold text-red-500 hover:text-red-600"
|
||||
>
|
||||
Remover arquivo
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-zinc-900 dark:text-white">Clique para selecionar ou arraste o arquivo</h4>
|
||||
<p className="text-xs text-zinc-500 mt-1">Apenas arquivos .csv são aceitos</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-100 dark:border-blue-800/30">
|
||||
<h5 className="text-xs font-bold text-blue-700 dark:text-blue-400 uppercase mb-2">Importação Inteligente</h5>
|
||||
<p className="text-xs text-blue-600 dark:text-blue-300 leading-relaxed">
|
||||
Nosso sistema detecta automaticamente os cabeçalhos. Você pode usar nomes como <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">Nome</code>, <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">E-mail</code>, <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">Celular</code> ou <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">Telefone</code>.
|
||||
Linhas de título extras no topo do arquivo também são ignoradas automaticamente.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 bg-zinc-50 dark:bg-zinc-800/50 border-t border-zinc-200 dark:border-zinc-800 flex justify-end">
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={importing || !!error || !csvFile}
|
||||
className="inline-flex items-center gap-2 px-6 py-2.5 bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg font-semibold text-sm hover:opacity-90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{importing ? (
|
||||
<ArrowPathIcon className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowUpTrayIcon className="w-4 h-4" />
|
||||
)}
|
||||
{importing ? 'Importando...' : 'Iniciar Importação'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 p-12 text-center">
|
||||
<div className="w-16 h-16 bg-zinc-100 dark:bg-zinc-800 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<ArrowPathIcon className="w-8 h-8 text-zinc-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-zinc-900 dark:text-white">Em Desenvolvimento</h3>
|
||||
<p className="text-zinc-500 dark:text-zinc-400 max-w-xs mx-auto mt-2">
|
||||
Este método de importação estará disponível em breve. Por enquanto, utilize o formato JSON.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{(importType === 'json' || importType === 'csv') && preview.length > 0 && (
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 p-6 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-zinc-900 dark:text-white mb-4">Pré-visualização (Primeiros 5)</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="text-zinc-500 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<th className="pb-2 font-medium">Nome</th>
|
||||
<th className="pb-2 font-medium">Email</th>
|
||||
<th className="pb-2 font-medium">Telefone</th>
|
||||
<th className="pb-2 font-medium">Origem</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-50 dark:divide-zinc-800">
|
||||
{preview.map((lead, i) => (
|
||||
<tr key={i}>
|
||||
<td className="py-2 text-zinc-900 dark:text-zinc-100">{lead.name || '-'}</td>
|
||||
<td className="py-2 text-zinc-600 dark:text-zinc-400">{lead.email || '-'}</td>
|
||||
<td className="py-2 text-zinc-600 dark:text-zinc-400">{lead.phone || '-'}</td>
|
||||
<td className="py-2">
|
||||
<span className="px-2 py-0.5 bg-zinc-100 dark:bg-zinc-800 rounded text-[10px] uppercase font-bold text-zinc-500">
|
||||
{lead.source || 'manual'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ImportLeadsPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
}>
|
||||
<ImportLeadsContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
1287
front-end-agency/app/(agency)/crm/leads/page.tsx
Normal file
1287
front-end-agency/app/(agency)/crm/leads/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
31
front-end-agency/app/(agency)/crm/negociacoes/page.tsx
Normal file
31
front-end-agency/app/(agency)/crm/negociacoes/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { CurrencyDollarIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
export default function NegociacoesPage() {
|
||||
return (
|
||||
<div className="p-6 h-full flex items-center justify-center">
|
||||
<div className="text-center max-w-md">
|
||||
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-green-500 to-emerald-600">
|
||||
<CurrencyDollarIcon className="h-10 w-10 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Negociações
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Esta funcionalidade está em desenvolvimento
|
||||
</p>
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
|
||||
<div className="flex gap-1">
|
||||
<span className="animate-bounce inline-block h-2 w-2 rounded-full bg-green-600" style={{ animationDelay: '0ms' }}></span>
|
||||
<span className="animate-bounce inline-block h-2 w-2 rounded-full bg-green-600" style={{ animationDelay: '150ms' }}></span>
|
||||
<span className="animate-bounce inline-block h-2 w-2 rounded-full bg-green-600" style={{ animationDelay: '300ms' }}></span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-green-600 dark:text-green-400">
|
||||
Em breve
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
249
front-end-agency/app/(agency)/crm/page.tsx
Normal file
249
front-end-agency/app/(agency)/crm/page.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { SolutionGuard } from '@/components/auth/SolutionGuard';
|
||||
import { useCRMFilter } from '@/contexts/CRMFilterContext';
|
||||
import KanbanBoard from '@/components/crm/KanbanBoard';
|
||||
import {
|
||||
UsersIcon,
|
||||
CurrencyDollarIcon,
|
||||
ChartPieIcon,
|
||||
ArrowTrendingUpIcon,
|
||||
ListBulletIcon,
|
||||
ArrowRightIcon,
|
||||
MegaphoneIcon,
|
||||
RectangleStackIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
function CRMDashboardContent() {
|
||||
const { selectedCustomerId } = useCRMFilter();
|
||||
console.log('🏠 CRMPage (Content) render, selectedCustomerId:', selectedCustomerId);
|
||||
|
||||
const [stats, setStats] = useState([
|
||||
{ name: 'Leads Totais', value: '0', icon: UsersIcon, color: 'blue' },
|
||||
{ name: 'Clientes', value: '0', icon: UsersIcon, color: 'green' },
|
||||
{ name: 'Campanhas', value: '0', icon: MegaphoneIcon, color: 'purple' },
|
||||
{ name: 'Taxa de Conversão', value: '0%', icon: ChartPieIcon, color: 'orange' },
|
||||
]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [defaultFunnelId, setDefaultFunnelId] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
console.log('🔄 CRM Dashboard: selectedCustomerId changed to:', selectedCustomerId);
|
||||
fetchDashboardData();
|
||||
fetchDefaultFunnel();
|
||||
}, [selectedCustomerId]);
|
||||
|
||||
const fetchDefaultFunnel = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/crm/funnels', {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.funnels?.length > 0) {
|
||||
setDefaultFunnelId(data.funnels[0].id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching funnels:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// Adicionando um timestamp para evitar cache agressivo do navegador
|
||||
const timestamp = new Date().getTime();
|
||||
const url = selectedCustomerId
|
||||
? `/api/crm/dashboard?customer_id=${selectedCustomerId}&t=${timestamp}`
|
||||
: `/api/crm/dashboard?t=${timestamp}`;
|
||||
|
||||
console.log(`📊 Fetching dashboard data from: ${url}`);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
'Cache-Control': 'no-cache',
|
||||
'Pragma': 'no-cache'
|
||||
},
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log('📊 Dashboard data received:', data);
|
||||
const s = data.stats;
|
||||
setStats([
|
||||
{ name: 'Leads Totais', value: s.total.toString(), icon: UsersIcon, color: 'blue' },
|
||||
{ name: 'Clientes', value: s.total_customers.toString(), icon: UsersIcon, color: 'green' },
|
||||
{ name: 'Campanhas', value: s.total_campaigns.toString(), icon: MegaphoneIcon, color: 'purple' },
|
||||
{ name: 'Taxa de Conversão', value: `${s.conversionRate || 0}%`, icon: ChartPieIcon, color: 'orange' },
|
||||
]);
|
||||
} else {
|
||||
console.error('📊 Error response from dashboard:', response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching CRM dashboard data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const quickLinks = [
|
||||
{
|
||||
name: 'Funis de Vendas',
|
||||
description: 'Configure seus processos e etapas',
|
||||
icon: RectangleStackIcon,
|
||||
href: '/crm/funis',
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
name: 'Clientes',
|
||||
description: 'Gerencie seus contatos e clientes',
|
||||
icon: UsersIcon,
|
||||
href: '/crm/clientes',
|
||||
color: 'indigo',
|
||||
},
|
||||
{
|
||||
name: 'Campanhas',
|
||||
description: 'Organize leads e rastreie origens',
|
||||
icon: MegaphoneIcon,
|
||||
href: '/crm/campanhas',
|
||||
color: 'purple',
|
||||
},
|
||||
{
|
||||
name: 'Leads',
|
||||
description: 'Gerencie potenciais clientes',
|
||||
icon: UsersIcon,
|
||||
href: '/crm/leads',
|
||||
color: 'green',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 h-full overflow-auto">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
CRM
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Visão geral do relacionamento com clientes
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{stats.map((stat) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<div
|
||||
key={stat.name}
|
||||
className="group relative overflow-hidden rounded-xl bg-white dark:bg-gray-900 p-4 border border-gray-200 dark:border-gray-800 transition-all"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{stat.name}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{stat.value}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={`rounded-lg p-2 bg-${stat.color}-100 dark:bg-${stat.color}-900/20`}
|
||||
>
|
||||
<Icon
|
||||
className={`h-5 w-5 text-${stat.color}-600 dark:text-${stat.color}-400`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Acesso Rápido
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{quickLinks.map((link) => {
|
||||
const Icon = link.icon;
|
||||
return (
|
||||
<Link
|
||||
key={link.name}
|
||||
href={link.href}
|
||||
className="group relative overflow-hidden rounded-xl bg-white dark:bg-gray-900 p-6 border border-gray-200 dark:border-gray-800 hover:border-gray-300 dark:hover:border-gray-700 transition-all hover:shadow-lg"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={`rounded-lg p-3 bg-${link.color}-100 dark:bg-${link.color}-900/20`}
|
||||
>
|
||||
<Icon
|
||||
className={`h-6 w-6 text-${link.color}-600 dark:text-${link.color}-400`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{link.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{link.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRightIcon className="w-5 h-5 text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300 group-hover:translate-x-1 transition-all" />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Monitoramento de Leads
|
||||
</h2>
|
||||
<Link href="/crm/funis" className="text-sm font-medium text-brand-600 hover:underline">
|
||||
Gerenciar Funis
|
||||
</Link>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-6 min-h-[500px]">
|
||||
{defaultFunnelId ? (
|
||||
<KanbanBoard funnelId={defaultFunnelId} />
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-center">
|
||||
<RectangleStackIcon className="h-12 w-12 text-gray-300 mb-4" />
|
||||
<p className="text-gray-500">Nenhum funil configurado.</p>
|
||||
<Link href="/crm/funis" className="mt-4 px-4 py-2 bg-brand-600 text-white rounded-lg text-sm font-bold">
|
||||
CRIAR PRIMEIRO FUNIL
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="rounded-xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-6 h-64 flex items-center justify-center">
|
||||
<p className="text-gray-500">Atividades Recentes (Em breve)</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-6 h-64 flex items-center justify-center">
|
||||
<p className="text-gray-500">Metas de Vendas (Em breve)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CRMPage() {
|
||||
return (
|
||||
<SolutionGuard requiredSolution="crm">
|
||||
<CRMDashboardContent />
|
||||
</SolutionGuard>
|
||||
);
|
||||
}
|
||||
@@ -1,178 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { getUser } from "@/lib/auth";
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getUser } from '@/lib/auth';
|
||||
import {
|
||||
RocketLaunchIcon,
|
||||
ChartBarIcon,
|
||||
UserGroupIcon,
|
||||
BriefcaseIcon,
|
||||
LifebuoyIcon,
|
||||
CreditCardIcon,
|
||||
DocumentTextIcon,
|
||||
FolderIcon,
|
||||
CurrencyDollarIcon,
|
||||
ShareIcon,
|
||||
ArrowTrendingUpIcon,
|
||||
ArrowTrendingDownIcon
|
||||
ArrowTrendingDownIcon,
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||
trend?: number;
|
||||
color: 'blue' | 'purple' | 'gray' | 'green';
|
||||
}
|
||||
|
||||
const colorClasses = {
|
||||
blue: {
|
||||
iconBg: 'bg-blue-50 dark:bg-blue-900/20',
|
||||
iconColor: 'text-blue-600 dark:text-blue-400',
|
||||
trend: 'text-blue-600 dark:text-blue-400'
|
||||
},
|
||||
purple: {
|
||||
iconBg: 'bg-purple-50 dark:bg-purple-900/20',
|
||||
iconColor: 'text-purple-600 dark:text-purple-400',
|
||||
trend: 'text-purple-600 dark:text-purple-400'
|
||||
},
|
||||
gray: {
|
||||
iconBg: 'bg-gray-50 dark:bg-gray-900/20',
|
||||
iconColor: 'text-gray-600 dark:text-gray-400',
|
||||
trend: 'text-gray-600 dark:text-gray-400'
|
||||
},
|
||||
green: {
|
||||
iconBg: 'bg-emerald-50 dark:bg-emerald-900/20',
|
||||
iconColor: 'text-emerald-600 dark:text-emerald-400',
|
||||
trend: 'text-emerald-600 dark:text-emerald-400'
|
||||
}
|
||||
};
|
||||
|
||||
function StatCard({ title, value, icon: Icon, trend, color }: StatCardProps) {
|
||||
const colors = colorClasses[color];
|
||||
const isPositive = trend && trend > 0;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700 hover:shadow-lg transition-shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">{title}</p>
|
||||
<p className="text-3xl font-semibold text-gray-900 dark:text-white mt-2">{value}</p>
|
||||
{trend !== undefined && (
|
||||
<div className="flex items-center mt-2">
|
||||
{isPositive ? (
|
||||
<ArrowTrendingUpIcon className="w-4 h-4 text-emerald-500" />
|
||||
) : (
|
||||
<ArrowTrendingDownIcon className="w-4 h-4 text-red-500" />
|
||||
)}
|
||||
<span className={`text-sm font-medium ml-1 ${isPositive ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}`}>
|
||||
{Math.abs(trend)}%
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 ml-1">vs mês anterior</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`${colors.iconBg} p-3 rounded-xl`}>
|
||||
<Icon className={`w-8 h-8 ${colors.iconColor}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const [stats, setStats] = useState({
|
||||
clientes: 0,
|
||||
projetos: 0,
|
||||
tarefas: 0,
|
||||
faturamento: 0
|
||||
});
|
||||
const [userName, setUserName] = useState('');
|
||||
const [greeting, setGreeting] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
// Verificar se é SUPERADMIN e redirecionar
|
||||
const user = getUser();
|
||||
if (user && user.role === 'SUPERADMIN') {
|
||||
router.push('/superadmin');
|
||||
return;
|
||||
if (user) {
|
||||
setUserName(user.name.split(' ')[0]); // Primeiro nome
|
||||
}
|
||||
|
||||
// Simulando carregamento de dados
|
||||
setTimeout(() => {
|
||||
setStats({
|
||||
clientes: 127,
|
||||
projetos: 18,
|
||||
tarefas: 64,
|
||||
faturamento: 87500
|
||||
});
|
||||
}, 300);
|
||||
}, [router]);
|
||||
const hour = new Date().getHours();
|
||||
if (hour >= 5 && hour < 12) setGreeting('Bom dia');
|
||||
else if (hour >= 12 && hour < 18) setGreeting('Boa tarde');
|
||||
else setGreeting('Boa noite');
|
||||
}, []);
|
||||
|
||||
const overviewStats = [
|
||||
{ name: 'Receita Total (Mês)', value: 'R$ 124.500', change: '+12%', changeType: 'increase', icon: ChartBarIcon, color: 'green' },
|
||||
{ name: 'Novos Leads', value: '45', change: '+5%', changeType: 'increase', icon: RocketLaunchIcon, color: 'blue' },
|
||||
{ name: 'Projetos Ativos', value: '12', change: '-1', changeType: 'decrease', icon: BriefcaseIcon, color: 'purple' },
|
||||
{ name: 'Chamados Abertos', value: '3', change: '-2', changeType: 'decrease', icon: LifebuoyIcon, color: 'orange' },
|
||||
];
|
||||
|
||||
const modules = [
|
||||
{
|
||||
title: 'CRM & Vendas',
|
||||
icon: RocketLaunchIcon,
|
||||
color: 'blue',
|
||||
stats: [
|
||||
{ label: 'Propostas Enviadas', value: '8' },
|
||||
{ label: 'Aguardando Aprovação', value: '3' },
|
||||
{ label: 'Taxa de Conversão', value: '24%' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Financeiro & ERP',
|
||||
icon: ChartBarIcon,
|
||||
color: 'green',
|
||||
stats: [
|
||||
{ label: 'A Receber', value: 'R$ 45.200' },
|
||||
{ label: 'A Pagar', value: 'R$ 12.800' },
|
||||
{ label: 'Fluxo de Caixa', value: 'Positivo' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Projetos & Tarefas',
|
||||
icon: BriefcaseIcon,
|
||||
color: 'purple',
|
||||
stats: [
|
||||
{ label: 'Em Andamento', value: '12' },
|
||||
{ label: 'Atrasados', value: '1' },
|
||||
{ label: 'Concluídos (Mês)', value: '4' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Helpdesk',
|
||||
icon: LifebuoyIcon,
|
||||
color: 'orange',
|
||||
stats: [
|
||||
{ label: 'Novos Chamados', value: '3' },
|
||||
{ label: 'Tempo Médio Resposta', value: '2h' },
|
||||
{ label: 'Satisfação', value: '4.8/5' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Documentos & Contratos',
|
||||
icon: DocumentTextIcon,
|
||||
color: 'indigo',
|
||||
stats: [
|
||||
{ label: 'Contratos Ativos', value: '28' },
|
||||
{ label: 'A Vencer (30 dias)', value: '2' },
|
||||
{ label: 'Docs Armazenados', value: '1.2GB' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Redes Sociais',
|
||||
icon: ShareIcon,
|
||||
color: 'pink',
|
||||
stats: [
|
||||
{ label: 'Posts Agendados', value: '14' },
|
||||
{ label: 'Engajamento', value: '+8.5%' },
|
||||
{ label: 'Novos Seguidores', value: '120' },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Dashboard
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Bem-vindo ao seu painel de controle
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<StatCard
|
||||
title="Clientes Ativos"
|
||||
value={stats.clientes}
|
||||
icon={UserGroupIcon}
|
||||
trend={12.5}
|
||||
color="blue"
|
||||
/>
|
||||
<StatCard
|
||||
title="Projetos em Andamento"
|
||||
value={stats.projetos}
|
||||
icon={FolderIcon}
|
||||
trend={8.2}
|
||||
color="purple"
|
||||
/>
|
||||
<StatCard
|
||||
title="Tarefas Pendentes"
|
||||
value={stats.tarefas}
|
||||
icon={ChartBarIcon}
|
||||
trend={-3.1}
|
||||
color="gray"
|
||||
/>
|
||||
<StatCard
|
||||
title="Faturamento"
|
||||
value={new Intl.NumberFormat('pt-BR', {
|
||||
style: 'currency',
|
||||
currency: 'BRL',
|
||||
minimumFractionDigits: 0
|
||||
}).format(stats.faturamento)}
|
||||
icon={CurrencyDollarIcon}
|
||||
trend={25.3}
|
||||
color="green"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Coming Soon Card */}
|
||||
<div className="bg-linear-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900 rounded-2xl border border-gray-200 dark:border-gray-700 p-12">
|
||||
<div className="max-w-2xl mx-auto text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl mb-6" style={{ background: 'var(--gradient-primary)' }}>
|
||||
<ChartBarIcon className="w-8 h-8 text-white" />
|
||||
<div className="p-6 h-full overflow-auto">
|
||||
<div className="space-y-8">
|
||||
{/* Header Personalizado */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-heading font-bold text-gray-900 dark:text-white">
|
||||
{greeting}, {userName || 'Administrador'}! 👋
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Aqui está o resumo da sua agência hoje. Tudo parece estar sob controle.
|
||||
</p>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-3">
|
||||
Em Desenvolvimento
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-medium px-3 py-1 rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 border border-green-200 dark:border-green-800 flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span>
|
||||
Sistema Operacional
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Stats */}
|
||||
<div>
|
||||
{/* Mobile: Scroll Horizontal */}
|
||||
<div className="md:hidden overflow-x-auto scrollbar-hide">
|
||||
<div className="flex gap-4 min-w-max">
|
||||
{overviewStats.map((stat) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<div
|
||||
key={stat.name}
|
||||
className="relative overflow-hidden rounded-xl bg-white dark:bg-zinc-900 p-4 border border-gray-200 dark:border-zinc-800 shadow-sm w-[280px] flex-shrink-0"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className={`rounded-lg p-2 bg-${stat.color}-50 dark:bg-${stat.color}-900/20`}>
|
||||
<Icon className={`h-6 w-6 text-${stat.color}-600 dark:text-${stat.color}-400`} />
|
||||
</div>
|
||||
<div className={`flex items-baseline text-sm font-semibold ${stat.changeType === 'increase' ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{stat.changeType === 'increase' ? (
|
||||
<ArrowTrendingUpIcon className="h-4 w-4 mr-1" />
|
||||
) : (
|
||||
<ArrowTrendingDownIcon className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
{stat.change}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{stat.name}</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">{stat.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop: Grid */}
|
||||
<div className="hidden md:grid md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{overviewStats.map((stat) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<div
|
||||
key={stat.name}
|
||||
className="relative overflow-hidden rounded-xl bg-white dark:bg-zinc-900 p-4 border border-gray-200 dark:border-zinc-800 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className={`rounded-lg p-2 bg-${stat.color}-50 dark:bg-${stat.color}-900/20`}>
|
||||
<Icon className={`h-6 w-6 text-${stat.color}-600 dark:text-${stat.color}-400`} />
|
||||
</div>
|
||||
<div className={`flex items-baseline text-sm font-semibold ${stat.changeType === 'increase' ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{stat.changeType === 'increase' ? (
|
||||
<ArrowTrendingUpIcon className="h-4 w-4 mr-1" />
|
||||
) : (
|
||||
<ArrowTrendingDownIcon className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
{stat.change}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{stat.name}</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">{stat.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modules Grid */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Performance por Módulo
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400 mb-8">
|
||||
Estamos construindo recursos incríveis de CRM e ERP para sua agência.
|
||||
Em breve você terá acesso a análises detalhadas, gestão completa de clientes e muito mais.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
{['CRM', 'ERP', 'Projetos', 'Pagamentos', 'Documentos', 'Suporte', 'Contratos'].map((item) => (
|
||||
<span
|
||||
key={item}
|
||||
className="inline-flex items-center px-4 py-2 rounded-full text-sm font-medium bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{modules.map((module) => {
|
||||
const Icon = module.icon;
|
||||
return (
|
||||
<div
|
||||
key={module.title}
|
||||
className="rounded-xl bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 p-6 hover:border-gray-200 dark:hover:border-zinc-700 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className={`p-2 rounded-lg bg-${module.color}-50 dark:bg-${module.color}-900/20`}>
|
||||
<Icon className={`h-5 w-5 text-${module.color}-600 dark:text-${module.color}-400`} />
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">
|
||||
{module.title}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{module.stats.map((stat, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500 dark:text-gray-400">{stat.label}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{stat.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
16
front-end-agency/app/(agency)/documentos/page.tsx
Normal file
16
front-end-agency/app/(agency)/documentos/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { SolutionGuard } from '@/components/auth/SolutionGuard';
|
||||
|
||||
export default function DocumentosPage() {
|
||||
return (
|
||||
<SolutionGuard requiredSolution="documentos">
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">Documentos</h1>
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-8 text-center">
|
||||
<p className="text-gray-500">Gestão Eletrônica de Documentos (GED) em breve</p>
|
||||
</div>
|
||||
</div>
|
||||
</SolutionGuard>
|
||||
);
|
||||
}
|
||||
16
front-end-agency/app/(agency)/erp/page.tsx
Normal file
16
front-end-agency/app/(agency)/erp/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { SolutionGuard } from '@/components/auth/SolutionGuard';
|
||||
|
||||
export default function ERPPage() {
|
||||
return (
|
||||
<SolutionGuard requiredSolution="erp">
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">ERP</h1>
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-8 text-center">
|
||||
<p className="text-gray-500">Sistema Integrado de Gestão Empresarial em breve</p>
|
||||
</div>
|
||||
</div>
|
||||
</SolutionGuard>
|
||||
);
|
||||
}
|
||||
16
front-end-agency/app/(agency)/helpdesk/page.tsx
Normal file
16
front-end-agency/app/(agency)/helpdesk/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { SolutionGuard } from '@/components/auth/SolutionGuard';
|
||||
|
||||
export default function HelpdeskPage() {
|
||||
return (
|
||||
<SolutionGuard requiredSolution="helpdesk">
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">Helpdesk</h1>
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-8 text-center">
|
||||
<p className="text-gray-500">Central de Suporte e Chamados em breve</p>
|
||||
</div>
|
||||
</div>
|
||||
</SolutionGuard>
|
||||
);
|
||||
}
|
||||
@@ -1,635 +1,34 @@
|
||||
"use client";
|
||||
import { Metadata } from 'next';
|
||||
import { getAgencyLogo, getAgencyColors } from '@/lib/server-api';
|
||||
import { AgencyLayoutClient } from './AgencyLayoutClient';
|
||||
|
||||
import { useEffect, useState, Fragment } from 'react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Menu, Transition } from '@headlessui/react';
|
||||
import {
|
||||
Bars3Icon,
|
||||
XMarkIcon,
|
||||
MagnifyingGlassIcon,
|
||||
BellIcon,
|
||||
Cog6ToothIcon,
|
||||
UserCircleIcon,
|
||||
ArrowRightOnRectangleIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
UserGroupIcon,
|
||||
BuildingOfficeIcon,
|
||||
FolderIcon,
|
||||
CreditCardIcon,
|
||||
DocumentTextIcon,
|
||||
LifebuoyIcon,
|
||||
DocumentCheckIcon,
|
||||
UsersIcon,
|
||||
UserPlusIcon,
|
||||
PhoneIcon,
|
||||
FunnelIcon,
|
||||
ChartBarIcon,
|
||||
HomeIcon,
|
||||
CubeIcon,
|
||||
ShoppingCartIcon,
|
||||
BanknotesIcon,
|
||||
DocumentDuplicateIcon,
|
||||
ShareIcon,
|
||||
DocumentMagnifyingGlassIcon,
|
||||
TrashIcon,
|
||||
RectangleStackIcon,
|
||||
CalendarIcon,
|
||||
UserGroupIcon as TeamIcon,
|
||||
ReceiptPercentIcon,
|
||||
CreditCardIcon as PaymentIcon,
|
||||
ChatBubbleLeftRightIcon,
|
||||
BookOpenIcon,
|
||||
ArchiveBoxIcon,
|
||||
PencilSquareIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
// Forçar renderização dinâmica (não estática) para este layout
|
||||
// Necessário porque usamos headers() para pegar o subdomínio
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const ThemeToggle = dynamic(() => import('@/components/ThemeToggle'), { ssr: false });
|
||||
const ThemeTester = dynamic(() => import('@/components/ThemeTester'), { ssr: false });
|
||||
const DynamicFavicon = dynamic(() => import('@/components/DynamicFavicon'), { ssr: false });
|
||||
/**
|
||||
* generateMetadata - Executado no servidor antes do render
|
||||
* Define o favicon dinamicamente baseado no subdomínio da agência
|
||||
*/
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const logoUrl = await getAgencyLogo();
|
||||
|
||||
const DEFAULT_GRADIENT = 'linear-gradient(135deg, #ff3a05, #ff0080)';
|
||||
return {
|
||||
icons: {
|
||||
icon: logoUrl || '/favicon.ico',
|
||||
shortcut: logoUrl || '/favicon.ico',
|
||||
apple: logoUrl || '/favicon.ico',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const setGradientVariables = (gradient: string) => {
|
||||
document.documentElement.style.setProperty('--gradient-primary', gradient);
|
||||
document.documentElement.style.setProperty('--gradient', gradient);
|
||||
document.documentElement.style.setProperty('--gradient-text', gradient.replace('90deg', 'to right'));
|
||||
document.documentElement.style.setProperty('--color-gradient-brand', gradient.replace('90deg', 'to right'));
|
||||
};
|
||||
|
||||
export default function AgencyLayout({
|
||||
export default async function AgencyLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [user, setUser] = useState<any>(null);
|
||||
const [agencyName, setAgencyName] = useState('');
|
||||
const [agencyLogo, setAgencyLogo] = useState('');
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [activeSubmenu, setActiveSubmenu] = useState<number | null>(null);
|
||||
const [selectedClient, setSelectedClient] = useState<any>(null);
|
||||
// Buscar cores da agência no servidor
|
||||
const colors = await getAgencyColors();
|
||||
|
||||
// Mock de clientes - no futuro virá da API
|
||||
const clients = [
|
||||
{ id: 1, name: 'Todos os Clientes', avatar: null },
|
||||
{ id: 2, name: 'Empresa ABC Ltda', avatar: 'A' },
|
||||
{ id: 3, name: 'Tech Solutions Inc', avatar: 'T' },
|
||||
{ id: 4, name: 'Marketing Pro', avatar: 'M' },
|
||||
{ id: 5, name: 'Design Studio', avatar: 'D' },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
const userData = localStorage.getItem('user');
|
||||
|
||||
if (!token || !userData) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
const parsedUser = JSON.parse(userData);
|
||||
setUser(parsedUser);
|
||||
|
||||
if (parsedUser.role === 'SUPERADMIN') {
|
||||
router.push('/superadmin');
|
||||
return;
|
||||
}
|
||||
|
||||
const hostname = window.location.hostname;
|
||||
const hostSubdomain = hostname.split('.')[0] || 'default';
|
||||
const themeKey = parsedUser?.subdomain || parsedUser?.tenantId || parsedUser?.tenant_id || hostSubdomain;
|
||||
|
||||
setAgencyName(parsedUser?.subdomain || hostSubdomain);
|
||||
|
||||
// Buscar logo da agência
|
||||
const fetchAgencyLogo = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/agency/profile', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.logo_url) {
|
||||
setAgencyLogo(data.logo_url);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar logo da agência:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAgencyLogo();
|
||||
|
||||
const storedGradient = localStorage.getItem(`agency-theme:${themeKey}`);
|
||||
setGradientVariables(storedGradient || DEFAULT_GRADIENT);
|
||||
|
||||
// Inicializar com "Todos os Clientes"
|
||||
setSelectedClient(clients[0]);
|
||||
|
||||
// Atalho de teclado para abrir pesquisa (Ctrl/Cmd + K)
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
setSearchOpen(true);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setSearchOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
setGradientVariables(DEFAULT_GRADIENT);
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
const hostname = window.location.hostname;
|
||||
const hostSubdomain = hostname.split('.')[0] || 'default';
|
||||
const userData = localStorage.getItem('user');
|
||||
const parsedUser = userData ? JSON.parse(userData) : null;
|
||||
const themeKey = parsedUser?.subdomain || parsedUser?.tenantId || parsedUser?.tenant_id || hostSubdomain;
|
||||
const storedGradient = localStorage.getItem(`agency-theme:${themeKey}`) || DEFAULT_GRADIENT;
|
||||
|
||||
setGradientVariables(storedGradient);
|
||||
}, [pathname]);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
icon: UserGroupIcon,
|
||||
label: 'CRM',
|
||||
href: '/crm',
|
||||
submenu: [
|
||||
{ icon: UsersIcon, label: 'Clientes', href: '/crm/clientes' },
|
||||
{ icon: UserPlusIcon, label: 'Leads', href: '/crm/leads' },
|
||||
{ icon: PhoneIcon, label: 'Contatos', href: '/crm/contatos' },
|
||||
{ icon: FunnelIcon, label: 'Funil de Vendas', href: '/crm/funil' },
|
||||
{ icon: ChartBarIcon, label: 'Relatórios', href: '/crm/relatorios' },
|
||||
]
|
||||
},
|
||||
{
|
||||
icon: BuildingOfficeIcon,
|
||||
label: 'ERP',
|
||||
href: '/erp',
|
||||
submenu: [
|
||||
{ icon: HomeIcon, label: 'Dashboard', href: '/erp/dashboard' },
|
||||
{ icon: CubeIcon, label: 'Estoque', href: '/erp/estoque' },
|
||||
{ icon: ShoppingCartIcon, label: 'Compras', href: '/erp/compras' },
|
||||
{ icon: BanknotesIcon, label: 'Vendas', href: '/erp/vendas' },
|
||||
{ icon: ChartBarIcon, label: 'Financeiro', href: '/erp/financeiro' },
|
||||
]
|
||||
},
|
||||
{
|
||||
icon: FolderIcon,
|
||||
label: 'Projetos',
|
||||
href: '/projetos',
|
||||
submenu: [
|
||||
{ icon: RectangleStackIcon, label: 'Todos Projetos', href: '/projetos/todos' },
|
||||
{ icon: RectangleStackIcon, label: 'Kanban', href: '/projetos/kanban' },
|
||||
{ icon: CalendarIcon, label: 'Calendário', href: '/projetos/calendario' },
|
||||
{ icon: TeamIcon, label: 'Equipes', href: '/projetos/equipes' },
|
||||
]
|
||||
},
|
||||
{
|
||||
icon: CreditCardIcon,
|
||||
label: 'Pagamentos',
|
||||
href: '/pagamentos',
|
||||
submenu: [
|
||||
{ icon: DocumentTextIcon, label: 'Faturas', href: '/pagamentos/faturas' },
|
||||
{ icon: ReceiptPercentIcon, label: 'Recebimentos', href: '/pagamentos/recebimentos' },
|
||||
{ icon: PaymentIcon, label: 'Assinaturas', href: '/pagamentos/assinaturas' },
|
||||
{ icon: BanknotesIcon, label: 'Gateway', href: '/pagamentos/gateway' },
|
||||
]
|
||||
},
|
||||
{
|
||||
icon: DocumentTextIcon,
|
||||
label: 'Documentos',
|
||||
href: '/documentos',
|
||||
submenu: [
|
||||
{ icon: FolderIcon, label: 'Meus Arquivos', href: '/documentos/arquivos' },
|
||||
{ icon: ShareIcon, label: 'Compartilhados', href: '/documentos/compartilhados' },
|
||||
{ icon: DocumentDuplicateIcon, label: 'Modelos', href: '/documentos/modelos' },
|
||||
{ icon: TrashIcon, label: 'Lixeira', href: '/documentos/lixeira' },
|
||||
]
|
||||
},
|
||||
{
|
||||
icon: LifebuoyIcon,
|
||||
label: 'Suporte',
|
||||
href: '/suporte',
|
||||
submenu: [
|
||||
{ icon: DocumentMagnifyingGlassIcon, label: 'Tickets', href: '/suporte/tickets' },
|
||||
{ icon: BookOpenIcon, label: 'Base de Conhecimento', href: '/suporte/kb' },
|
||||
{ icon: ChatBubbleLeftRightIcon, label: 'Chat', href: '/suporte/chat' },
|
||||
]
|
||||
},
|
||||
{
|
||||
icon: DocumentCheckIcon,
|
||||
label: 'Contratos',
|
||||
href: '/contratos',
|
||||
submenu: [
|
||||
{ icon: DocumentCheckIcon, label: 'Ativos', href: '/contratos/ativos' },
|
||||
{ icon: PencilSquareIcon, label: 'Rascunhos', href: '/contratos/rascunhos' },
|
||||
{ icon: ArchiveBoxIcon, label: 'Arquivados', href: '/contratos/arquivados' },
|
||||
{ icon: DocumentDuplicateIcon, label: 'Modelos', href: '/contratos/modelos' },
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
router.push('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-50 dark:bg-gray-950">
|
||||
{/* Favicon Dinâmico */}
|
||||
<DynamicFavicon logoUrl={agencyLogo} />
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className={`${activeSubmenu !== null ? 'w-20' : (sidebarOpen ? 'w-64' : 'w-20')} transition-all duration-300 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col`}>
|
||||
{/* Logo */}
|
||||
<div className="h-16 flex items-center justify-center border-b border-gray-200 dark:border-gray-800">
|
||||
{(sidebarOpen && activeSubmenu === null) ? (
|
||||
<div className="flex items-center justify-between px-4 w-full">
|
||||
<div className="flex items-center space-x-3">
|
||||
{agencyLogo ? (
|
||||
<img src={agencyLogo} alt="Logo" className="w-8 h-8 rounded-lg object-contain shrink-0" />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-lg shrink-0" style={{ background: 'var(--gradient-primary)' }}></div>
|
||||
)}
|
||||
<span className="font-bold text-lg dark:text-white capitalize">{agencyName}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors cursor-pointer"
|
||||
>
|
||||
<XMarkIcon className="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{agencyLogo ? (
|
||||
<img src={agencyLogo} alt="Logo" className="w-8 h-8 rounded-lg object-contain" />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-lg" style={{ background: 'var(--gradient-primary)' }}></div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Menu */}
|
||||
<nav className="flex-1 overflow-y-auto py-4 px-3">
|
||||
{menuItems.map((item, idx) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeSubmenu === idx;
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => setActiveSubmenu(isActive ? null : idx)}
|
||||
className={`w-full flex items-center ${(sidebarOpen && activeSubmenu === null) ? 'space-x-3 px-3' : 'justify-center px-0'} py-2.5 mb-1 rounded-lg transition-all group cursor-pointer ${isActive
|
||||
? 'bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`${(sidebarOpen && activeSubmenu === null) ? 'w-5 h-5' : 'w-[18px] h-[18px]'} stroke-[1.5]`} />
|
||||
{(sidebarOpen && activeSubmenu === null) && (
|
||||
<>
|
||||
<span className="flex-1 text-left text-sm font-normal">{item.label}</span>
|
||||
<ChevronRightIcon className={`w-4 h-4 transition-transform ${isActive ? 'rotate-90' : ''}`} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User Menu */}
|
||||
<div className="p-4 border-t border-gray-200 dark:border-gray-800">
|
||||
{(sidebarOpen && activeSubmenu === null) ? (
|
||||
<Menu as="div" className="relative">
|
||||
<Menu.Button className="w-full flex items-center space-x-3 p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl transition-colors">
|
||||
<div className="w-10 h-10 rounded-full flex items-center justify-center text-white font-semibold" style={{ background: 'var(--gradient-primary)' }}>
|
||||
{user?.name?.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 text-left">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">{user?.name}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{user?.role === 'ADMIN_AGENCIA' ? 'Admin' : 'Cliente'}</p>
|
||||
</div>
|
||||
<ChevronDownIcon className="w-4 h-4 text-gray-400" />
|
||||
</Menu.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items className="absolute bottom-full left-0 right-0 mb-2 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<a
|
||||
href="/perfil"
|
||||
className={`${active ? 'bg-gray-100 dark:bg-gray-700' : ''} flex items-center px-4 py-3 text-sm text-gray-700 dark:text-gray-300`}
|
||||
>
|
||||
<UserCircleIcon className="w-5 h-5 mr-3" />
|
||||
Meu Perfil
|
||||
</a>
|
||||
)}
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className={`${active ? 'bg-gray-100 dark:bg-gray-700' : ''} w-full flex items-center px-4 py-3 text-sm text-red-600 dark:text-red-400`}
|
||||
>
|
||||
<ArrowRightOnRectangleIcon className="w-5 h-5 mr-3" />
|
||||
Sair
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-10 h-10 mx-auto rounded-full flex items-center justify-center text-white cursor-pointer"
|
||||
style={{ background: 'var(--gradient-primary)' }}
|
||||
title="Sair"
|
||||
>
|
||||
<ArrowRightOnRectangleIcon className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Submenu Lateral */}
|
||||
<Transition
|
||||
show={activeSubmenu !== null}
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="transform -translate-x-full opacity-0"
|
||||
enterTo="transform translate-x-0 opacity-100"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="transform translate-x-0 opacity-100"
|
||||
leaveTo="transform -translate-x-full opacity-0"
|
||||
>
|
||||
<aside className="w-64 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col">
|
||||
{activeSubmenu !== null && (
|
||||
<>
|
||||
<div className="h-16 flex items-center justify-between px-4 border-b border-gray-200 dark:border-gray-800">
|
||||
<h2 className="font-semibold text-gray-900 dark:text-white">{menuItems[activeSubmenu].label}</h2>
|
||||
<button
|
||||
onClick={() => setActiveSubmenu(null)}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors cursor-pointer"
|
||||
>
|
||||
<XMarkIcon className="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto py-4 px-3">
|
||||
{menuItems[activeSubmenu].submenu?.map((subItem, idx) => {
|
||||
const SubIcon = subItem.icon;
|
||||
return (
|
||||
<a
|
||||
key={idx}
|
||||
href={subItem.href}
|
||||
className="flex items-center space-x-3 px-4 py-2.5 mb-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 text-sm text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
<SubIcon className="w-5 h-5 stroke-[1.5]" />
|
||||
<span>{subItem.label}</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
</Transition>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<header className="h-16 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 flex items-center justify-between px-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<h1 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Dashboard
|
||||
</h1>
|
||||
|
||||
{/* Seletor de Cliente */}
|
||||
<Menu as="div" className="relative">
|
||||
<Menu.Button className="flex items-center space-x-2 px-3 py-1.5 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors cursor-pointer">
|
||||
{selectedClient?.avatar ? (
|
||||
<div className="w-6 h-6 rounded-full flex items-center justify-center text-white text-xs font-semibold" style={{ background: 'var(--gradient-primary)' }}>
|
||||
{selectedClient.avatar}
|
||||
</div>
|
||||
) : (
|
||||
<UsersIcon className="w-4 h-4 text-gray-600 dark:text-gray-400" />
|
||||
)}
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{selectedClient?.name || 'Selecionar Cliente'}
|
||||
</span>
|
||||
<ChevronDownIcon className="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||
</Menu.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items className="absolute left-0 mt-2 w-72 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden z-50">
|
||||
<div className="p-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar cliente..."
|
||||
className="w-full pl-9 pr-3 py-2 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto p-2">
|
||||
{clients.map((client) => (
|
||||
<Menu.Item key={client.id}>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={() => setSelectedClient(client)}
|
||||
className={`${active ? 'bg-gray-100 dark:bg-gray-700' : ''
|
||||
} ${selectedClient?.id === client.id ? 'bg-gray-100 dark:bg-gray-800' : ''
|
||||
} w-full flex items-center space-x-3 px-3 py-2.5 rounded-lg transition-colors cursor-pointer`}
|
||||
>
|
||||
{client.avatar ? (
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-semibold shrink-0" style={{ background: 'var(--gradient-primary)' }}>
|
||||
{client.avatar}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center shrink-0">
|
||||
<UsersIcon className="w-4 h-4 text-gray-600 dark:text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
<span className="flex-1 text-left text-sm font-medium text-gray-900 dark:text-white">
|
||||
{client.name}
|
||||
</span>
|
||||
{selectedClient?.id === client.id && (
|
||||
<div className="w-2 h-2 rounded-full bg-gray-900 dark:bg-gray-100"></div>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* Pesquisa */}
|
||||
<button
|
||||
onClick={() => setSearchOpen(true)}
|
||||
className="flex items-center space-x-2 px-3 py-2 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<MagnifyingGlassIcon className="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Pesquisar...</span>
|
||||
<kbd className="hidden sm:inline-flex items-center px-2 py-0.5 text-xs font-medium text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded">
|
||||
Ctrl K
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
<ThemeToggle />
|
||||
|
||||
{/* Notificações */}
|
||||
<Menu as="div" className="relative">
|
||||
<Menu.Button className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg relative transition-colors">
|
||||
<BellIcon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
</Menu.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items className="absolute right-0 mt-2 w-80 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">Notificações</h3>
|
||||
</div>
|
||||
<div className="p-4 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
Nenhuma notificação no momento
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
|
||||
{/* Configurações */}
|
||||
<a
|
||||
href="/configuracoes"
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<Cog6ToothIcon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page Content */}
|
||||
<main className="flex-1 overflow-y-auto bg-gray-50 dark:bg-gray-950">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Modal de Pesquisa */}
|
||||
<Transition appear show={searchOpen} as={Fragment}>
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-200"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-150"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setSearchOpen(false)} />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="flex min-h-full items-start justify-center p-4 pt-[15vh]">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-200"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-150"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<div className="w-full max-w-2xl bg-white dark:bg-gray-900 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-800 overflow-hidden relative z-10">
|
||||
<div className="flex items-center px-4 border-b border-gray-200 dark:border-gray-800">
|
||||
<MagnifyingGlassIcon className="w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Pesquisar páginas, clientes, projetos..."
|
||||
autoFocus
|
||||
className="w-full px-4 py-4 bg-transparent text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setSearchOpen(false)}
|
||||
className="text-xs text-gray-500 dark:text-gray-400 px-2 py-1 border border-gray-300 dark:border-gray-700 rounded"
|
||||
>
|
||||
ESC
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 max-h-96 overflow-y-auto">
|
||||
<div className="text-center py-12">
|
||||
<MagnifyingGlassIcon className="w-12 h-12 text-gray-300 dark:text-gray-700 mx-auto mb-3" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Digite para buscar...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 border-t border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-950">
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="flex items-center">
|
||||
<kbd className="px-2 py-1 bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded mr-1">↑↓</kbd>
|
||||
navegar
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<kbd className="px-2 py-1 bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded mr-1">↵</kbd>
|
||||
selecionar
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
{/* Theme Tester - Temporário para desenvolvimento */}
|
||||
<ThemeTester />
|
||||
</div>
|
||||
);
|
||||
return <AgencyLayoutClient colors={colors}>{children}</AgencyLayoutClient>;
|
||||
}
|
||||
|
||||
16
front-end-agency/app/(agency)/pagamentos/page.tsx
Normal file
16
front-end-agency/app/(agency)/pagamentos/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { SolutionGuard } from '@/components/auth/SolutionGuard';
|
||||
|
||||
export default function PagamentosPage() {
|
||||
return (
|
||||
<SolutionGuard requiredSolution="pagamentos">
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">Pagamentos</h1>
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-8 text-center">
|
||||
<p className="text-gray-500">Gestão de Pagamentos e Cobranças em breve</p>
|
||||
</div>
|
||||
</div>
|
||||
</SolutionGuard>
|
||||
);
|
||||
}
|
||||
5
front-end-agency/app/(agency)/page.tsx
Normal file
5
front-end-agency/app/(agency)/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function AgencyRootPage() {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
16
front-end-agency/app/(agency)/projetos/page.tsx
Normal file
16
front-end-agency/app/(agency)/projetos/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { SolutionGuard } from '@/components/auth/SolutionGuard';
|
||||
|
||||
export default function ProjetosPage() {
|
||||
return (
|
||||
<SolutionGuard requiredSolution="projetos">
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">Projetos</h1>
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-8 text-center">
|
||||
<p className="text-gray-500">Gestão de Projetos em breve</p>
|
||||
</div>
|
||||
</div>
|
||||
</SolutionGuard>
|
||||
);
|
||||
}
|
||||
16
front-end-agency/app/(agency)/social/page.tsx
Normal file
16
front-end-agency/app/(agency)/social/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { SolutionGuard } from '@/components/auth/SolutionGuard';
|
||||
|
||||
export default function SocialPage() {
|
||||
return (
|
||||
<SolutionGuard requiredSolution="social">
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">Gestão de Redes Sociais</h1>
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-8 text-center">
|
||||
<p className="text-gray-500">Planejamento e Publicação de Posts em breve</p>
|
||||
</div>
|
||||
</div>
|
||||
</SolutionGuard>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { Button, Input } from "@/components/ui";
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import { EnvelopeIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
export default function RecuperarSenhaPage() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
const [emailSent, setEmailSent] = useState(false);
|
||||
const [subdomain, setSubdomain] = useState<string>('');
|
||||
const [isSuperAdmin, setIsSuperAdmin] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const hostname = window.location.hostname;
|
||||
const sub = hostname.split('.')[0];
|
||||
setSubdomain(sub);
|
||||
setIsSuperAdmin(sub === 'dash');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -77,8 +89,10 @@ export default function RecuperarSenhaPage() {
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo mobile */}
|
||||
<div className="lg:hidden text-center mb-8">
|
||||
<div className="inline-block px-6 py-3 rounded-2xl" style={{ background: 'var(--gradient-primary)' }}>
|
||||
<h1 className="text-3xl font-bold text-white">aggios</h1>
|
||||
<div className="inline-block px-6 py-3 rounded-2xl" style={{ background: 'var(--brand-color)' }}>
|
||||
<h1 className="text-3xl font-bold text-white">
|
||||
{isSuperAdmin ? 'aggios' : subdomain}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -100,7 +114,7 @@ export default function RecuperarSenhaPage() {
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="seu@email.com"
|
||||
leftIcon="ri-mail-line"
|
||||
leftIcon={<EnvelopeIcon className="w-5 h-5" />}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
@@ -109,142 +123,71 @@ export default function RecuperarSenhaPage() {
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Enviar link de recuperação
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Back to login */}
|
||||
<div className="mt-6 text-center">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-[14px] gradient-text hover:underline inline-flex items-center gap-2 font-medium cursor-pointer"
|
||||
>
|
||||
<i className="ri-arrow-left-line" />
|
||||
Voltar para o login
|
||||
</Link>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-[14px] font-medium hover:opacity-80 transition-opacity"
|
||||
style={{ color: 'var(--brand-color)' }}
|
||||
>
|
||||
Voltar para o login
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Success Message */}
|
||||
<div className="text-center">
|
||||
<div className="w-20 h-20 rounded-full bg-[#10B981]/10 flex items-center justify-center mx-auto mb-6">
|
||||
<i className="ri-mail-check-line text-4xl text-[#10B981]" />
|
||||
</div>
|
||||
|
||||
<h2 className="text-[28px] font-bold text-zinc-900 dark:text-white mb-4">
|
||||
Email enviado!
|
||||
</h2>
|
||||
|
||||
<p className="text-[14px] text-zinc-600 dark:text-zinc-400 mb-2">
|
||||
Enviamos um link de recuperação para:
|
||||
</p>
|
||||
|
||||
<p className="text-[16px] font-semibold text-zinc-900 dark:text-white mb-6">
|
||||
{email}
|
||||
</p>
|
||||
|
||||
<div className="p-6 bg-[#F0F9FF] border border-[#BAE6FD] rounded-md text-left mb-6">
|
||||
<div className="flex gap-4">
|
||||
<i className="ri-information-line text-[#ff3a05] text-xl mt-0.5" />
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-zinc-900 dark:text-white mb-1">
|
||||
Verifique sua caixa de entrada
|
||||
</h4>
|
||||
<p className="text-xs text-zinc-600 dark:text-zinc-400">
|
||||
Clique no link que enviamos para redefinir sua senha.
|
||||
Se não receber em alguns minutos, verifique sua pasta de spam.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full mb-4"
|
||||
onClick={() => setEmailSent(false)}
|
||||
>
|
||||
Enviar novamente
|
||||
</Button>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<i className="ri-mail-check-line text-3xl text-green-600"></i>
|
||||
</div>
|
||||
<h2 className="text-[24px] font-bold text-zinc-900 dark:text-white mb-2">
|
||||
Verifique seu email
|
||||
</h2>
|
||||
<p className="text-zinc-600 dark:text-zinc-400 mb-8">
|
||||
Enviamos um link de recuperação para <strong>{email}</strong>
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setEmailSent(false)}
|
||||
>
|
||||
Tentar outro email
|
||||
</Button>
|
||||
<div className="mt-6">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-[14px] gradient-text hover:underline inline-flex items-center gap-2 font-medium cursor-pointer"
|
||||
className="text-[14px] font-medium hover:opacity-80 transition-opacity"
|
||||
style={{ color: 'var(--brand-color)' }}
|
||||
>
|
||||
<i className="ri-arrow-left-line" />
|
||||
Voltar para o login
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lado Direito - Branding */}
|
||||
<div className="hidden lg:flex lg:w-1/2 relative overflow-hidden" style={{ background: 'var(--gradient-primary)' }}>
|
||||
<div className="relative z-10 flex flex-col justify-center items-center w-full p-12 text-white">
|
||||
{/* Logo */}
|
||||
<div className="mb-8">
|
||||
<div className="inline-block px-6 py-3 rounded-2xl bg-white/10 backdrop-blur-sm border border-white/20">
|
||||
<h1 className="text-5xl font-bold tracking-tight text-white">
|
||||
aggios
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conteúdo */}
|
||||
<div className="max-w-lg text-center">
|
||||
<div className="w-20 h-20 rounded-2xl bg-white/20 flex items-center justify-center mb-6 mx-auto">
|
||||
<i className="ri-lock-password-line text-4xl" />
|
||||
</div>
|
||||
<h2 className="text-4xl font-bold mb-4">Recuperação segura</h2>
|
||||
<p className="text-white/80 text-lg mb-8">
|
||||
Protegemos seus dados com os mais altos padrões de segurança.
|
||||
Seu link de recuperação é único e expira em 24 horas.
|
||||
<div className="hidden lg:flex lg:w-1/2 relative overflow-hidden" style={{ background: 'var(--brand-color)' }}>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center p-12 text-white">
|
||||
<div className="max-w-md text-center">
|
||||
<h1 className="text-5xl font-bold mb-6">
|
||||
{isSuperAdmin ? 'aggios' : subdomain}
|
||||
</h1>
|
||||
<p className="text-xl opacity-90">
|
||||
Recupere o acesso à sua conta de forma segura e rápida.
|
||||
</p>
|
||||
|
||||
{/* Features */}
|
||||
<div className="space-y-4 text-left">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-6 h-6 rounded-full bg-white/20 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<i className="ri-shield-check-line text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold mb-1">Criptografia de ponta</h4>
|
||||
<p className="text-white/70 text-sm">Seus dados são protegidos com tecnologia de última geração</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-6 h-6 rounded-full bg-white/20 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<i className="ri-time-line text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold mb-1">Link temporário</h4>
|
||||
<p className="text-white/70 text-sm">O link expira em 24h para sua segurança</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-6 h-6 rounded-full bg-white/20 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<i className="ri-customer-service-2-line text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold mb-1">Suporte disponível</h4>
|
||||
<p className="text-white/70 text-sm">Nossa equipe está pronta para ajudar caso precise</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Círculos decorativos */}
|
||||
<div className="absolute top-0 right-0 w-96 h-96 bg-white/5 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-0 left-0 w-96 h-96 bg-white/5 rounded-full blur-3xl" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
7
front-end-agency/app/ClientProviders.tsx
Normal file
7
front-end-agency/app/ClientProviders.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { ToastProvider } from '@/components/layout/ToastContext';
|
||||
|
||||
export function ClientProviders({ children }: { children: React.ReactNode }) {
|
||||
return <ToastProvider>{children}</ToastProvider>;
|
||||
}
|
||||
@@ -1,25 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
const DEFAULT_GRADIENT = 'linear-gradient(135deg, #ff3a05, #ff0080)';
|
||||
// Helper to lighten color
|
||||
const lightenColor = (color: string, percent: number) => {
|
||||
const num = parseInt(color.replace("#", ""), 16),
|
||||
amt = Math.round(2.55 * percent),
|
||||
R = (num >> 16) + amt,
|
||||
B = ((num >> 8) & 0x00ff) + amt,
|
||||
G = (num & 0x0000ff) + amt;
|
||||
return (
|
||||
"#" +
|
||||
(
|
||||
0x1000000 +
|
||||
(R < 255 ? (R < 1 ? 0 : R) : 255) * 0x10000 +
|
||||
(B < 255 ? (B < 1 ? 0 : B) : 255) * 0x100 +
|
||||
(G < 255 ? (G < 1 ? 0 : G) : 255)
|
||||
)
|
||||
.toString(16)
|
||||
.slice(1)
|
||||
);
|
||||
};
|
||||
|
||||
const setGradientVariables = (gradient: string) => {
|
||||
document.documentElement.style.setProperty('--gradient-primary', gradient);
|
||||
document.documentElement.style.setProperty('--gradient', gradient);
|
||||
document.documentElement.style.setProperty('--gradient-text', gradient.replace('90deg', 'to right'));
|
||||
document.documentElement.style.setProperty('--color-gradient-brand', gradient.replace('90deg', 'to right'));
|
||||
const setBrandColors = (primary: string, secondary: string) => {
|
||||
document.documentElement.style.setProperty('--brand-color', primary);
|
||||
document.documentElement.style.setProperty('--brand-color-strong', secondary);
|
||||
|
||||
// Create a lighter version of primary for hover
|
||||
const primaryLight = lightenColor(primary, 20); // Lighten by 20%
|
||||
document.documentElement.style.setProperty('--brand-color-hover', primaryLight);
|
||||
|
||||
// Set RGB variables if needed by other components
|
||||
const hexToRgb = (hex: string) => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result ? `${parseInt(result[1], 16)} ${parseInt(result[2], 16)} ${parseInt(result[3], 16)}` : null;
|
||||
};
|
||||
|
||||
const primaryRgb = hexToRgb(primary);
|
||||
const secondaryRgb = hexToRgb(secondary);
|
||||
const primaryLightRgb = hexToRgb(primaryLight);
|
||||
|
||||
if (primaryRgb) document.documentElement.style.setProperty('--brand-rgb', primaryRgb);
|
||||
if (secondaryRgb) document.documentElement.style.setProperty('--brand-strong-rgb', secondaryRgb);
|
||||
if (primaryLightRgb) document.documentElement.style.setProperty('--brand-hover-rgb', primaryLightRgb);
|
||||
};
|
||||
|
||||
export default function LayoutWrapper({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
// Em toda troca de rota, volta para o tema padrão; layouts específicos (ex.: agência) aplicam o próprio na sequência
|
||||
setGradientVariables(DEFAULT_GRADIENT);
|
||||
}, [pathname]);
|
||||
|
||||
// Temporariamente desativado o carregamento dinâmico de cores/tema para eliminar possíveis
|
||||
// efeitos colaterais de hidratação e 429 no middleware/backend. Se precisar reativar, mover
|
||||
// para nível de servidor (next/head ou metadata) para evitar mutações de DOM no cliente.
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
55
front-end-agency/app/api/agency/branding/route.ts
Normal file
55
front-end-agency/app/api/agency/branding/route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Obter subdomain do header (definido pelo middleware)
|
||||
const subdomain = request.headers.get('x-tenant-subdomain');
|
||||
|
||||
if (!subdomain) {
|
||||
console.log('[Branding API] Subdomain não encontrado nos headers');
|
||||
return NextResponse.json(
|
||||
{ error: 'Subdomain não identificado' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[Branding API] Buscando tenant para subdomain: ${subdomain}`);
|
||||
|
||||
// Buscar tenant por subdomain
|
||||
const response = await fetch(`http://aggios-backend:8080/api/tenant/check?subdomain=${subdomain}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`[Branding API] Erro ao buscar tenant: ${response.status}`);
|
||||
return NextResponse.json(
|
||||
{ error: 'Tenant não encontrado' },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
console.log(`[Branding API] Tenant encontrado:`, {
|
||||
id: data.tenant?.id,
|
||||
name: data.tenant?.name,
|
||||
subdomain: data.tenant?.subdomain
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
primary_color: data.tenant?.primary_color || '#6366f1',
|
||||
logo_url: data.tenant?.logo_url,
|
||||
company: data.tenant?.name || data.tenant?.company,
|
||||
tenant_id: data.tenant?.id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Branding API] Erro:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Erro ao buscar branding' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,19 +4,33 @@ const BACKEND_URL = process.env.API_INTERNAL_URL || 'http://aggios-backend:8080'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
console.log('🔵 [Next.js] Logo upload route called');
|
||||
|
||||
const authorization = request.headers.get('authorization');
|
||||
|
||||
if (!authorization) {
|
||||
console.log('❌ [Next.js] No authorization header');
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log('✅ [Next.js] Authorization header present');
|
||||
|
||||
// Get form data from request
|
||||
const formData = await request.formData();
|
||||
const logo = formData.get('logo');
|
||||
const type = formData.get('type');
|
||||
|
||||
console.log('Forwarding logo upload to backend:', BACKEND_URL);
|
||||
console.log('📦 [Next.js] FormData received:', {
|
||||
hasLogo: !!logo,
|
||||
logoType: logo ? (logo as File).type : null,
|
||||
logoSize: logo ? (logo as File).size : null,
|
||||
type: type
|
||||
});
|
||||
|
||||
console.log('🚀 [Next.js] Forwarding to backend:', BACKEND_URL);
|
||||
|
||||
// Forward to backend
|
||||
const response = await fetch(`${BACKEND_URL}/api/agency/logo`, {
|
||||
@@ -27,7 +41,7 @@ export async function POST(request: NextRequest) {
|
||||
body: formData,
|
||||
});
|
||||
|
||||
console.log('Backend response status:', response.status);
|
||||
console.log('📡 [Next.js] Backend response status:', response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
const token = request.headers.get('authorization');
|
||||
const body = await request.json();
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Token não fornecido' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(`http://aggios-backend:8080/api/crm/customers/${id}/portal-access`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': token,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error('Portal access generation error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Erro ao gerar acesso ao portal' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
126
front-end-agency/app/api/crm/customers/[id]/route.ts
Normal file
126
front-end-agency/app/api/crm/customers/[id]/route.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const API_URL = 'http://aggios-backend:8080';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
const token = request.headers.get('authorization');
|
||||
const subdomain = request.headers.get('host')?.split('.')[0] || '';
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_URL}/api/crm/customers/${id}`, {
|
||||
headers: {
|
||||
'Authorization': token,
|
||||
'X-Tenant-Subdomain': subdomain,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
return NextResponse.json(error, { status: response.status });
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching customer:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch customer' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
const token = request.headers.get('authorization');
|
||||
const subdomain = request.headers.get('host')?.split('.')[0] || '';
|
||||
const body = await request.json();
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_URL}/api/crm/customers/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': token,
|
||||
'X-Tenant-Subdomain': subdomain,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
return NextResponse.json(error, { status: response.status });
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error('Error updating customer:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update customer' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
const token = request.headers.get('authorization');
|
||||
const subdomain = request.headers.get('host')?.split('.')[0] || '';
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_URL}/api/crm/customers/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': token,
|
||||
'X-Tenant-Subdomain': subdomain,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
return NextResponse.json(error, { status: response.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting customer:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete customer' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
66
front-end-agency/app/api/crm/customers/route.ts
Normal file
66
front-end-agency/app/api/crm/customers/route.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const API_URL = 'http://aggios-backend:8080';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const token = request.headers.get('authorization') || '';
|
||||
const subdomain = request.headers.get('x-tenant-subdomain') || request.headers.get('host')?.split('.')[0] || '';
|
||||
|
||||
console.log('[API Route] GET /api/crm/customers - subdomain:', subdomain);
|
||||
|
||||
const response = await fetch(`${API_URL}/api/crm/customers`, {
|
||||
headers: {
|
||||
'Authorization': token,
|
||||
'X-Tenant-Subdomain': subdomain,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error('[API Route] Error fetching customers:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch customers', details: String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const token = request.headers.get('authorization') || '';
|
||||
const subdomain = request.headers.get('x-tenant-subdomain') || request.headers.get('host')?.split('.')[0] || '';
|
||||
const body = await request.json();
|
||||
|
||||
const response = await fetch(`${API_URL}/api/crm/customers`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': token,
|
||||
'X-Tenant-Subdomain': subdomain,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error('Error creating customer:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create customer' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
48
front-end-agency/app/api/portal/change-password/route.ts
Normal file
48
front-end-agency/app/api/portal/change-password/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Token não fornecido' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
if (!body.current_password || !body.new_password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Senha atual e nova senha são obrigatórias' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch('http://aggios-backend:8080/api/portal/change-password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
return NextResponse.json(
|
||||
{ error: errorData.error || 'Erro ao alterar senha' },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Senha alterada com sucesso' });
|
||||
} catch (error) {
|
||||
console.error('Change password error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Erro ao alterar senha' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
34
front-end-agency/app/api/portal/dashboard/route.ts
Normal file
34
front-end-agency/app/api/portal/dashboard/route.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const token = request.headers.get('authorization');
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Token não fornecido' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch('http://aggios-backend:8080/api/portal/dashboard', {
|
||||
headers: {
|
||||
'Authorization': token,
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error('Dashboard fetch error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Erro ao buscar dados do dashboard' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
34
front-end-agency/app/api/portal/leads/route.ts
Normal file
34
front-end-agency/app/api/portal/leads/route.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const token = request.headers.get('authorization');
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Token não fornecido' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch('http://aggios-backend:8080/api/portal/leads', {
|
||||
headers: {
|
||||
'Authorization': token,
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error('Leads fetch error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Erro ao buscar leads' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
30
front-end-agency/app/api/portal/login/route.ts
Normal file
30
front-end-agency/app/api/portal/login/route.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// Usar endpoint unificado
|
||||
const response = await fetch('http://aggios-backend:8080/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error('Customer login error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Erro ao processar login' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
36
front-end-agency/app/api/portal/profile/route.ts
Normal file
36
front-end-agency/app/api/portal/profile/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Token não fornecido' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch('http://aggios-backend:8080/api/portal/profile', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Erro ao buscar perfil' },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error('Profile fetch error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Erro ao buscar perfil' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
125
front-end-agency/app/api/portal/register/route.ts
Normal file
125
front-end-agency/app/api/portal/register/route.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
|
||||
// Extrair campos do FormData
|
||||
const personType = formData.get('person_type') as string;
|
||||
const email = formData.get('email') as string;
|
||||
const phone = formData.get('phone') as string;
|
||||
const cpf = formData.get('cpf') as string || '';
|
||||
const fullName = formData.get('full_name') as string || '';
|
||||
const cnpj = formData.get('cnpj') as string || '';
|
||||
const companyName = formData.get('company_name') as string || '';
|
||||
const tradeName = formData.get('trade_name') as string || '';
|
||||
const postalCode = formData.get('postal_code') as string || '';
|
||||
const street = formData.get('street') as string || '';
|
||||
const number = formData.get('number') as string || '';
|
||||
const complement = formData.get('complement') as string || '';
|
||||
const neighborhood = formData.get('neighborhood') as string || '';
|
||||
const city = formData.get('city') as string || '';
|
||||
const state = formData.get('state') as string || '';
|
||||
const message = formData.get('message') as string || '';
|
||||
const logoFile = formData.get('logo') as File | null;
|
||||
|
||||
// Validar campos obrigatórios
|
||||
if (!email || !phone) {
|
||||
return NextResponse.json(
|
||||
{ error: 'E-mail e telefone são obrigatórios' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validar campos específicos por tipo
|
||||
if (personType === 'pf') {
|
||||
if (!cpf || !fullName) {
|
||||
return NextResponse.json(
|
||||
{ error: 'CPF e Nome Completo são obrigatórios para Pessoa Física' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} else if (personType === 'pj') {
|
||||
if (!cnpj || !companyName) {
|
||||
return NextResponse.json(
|
||||
{ error: 'CNPJ e Razão Social são obrigatórios para Pessoa Jurídica' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Processar upload de logo
|
||||
let logoPath = '';
|
||||
if (logoFile && logoFile.size > 0) {
|
||||
try {
|
||||
const bytes = await logoFile.arrayBuffer();
|
||||
const buffer = Buffer.from(bytes);
|
||||
|
||||
// Criar nome único para o arquivo
|
||||
const timestamp = Date.now();
|
||||
const fileExt = logoFile.name.split('.').pop();
|
||||
const fileName = `logo-${timestamp}.${fileExt}`;
|
||||
const uploadDir = join(process.cwd(), 'public', 'uploads', 'logos');
|
||||
logoPath = `/uploads/logos/${fileName}`;
|
||||
|
||||
// Salvar arquivo (em produção, use S3, Cloudinary, etc.)
|
||||
await writeFile(join(uploadDir, fileName), buffer);
|
||||
} catch (uploadError) {
|
||||
console.error('Error uploading logo:', uploadError);
|
||||
// Continuar sem logo em caso de erro
|
||||
}
|
||||
}
|
||||
|
||||
// Buscar tenant_id do subdomínio (por enquanto hardcoded como 1)
|
||||
const tenantId = 1;
|
||||
|
||||
// Preparar nome baseado no tipo
|
||||
const customerName = personType === 'pf' ? fullName : (tradeName || companyName);
|
||||
|
||||
// Preparar endereço completo
|
||||
const addressParts = [street, number, complement, neighborhood, city, state, postalCode].filter(Boolean);
|
||||
const fullAddress = addressParts.join(', ');
|
||||
|
||||
// Criar o cliente no backend
|
||||
const response = await fetch('http://aggios-backend:8080/api/crm/customers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
tenant_id: tenantId,
|
||||
name: customerName,
|
||||
email: email,
|
||||
phone: phone,
|
||||
company: personType === 'pj' ? companyName : '',
|
||||
address: fullAddress,
|
||||
notes: JSON.stringify({
|
||||
person_type: personType,
|
||||
cpf, cnpj, full_name: fullName, company_name: companyName, trade_name: tradeName,
|
||||
postal_code: postalCode, street, number, complement, neighborhood, city, state,
|
||||
message, logo_path: logoPath,
|
||||
}),
|
||||
status: 'lead',
|
||||
source: 'cadastro_publico',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Erro ao criar cadastro');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Cadastro realizado com sucesso! Você receberá um e-mail com as credenciais.',
|
||||
customer_id: data.customer?.id,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Register error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Erro ao processar cadastro' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
51
front-end-agency/app/api/tenant/public-config/route.ts
Normal file
51
front-end-agency/app/api/tenant/public-config/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const API_BASE_URL = process.env.API_INTERNAL_URL || 'http://backend:8080';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const subdomain = searchParams.get('subdomain');
|
||||
|
||||
if (!subdomain) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Subdomain is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Buscar configuração pública do tenant
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/tenant/config?subdomain=${subdomain}`,
|
||||
{
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Tenant not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Retornar apenas dados públicos
|
||||
return NextResponse.json({
|
||||
name: data.name,
|
||||
primary_color: data.primary_color,
|
||||
secondary_color: data.secondary_color,
|
||||
logo_url: data.logo_url,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching tenant config:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
272
front-end-agency/app/cliente/(portal)/dashboard/page.tsx
Normal file
272
front-end-agency/app/cliente/(portal)/dashboard/page.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
UserCircleIcon,
|
||||
EnvelopeIcon,
|
||||
PhoneIcon,
|
||||
BuildingOfficeIcon,
|
||||
CalendarIcon,
|
||||
ChartBarIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
interface Lead {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
status: string;
|
||||
source: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface CustomerData {
|
||||
customer: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
company: string;
|
||||
portal_last_login: string | null;
|
||||
portal_created_at: string;
|
||||
has_portal_access: boolean;
|
||||
is_active: boolean;
|
||||
};
|
||||
leads?: Lead[];
|
||||
stats?: {
|
||||
total_leads: number;
|
||||
active_leads: number;
|
||||
converted: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function CustomerDashboardPage() {
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<CustomerData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboard();
|
||||
}, []);
|
||||
|
||||
const fetchDashboard = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch('/api/portal/dashboard', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Erro ao buscar dados');
|
||||
|
||||
const result = await response.json();
|
||||
setData(result);
|
||||
} catch (error) {
|
||||
console.error('Error fetching dashboard:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<svg className="animate-spin h-12 w-12 mx-auto text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Carregando...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const customer = data?.customer;
|
||||
const stats = data?.stats;
|
||||
const leads = data?.leads || [];
|
||||
const firstName = customer?.name?.split(' ')[0] || 'Cliente';
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
novo: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
qualificado: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400',
|
||||
negociacao: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300',
|
||||
convertido: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||
perdido: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
||||
};
|
||||
return colors[status] || 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-[1600px] mx-auto space-y-8">
|
||||
{/* Header - Template Pattern */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white tracking-tight">
|
||||
Olá, {firstName}! 👋
|
||||
</h1>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
|
||||
Bem-vindo ao seu portal. Acompanhe seus leads e o desempenho da sua conta.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
href="/cliente/perfil"
|
||||
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium text-zinc-700 dark:text-zinc-200 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<UserCircleIcon className="w-4 h-4" />
|
||||
Meu Perfil
|
||||
</Link>
|
||||
<Link
|
||||
href="/cliente/leads"
|
||||
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium text-white rounded-lg hover:opacity-90 transition-opacity"
|
||||
style={{ background: 'var(--brand-color, #3B82F6)' }}
|
||||
>
|
||||
<ChartBarIcon className="w-4 h-4" />
|
||||
Ver Todos os Leads
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-white dark:bg-zinc-900 p-6 rounded-xl border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Total de Leads</p>
|
||||
<p className="text-3xl font-bold text-zinc-900 dark:text-white mt-1">{stats?.total_leads || 0}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<ChartBarIcon className="w-6 h-6 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-zinc-900 p-6 rounded-xl border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Leads Convertidos</p>
|
||||
<p className="text-3xl font-bold text-zinc-900 dark:text-white mt-1">{stats?.converted || 0}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<CheckCircleIcon className="w-6 h-6 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-zinc-900 p-6 rounded-xl border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Em Andamento</p>
|
||||
<p className="text-3xl font-bold text-zinc-900 dark:text-white mt-1">{stats?.active_leads || 0}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
|
||||
<ClockIcon className="w-6 h-6 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Leads List - Template Pattern */}
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 overflow-hidden shadow-sm">
|
||||
<div className="px-6 py-4 border-b border-zinc-200 dark:border-zinc-800 flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">Leads Recentes</h2>
|
||||
<Link href="/cliente/leads" className="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400">
|
||||
Ver todos →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-zinc-50/50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-800">
|
||||
<th className="px-6 py-3 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Lead</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Contato</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Status</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Data</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
{leads.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-12 text-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<ChartBarIcon className="w-12 h-12 text-zinc-300 mb-3" />
|
||||
<p className="text-zinc-500 dark:text-zinc-400">Nenhum lead encontrado.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
leads.slice(0, 5).map((lead) => (
|
||||
<tr key={lead.id} className="hover:bg-zinc-50 dark:hover:bg-zinc-800/50 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-zinc-600 dark:text-zinc-400 font-bold text-xs">
|
||||
{lead.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-zinc-900 dark:text-white">{lead.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-zinc-600 dark:text-zinc-400">{lead.email}</span>
|
||||
<span className="text-xs text-zinc-400">{lead.phone || 'Sem telefone'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(lead.status)}`}>
|
||||
{lead.status.charAt(0).toUpperCase() + lead.status.slice(1)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{new Date(lead.created_at).toLocaleDateString('pt-BR')}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Info Section */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white dark:bg-zinc-900 p-6 rounded-xl border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
||||
<h3 className="text-lg font-bold text-zinc-900 dark:text-white mb-4">Informações da Conta</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<span className="text-sm text-zinc-500">Empresa</span>
|
||||
<span className="text-sm font-medium text-zinc-900 dark:text-white">{customer?.company}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<span className="text-sm text-zinc-500">E-mail</span>
|
||||
<span className="text-sm font-medium text-zinc-900 dark:text-white">{customer?.email}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-sm text-zinc-500">Status</span>
|
||||
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-green-600 dark:text-green-400">
|
||||
<CheckCircleIcon className="w-4 h-4" />
|
||||
Ativo
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-zinc-900 p-6 rounded-xl border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
||||
<h3 className="text-lg font-bold text-zinc-900 dark:text-white mb-4">Suporte e Ajuda</h3>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mb-6">
|
||||
Precisa de ajuda com seus leads ou tem alguma dúvida sobre o portal? Nossa equipe está à disposição.
|
||||
</p>
|
||||
<button className="w-full py-2.5 bg-zinc-100 dark:bg-zinc-800 text-zinc-900 dark:text-white rounded-lg text-sm font-medium hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors">
|
||||
Falar com Suporte
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
front-end-agency/app/cliente/(portal)/layout.tsx
Normal file
73
front-end-agency/app/cliente/(portal)/layout.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { DashboardLayout } from '@/components/layout/DashboardLayout';
|
||||
import { AgencyBranding } from '@/components/layout/AgencyBranding';
|
||||
import AuthGuard from '@/components/auth/AuthGuard';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
HomeIcon,
|
||||
UsersIcon,
|
||||
ListBulletIcon,
|
||||
UserCircleIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
const CUSTOMER_MENU_ITEMS = [
|
||||
{ id: 'dashboard', label: 'Dashboard', href: '/cliente/dashboard', icon: HomeIcon },
|
||||
{
|
||||
id: 'crm',
|
||||
label: 'CRM',
|
||||
href: '#',
|
||||
icon: UsersIcon,
|
||||
subItems: [
|
||||
{ label: 'Leads', href: '/cliente/leads' },
|
||||
{ label: 'Listas', href: '/cliente/listas' },
|
||||
]
|
||||
},
|
||||
{ id: 'perfil', label: 'Meu Perfil', href: '/cliente/perfil', icon: UserCircleIcon },
|
||||
];
|
||||
|
||||
interface CustomerPortalLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function CustomerPortalLayout({ children }: CustomerPortalLayoutProps) {
|
||||
const router = useRouter();
|
||||
const [colors, setColors] = useState<{ primary: string; secondary: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Buscar cores da agência
|
||||
fetchBranding();
|
||||
}, []);
|
||||
|
||||
const fetchBranding = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/tenant/branding', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.primary_color) {
|
||||
setColors({
|
||||
primary: data.primary_color,
|
||||
secondary: data.secondary_color || data.primary_color,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching branding:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthGuard allowedTypes={['customer']}>
|
||||
<AgencyBranding colors={colors} />
|
||||
<DashboardLayout menuItems={CUSTOMER_MENU_ITEMS}>
|
||||
{children}
|
||||
</DashboardLayout>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
193
front-end-agency/app/cliente/(portal)/leads/page.tsx
Normal file
193
front-end-agency/app/cliente/(portal)/leads/page.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
EnvelopeIcon,
|
||||
PhoneIcon,
|
||||
MagnifyingGlassIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
interface Lead {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
status: string;
|
||||
source: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export default function CustomerLeadsPage() {
|
||||
const router = useRouter();
|
||||
const [leads, setLeads] = useState<Lead[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchLeads();
|
||||
}, []);
|
||||
|
||||
const fetchLeads = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch('/api/portal/leads', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Erro ao buscar leads');
|
||||
|
||||
const data = await response.json();
|
||||
setLeads(data.leads || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching leads:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
novo: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
qualificado: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
|
||||
negociacao: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300',
|
||||
convertido: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
|
||||
perdido: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300',
|
||||
};
|
||||
return colors[status] || 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300';
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
novo: 'Novo',
|
||||
qualificado: 'Qualificado',
|
||||
negociacao: 'Em Negociação',
|
||||
convertido: 'Convertido',
|
||||
perdido: 'Perdido',
|
||||
};
|
||||
return labels[status] || status;
|
||||
};
|
||||
|
||||
const filteredLeads = leads.filter(lead =>
|
||||
lead.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
lead.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
lead.phone?.includes(searchTerm)
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<svg className="animate-spin h-12 w-12 mx-auto text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Carregando...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 lg:p-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Meus Leads
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Lista completa dos seus leads
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="mb-6">
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Buscar por nome, email ou telefone..."
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Nome
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Contato
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Origem
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Data
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{filteredLeads.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
|
||||
{searchTerm ? 'Nenhum lead encontrado com esse filtro' : 'Nenhum lead encontrado'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredLeads.map((lead) => (
|
||||
<tr key={lead.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{lead.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<EnvelopeIcon className="h-4 w-4" />
|
||||
{lead.email}
|
||||
</div>
|
||||
{lead.phone && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<PhoneIcon className="h-4 w-4" />
|
||||
{lead.phone}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 capitalize">
|
||||
{lead.source || 'Manual'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(lead.status)}`}>
|
||||
{getStatusLabel(lead.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">
|
||||
{new Date(lead.created_at).toLocaleDateString('pt-BR')}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
front-end-agency/app/cliente/(portal)/listas/page.tsx
Normal file
138
front-end-agency/app/cliente/(portal)/listas/page.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
ListBulletIcon,
|
||||
MagnifyingGlassIcon,
|
||||
UserGroupIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
interface List {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
color: string;
|
||||
customer_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export default function CustomerListsPage() {
|
||||
const [lists, setLists] = useState<List[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchLists();
|
||||
}, []);
|
||||
|
||||
const fetchLists = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/portal/lists', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setLists(data.lists || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching lists:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredLists = lists.filter(list =>
|
||||
list.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
list.description.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Minhas Listas</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Visualize as listas e segmentos onde seus leads estão organizados.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filtros e Busca */}
|
||||
<div className="bg-white dark:bg-zinc-900 p-4 rounded-xl border border-gray-200 dark:border-zinc-800 shadow-sm">
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar listas..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 bg-gray-50 dark:bg-zinc-800 border border-gray-200 dark:border-zinc-700 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid de Listas */}
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-48 bg-gray-100 dark:bg-zinc-800 animate-pulse rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : filteredLists.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredLists.map((list) => (
|
||||
<div
|
||||
key={list.id}
|
||||
className="bg-white dark:bg-zinc-900 rounded-xl border border-gray-200 dark:border-zinc-800 shadow-sm hover:shadow-md transition-all overflow-hidden group"
|
||||
>
|
||||
<div
|
||||
className="h-2 w-full"
|
||||
style={{ backgroundColor: list.color || '#3B82F6' }}
|
||||
/>
|
||||
<div className="p-5">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="p-2 rounded-lg bg-gray-50 dark:bg-zinc-800 group-hover:scale-110 transition-transform">
|
||||
<ListBulletIcon
|
||||
className="w-6 h-6"
|
||||
style={{ color: list.color || '#3B82F6' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 text-xs font-medium">
|
||||
<UserGroupIcon className="w-3.5 h-3.5" />
|
||||
{list.customer_count || 0} Leads
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-1 group-hover:text-blue-600 transition-colors">
|
||||
{list.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-2 mb-4 h-10">
|
||||
{list.description || 'Sem descrição disponível.'}
|
||||
</p>
|
||||
|
||||
<div className="pt-4 border-t border-gray-100 dark:border-zinc-800 flex items-center justify-between">
|
||||
<span className="text-xs text-gray-400">
|
||||
Criada em {new Date(list.created_at).toLocaleDateString('pt-BR')}
|
||||
</span>
|
||||
<button className="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
|
||||
Ver Leads →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-20 bg-white dark:bg-zinc-900 rounded-xl border border-dashed border-gray-300 dark:border-zinc-700">
|
||||
<ListBulletIcon className="w-12 h-12 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Nenhuma lista encontrada</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{searchTerm ? 'Tente ajustar sua busca.' : 'Você ainda não possui listas associadas aos seus leads.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
404
front-end-agency/app/cliente/(portal)/perfil/page.tsx
Normal file
404
front-end-agency/app/cliente/(portal)/perfil/page.tsx
Normal file
@@ -0,0 +1,404 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
UserCircleIcon,
|
||||
EnvelopeIcon,
|
||||
PhoneIcon,
|
||||
BuildingOfficeIcon,
|
||||
KeyIcon,
|
||||
CalendarIcon,
|
||||
ChartBarIcon,
|
||||
ClockIcon,
|
||||
ShieldCheckIcon,
|
||||
ArrowPathIcon,
|
||||
CameraIcon,
|
||||
PhotoIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { Button, Input } from '@/components/ui';
|
||||
import { useToast } from '@/components/layout/ToastContext';
|
||||
|
||||
interface CustomerProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
company: string;
|
||||
logo_url?: string;
|
||||
portal_last_login: string | null;
|
||||
created_at: string;
|
||||
total_leads: number;
|
||||
converted_leads: number;
|
||||
}
|
||||
|
||||
export default function PerfilPage() {
|
||||
const toast = useToast();
|
||||
const [profile, setProfile] = useState<CustomerProfile | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isChangingPassword, setIsChangingPassword] = useState(false);
|
||||
const [isUploadingLogo, setIsUploadingLogo] = useState(false);
|
||||
const [passwordForm, setPasswordForm] = useState({
|
||||
current_password: '',
|
||||
new_password: '',
|
||||
confirm_password: '',
|
||||
});
|
||||
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||
const [passwordSuccess, setPasswordSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProfile();
|
||||
}, []);
|
||||
|
||||
const fetchProfile = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await fetch('/api/portal/profile', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Erro ao carregar perfil');
|
||||
|
||||
const data = await res.json();
|
||||
setProfile(data.customer);
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar perfil:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validar tamanho (2MB)
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast.error('Arquivo muito grande', 'O logo deve ter no máximo 2MB.');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('logo', file);
|
||||
|
||||
setIsUploadingLogo(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await fetch('/api/portal/logo', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Erro ao fazer upload do logo');
|
||||
|
||||
const data = await res.json();
|
||||
setProfile(prev => prev ? { ...prev, logo_url: data.logo_url } : null);
|
||||
toast.success('Logo atualizado', 'Seu logo foi atualizado com sucesso.');
|
||||
} catch (error) {
|
||||
console.error('Error uploading logo:', error);
|
||||
toast.error('Erro no upload', 'Não foi possível atualizar seu logo.');
|
||||
} finally {
|
||||
setIsUploadingLogo(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordChange = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setPasswordError(null);
|
||||
setPasswordSuccess(false);
|
||||
|
||||
if (passwordForm.new_password !== passwordForm.confirm_password) {
|
||||
setPasswordError('As senhas não coincidem');
|
||||
return;
|
||||
}
|
||||
|
||||
if (passwordForm.new_password.length < 6) {
|
||||
setPasswordError('A nova senha deve ter no mínimo 6 caracteres');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsChangingPassword(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await fetch('/api/portal/change-password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
current_password: passwordForm.current_password,
|
||||
new_password: passwordForm.new_password,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Erro ao alterar senha');
|
||||
|
||||
setPasswordSuccess(true);
|
||||
setPasswordForm({
|
||||
current_password: '',
|
||||
new_password: '',
|
||||
confirm_password: '',
|
||||
});
|
||||
} catch (error: any) {
|
||||
setPasswordError(error.message);
|
||||
} finally {
|
||||
setIsChangingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[60vh]">
|
||||
<div className="text-center">
|
||||
<ArrowPathIcon className="w-10 h-10 animate-spin mx-auto text-brand-500" />
|
||||
<p className="mt-4 text-gray-500 dark:text-zinc-400">Carregando seu perfil...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-[60vh] text-center px-4">
|
||||
<div className="w-16 h-16 bg-red-100 dark:bg-red-900/20 rounded-full flex items-center justify-center mb-4">
|
||||
<UserCircleIcon className="w-10 h-10 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Ops! Algo deu errado</h2>
|
||||
<p className="mt-2 text-gray-500 dark:text-zinc-400 max-w-xs">
|
||||
Não conseguimos carregar suas informações. Por favor, tente novamente mais tarde.
|
||||
</p>
|
||||
<Button onClick={fetchProfile} className="mt-6">
|
||||
Tentar Novamente
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 lg:p-8 max-w-5xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Meu Perfil</h1>
|
||||
<p className="text-gray-500 dark:text-zinc-400 mt-1">
|
||||
Gerencie suas informações pessoais e segurança da conta.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Coluna da Esquerda: Info do Usuário */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Card de Informações Básicas */}
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-gray-200 dark:border-zinc-800 overflow-hidden shadow-sm">
|
||||
<div className="h-32 bg-gradient-to-r from-brand-500/20 to-brand-600/20 dark:from-brand-500/10 dark:to-brand-600/10 relative">
|
||||
<div className="absolute -bottom-12 left-8">
|
||||
<div className="relative group">
|
||||
<div className="w-24 h-24 rounded-2xl bg-white dark:bg-zinc-800 border-4 border-white dark:border-zinc-900 shadow-xl flex items-center justify-center overflow-hidden">
|
||||
{profile.logo_url ? (
|
||||
<img src={profile.logo_url} alt={profile.name} className="w-full h-full object-contain p-2" />
|
||||
) : (
|
||||
<UserCircleIcon className="w-16 h-16 text-gray-300 dark:text-zinc-600" />
|
||||
)}
|
||||
|
||||
{isUploadingLogo && (
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
|
||||
<ArrowPathIcon className="w-8 h-8 text-white animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="absolute -bottom-2 -right-2 w-8 h-8 bg-brand-500 hover:bg-brand-600 text-white rounded-full flex items-center justify-center cursor-pointer shadow-lg transition-all transform group-hover:scale-110">
|
||||
<CameraIcon className="w-4 h-4" />
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept="image/png,image/jpeg,image/jpg,image/svg+xml"
|
||||
onChange={handleLogoUpload}
|
||||
disabled={isUploadingLogo}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-16 pb-8 px-8">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{profile.name}</h2>
|
||||
<p className="text-brand-600 dark:text-brand-400 font-medium">{profile.company || 'Cliente Aggios'}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded-full text-sm font-medium self-start">
|
||||
<ShieldCheckIcon className="w-4 h-4" />
|
||||
Conta Ativa
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 text-gray-600 dark:text-zinc-400">
|
||||
<EnvelopeIcon className="w-5 h-5 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-400 uppercase tracking-wider">E-mail</p>
|
||||
<p className="text-gray-900 dark:text-white">{profile.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-gray-600 dark:text-zinc-400">
|
||||
<PhoneIcon className="w-5 h-5 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-400 uppercase tracking-wider">Telefone</p>
|
||||
<p className="text-gray-900 dark:text-white">{profile.phone || 'Não informado'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 text-gray-600 dark:text-zinc-400">
|
||||
<CalendarIcon className="w-5 h-5 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-400 uppercase tracking-wider">Membro desde</p>
|
||||
<p className="text-gray-900 dark:text-white">
|
||||
{new Date(profile.created_at).toLocaleDateString('pt-BR', { day: '2-digit', month: 'long', year: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-gray-600 dark:text-zinc-400">
|
||||
<ClockIcon className="w-5 h-5 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-400 uppercase tracking-wider">Último Acesso</p>
|
||||
<p className="text-gray-900 dark:text-white">
|
||||
{profile.portal_last_login
|
||||
? new Date(profile.portal_last_login).toLocaleString('pt-BR')
|
||||
: 'Primeiro acesso'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card de Estatísticas Rápidas */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="bg-white dark:bg-zinc-900 p-6 rounded-2xl border border-gray-200 dark:border-zinc-800 shadow-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-brand-100 dark:bg-brand-900/20 rounded-xl flex items-center justify-center">
|
||||
<ChartBarIcon className="w-6 h-6 text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-zinc-400">Total de Leads</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">{profile.total_leads}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-zinc-900 p-6 rounded-2xl border border-gray-200 dark:border-zinc-800 shadow-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-green-100 dark:bg-green-900/20 rounded-xl flex items-center justify-center">
|
||||
<ShieldCheckIcon className="w-6 h-6 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-zinc-400">Leads Convertidos</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">{profile.converted_leads}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Coluna da Direita: Segurança */}
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white dark:bg-zinc-900 p-6 rounded-2xl border border-gray-200 dark:border-zinc-800 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 bg-amber-100 dark:bg-amber-900/20 rounded-lg flex items-center justify-center">
|
||||
<KeyIcon className="w-5 h-5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white">Segurança</h3>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handlePasswordChange} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-1.5">
|
||||
Senha Atual
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={passwordForm.current_password}
|
||||
onChange={(e) => setPasswordForm({ ...passwordForm, current_password: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-gray-100 dark:bg-zinc-800 my-2" />
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-1.5">
|
||||
Nova Senha
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Mínimo 6 caracteres"
|
||||
value={passwordForm.new_password}
|
||||
onChange={(e) => setPasswordForm({ ...passwordForm, new_password: e.target.value })}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-1.5">
|
||||
Confirmar Nova Senha
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Repita a nova senha"
|
||||
value={passwordForm.confirm_password}
|
||||
onChange={(e) => setPasswordForm({ ...passwordForm, confirm_password: e.target.value })}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{passwordError && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-100 dark:border-red-900/30 rounded-xl text-red-600 dark:text-red-400 text-sm">
|
||||
{passwordError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{passwordSuccess && (
|
||||
<div className="p-3 bg-green-50 dark:bg-green-900/20 border border-green-100 dark:border-green-900/30 rounded-xl text-green-600 dark:text-green-400 text-sm">
|
||||
Senha alterada com sucesso!
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
isLoading={isChangingPassword}
|
||||
>
|
||||
Atualizar Senha
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="bg-brand-50 dark:bg-brand-900/10 p-6 rounded-2xl border border-brand-100 dark:border-brand-900/20">
|
||||
<h4 className="text-brand-900 dark:text-brand-300 font-bold mb-2">Precisa de ajuda?</h4>
|
||||
<p className="text-brand-700 dark:text-brand-400 text-sm mb-4">
|
||||
Se você tiver problemas com sua conta ou precisar alterar dados cadastrais, entre em contato com o suporte da agência.
|
||||
</p>
|
||||
<a
|
||||
href="mailto:suporte@aggios.app"
|
||||
className="text-brand-600 dark:text-brand-400 text-sm font-bold hover:underline"
|
||||
>
|
||||
suporte@aggios.app
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
1087
front-end-agency/app/cliente/cadastro/cadastro-client.tsx
Normal file
1087
front-end-agency/app/cliente/cadastro/cadastro-client.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user