3 Commits

Author SHA1 Message Date
Erik Silva
3be732b1cc docs: corrige nome da branch no README 2025-12-24 18:01:47 -03:00
Erik Silva
21fbdd3692 docs: atualiza README com funcionalidades da v1.5 - CRM Beta 2025-12-24 17:39:20 -03:00
Erik Silva
dfb91c8ba5 feat: versão 1.5 - CRM Beta com leads, funis, campanhas e portal do cliente 2025-12-24 17:36:52 -03:00
99 changed files with 18340 additions and 1474 deletions

10
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,10 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build-agency-frontend",
"type": "shell",
"command": "docker compose build agency"
}
]
}

View File

@@ -5,18 +5,62 @@ Plataforma composta por serviços de autenticação, painel administrativo (supe
## Visão geral ## Visão geral
- **Objetivo**: permitir que superadministradores cadastrem e gerenciem agências (tenants) enquanto o site institucional apresenta informações públicas da empresa. - **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 16 (dashboard e site), PostgreSQL, Traefik, Docker. - **Stack**: Go (backend), Next.js 16 (dashboard e site), PostgreSQL, Traefik, Docker.
- **Status**: Sistema multi-tenant completo com segurança cross-tenant validada, branding dinâmico e file serving via API. - **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 ## 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 e autenticação tenant-aware. - `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. - `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. - `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. - `traefik/`: reverse proxy e certificados automatizados.
## Funcionalidades entregues ## Funcionalidades entregues
### **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)** ### **v1.4 - Segurança Multi-tenant e File Serving (13/12/2025)**
- **🔒 Segurança Cross-Tenant Crítica**: - **🔒 Segurança Cross-Tenant Crítica**:
- Validação de tenant_id em endpoints de login (bloqueio de cross-tenant authentication) - Validação de tenant_id em endpoints de login (bloqueio de cross-tenant authentication)
@@ -69,6 +113,7 @@ Plataforma composta por serviços de autenticação, painel administrativo (supe
4. **Hosts locais**: 4. **Hosts locais**:
- Painel SuperAdmin: `http://dash.localhost` - Painel SuperAdmin: `http://dash.localhost`
- Painel Agência: `http://{agencia}.localhost` (ex: `http://idealpages.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` - Site: `http://aggios.app.localhost`
- API: `http://api.localhost` - API: `http://api.localhost`
- Console MinIO: `http://minio.localhost` (admin: minioadmin / M1n10_S3cur3_P@ss_2025!) - Console MinIO: `http://minio.localhost` (admin: minioadmin / M1n10_S3cur3_P@ss_2025!)
@@ -87,15 +132,46 @@ backend/ API Go (config, domínio, handlers, serviço
internal/ internal/
api/ api/
handlers/ handlers/
files.go 🆕 Handler para servir arquivos via API 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 auth.go 🔒 Validação cross-tenant no login
middleware/ 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 auth.go 🔒 Validação tenant em rotas protegidas
tenant.go 🔧 Detecção de tenant via headers 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 backend/internal/data/postgres/ Scripts SQL de seed
front-end-agency/ 🆕 Dashboard Next.js para Agências migrations/
app/login/page.tsx 🎨 Login com mensagens humanizadas 015_create_crm_leads.sql 🆕 Estrutura de leads
middleware.ts 🔧 Injeção de headers tenant 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 front-end-dash.aggios.app/ Dashboard Next.js Superadmin
frontend-aggios.app/ Site institucional Next.js frontend-aggios.app/ Site institucional Next.js
traefik/ Regras de roteamento e TLS traefik/ Regras de roteamento e TLS
@@ -121,4 +197,4 @@ traefik/ Regras de roteamento e TLS
## Repositório ## Repositório
- Principal: https://git.stackbyte.cloud/erik/aggios.app.git - Principal: https://git.stackbyte.cloud/erik/aggios.app.git
- Branch: dev-1.4 (Segurança Multi-tenant + File Serving) - Branch: 1.5-crm-beta (v1.5 - CRM Beta com leads, funis, campanhas e portal do cliente)

View File

@@ -62,7 +62,7 @@ func main() {
solutionRepo := repository.NewSolutionRepository(db) solutionRepo := repository.NewSolutionRepository(db)
// Initialize services // Initialize services
authService := service.NewAuthService(userRepo, tenantRepo, cfg) authService := service.NewAuthService(userRepo, tenantRepo, crmRepo, cfg)
agencyService := service.NewAgencyService(userRepo, tenantRepo, cfg, db) agencyService := service.NewAgencyService(userRepo, tenantRepo, cfg, db)
tenantService := service.NewTenantService(tenantRepo, db) tenantService := service.NewTenantService(tenantRepo, db)
companyService := service.NewCompanyService(companyRepo) companyService := service.NewCompanyService(companyRepo)
@@ -73,6 +73,7 @@ func main() {
authHandler := handlers.NewAuthHandler(authService) authHandler := handlers.NewAuthHandler(authService)
agencyProfileHandler := handlers.NewAgencyHandler(tenantRepo, cfg) agencyProfileHandler := handlers.NewAgencyHandler(tenantRepo, cfg)
agencyHandler := handlers.NewAgencyRegistrationHandler(agencyService, cfg) agencyHandler := handlers.NewAgencyRegistrationHandler(agencyService, cfg)
collaboratorHandler := handlers.NewCollaboratorHandler(userRepo, agencyService)
tenantHandler := handlers.NewTenantHandler(tenantService) tenantHandler := handlers.NewTenantHandler(tenantService)
companyHandler := handlers.NewCompanyHandler(companyService) companyHandler := handlers.NewCompanyHandler(companyService)
planHandler := handlers.NewPlanHandler(planService) planHandler := handlers.NewPlanHandler(planService)
@@ -81,6 +82,7 @@ func main() {
signupTemplateHandler := handlers.NewSignupTemplateHandler(signupTemplateRepo, userRepo, tenantRepo, agencyService) signupTemplateHandler := handlers.NewSignupTemplateHandler(signupTemplateRepo, userRepo, tenantRepo, agencyService)
agencyTemplateHandler := handlers.NewAgencyTemplateHandler(agencyTemplateRepo, agencyService, userRepo, tenantRepo) agencyTemplateHandler := handlers.NewAgencyTemplateHandler(agencyTemplateRepo, agencyService, userRepo, tenantRepo)
filesHandler := handlers.NewFilesHandler(cfg) filesHandler := handlers.NewFilesHandler(cfg)
customerPortalHandler := handlers.NewCustomerPortalHandler(crmRepo, authService, cfg)
// Initialize upload handler // Initialize upload handler
uploadHandler, err := handlers.NewUploadHandler(cfg) uploadHandler, err := handlers.NewUploadHandler(cfg)
@@ -112,7 +114,8 @@ func main() {
router.HandleFunc("/api/health", healthHandler.Check) router.HandleFunc("/api/health", healthHandler.Check)
// Auth // 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") router.HandleFunc("/api/auth/register", agencyHandler.PublicRegister).Methods("POST")
// Public agency template registration (for creating new agencies) // Public agency template registration (for creating new agencies)
@@ -133,6 +136,13 @@ func main() {
// Tenant check (public) // Tenant check (public)
router.HandleFunc("/api/tenant/check", tenantHandler.CheckExists).Methods("GET") router.HandleFunc("/api/tenant/check", tenantHandler.CheckExists).Methods("GET")
router.HandleFunc("/api/tenant/config", tenantHandler.GetPublicConfig).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) // Hash generator (dev only - remove in production)
router.HandleFunc("/api/hash", handlers.GenerateHash).Methods("POST") router.HandleFunc("/api/hash", handlers.GenerateHash).Methods("POST")
@@ -239,6 +249,9 @@ func main() {
// Tenant solutions (which solutions the tenant has access to) // Tenant solutions (which solutions the tenant has access to)
router.Handle("/api/tenant/solutions", authMiddleware(http.HandlerFunc(solutionHandler.GetTenantSolutions))).Methods("GET") 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 // Customers
router.Handle("/api/crm/customers", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { router.Handle("/api/crm/customers", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
@@ -281,6 +294,8 @@ func main() {
} }
}))).Methods("GET", "PUT", "PATCH", "DELETE") }))).Methods("GET", "PUT", "PATCH", "DELETE")
router.Handle("/api/crm/lists/{id}/leads", authMiddleware(http.HandlerFunc(crmHandler.GetLeadsByList))).Methods("GET")
// Customer <-> List relationship // Customer <-> List relationship
router.Handle("/api/crm/customers/{customer_id}/lists/{list_id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { router.Handle("/api/crm/customers/{customer_id}/lists/{list_id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
@@ -291,6 +306,124 @@ func main() {
} }
}))).Methods("POST", "DELETE") }))).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 // Apply global middlewares: tenant -> cors -> security -> rateLimit -> router
handler := tenantDetector(corsMiddleware(securityMiddleware(rateLimitMiddleware(router)))) handler := tenantDetector(corsMiddleware(securityMiddleware(rateLimitMiddleware(router))))

View File

@@ -7,5 +7,6 @@ require (
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
github.com/minio/minio-go/v7 v7.0.63 github.com/minio/minio-go/v7 v7.0.63
github.com/xuri/excelize/v2 v2.8.1
golang.org/x/crypto v0.27.0 golang.org/x/crypto v0.27.0
) )

View File

@@ -167,3 +167,94 @@ func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
"message": "Password changed successfully", "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)
}

View 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
}

File diff suppressed because it is too large Load Diff

View 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,
})
}

View 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)
}
}

View File

@@ -5,7 +5,10 @@ import (
"log" "log"
"net/http" "net/http"
"aggios-app/backend/internal/api/middleware"
"aggios-app/backend/internal/service" "aggios-app/backend/internal/service"
"github.com/google/uuid"
) )
// TenantHandler handles tenant/agency listing endpoints // TenantHandler handles tenant/agency listing endpoints
@@ -93,7 +96,8 @@ func (h *TenantHandler) GetPublicConfig(w http.ResponseWriter, r *http.Request)
} }
// Return only public info // Return only public info
response := map[string]string{ response := map[string]interface{}{
"id": tenant.ID.String(),
"name": tenant.Name, "name": tenant.Name,
"primary_color": tenant.PrimaryColor, "primary_color": tenant.PrimaryColor,
"secondary_color": tenant.SecondaryColor, "secondary_color": tenant.SecondaryColor,
@@ -106,3 +110,88 @@ func (h *TenantHandler) GetPublicConfig(w http.ResponseWriter, r *http.Request)
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
json.NewEncoder(w).Encode(response) 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)
}

View File

@@ -65,6 +65,16 @@ func Auth(cfg *config.Config) func(http.Handler) http.Handler {
tenantIDFromJWT, _ = tenantIDClaim.(string) tenantIDFromJWT, _ = tenantIDClaim.(string)
} }
// 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 // 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) // Pegar o tenant_id do contexto (detectado pelo TenantDetector middleware ANTES deste)
tenantIDFromContext := "" tenantIDFromContext := ""

View 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)
})
}

View 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))
})
}
}

View 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
}

View File

@@ -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);

View 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
}

View File

@@ -1,6 +1,9 @@
package domain package domain
import "time" import (
"encoding/json"
"time"
)
type CRMCustomer struct { type CRMCustomer struct {
ID string `json:"id" db:"id"` ID string `json:"id" db:"id"`
@@ -17,15 +20,22 @@ type CRMCustomer struct {
Country string `json:"country" db:"country"` Country string `json:"country" db:"country"`
Notes string `json:"notes" db:"notes"` Notes string `json:"notes" db:"notes"`
Tags []string `json:"tags" db:"tags"` Tags []string `json:"tags" db:"tags"`
LogoURL string `json:"logo_url" db:"logo_url"`
IsActive bool `json:"is_active" db:"is_active"` IsActive bool `json:"is_active" db:"is_active"`
CreatedBy string `json:"created_by" db:"created_by"` CreatedBy string `json:"created_by" db:"created_by"`
CreatedAt time.Time `json:"created_at" db:"created_at"` CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_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 { type CRMList struct {
ID string `json:"id" db:"id"` ID string `json:"id" db:"id"`
TenantID string `json:"tenant_id" db:"tenant_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"` Name string `json:"name" db:"name"`
Description string `json:"description" db:"description"` Description string `json:"description" db:"description"`
Color string `json:"color" db:"color"` Color string `json:"color" db:"color"`
@@ -49,5 +59,77 @@ type CRMCustomerWithLists struct {
type CRMListWithCustomers struct { type CRMListWithCustomers struct {
CRMList CRMList
CustomerName string `json:"customer_name"`
CustomerCount int `json:"customer_count"` 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"`
} }

View File

@@ -14,6 +14,9 @@ type User struct {
Password string `json:"-" db:"password_hash"` Password string `json:"-" db:"password_hash"`
Name string `json:"name" db:"first_name"` Name string `json:"name" db:"first_name"`
Role string `json:"role" db:"role"` // SUPERADMIN, ADMIN_AGENCIA, CLIENTE 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"` CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
} }

View File

@@ -4,6 +4,7 @@ import (
"aggios-app/backend/internal/domain" "aggios-app/backend/internal/domain"
"database/sql" "database/sql"
"fmt" "fmt"
"log"
"github.com/lib/pq" "github.com/lib/pq"
) )
@@ -23,17 +24,25 @@ func (r *CRMRepository) CreateCustomer(customer *domain.CRMCustomer) error {
INSERT INTO crm_customers ( INSERT INTO crm_customers (
id, tenant_id, name, email, phone, company, position, id, tenant_id, name, email, phone, company, position,
address, city, state, zip_code, country, notes, tags, address, city, state, zip_code, country, notes, tags,
is_active, created_by is_active, created_by, logo_url
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
RETURNING created_at, updated_at RETURNING created_at, updated_at
` `
// Handle optional created_by field (NULL for public registrations)
var createdBy interface{}
if customer.CreatedBy != "" {
createdBy = customer.CreatedBy
} else {
createdBy = nil
}
return r.db.QueryRow( return r.db.QueryRow(
query, query,
customer.ID, customer.TenantID, customer.Name, customer.Email, customer.Phone, customer.ID, customer.TenantID, customer.Name, customer.Email, customer.Phone,
customer.Company, customer.Position, customer.Address, customer.City, customer.State, customer.Company, customer.Position, customer.Address, customer.City, customer.State,
customer.ZipCode, customer.Country, customer.Notes, pq.Array(customer.Tags), customer.ZipCode, customer.Country, customer.Notes, pq.Array(customer.Tags),
customer.IsActive, customer.CreatedBy, customer.IsActive, createdBy, customer.LogoURL,
).Scan(&customer.CreatedAt, &customer.UpdatedAt) ).Scan(&customer.CreatedAt, &customer.UpdatedAt)
} }
@@ -41,7 +50,8 @@ func (r *CRMRepository) GetCustomersByTenant(tenantID string) ([]domain.CRMCusto
query := ` query := `
SELECT id, tenant_id, name, email, phone, company, position, SELECT id, tenant_id, name, email, phone, company, position,
address, city, state, zip_code, country, notes, tags, address, city, state, zip_code, country, notes, tags,
is_active, created_by, created_at, updated_at is_active, COALESCE(created_by::text, '') AS created_by, created_at, updated_at,
COALESCE(logo_url, '') as logo_url
FROM crm_customers FROM crm_customers
WHERE tenant_id = $1 AND is_active = true WHERE tenant_id = $1 AND is_active = true
ORDER BY created_at DESC ORDER BY created_at DESC
@@ -59,7 +69,7 @@ func (r *CRMRepository) GetCustomersByTenant(tenantID string) ([]domain.CRMCusto
err := rows.Scan( err := rows.Scan(
&c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position, &c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position,
&c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags), &c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags),
&c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, &c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, &c.LogoURL,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -74,7 +84,8 @@ func (r *CRMRepository) GetCustomerByID(id string, tenantID string) (*domain.CRM
query := ` query := `
SELECT id, tenant_id, name, email, phone, company, position, SELECT id, tenant_id, name, email, phone, company, position,
address, city, state, zip_code, country, notes, tags, address, city, state, zip_code, country, notes, tags,
is_active, created_by, created_at, updated_at is_active, COALESCE(created_by::text, '') AS created_by, created_at, updated_at,
COALESCE(logo_url, '') as logo_url
FROM crm_customers FROM crm_customers
WHERE id = $1 AND tenant_id = $2 WHERE id = $1 AND tenant_id = $2
` `
@@ -83,7 +94,7 @@ func (r *CRMRepository) GetCustomerByID(id string, tenantID string) (*domain.CRM
err := r.db.QueryRow(query, id, tenantID).Scan( err := r.db.QueryRow(query, id, tenantID).Scan(
&c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position, &c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position,
&c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags), &c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags),
&c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, &c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, &c.LogoURL,
) )
if err != nil { if err != nil {
@@ -98,15 +109,15 @@ func (r *CRMRepository) UpdateCustomer(customer *domain.CRMCustomer) error {
UPDATE crm_customers SET UPDATE crm_customers SET
name = $1, email = $2, phone = $3, company = $4, position = $5, name = $1, email = $2, phone = $3, company = $4, position = $5,
address = $6, city = $7, state = $8, zip_code = $9, country = $10, address = $6, city = $7, state = $8, zip_code = $9, country = $10,
notes = $11, tags = $12, is_active = $13 notes = $11, tags = $12, is_active = $13, logo_url = $14
WHERE id = $14 AND tenant_id = $15 WHERE id = $15 AND tenant_id = $16
` `
result, err := r.db.Exec( result, err := r.db.Exec(
query, query,
customer.Name, customer.Email, customer.Phone, customer.Company, customer.Position, customer.Name, customer.Email, customer.Phone, customer.Company, customer.Position,
customer.Address, customer.City, customer.State, customer.ZipCode, customer.Country, customer.Address, customer.City, customer.State, customer.ZipCode, customer.Country,
customer.Notes, pq.Array(customer.Tags), customer.IsActive, customer.Notes, pq.Array(customer.Tags), customer.IsActive, customer.LogoURL,
customer.ID, customer.TenantID, customer.ID, customer.TenantID,
) )
@@ -150,26 +161,27 @@ func (r *CRMRepository) DeleteCustomer(id string, tenantID string) error {
func (r *CRMRepository) CreateList(list *domain.CRMList) error { func (r *CRMRepository) CreateList(list *domain.CRMList) error {
query := ` query := `
INSERT INTO crm_lists (id, tenant_id, name, description, color, created_by) INSERT INTO crm_lists (id, tenant_id, customer_id, funnel_id, name, description, color, created_by)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING created_at, updated_at RETURNING created_at, updated_at
` `
return r.db.QueryRow( return r.db.QueryRow(
query, query,
list.ID, list.TenantID, list.Name, list.Description, list.Color, list.CreatedBy, list.ID, list.TenantID, list.CustomerID, list.FunnelID, list.Name, list.Description, list.Color, list.CreatedBy,
).Scan(&list.CreatedAt, &list.UpdatedAt) ).Scan(&list.CreatedAt, &list.UpdatedAt)
} }
func (r *CRMRepository) GetListsByTenant(tenantID string) ([]domain.CRMListWithCustomers, error) { func (r *CRMRepository) GetListsByTenant(tenantID string) ([]domain.CRMListWithCustomers, error) {
query := ` query := `
SELECT l.id, l.tenant_id, l.name, l.description, l.color, l.created_by, SELECT l.id, l.tenant_id, l.customer_id, l.funnel_id, l.name, l.description, l.color, l.created_by,
l.created_at, l.updated_at, l.created_at, l.updated_at,
COUNT(cl.customer_id) as customer_count COALESCE(c.name, '') as customer_name,
(SELECT COUNT(*) FROM crm_customer_lists cl WHERE cl.list_id = l.id) as customer_count,
(SELECT COUNT(*) FROM crm_lead_lists ll WHERE ll.list_id = l.id) as lead_count
FROM crm_lists l FROM crm_lists l
LEFT JOIN crm_customer_lists cl ON l.id = cl.list_id LEFT JOIN crm_customers c ON l.customer_id = c.id
WHERE l.tenant_id = $1 WHERE l.tenant_id = $1
GROUP BY l.id
ORDER BY l.created_at DESC ORDER BY l.created_at DESC
` `
@@ -183,8 +195,8 @@ func (r *CRMRepository) GetListsByTenant(tenantID string) ([]domain.CRMListWithC
for rows.Next() { for rows.Next() {
var l domain.CRMListWithCustomers var l domain.CRMListWithCustomers
err := rows.Scan( err := rows.Scan(
&l.ID, &l.TenantID, &l.Name, &l.Description, &l.Color, &l.CreatedBy, &l.ID, &l.TenantID, &l.CustomerID, &l.FunnelID, &l.Name, &l.Description, &l.Color, &l.CreatedBy,
&l.CreatedAt, &l.UpdatedAt, &l.CustomerCount, &l.CreatedAt, &l.UpdatedAt, &l.CustomerName, &l.CustomerCount, &l.LeadCount,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -197,14 +209,14 @@ func (r *CRMRepository) GetListsByTenant(tenantID string) ([]domain.CRMListWithC
func (r *CRMRepository) GetListByID(id string, tenantID string) (*domain.CRMList, error) { func (r *CRMRepository) GetListByID(id string, tenantID string) (*domain.CRMList, error) {
query := ` query := `
SELECT id, tenant_id, name, description, color, created_by, created_at, updated_at SELECT id, tenant_id, customer_id, funnel_id, name, description, color, created_by, created_at, updated_at
FROM crm_lists FROM crm_lists
WHERE id = $1 AND tenant_id = $2 WHERE id = $1 AND tenant_id = $2
` `
var l domain.CRMList var l domain.CRMList
err := r.db.QueryRow(query, id, tenantID).Scan( err := r.db.QueryRow(query, id, tenantID).Scan(
&l.ID, &l.TenantID, &l.Name, &l.Description, &l.Color, &l.CreatedBy, &l.ID, &l.TenantID, &l.CustomerID, &l.FunnelID, &l.Name, &l.Description, &l.Color, &l.CreatedBy,
&l.CreatedAt, &l.UpdatedAt, &l.CreatedAt, &l.UpdatedAt,
) )
@@ -218,11 +230,11 @@ func (r *CRMRepository) GetListByID(id string, tenantID string) (*domain.CRMList
func (r *CRMRepository) UpdateList(list *domain.CRMList) error { func (r *CRMRepository) UpdateList(list *domain.CRMList) error {
query := ` query := `
UPDATE crm_lists SET UPDATE crm_lists SET
name = $1, description = $2, color = $3 name = $1, description = $2, color = $3, customer_id = $4, funnel_id = $5
WHERE id = $4 AND tenant_id = $5 WHERE id = $6 AND tenant_id = $7
` `
result, err := r.db.Exec(query, list.Name, list.Description, list.Color, list.ID, list.TenantID) result, err := r.db.Exec(query, list.Name, list.Description, list.Color, list.CustomerID, list.FunnelID, list.ID, list.TenantID)
if err != nil { if err != nil {
return err return err
} }
@@ -315,7 +327,8 @@ func (r *CRMRepository) GetListCustomers(listID string, tenantID string) ([]doma
query := ` query := `
SELECT c.id, c.tenant_id, c.name, c.email, c.phone, c.company, c.position, SELECT c.id, c.tenant_id, c.name, c.email, c.phone, c.company, c.position,
c.address, c.city, c.state, c.zip_code, c.country, c.notes, c.tags, c.address, c.city, c.state, c.zip_code, c.country, c.notes, c.tags,
c.is_active, c.created_by, c.created_at, c.updated_at c.is_active, c.created_by, c.created_at, c.updated_at,
COALESCE(c.logo_url, '') as logo_url
FROM crm_customers c FROM crm_customers c
INNER JOIN crm_customer_lists cl ON c.id = cl.customer_id INNER JOIN crm_customer_lists cl ON c.id = cl.customer_id
WHERE cl.list_id = $1 AND c.tenant_id = $2 AND c.is_active = true WHERE cl.list_id = $1 AND c.tenant_id = $2 AND c.is_active = true
@@ -334,7 +347,7 @@ func (r *CRMRepository) GetListCustomers(listID string, tenantID string) ([]doma
err := rows.Scan( err := rows.Scan(
&c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position, &c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position,
&c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags), &c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags),
&c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, &c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, &c.LogoURL,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -344,3 +357,803 @@ func (r *CRMRepository) GetListCustomers(listID string, tenantID string) ([]doma
return customers, nil return customers, nil
} }
// ==================== LEADS ====================
func (r *CRMRepository) CreateLead(lead *domain.CRMLead) error {
query := `
INSERT INTO crm_leads (
id, tenant_id, customer_id, funnel_id, stage_id, name, email, phone, source, source_meta,
status, notes, tags, is_active, created_by
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
RETURNING created_at, updated_at
`
return r.db.QueryRow(
query,
lead.ID, lead.TenantID, lead.CustomerID, lead.FunnelID, lead.StageID, lead.Name, lead.Email, lead.Phone,
lead.Source, lead.SourceMeta, lead.Status, lead.Notes, pq.Array(lead.Tags),
lead.IsActive, lead.CreatedBy,
).Scan(&lead.CreatedAt, &lead.UpdatedAt)
}
func (r *CRMRepository) AddLeadToList(leadID, listID, addedBy string) error {
query := `
INSERT INTO crm_lead_lists (lead_id, list_id, added_by)
VALUES ($1, $2, $3)
ON CONFLICT (lead_id, list_id) DO NOTHING
`
_, err := r.db.Exec(query, leadID, listID, addedBy)
return err
}
func (r *CRMRepository) BulkAddLeadsToList(leadIDs []string, listID string, addedBy string) error {
tx, err := r.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
stmt, err := tx.Prepare(pq.CopyIn("crm_lead_lists", "lead_id", "list_id", "added_by"))
if err != nil {
return err
}
defer stmt.Close()
for _, leadID := range leadIDs {
_, err = stmt.Exec(leadID, listID, addedBy)
if err != nil {
return err
}
}
_, err = stmt.Exec()
if err != nil {
return err
}
return tx.Commit()
}
func (r *CRMRepository) BulkCreateLeads(leads []domain.CRMLead) error {
tx, err := r.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
query := `
INSERT INTO crm_leads (
id, tenant_id, customer_id, funnel_id, stage_id, name, email, phone, source,
source_meta, status, notes, tags, is_active, created_by
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15
) ON CONFLICT (tenant_id, email) DO UPDATE SET
customer_id = COALESCE(EXCLUDED.customer_id, crm_leads.customer_id),
funnel_id = COALESCE(EXCLUDED.funnel_id, crm_leads.funnel_id),
stage_id = COALESCE(EXCLUDED.stage_id, crm_leads.stage_id),
name = COALESCE(EXCLUDED.name, crm_leads.name),
phone = COALESCE(EXCLUDED.phone, crm_leads.phone),
source = EXCLUDED.source,
source_meta = EXCLUDED.source_meta,
status = EXCLUDED.status,
notes = COALESCE(EXCLUDED.notes, crm_leads.notes),
tags = EXCLUDED.tags,
updated_at = CURRENT_TIMESTAMP
RETURNING id
`
stmt, err := tx.Prepare(query)
if err != nil {
return err
}
defer stmt.Close()
for i := range leads {
var returnedID string
err = stmt.QueryRow(
leads[i].ID, leads[i].TenantID, leads[i].CustomerID, leads[i].FunnelID, leads[i].StageID, leads[i].Name, leads[i].Email, leads[i].Phone,
leads[i].Source, string(leads[i].SourceMeta), leads[i].Status, leads[i].Notes, pq.Array(leads[i].Tags),
leads[i].IsActive, leads[i].CreatedBy,
).Scan(&returnedID)
if err != nil {
return err
}
// Atualiza o ID do lead com o ID retornado (pode ser diferente em caso de conflito)
leads[i].ID = returnedID
}
return tx.Commit()
}
func (r *CRMRepository) GetLeadsByTenant(tenantID string) ([]domain.CRMLead, error) {
query := `
SELECT id, tenant_id, customer_id, funnel_id, stage_id, name, email, phone, source, source_meta,
status, COALESCE(notes, ''), tags, is_active, created_by, created_at, updated_at
FROM crm_leads
WHERE tenant_id = $1 AND is_active = true
ORDER BY created_at DESC
`
rows, err := r.db.Query(query, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var leads []domain.CRMLead
for rows.Next() {
var l domain.CRMLead
err := rows.Scan(
&l.ID, &l.TenantID, &l.CustomerID, &l.FunnelID, &l.StageID, &l.Name, &l.Email, &l.Phone, &l.Source, &l.SourceMeta,
&l.Status, &l.Notes, pq.Array(&l.Tags), &l.IsActive, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt,
)
if err != nil {
log.Printf("Error scanning lead: %v", err)
continue
}
leads = append(leads, l)
}
return leads, nil
}
func (r *CRMRepository) GetLeadsWithListsByTenant(tenantID string) ([]domain.CRMLeadWithLists, error) {
leads, err := r.GetLeadsByTenant(tenantID)
if err != nil {
return nil, err
}
var leadsWithLists []domain.CRMLeadWithLists
for _, l := range leads {
lists, err := r.GetListsByLeadID(l.ID)
if err != nil {
lists = []domain.CRMList{}
}
leadsWithLists = append(leadsWithLists, domain.CRMLeadWithLists{
CRMLead: l,
Lists: lists,
})
}
return leadsWithLists, nil
}
func (r *CRMRepository) GetListsByLeadID(leadID string) ([]domain.CRMList, error) {
query := `
SELECT l.id, l.tenant_id, l.customer_id, l.name, l.description, l.color, l.created_by, l.created_at, l.updated_at
FROM crm_lists l
JOIN crm_lead_lists cll ON l.id = cll.list_id
WHERE cll.lead_id = $1
`
rows, err := r.db.Query(query, leadID)
if err != nil {
return nil, err
}
defer rows.Close()
var lists []domain.CRMList
for rows.Next() {
var l domain.CRMList
err := rows.Scan(
&l.ID, &l.TenantID, &l.CustomerID, &l.Name, &l.Description, &l.Color, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt,
)
if err != nil {
return nil, err
}
lists = append(lists, l)
}
return lists, nil
}
func (r *CRMRepository) GetLeadByID(id string, tenantID string) (*domain.CRMLead, error) {
query := `
SELECT id, tenant_id, customer_id, funnel_id, stage_id, name, email, phone, source, source_meta,
status, COALESCE(notes, ''), tags, is_active, created_by, created_at, updated_at
FROM crm_leads
WHERE id = $1 AND tenant_id = $2
`
var l domain.CRMLead
err := r.db.QueryRow(query, id, tenantID).Scan(
&l.ID, &l.TenantID, &l.CustomerID, &l.FunnelID, &l.StageID, &l.Name, &l.Email, &l.Phone, &l.Source, &l.SourceMeta,
&l.Status, &l.Notes, pq.Array(&l.Tags), &l.IsActive, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt,
)
if err != nil {
return nil, err
}
return &l, nil
}
func (r *CRMRepository) UpdateLead(lead *domain.CRMLead) error {
query := `
UPDATE crm_leads SET
customer_id = $1,
funnel_id = $2,
stage_id = $3,
name = $4,
email = $5,
phone = $6,
source = $7,
source_meta = $8,
status = $9,
notes = $10,
tags = $11,
is_active = $12
WHERE id = $13 AND tenant_id = $14
`
result, err := r.db.Exec(
query,
lead.CustomerID, lead.FunnelID, lead.StageID, lead.Name, lead.Email, lead.Phone, lead.Source, lead.SourceMeta,
lead.Status, lead.Notes, pq.Array(lead.Tags), lead.IsActive,
lead.ID, lead.TenantID,
)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return fmt.Errorf("lead not found")
}
return nil
}
func (r *CRMRepository) DeleteLead(id string, tenantID string) error {
query := `DELETE FROM crm_leads WHERE id = $1 AND tenant_id = $2`
result, err := r.db.Exec(query, id, tenantID)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return fmt.Errorf("lead not found")
}
return nil
}
func (r *CRMRepository) GetLeadByEmailOrPhone(tenantID, email, phone string) (*domain.CRMLead, error) {
query := `
SELECT id, tenant_id, customer_id, name, email, phone, source, source_meta,
status, COALESCE(notes, ''), tags, is_active, created_by, created_at, updated_at
FROM crm_leads
WHERE tenant_id = $1
AND (
(email IS NOT NULL AND $2 <> '' AND LOWER(email) = LOWER($2))
OR (phone IS NOT NULL AND $3 <> '' AND phone = $3)
)
ORDER BY created_at DESC
LIMIT 1
`
var l domain.CRMLead
err := r.db.QueryRow(query, tenantID, email, phone).Scan(
&l.ID, &l.TenantID, &l.CustomerID, &l.Name, &l.Email, &l.Phone, &l.Source, &l.SourceMeta,
&l.Status, &l.Notes, pq.Array(&l.Tags), &l.IsActive, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt,
)
if err != nil {
return nil, err
}
return &l, nil
}
func (r *CRMRepository) RemoveLeadFromList(leadID, listID string) error {
query := `DELETE FROM crm_lead_lists WHERE lead_id = $1 AND list_id = $2`
_, err := r.db.Exec(query, leadID, listID)
return err
}
func (r *CRMRepository) GetLeadLists(leadID string) ([]domain.CRMList, error) {
query := `
SELECT l.id, l.tenant_id, l.name, COALESCE(l.description, ''), l.color, l.created_by,
l.created_at, l.updated_at
FROM crm_lists l
INNER JOIN crm_lead_lists ll ON l.id = ll.list_id
WHERE ll.lead_id = $1
ORDER BY l.name
`
rows, err := r.db.Query(query, leadID)
if err != nil {
return nil, err
}
defer rows.Close()
var lists []domain.CRMList
for rows.Next() {
var l domain.CRMList
err := rows.Scan(
&l.ID, &l.TenantID, &l.Name, &l.Description, &l.Color, &l.CreatedBy,
&l.CreatedAt, &l.UpdatedAt,
)
if err != nil {
return nil, err
}
lists = append(lists, l)
}
return lists, nil
}
func (r *CRMRepository) GetListByName(tenantID, name string) (*domain.CRMList, error) {
query := `
SELECT id, tenant_id, name, description, color, created_by, created_at, updated_at
FROM crm_lists
WHERE tenant_id = $1 AND LOWER(name) = LOWER($2)
LIMIT 1
`
var l domain.CRMList
err := r.db.QueryRow(query, tenantID, name).Scan(
&l.ID, &l.TenantID, &l.Name, &l.Description, &l.Color, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt,
)
if err != nil {
return nil, err
}
return &l, nil
}
// CreateShareToken cria um novo token de compartilhamento
func (r *CRMRepository) CreateShareToken(token *domain.CRMShareToken) error {
query := `
INSERT INTO crm_share_tokens (id, tenant_id, customer_id, token, expires_at, created_by, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`
_, err := r.db.Exec(query, token.ID, token.TenantID, token.CustomerID, token.Token, token.ExpiresAt, token.CreatedBy, token.CreatedAt)
return err
}
// GetShareTokenByToken busca um token de compartilhamento pelo token
func (r *CRMRepository) GetShareTokenByToken(token string) (*domain.CRMShareToken, error) {
query := `
SELECT id, tenant_id, customer_id, token, expires_at, created_by, created_at
FROM crm_share_tokens
WHERE token = $1
`
var st domain.CRMShareToken
err := r.db.QueryRow(query, token).Scan(
&st.ID, &st.TenantID, &st.CustomerID, &st.Token, &st.ExpiresAt, &st.CreatedBy, &st.CreatedAt,
)
if err != nil {
return nil, err
}
return &st, nil
}
// GetLeadsByCustomerID retorna todos os leads de um cliente específico
func (r *CRMRepository) GetLeadsByCustomerID(customerID string) ([]domain.CRMLead, error) {
query := `
SELECT id, tenant_id, customer_id, name, email, phone, source, source_meta,
status, notes, tags, is_active, created_by, created_at, updated_at
FROM crm_leads
WHERE customer_id = $1 AND is_active = true
ORDER BY created_at DESC
`
rows, err := r.db.Query(query, customerID)
if err != nil {
return nil, err
}
defer rows.Close()
var leads []domain.CRMLead
for rows.Next() {
var l domain.CRMLead
err := rows.Scan(
&l.ID, &l.TenantID, &l.CustomerID, &l.Name, &l.Email, &l.Phone, &l.Source, &l.SourceMeta,
&l.Status, &l.Notes, &l.Tags, &l.IsActive, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt,
)
if err != nil {
return nil, err
}
leads = append(leads, l)
}
if leads == nil {
leads = []domain.CRMLead{}
}
return leads, nil
}
// GetListsByCustomerID retorna todas as listas que possuem leads de um cliente específico
func (r *CRMRepository) GetListsByCustomerID(customerID string) ([]domain.CRMList, error) {
query := `
SELECT DISTINCT l.id, l.tenant_id, l.name, l.description, l.color, l.created_by,
l.created_at, l.updated_at
FROM crm_lists l
INNER JOIN crm_lead_lists ll ON l.id = ll.list_id
INNER JOIN crm_leads le ON ll.lead_id = le.id
WHERE le.customer_id = $1
ORDER BY l.name
`
rows, err := r.db.Query(query, customerID)
if err != nil {
return nil, err
}
defer rows.Close()
var lists []domain.CRMList
for rows.Next() {
var l domain.CRMList
err := rows.Scan(
&l.ID, &l.TenantID, &l.Name, &l.Description, &l.Color, &l.CreatedBy,
&l.CreatedAt, &l.UpdatedAt,
)
if err != nil {
return nil, err
}
lists = append(lists, l)
}
if lists == nil {
lists = []domain.CRMList{}
}
return lists, nil
}
// GetCustomerByEmail busca um cliente pelo email
func (r *CRMRepository) GetCustomerByEmail(email string) (*domain.CRMCustomer, error) {
query := `
SELECT id, tenant_id, name, email,
COALESCE(phone, '') as phone,
COALESCE(company, '') as company,
COALESCE(position, '') as position,
COALESCE(address, '') as address,
COALESCE(city, '') as city,
COALESCE(state, '') as state,
COALESCE(zip_code, '') as zip_code,
COALESCE(country, '') as country,
COALESCE(notes, '{}') as notes,
COALESCE(tags, '{}') as tags,
is_active,
created_by,
created_at,
updated_at,
COALESCE(password_hash, '') as password_hash,
has_portal_access,
portal_last_login,
portal_created_at
FROM crm_customers
WHERE email = $1 AND is_active = true
`
var c domain.CRMCustomer
var createdBy sql.NullString
err := r.db.QueryRow(query, email).Scan(
&c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position,
&c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags),
&c.IsActive, &createdBy, &c.CreatedAt, &c.UpdatedAt,
&c.PasswordHash, &c.HasPortalAccess, &c.PortalLastLogin, &c.PortalCreatedAt,
)
if err != nil {
return nil, err
}
if createdBy.Valid {
c.CreatedBy = createdBy.String
}
return &c, nil
}
// UpdateCustomerLastLogin atualiza o último login do cliente no portal
func (r *CRMRepository) UpdateCustomerLastLogin(customerID string) error {
query := `UPDATE crm_customers SET portal_last_login = NOW() WHERE id = $1`
_, err := r.db.Exec(query, customerID)
return err
}
// SetCustomerPortalAccess define o acesso ao portal e senha para um cliente
func (r *CRMRepository) SetCustomerPortalAccess(customerID, passwordHash string, hasAccess bool) error {
query := `
UPDATE crm_customers
SET password_hash = $1,
has_portal_access = $2,
portal_created_at = CASE
WHEN portal_created_at IS NULL THEN NOW()
ELSE portal_created_at
END
WHERE id = $3
`
_, err := r.db.Exec(query, passwordHash, hasAccess, customerID)
return err
}
// UpdateCustomerPassword atualiza apenas a senha do cliente
func (r *CRMRepository) UpdateCustomerPassword(customerID, passwordHash string) error {
query := `
UPDATE crm_customers
SET password_hash = $1
WHERE id = $2
`
_, err := r.db.Exec(query, passwordHash, customerID)
return err
}
// UpdateCustomerLogo atualiza apenas o logo do cliente
func (r *CRMRepository) UpdateCustomerLogo(customerID, tenantID, logoURL string) error {
query := `
UPDATE crm_customers
SET logo_url = $1
WHERE id = $2 AND tenant_id = $3
`
_, err := r.db.Exec(query, logoURL, customerID, tenantID)
return err
}
// GetCustomerByEmailAndTenant checks if a customer with the given email exists for the tenant
func (r *CRMRepository) GetCustomerByEmailAndTenant(email string, tenantID string) (*domain.CRMCustomer, error) {
query := `
SELECT id, tenant_id, name, email, phone, company, position,
address, city, state, zip_code, country, notes, tags,
is_active, created_by, created_at, updated_at
FROM crm_customers
WHERE LOWER(email) = LOWER($1) AND tenant_id = $2
LIMIT 1
`
var c domain.CRMCustomer
err := r.db.QueryRow(query, email, tenantID).Scan(
&c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position,
&c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags),
&c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil // Not found is not an error
}
if err != nil {
return nil, err
}
return &c, nil
}
// TenantExists checks if a tenant with the given ID exists
func (r *CRMRepository) TenantExists(tenantID string) (bool, error) {
query := `SELECT EXISTS(SELECT 1 FROM tenants WHERE id = $1 AND is_active = true)`
var exists bool
err := r.db.QueryRow(query, tenantID).Scan(&exists)
return exists, err
}
// EnableCustomerPortalAccess habilita o acesso ao portal para um cliente (usado na aprovação)
func (r *CRMRepository) EnableCustomerPortalAccess(customerID string) error {
query := `
UPDATE crm_customers
SET has_portal_access = true,
portal_created_at = COALESCE(portal_created_at, NOW())
WHERE id = $1
`
_, err := r.db.Exec(query, customerID)
return err
}
// GetCustomerByCPF checks if a customer with the given CPF exists for the tenant
func (r *CRMRepository) GetCustomerByCPF(cpf string, tenantID string) (*domain.CRMCustomer, error) {
query := `
SELECT id, tenant_id, name, email, phone, company, position,
address, city, state, zip_code, country, notes, tags,
is_active, created_by, created_at, updated_at
FROM crm_customers
WHERE tenant_id = $1 AND notes LIKE '%"cpf":"' || $2 || '"%'
LIMIT 1
`
var c domain.CRMCustomer
err := r.db.QueryRow(query, tenantID, cpf).Scan(
&c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position,
&c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags),
&c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &c, nil
}
// GetCustomerByCNPJ checks if a customer with the given CNPJ exists for the tenant
func (r *CRMRepository) GetCustomerByCNPJ(cnpj string, tenantID string) (*domain.CRMCustomer, error) {
query := `
SELECT id, tenant_id, name, email, phone, company, position,
address, city, state, zip_code, country, notes, tags,
is_active, created_by, created_at, updated_at
FROM crm_customers
WHERE tenant_id = $1 AND notes LIKE '%"cnpj":"' || $2 || '"%'
LIMIT 1
`
var c domain.CRMCustomer
err := r.db.QueryRow(query, tenantID, cnpj).Scan(
&c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position,
&c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags),
&c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &c, nil
}
func (r *CRMRepository) GetLeadsByListID(listID string) ([]domain.CRMLead, error) {
query := `
SELECT l.id, l.tenant_id, l.customer_id, l.funnel_id, l.stage_id, l.name, l.email, l.phone,
l.source, l.source_meta, l.status, COALESCE(l.notes, ''), l.tags,
l.is_active, COALESCE(l.created_by::text, '') as created_by, l.created_at, l.updated_at
FROM crm_leads l
INNER JOIN crm_lead_lists ll ON l.id = ll.lead_id
WHERE ll.list_id = $1
ORDER BY l.created_at DESC
`
rows, err := r.db.Query(query, listID)
if err != nil {
return nil, err
}
defer rows.Close()
var leads []domain.CRMLead
for rows.Next() {
var l domain.CRMLead
var sourceMeta []byte
err := rows.Scan(
&l.ID, &l.TenantID, &l.CustomerID, &l.FunnelID, &l.StageID, &l.Name, &l.Email, &l.Phone,
&l.Source, &sourceMeta, &l.Status, &l.Notes, pq.Array(&l.Tags),
&l.IsActive, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt,
)
if err != nil {
log.Printf("Error scanning lead from list: %v", err)
continue
}
if sourceMeta != nil {
l.SourceMeta = sourceMeta
}
leads = append(leads, l)
}
return leads, nil
}
// ==================== FUNNELS & STAGES ====================
func (r *CRMRepository) CreateFunnel(funnel *domain.CRMFunnel) error {
query := `
INSERT INTO crm_funnels (id, tenant_id, name, description, is_default)
VALUES ($1, $2, $3, $4, $5)
RETURNING created_at, updated_at
`
return r.db.QueryRow(query, funnel.ID, funnel.TenantID, funnel.Name, funnel.Description, funnel.IsDefault).
Scan(&funnel.CreatedAt, &funnel.UpdatedAt)
}
func (r *CRMRepository) GetFunnelsByTenant(tenantID string) ([]domain.CRMFunnel, error) {
query := `SELECT id, tenant_id, name, COALESCE(description, ''), is_default, created_at, updated_at FROM crm_funnels WHERE tenant_id = $1 ORDER BY created_at ASC`
rows, err := r.db.Query(query, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var funnels []domain.CRMFunnel
for rows.Next() {
var f domain.CRMFunnel
if err := rows.Scan(&f.ID, &f.TenantID, &f.Name, &f.Description, &f.IsDefault, &f.CreatedAt, &f.UpdatedAt); err != nil {
return nil, err
}
funnels = append(funnels, f)
}
return funnels, nil
}
func (r *CRMRepository) GetFunnelByID(id, tenantID string) (*domain.CRMFunnel, error) {
query := `SELECT id, tenant_id, name, COALESCE(description, ''), is_default, created_at, updated_at FROM crm_funnels WHERE id = $1 AND tenant_id = $2`
var f domain.CRMFunnel
err := r.db.QueryRow(query, id, tenantID).Scan(&f.ID, &f.TenantID, &f.Name, &f.Description, &f.IsDefault, &f.CreatedAt, &f.UpdatedAt)
if err != nil {
return nil, err
}
return &f, nil
}
func (r *CRMRepository) UpdateFunnel(funnel *domain.CRMFunnel) error {
query := `UPDATE crm_funnels SET name = $1, description = $2, is_default = $3, updated_at = CURRENT_TIMESTAMP WHERE id = $4 AND tenant_id = $5`
_, err := r.db.Exec(query, funnel.Name, funnel.Description, funnel.IsDefault, funnel.ID, funnel.TenantID)
return err
}
func (r *CRMRepository) DeleteFunnel(id, tenantID string) error {
query := `DELETE FROM crm_funnels WHERE id = $1 AND tenant_id = $2`
_, err := r.db.Exec(query, id, tenantID)
return err
}
func (r *CRMRepository) CreateFunnelStage(stage *domain.CRMFunnelStage) error {
query := `
INSERT INTO crm_funnel_stages (id, funnel_id, name, description, color, order_index)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING created_at, updated_at
`
return r.db.QueryRow(query, stage.ID, stage.FunnelID, stage.Name, stage.Description, stage.Color, stage.OrderIndex).
Scan(&stage.CreatedAt, &stage.UpdatedAt)
}
func (r *CRMRepository) GetStagesByFunnelID(funnelID string) ([]domain.CRMFunnelStage, error) {
query := `SELECT id, funnel_id, name, COALESCE(description, ''), color, order_index, created_at, updated_at FROM crm_funnel_stages WHERE funnel_id = $1 ORDER BY order_index ASC`
rows, err := r.db.Query(query, funnelID)
if err != nil {
return nil, err
}
defer rows.Close()
var stages []domain.CRMFunnelStage
for rows.Next() {
var s domain.CRMFunnelStage
if err := rows.Scan(&s.ID, &s.FunnelID, &s.Name, &s.Description, &s.Color, &s.OrderIndex, &s.CreatedAt, &s.UpdatedAt); err != nil {
return nil, err
}
stages = append(stages, s)
}
return stages, nil
}
func (r *CRMRepository) UpdateFunnelStage(stage *domain.CRMFunnelStage) error {
query := `UPDATE crm_funnel_stages SET name = $1, description = $2, color = $3, order_index = $4, updated_at = CURRENT_TIMESTAMP WHERE id = $5`
_, err := r.db.Exec(query, stage.Name, stage.Description, stage.Color, stage.OrderIndex, stage.ID)
return err
}
func (r *CRMRepository) DeleteFunnelStage(id string) error {
query := `DELETE FROM crm_funnel_stages WHERE id = $1`
_, err := r.db.Exec(query, id)
return err
}
func (r *CRMRepository) UpdateLeadStage(leadID, tenantID, funnelID, stageID string) error {
query := `UPDATE crm_leads SET funnel_id = $1, stage_id = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3 AND tenant_id = $4`
_, err := r.db.Exec(query, funnelID, stageID, leadID, tenantID)
return err
}
func (r *CRMRepository) EnsureDefaultFunnel(tenantID string) (string, error) {
// Check if tenant already has a funnel
var funnelID string
query := `SELECT id FROM crm_funnels WHERE tenant_id = $1 LIMIT 1`
err := r.db.QueryRow(query, tenantID).Scan(&funnelID)
if err == nil {
return funnelID, nil
}
// If not, create default using the function we defined in migration
query = `SELECT create_default_crm_funnel($1)`
err = r.db.QueryRow(query, tenantID).Scan(&funnelID)
return funnelID, err
}

View File

@@ -161,3 +161,73 @@ func (r *UserRepository) FindAdminByTenantID(tenantID uuid.UUID) (*domain.User,
return user, nil 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
}

View File

@@ -26,14 +26,16 @@ var (
type AuthService struct { type AuthService struct {
userRepo *repository.UserRepository userRepo *repository.UserRepository
tenantRepo *repository.TenantRepository tenantRepo *repository.TenantRepository
crmRepo *repository.CRMRepository
cfg *config.Config cfg *config.Config
} }
// NewAuthService creates a new auth service // 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{ return &AuthService{
userRepo: userRepo, userRepo: userRepo,
tenantRepo: tenantRepo, tenantRepo: tenantRepo,
crmRepo: crmRepo,
cfg: cfg, cfg: cfg,
} }
} }
@@ -175,3 +177,158 @@ func (s *AuthService) ChangePassword(userID string, currentPassword, newPassword
func parseUUID(s string) (uuid.UUID, error) { func parseUUID(s string) (uuid.UUID, error) {
return uuid.Parse(s) 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))
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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

View 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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

159
docs/COLABORADORES_SETUP.md Normal file
View 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

View File

@@ -30,6 +30,12 @@ RUN npm ci --omit=dev
COPY --from=builder /app/.next ./.next COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public 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 port
EXPOSE 3000 EXPOSE 3000

View File

@@ -3,110 +3,31 @@
import { DashboardLayout } from '@/components/layout/DashboardLayout'; import { DashboardLayout } from '@/components/layout/DashboardLayout';
import { AgencyBranding } from '@/components/layout/AgencyBranding'; import { AgencyBranding } from '@/components/layout/AgencyBranding';
import AuthGuard from '@/components/auth/AuthGuard'; import AuthGuard from '@/components/auth/AuthGuard';
import { CRMFilterProvider } from '@/contexts/CRMFilterContext';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { import {
HomeIcon, HomeIcon,
RocketLaunchIcon, RocketLaunchIcon,
ChartBarIcon, UserPlusIcon,
BriefcaseIcon, RectangleStackIcon,
LifebuoyIcon, UsersIcon,
CreditCardIcon, MegaphoneIcon,
DocumentTextIcon,
FolderIcon,
ShareIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
const AGENCY_MENU_ITEMS = [ const AGENCY_MENU_ITEMS = [
{ id: 'dashboard', label: 'Visão Geral', href: '/dashboard', icon: HomeIcon }, { id: 'dashboard', label: 'Dashboard', href: '/dashboard', icon: HomeIcon },
{ {
id: 'crm', id: 'crm',
label: 'CRM', label: 'CRM',
href: '/crm', href: '/crm',
icon: RocketLaunchIcon, icon: RocketLaunchIcon,
requiredSolution: 'crm',
subItems: [ subItems: [
{ label: 'Dashboard', href: '/crm' }, { label: 'Visão Geral', href: '/crm', icon: HomeIcon },
{ label: 'Clientes', href: '/crm/clientes' }, { label: 'Funis de Vendas', href: '/crm/funis', icon: RectangleStackIcon },
{ label: 'Funis', href: '/crm/funis' }, { label: 'Clientes', href: '/crm/clientes', icon: UsersIcon },
{ label: 'Negociações', href: '/crm/negociacoes' }, { label: 'Campanhas', href: '/crm/campanhas', icon: MegaphoneIcon },
] { label: 'Leads', href: '/crm/leads', icon: UserPlusIcon },
},
{
id: 'erp',
label: 'ERP',
href: '/erp',
icon: ChartBarIcon,
subItems: [
{ label: 'Dashboard', href: '/erp' },
{ label: 'Fluxo de Caixa', href: '/erp/fluxo-caixa' },
{ label: 'Contas a Pagar', href: '/erp/contas-pagar' },
{ label: 'Contas a Receber', href: '/erp/contas-receber' },
]
},
{
id: 'projetos',
label: 'Projetos',
href: '/projetos',
icon: BriefcaseIcon,
subItems: [
{ label: 'Dashboard', href: '/projetos' },
{ label: 'Meus Projetos', href: '/projetos/lista' },
{ label: 'Tarefas', href: '/projetos/tarefas' },
{ label: 'Cronograma', href: '/projetos/cronograma' },
]
},
{
id: 'helpdesk',
label: 'Helpdesk',
href: '/helpdesk',
icon: LifebuoyIcon,
subItems: [
{ label: 'Dashboard', href: '/helpdesk' },
{ label: 'Chamados', href: '/helpdesk/chamados' },
{ label: 'Base de Conhecimento', href: '/helpdesk/kb' },
]
},
{
id: 'pagamentos',
label: 'Pagamentos',
href: '/pagamentos',
icon: CreditCardIcon,
subItems: [
{ label: 'Dashboard', href: '/pagamentos' },
{ label: 'Cobranças', href: '/pagamentos/cobrancas' },
{ label: 'Assinaturas', href: '/pagamentos/assinaturas' },
]
},
{
id: 'contratos',
label: 'Contratos',
href: '/contratos',
icon: DocumentTextIcon,
subItems: [
{ label: 'Dashboard', href: '/contratos' },
{ label: 'Ativos', href: '/contratos/ativos' },
{ label: 'Modelos', href: '/contratos/modelos' },
]
},
{
id: 'documentos',
label: 'Documentos',
href: '/documentos',
icon: FolderIcon,
subItems: [
{ label: 'Meus Arquivos', href: '/documentos' },
{ label: 'Compartilhados', href: '/documentos/compartilhados' },
{ label: 'Lixeira', href: '/documentos/lixeira' },
]
},
{
id: 'social',
label: 'Redes Sociais',
href: '/social',
icon: ShareIcon,
subItems: [
{ label: 'Dashboard', href: '/social' },
{ label: 'Agendamento', href: '/social/agendamento' },
{ label: 'Relatórios', href: '/social/relatorios' },
] ]
}, },
]; ];
@@ -148,7 +69,8 @@ export function AgencyLayoutClient({ children, colors }: AgencyLayoutClientProps
// Sempre mostrar dashboard + soluções disponíveis // Sempre mostrar dashboard + soluções disponíveis
const filtered = AGENCY_MENU_ITEMS.filter(item => { const filtered = AGENCY_MENU_ITEMS.filter(item => {
if (item.id === 'dashboard') return true; if (item.id === 'dashboard') return true;
return solutionSlugs.includes(item.id); const requiredSolution = (item as any).requiredSolution;
return solutionSlugs.includes((requiredSolution || item.id).toLowerCase());
}); });
console.log('📋 Menu filtrado:', filtered.map(i => i.id)); console.log('📋 Menu filtrado:', filtered.map(i => i.id));
@@ -171,11 +93,13 @@ export function AgencyLayoutClient({ children, colors }: AgencyLayoutClientProps
}, []); }, []);
return ( return (
<AuthGuard> <AuthGuard allowedTypes={['agency_user']}>
<CRMFilterProvider>
<AgencyBranding colors={colors} /> <AgencyBranding colors={colors} />
<DashboardLayout menuItems={loading ? [AGENCY_MENU_ITEMS[0]] : filteredMenuItems}> <DashboardLayout menuItems={loading ? [AGENCY_MENU_ITEMS[0]] : filteredMenuItems}>
{children} {children}
</DashboardLayout> </DashboardLayout>
</CRMFilterProvider>
</AuthGuard> </AuthGuard>
); );
} }

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import { Tab } from '@headlessui/react'; import { Tab } from '@headlessui/react';
import { Button, Dialog, Input } from '@/components/ui'; import { Button, Dialog, Input } from '@/components/ui';
import { Toaster, toast } from 'react-hot-toast'; import { Toaster, toast } from 'react-hot-toast';
import TeamManagement from '@/components/team/TeamManagement';
import { import {
BuildingOfficeIcon, BuildingOfficeIcon,
PhotoIcon, PhotoIcon,
@@ -1040,19 +1041,7 @@ export default function ConfiguracoesPage() {
{/* Tab 3: Equipe */} {/* Tab 3: Equipe */}
<Tab.Panel className="rounded-xl bg-white dark:bg-gray-800 p-6 border border-gray-200 dark:border-gray-700"> <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"> <TeamManagement />
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 variant="primary">
Convidar Membro
</Button>
</div>
</Tab.Panel> </Tab.Panel>
{/* Tab 3: Segurança */} {/* Tab 3: Segurança */}

View 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>
);
}

View 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>
);
}

File diff suppressed because it is too large Load Diff

View 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>
);
}

View File

@@ -1,31 +1,456 @@
"use client"; "use client";
import { FunnelIcon } from '@heroicons/react/24/outline'; 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() { 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 ( return (
<div className="p-6 h-full flex items-center justify-center"> <div className="p-6 max-w-[1600px] mx-auto space-y-6">
<div className="text-center max-w-md"> {/* Header */}
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-blue-500 to-purple-600"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<FunnelIcon className="h-10 w-10 text-white" /> <div>
</div> <h1 className="text-2xl font-bold text-zinc-900 dark:text-white tracking-tight">Funis de Vendas</h1>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2"> <p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
Funis de Vendas Gerencie seus funis e acompanhe o progresso dos leads
</h1>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Esta funcionalidade está em desenvolvimento
</p> </p>
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
<div className="flex gap-1">
<span className="animate-bounce inline-block h-2 w-2 rounded-full bg-blue-600" style={{ animationDelay: '0ms' }}></span>
<span className="animate-bounce inline-block h-2 w-2 rounded-full bg-blue-600" style={{ animationDelay: '150ms' }}></span>
<span className="animate-bounce inline-block h-2 w-2 rounded-full bg-blue-600" style={{ animationDelay: '300ms' }}></span>
</div> </div>
<span className="text-sm font-medium text-blue-600 dark:text-blue-400"> <button
Em breve 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> </span>
)}
</div>
{funnel.description && (
<div className="text-sm text-zinc-500 dark:text-zinc-400 truncate max-w-md">
{funnel.description}
</div>
)}
</div> </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> </div>
); );
} }

View 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>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,432 +0,0 @@
"use client";
import { Fragment, useEffect, useState } from 'react';
import { Menu, Transition } from '@headlessui/react';
import ConfirmDialog from '@/components/layout/ConfirmDialog';
import { useToast } from '@/components/layout/ToastContext';
import {
ListBulletIcon,
TrashIcon,
PencilIcon,
EllipsisVerticalIcon,
MagnifyingGlassIcon,
PlusIcon,
XMarkIcon,
UserGroupIcon,
} from '@heroicons/react/24/outline';
interface List {
id: string;
tenant_id: string;
name: string;
description: string;
color: string;
customer_count: number;
created_at: string;
updated_at: 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' },
];
export default function ListsPage() {
const toast = useToast();
const [lists, setLists] = useState<List[]>([]);
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 [formData, setFormData] = useState({
name: '',
description: '',
color: COLORS[0].value,
});
useEffect(() => {
fetchLists();
}, []);
const fetchLists = async () => {
try {
const response = await fetch('/api/crm/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 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 ? 'Lista atualizada' : 'Lista criada',
editingList ? 'A lista foi atualizada com sucesso.' : 'A nova lista foi criada com sucesso.'
);
fetchLists();
handleCloseModal();
} else {
const error = await response.json();
toast.error('Erro', error.message || 'Não foi possível salvar a lista.');
}
} catch (error) {
console.error('Error saving list:', error);
toast.error('Erro', 'Ocorreu um erro ao salvar a lista.');
}
};
const handleEdit = (list: List) => {
setEditingList(list);
setFormData({
name: list.name,
description: list.description,
color: list.color,
});
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('Lista excluída', 'A lista foi excluída com sucesso.');
} else {
toast.error('Erro ao excluir', 'Não foi possível excluir a lista.');
}
} catch (error) {
console.error('Error deleting list:', error);
toast.error('Erro ao excluir', 'Ocorreu um erro ao excluir a lista.');
} finally {
setConfirmOpen(false);
setListToDelete(null);
}
};
const handleCloseModal = () => {
setIsModalOpen(false);
setEditingList(null);
setFormData({
name: '',
description: '',
color: COLORS[0].value,
});
};
const filteredLists = lists.filter((list) => {
const searchLower = searchTerm.toLowerCase();
return (
(list.name?.toLowerCase() || '').includes(searchLower) ||
(list.description?.toLowerCase() || '').includes(searchLower)
);
});
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">Listas</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
Organize seus clientes em listas personalizadas
</p>
</div>
<button
onClick={() => setIsModalOpen(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" />
Nova Lista
</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 listas..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
{/* Grid */}
{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 lista encontrada
</h3>
<p className="text-zinc-500 dark:text-zinc-400 max-w-sm mx-auto">
{searchTerm ? 'Nenhuma lista corresponde à sua busca.' : 'Comece criando sua primeira lista.'}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredLists.map((list) => (
<div
key={list.id}
className="group relative bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 p-6 hover:shadow-lg transition-all"
>
{/* Color indicator */}
<div
className="absolute top-0 left-0 w-1 h-full rounded-l-xl"
style={{ backgroundColor: list.color }}
/>
<div className="flex items-start justify-between mb-4 pl-3">
<div className="flex items-center gap-3">
<div
className="w-12 h-12 rounded-lg flex items-center justify-center text-white"
style={{ backgroundColor: list.color }}
>
<ListBulletIcon className="w-6 h-6" />
</div>
<div>
<h3 className="text-lg font-semibold text-zinc-900 dark:text-white">
{list.name}
</h3>
<div className="flex items-center gap-1 mt-1 text-sm text-zinc-500 dark:text-zinc-400">
<UserGroupIcon className="w-4 h-4" />
<span>{list.customer_count || 0} clientes</span>
</div>
</div>
</div>
<Menu as="div" className="relative">
<Menu.Button className="p-1.5 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 transition-colors outline-none opacity-0 group-hover:opacity-100">
<EllipsisVerticalIcon className="w-5 h-5" />
</Menu.Button>
<Menu.Items
transition
portal
anchor="bottom end"
className="w-48 origin-top-right divide-y divide-zinc-100 dark:divide-zinc-800 rounded-xl bg-white dark:bg-zinc-900 focus:outline-none z-50 border border-zinc-200 dark:border-zinc-800 [--anchor-gap:8px] transition duration-100 ease-out data-[closed]:scale-95 data-[closed]:opacity-0"
>
<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-2 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-2 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>
</Menu>
</div>
{list.description && (
<p className="text-sm text-zinc-600 dark:text-zinc-400 pl-3 line-clamp-2">
{list.description}
</p>
)}
</div>
))}
</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 Lista' : 'Nova Lista'}
</h3>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
{editingList ? 'Atualize as informações da lista.' : 'Crie uma nova lista para organizar seus clientes.'}
</p>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Nome da Lista *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Ex: Clientes VIP"
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 lista"
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>
</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 Lista'}
</button>
</div>
</form>
</div>
</div>
</div>
)}
<ConfirmDialog
isOpen={confirmOpen}
onClose={() => {
setConfirmOpen(false);
setListToDelete(null);
}}
onConfirm={handleConfirmDelete}
title="Excluir Lista"
message="Tem certeza que deseja excluir esta lista? Os clientes não serão excluídos, apenas removidos da lista."
confirmText="Excluir"
cancelText="Cancelar"
variant="danger"
/>
</div>
);
}

View File

@@ -1,7 +1,10 @@
"use client"; "use client";
import { useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { SolutionGuard } from '@/components/auth/SolutionGuard'; import { SolutionGuard } from '@/components/auth/SolutionGuard';
import { useCRMFilter } from '@/contexts/CRMFilterContext';
import KanbanBoard from '@/components/crm/KanbanBoard';
import { import {
UsersIcon, UsersIcon,
CurrencyDollarIcon, CurrencyDollarIcon,
@@ -9,35 +12,116 @@ import {
ArrowTrendingUpIcon, ArrowTrendingUpIcon,
ListBulletIcon, ListBulletIcon,
ArrowRightIcon, ArrowRightIcon,
MegaphoneIcon,
RectangleStackIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
export default function CRMPage() { function CRMDashboardContent() {
const stats = [ const { selectedCustomerId } = useCRMFilter();
{ name: 'Leads Totais', value: '124', icon: UsersIcon, color: 'blue' }, console.log('🏠 CRMPage (Content) render, selectedCustomerId:', selectedCustomerId);
{ name: 'Oportunidades', value: 'R$ 450k', icon: CurrencyDollarIcon, color: 'green' },
{ name: 'Taxa de Conversão', value: '24%', icon: ChartPieIcon, color: 'purple' }, const [stats, setStats] = useState([
{ name: 'Crescimento', value: '+12%', icon: ArrowTrendingUpIcon, color: 'orange' }, { 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 = [ const quickLinks = [
{
name: 'Funis de Vendas',
description: 'Configure seus processos e etapas',
icon: RectangleStackIcon,
href: '/crm/funis',
color: 'blue',
},
{ {
name: 'Clientes', name: 'Clientes',
description: 'Gerencie seus contatos e clientes', description: 'Gerencie seus contatos e clientes',
icon: UsersIcon, icon: UsersIcon,
href: '/crm/clientes', href: '/crm/clientes',
color: 'blue', color: 'indigo',
}, },
{ {
name: 'Listas', name: 'Campanhas',
description: 'Organize clientes em listas', description: 'Organize leads e rastreie origens',
icon: ListBulletIcon, icon: MegaphoneIcon,
href: '/crm/listas', href: '/crm/campanhas',
color: 'purple', color: 'purple',
}, },
{
name: 'Leads',
description: 'Gerencie potenciais clientes',
icon: UsersIcon,
href: '/crm/leads',
color: 'green',
},
]; ];
return ( return (
<SolutionGuard requiredSolution="crm">
<div className="p-6 h-full overflow-auto"> <div className="p-6 h-full overflow-auto">
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
@@ -119,16 +203,47 @@ export default function CRMPage() {
</div> </div>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="space-y-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"> <div className="flex items-center justify-between">
<p className="text-gray-500">Funil de Vendas (Em breve)</p> <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>
<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"> <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> <p className="text-gray-500">Atividades Recentes (Em breve)</p>
</div> </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>
</div> </div>
</div>
);
}
export default function CRMPage() {
return (
<SolutionGuard requiredSolution="crm">
<CRMDashboardContent />
</SolutionGuard> </SolutionGuard>
); );
} }

View 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 }
);
}
}

View File

@@ -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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,8 @@
import { getBranding } from '@/lib/branding';
import CadastroClientePage from './cadastro-client';
export default async function CadastroPage() {
const branding = await getBranding();
return <CadastroClientePage branding={branding} />;
}

View File

@@ -0,0 +1,49 @@
import { getBranding } from '@/lib/branding';
import SucessoClient from './sucesso-client';
const lightenColor = (hexColor: string, amount = 20) => {
const fallback = '#3b82f6';
if (!hexColor) return fallback;
let color = hexColor.replace('#', '');
if (color.length === 3) {
color = color.split('').map(char => char + char).join('');
}
if (color.length !== 6) return fallback;
const num = parseInt(color, 16);
if (Number.isNaN(num)) return fallback;
const clamp = (value: number) => Math.max(0, Math.min(255, value));
const r = clamp((num >> 16) + amount);
const g = clamp(((num >> 8) & 0x00ff) + amount);
const b = clamp((num & 0x0000ff) + amount);
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
};
export default async function CadastroSucessoPage() {
const branding = await getBranding();
const primaryColor = branding.primary_color || '#3b82f6';
const accentColor = lightenColor(primaryColor, 30);
const now = new Date();
const submittedAt = now.toLocaleString('pt-BR', {
day: '2-digit',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
return (
<SucessoClient
branding={{
name: branding.name,
logo_url: branding.logo_url,
primary_color: primaryColor
}}
accentColor={accentColor}
submittedAt={submittedAt}
/>
);
}

View File

@@ -0,0 +1,218 @@
'use client';
import { useEffect, useState } from 'react';
import { CheckCircleIcon, ClockIcon, UserCircleIcon } from '@heroicons/react/24/solid';
import { SparklesIcon } from '@heroicons/react/24/outline';
import Link from 'next/link';
interface SucessoClientProps {
branding: {
name: string;
logo_url?: string;
primary_color: string;
};
accentColor: string;
submittedAt: string;
}
const timeline = [
{
title: 'Cadastro recebido',
description: 'Confirmamos seus dados e senha automaticamente.',
status: 'done' as const,
},
{
title: 'Análise da equipe',
description: 'Nossa equipe valida seus dados e configura seu acesso.',
status: 'current' as const,
},
{
title: 'Acesso liberado',
description: 'Você receberá aviso e poderá fazer login com sua senha.',
status: 'upcoming' as const,
},
];
export default function SucessoClient({ branding, accentColor, submittedAt }: SucessoClientProps) {
const [customerName, setCustomerName] = useState<string | null>(null);
const [customerEmail, setCustomerEmail] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const name = sessionStorage.getItem('customer_name');
const email = sessionStorage.getItem('customer_email');
setCustomerName(name);
setCustomerEmail(email);
setIsLoading(false);
// Limpar sessionStorage após carregar
if (name || email) {
sessionStorage.removeItem('customer_name');
sessionStorage.removeItem('customer_email');
}
}, []);
const primaryColor = branding.primary_color || '#3b82f6';
const firstName = customerName?.split(' ')[0] || 'Cliente';
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-100 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-3xl mx-auto space-y-8">
<div className="text-center space-y-4">
{branding.logo_url ? (
<img src={branding.logo_url} alt={branding.name} className="mx-auto h-16 w-auto object-contain" />
) : (
<div className="mx-auto h-16 w-16 rounded-2xl flex items-center justify-center text-white text-2xl font-semibold" style={{ backgroundColor: primaryColor }}>
{branding.name?.substring(0, 2).toUpperCase() || 'AG'}
</div>
)}
<p className="text-sm uppercase tracking-[0.25em] text-gray-500 font-medium">Portal do Cliente</p>
</div>
<div className="bg-white rounded-3xl shadow-2xl overflow-hidden border border-gray-200">
<div className="h-3" style={{ backgroundImage: `linear-gradient(120deg, ${primaryColor}, ${accentColor})` }} />
<div className="p-8 sm:p-12 space-y-8">
{/* Header Premium com Nome */}
<div className="flex flex-col items-center text-center space-y-6">
<div className="relative">
<div className="h-24 w-24 rounded-full flex items-center justify-center bg-gradient-to-br from-green-100 to-emerald-50 shadow-lg">
<CheckCircleIcon className="h-14 w-14 text-green-600" />
</div>
<div className="absolute -bottom-1 -right-1 h-8 w-8 rounded-full bg-white flex items-center justify-center shadow-md">
<SparklesIcon className="h-5 w-5 text-amber-500" />
</div>
</div>
{!isLoading && customerName ? (
<div className="space-y-2">
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900">
Tudo certo, {firstName}! 🎉
</h1>
<p className="text-lg text-gray-600">
Seu cadastro foi enviado com sucesso
</p>
</div>
) : (
<div className="space-y-2">
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900">
Cadastro enviado com sucesso! 🎉
</h1>
<p className="text-lg text-gray-600">
Recebemos todas as suas informações
</p>
</div>
)}
<div className="bg-blue-50 border border-blue-200 rounded-2xl p-4 max-w-lg">
<div className="flex items-start gap-3">
<UserCircleIcon className="h-6 w-6 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-left">
<p className="text-sm font-semibold text-blue-900">Sua senha está segura</p>
<p className="text-sm text-blue-700 mt-1">
Você definiu sua senha de acesso. Assim que a agência liberar seu cadastro,
você poderá fazer login imediatamente no portal.
</p>
</div>
</div>
</div>
{!isLoading && customerEmail && (
<p className="text-sm text-gray-500">
Login: <span className="font-mono font-semibold text-gray-700">{customerEmail}</span>
</p>
)}
<p className="text-xs text-gray-400">Enviado em {submittedAt}</p>
</div>
{/* Timeline */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{timeline.map((item, idx) => (
<div
key={item.title}
className={`rounded-2xl border-2 p-5 flex flex-col gap-3 transition-all ${item.status === 'done'
? 'border-green-200 bg-green-50/50'
: item.status === 'current'
? 'border-indigo-300 bg-indigo-50/50 shadow-lg'
: 'border-gray-200 bg-gray-50'
}`}
>
<div className="flex items-center justify-between">
<div className={`h-10 w-10 rounded-full flex items-center justify-center font-bold ${item.status === 'done'
? 'bg-green-500 text-white'
: item.status === 'current'
? 'bg-indigo-500 text-white'
: 'bg-gray-200 text-gray-400'
}`}>
{idx + 1}
</div>
{item.status === 'current' && (
<div className="flex space-x-1">
<div className="w-2 h-2 rounded-full bg-indigo-500 animate-pulse" />
<div className="w-2 h-2 rounded-full bg-indigo-500 animate-pulse" style={{ animationDelay: '0.2s' }} />
<div className="w-2 h-2 rounded-full bg-indigo-500 animate-pulse" style={{ animationDelay: '0.4s' }} />
</div>
)}
</div>
<div>
<p className="text-sm font-semibold text-gray-900">{item.title}</p>
<p className="text-sm text-gray-600 mt-1">{item.description}</p>
</div>
</div>
))}
</div>
{/* Informações */}
<div className="bg-gradient-to-br from-gray-50 to-white rounded-2xl p-6 border border-gray-200">
<p className="font-semibold text-gray-900 mb-3 flex items-center gap-2">
<ClockIcon className="h-5 w-5 text-amber-500" />
O que acontece agora?
</p>
<ul className="space-y-2 text-sm text-gray-700">
<li className="flex items-start gap-2">
<span className="text-green-500 font-bold mt-0.5"></span>
<span>Nossa equipe valida seus dados e configura seu ambiente no portal</span>
</li>
<li className="flex items-start gap-2">
<span className="text-green-500 font-bold mt-0.5"></span>
<span>Assim que aprovado, você receberá aviso pelos contatos informados</span>
</li>
<li className="flex items-start gap-2">
<span className="text-green-500 font-bold mt-0.5"></span>
<span>Use o login <strong>{customerEmail || 'seu e-mail'}</strong> e a senha que você criou para acessar</span>
</li>
<li className="flex items-start gap-2">
<span className="text-amber-500 font-bold mt-0.5">!</span>
<span>Em caso de urgência, fale com a equipe {branding.name} pelo telefone ou WhatsApp</span>
</li>
</ul>
</div>
{/* CTAs */}
<div className="space-y-3 pt-4">
<Link
href="/login"
className="w-full inline-flex items-center justify-center gap-2 rounded-xl px-6 py-4 text-white font-semibold shadow-lg transition-all hover:shadow-xl hover:-translate-y-0.5"
style={{ backgroundImage: `linear-gradient(120deg, ${primaryColor}, ${accentColor})` }}
>
Ir para o login do cliente
</Link>
<Link
href="/"
className="w-full inline-flex items-center justify-center gap-2 rounded-xl px-6 py-3 font-semibold border-2 border-gray-300 text-gray-700 hover:bg-gray-50 transition-colors"
>
Voltar para o site da agência
</Link>
</div>
</div>
</div>
<div className="text-center text-sm text-gray-500 bg-white/70 backdrop-blur-sm rounded-xl p-4 border border-gray-200">
Precisa ajustar alguma informação? Entre em contato com a equipe <strong>{branding.name}</strong> pelos
canais que você informou no cadastro.
</div>
</div>
</div>
);
}

View File

@@ -137,12 +137,20 @@ export default function LoginPage() {
saveAuth(data.token, data.user); saveAuth(data.token, data.user);
console.log('Login successful:', data.user); console.log('Login successful:', data);
setSuccessMessage('Login realizado com sucesso! Redirecionando você agora...'); setSuccessMessage('Login realizado com sucesso! Redirecionando você agora...');
setTimeout(() => { setTimeout(() => {
const target = isSuperAdmin ? '/superadmin' : '/dashboard'; // Redirecionar baseado no tipo de usuário
let target = '/dashboard';
if (isSuperAdmin) {
target = '/superadmin';
} else if (data.user_type === 'customer') {
target = '/cliente/dashboard';
}
window.location.href = target; window.location.href = target;
}, 1000); }, 1000);
} catch (error: any) { } catch (error: any) {
@@ -291,10 +299,21 @@ export default function LoginPage() {
{isLoading ? 'Entrando...' : 'Entrar'} {isLoading ? 'Entrando...' : 'Entrar'}
</Button> </Button>
{/* Link para cadastro - apenas para agências */} {/* Link para cadastro - agências e clientes */}
{!isSuperAdmin && ( {!isSuperAdmin && (
<div className="space-y-2">
<p className="text-center text-[14px] text-[#7D7D7D] dark:text-gray-400"> <p className="text-center text-[14px] text-[#7D7D7D] dark:text-gray-400">
Ainda não tem conta?{' '} Cliente novo?{' '}
<Link
href="/cliente/cadastro"
className="font-medium hover:opacity-80 transition-opacity"
style={{ color: 'var(--brand-color)' }}
>
Cadastre-se aqui
</Link>
</p>
<p className="text-center text-[14px] text-[#7D7D7D] dark:text-gray-400">
Agência?{' '}
<a <a
href="http://dash.localhost/cadastro" href="http://dash.localhost/cadastro"
className="font-medium hover:opacity-80 transition-opacity" className="font-medium hover:opacity-80 transition-opacity"
@@ -303,6 +322,7 @@ export default function LoginPage() {
Cadastre sua agência Cadastre sua agência
</a> </a>
</p> </p>
</div>
)} )}
</form> </form>
</div> </div>

View File

@@ -0,0 +1,310 @@
"use client";
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import {
ChartBarIcon,
UsersIcon,
FunnelIcon,
ArrowTrendingUpIcon,
ArrowTrendingDownIcon,
EnvelopeIcon,
PhoneIcon,
TagIcon,
UserPlusIcon,
} from '@heroicons/react/24/outline';
interface Lead {
id: string;
name: string;
email: string;
phone: string;
source: string;
status: string;
tags: string[];
created_at: string;
}
interface SharedData {
customer: {
name: string;
company: string;
};
leads: Lead[];
stats: {
total: number;
novo: number;
qualificado: number;
negociacao: number;
convertido: number;
perdido: number;
bySource: Record<string, number>;
conversionRate: number;
thisMonth: number;
lastMonth: number;
};
}
const STATUS_OPTIONS = [
{ value: 'novo', label: 'Novo', color: 'bg-blue-100 text-blue-800' },
{ value: 'qualificado', label: 'Qualificado', color: 'bg-green-100 text-green-800' },
{ value: 'negociacao', label: 'Em Negociação', color: 'bg-yellow-100 text-yellow-800' },
{ value: 'convertido', label: 'Convertido', color: 'bg-purple-100 text-purple-800' },
{ value: 'perdido', label: 'Perdido', color: 'bg-red-100 text-red-800' },
];
export default function SharedLeadsPage() {
const params = useParams();
const token = params?.token as string;
const [data, setData] = useState<SharedData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (token) {
fetchSharedData();
}
}, [token]);
const fetchSharedData = async () => {
try {
const response = await fetch(`/api/crm/share/${token}`);
if (!response.ok) {
throw new Error('Link inválido ou expirado');
}
const result = await response.json();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Erro ao carregar dados');
} finally {
setLoading(false);
}
};
const getStatusColor = (status: string) => {
return STATUS_OPTIONS.find(s => s.value === status)?.color || 'bg-gray-100 text-gray-800';
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin w-12 h-12 border-4 border-brand-500 border-t-transparent rounded-full mx-auto mb-4"></div>
<p className="text-gray-600">Carregando dados...</p>
</div>
</div>
);
}
if (error || !data) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center max-w-md mx-auto p-6">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">Link Inválido</h1>
<p className="text-gray-600">{error || 'Não foi possível acessar os dados compartilhados.'}</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="bg-white border-b border-gray-200 shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">
Dashboard de Leads
</h1>
<p className="text-sm text-gray-600 mt-1">
{data.customer.company || data.customer.name}
</p>
</div>
<div className="text-right text-sm text-gray-500">
<p>Atualizado em</p>
<p className="font-medium text-gray-900">{new Date().toLocaleDateString('pt-BR')}</p>
</div>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Cards de Métricas */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-gray-600">Total de Leads</h3>
<UsersIcon className="w-5 h-5 text-brand-500" />
</div>
<p className="text-3xl font-bold text-gray-900">{data.stats.total}</p>
<div className="mt-2 flex items-center text-sm">
{data.stats.thisMonth >= data.stats.lastMonth ? (
<ArrowTrendingUpIcon className="w-4 h-4 text-green-500 mr-1" />
) : (
<ArrowTrendingDownIcon className="w-4 h-4 text-red-500 mr-1" />
)}
<span className={data.stats.thisMonth >= data.stats.lastMonth ? 'text-green-600' : 'text-red-600'}>
{data.stats.thisMonth} este mês
</span>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-gray-600">Taxa de Conversão</h3>
<FunnelIcon className="w-5 h-5 text-purple-500" />
</div>
<p className="text-3xl font-bold text-gray-900">
{data.stats.conversionRate.toFixed(1)}%
</p>
<p className="mt-2 text-sm text-gray-600">
{data.stats.convertido} convertidos
</p>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-gray-600">Novos Leads</h3>
<UserPlusIcon className="w-5 h-5 text-blue-500" />
</div>
<p className="text-3xl font-bold text-gray-900">{data.stats.novo}</p>
<p className="mt-2 text-sm text-gray-600">
Aguardando qualificação
</p>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-gray-600">Em Negociação</h3>
<TagIcon className="w-5 h-5 text-yellow-500" />
</div>
<p className="text-3xl font-bold text-gray-900">{data.stats.negociacao}</p>
<p className="mt-2 text-sm text-gray-600">
Potencial de conversão
</p>
</div>
</div>
{/* Distribuição por Status */}
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm mb-8">
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<ChartBarIcon className="w-5 h-5" />
Distribuição por Status
</h3>
<div className="space-y-3">
{STATUS_OPTIONS.map(status => {
const count = data.stats[status.value as keyof typeof data.stats] as number || 0;
const percentage = data.stats.total > 0 ? (count / data.stats.total) * 100 : 0;
return (
<div key={status.value}>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-gray-700">
{status.label}
</span>
<span className="text-sm text-gray-600">
{count} ({percentage.toFixed(1)}%)
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${status.color.split(' ')[0]}`}
style={{ width: `${percentage}%` }}
></div>
</div>
</div>
);
})}
</div>
</div>
{/* Leads por Origem */}
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm mb-8">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Leads por Origem
</h3>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{Object.entries(data.stats.bySource).map(([source, count]) => (
<div key={source} className="bg-gray-50 rounded-lg p-4">
<p className="text-sm text-gray-600 capitalize">{source}</p>
<p className="text-2xl font-bold text-gray-900 mt-1">{count}</p>
</div>
))}
</div>
</div>
{/* Lista de Leads */}
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Todos os Leads ({data.leads.length})
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{data.leads.map((lead) => (
<div
key={lead.id}
className="bg-gray-50 rounded-lg border border-gray-200 p-4 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-gray-900 truncate">
{lead.name || 'Sem nome'}
</h4>
<span className={`inline-block px-2 py-0.5 text-xs font-medium rounded-full mt-1 ${getStatusColor(lead.status)}`}>
{STATUS_OPTIONS.find(s => s.value === lead.status)?.label || lead.status}
</span>
</div>
</div>
<div className="space-y-2 text-sm">
{lead.email && (
<div className="flex items-center gap-2 text-gray-600">
<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-gray-600">
<PhoneIcon className="w-4 h-4 flex-shrink-0" />
<span>{lead.phone}</span>
</div>
)}
{lead.tags && lead.tags.length > 0 && (
<div className="flex items-center gap-2 flex-wrap mt-2">
{lead.tags.map((tag, idx) => (
<span
key={idx}
className="inline-flex items-center gap-1 px-2 py-0.5 bg-gray-200 text-gray-700 text-xs rounded"
>
<TagIcon className="w-3 h-3" />
{tag}
</span>
))}
</div>
)}
</div>
<div className="mt-3 pt-3 border-t border-gray-300 text-xs text-gray-500">
Origem: <span className="font-medium">{lead.source || 'manual'}</span>
</div>
</div>
))}
</div>
</div>
{/* Footer */}
<div className="mt-8 text-center text-sm text-gray-500">
<p>Dados atualizados em tempo real</p>
<p className="mt-1">Powered by Aggios CRM</p>
</div>
</div>
</div>
);
}

View File

@@ -2,9 +2,14 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useRouter, usePathname } from 'next/navigation'; import { useRouter, usePathname } from 'next/navigation';
import { isAuthenticated, clearAuth } from '@/lib/auth'; import { isAuthenticated, getUser, clearAuth } from '@/lib/auth';
export default function AuthGuard({ children }: { children: React.ReactNode }) { interface AuthGuardProps {
children: React.ReactNode;
allowedTypes?: ('agency_user' | 'customer' | 'superadmin')[];
}
export default function AuthGuard({ children, allowedTypes }: AuthGuardProps) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const [authorized, setAuthorized] = useState<boolean | null>(null); const [authorized, setAuthorized] = useState<boolean | null>(null);
@@ -19,16 +24,34 @@ export default function AuthGuard({ children }: { children: React.ReactNode }) {
const checkAuth = () => { const checkAuth = () => {
const isAuth = isAuthenticated(); const isAuth = isAuthenticated();
const user = getUser();
if (!isAuth) { if (!isAuth) {
setAuthorized(false); setAuthorized(false);
// Evitar redirect loop se já estiver no login
if (pathname !== '/login') { if (pathname !== '/login') {
router.push('/login?error=unauthorized'); router.push('/login?error=unauthorized');
} }
} else { return;
setAuthorized(true);
} }
// Verificar tipo de usuário se especificado
if (allowedTypes && user) {
const userType = user.user_type;
if (!userType || !allowedTypes.includes(userType)) {
console.warn(`🚫 Access denied for user type: ${userType}. Allowed: ${allowedTypes}`);
setAuthorized(false);
// Redirecionar para o dashboard apropriado se estiver no lugar errado
if (userType === 'customer') {
router.push('/cliente/dashboard');
} else {
router.push('/login?error=forbidden');
}
return;
}
}
setAuthorized(true);
}; };
checkAuth(); checkAuth();

View File

@@ -0,0 +1,226 @@
"use client";
import { useEffect, useState, Fragment } from 'react';
import { useCRMFilter } from '@/contexts/CRMFilterContext';
import { Combobox, Transition } from '@headlessui/react';
import {
FunnelIcon,
XMarkIcon,
CheckIcon,
ChevronUpDownIcon,
MagnifyingGlassIcon
} from '@heroicons/react/24/outline';
interface Customer {
id: string;
name: string;
company?: string;
logo_url?: string;
}
export function CRMCustomerFilter() {
const { selectedCustomerId, setSelectedCustomerId, customers, setCustomers, loading, setLoading } = useCRMFilter();
const [query, setQuery] = useState('');
console.log('🔍 CRMCustomerFilter render, selectedCustomerId:', selectedCustomerId);
useEffect(() => {
fetchCustomers();
}, []);
const fetchCustomers = async () => {
try {
setLoading(true);
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);
} finally {
setLoading(false);
}
};
const handleClearFilter = () => {
setSelectedCustomerId(null);
setQuery('');
};
const selectedCustomer = customers.find(c => c.id === selectedCustomerId);
const filteredCustomers =
query === ''
? customers
: customers.filter((customer: Customer) => {
const nameMatch = customer.name.toLowerCase().includes(query.toLowerCase());
const companyMatch = customer.company?.toLowerCase().includes(query.toLowerCase());
return nameMatch || companyMatch;
});
return (
<div className="flex items-center gap-2">
<div className="hidden md:flex items-center gap-1 text-gray-400 mr-1">
<FunnelIcon className="w-4 h-4" />
<span className="text-xs font-medium uppercase tracking-wider">Filtro CRM</span>
</div>
<Combobox
value={selectedCustomerId}
onChange={(value) => {
console.log('🎯 CRMCustomerFilter: Selecting customer ID:', value);
setSelectedCustomerId(value);
setQuery('');
}}
disabled={loading}
>
<div className="relative">
<div className="relative w-full min-w-[320px]">
<Combobox.Input
className="w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 py-2.5 pl-10 pr-10 text-sm leading-5 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:bg-white dark:focus:bg-gray-800 transition-all duration-200"
displayValue={(customerId: string) => {
const customer = customers.find(c => c.id === customerId);
if (!customer) return '';
return customer.company
? `${customer.name} (${customer.company})`
: customer.name;
}}
onChange={(event) => setQuery(event.target.value)}
placeholder="Pesquisar por nome ou empresa..."
/>
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
{selectedCustomer?.logo_url ? (
<img
src={selectedCustomer.logo_url}
className="h-5 w-5 rounded-full object-cover border border-gray-200 dark:border-gray-700"
alt=""
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<MagnifyingGlassIcon
className="h-4 w-4 text-gray-400"
aria-hidden="true"
/>
)}
</div>
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon
className="h-5 w-5 text-gray-400 hover:text-gray-600 transition-colors"
aria-hidden="true"
/>
</Combobox.Button>
</div>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
afterLeave={() => setQuery('')}
>
<Combobox.Options className="absolute z-50 mt-2 max-h-80 w-full overflow-auto rounded-xl bg-white dark:bg-gray-800 py-1 text-base shadow-2xl ring-1 ring-black/5 dark:ring-white/10 focus:outline-none sm:text-sm border border-gray-100 dark:border-gray-700">
<Combobox.Option
value={null}
className={({ active }) =>
`relative cursor-pointer select-none py-3 pl-10 pr-4 ${active
? 'bg-brand-50 dark:bg-brand-900/20 text-brand-900 dark:text-brand-100'
: 'text-gray-900 dark:text-white'
}`
}
>
{({ selected, active }) => (
<>
<span className={`block truncate ${selected ? 'font-semibold' : 'font-normal'}`}>
Todos os Clientes (Visão Geral)
</span>
{selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-brand-600">
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
) : null}
</>
)}
</Combobox.Option>
<div className="px-3 py-2 text-[10px] font-bold text-gray-400 uppercase tracking-widest border-t border-gray-50 dark:border-gray-700/50 mt-1">
Clientes Disponíveis
</div>
{filteredCustomers.length === 0 && query !== '' ? (
<div className="relative cursor-default select-none py-4 px-4 text-center text-gray-500 dark:text-gray-400">
<p className="text-sm">Nenhum cliente encontrado</p>
<p className="text-xs mt-1">Tente outro termo de busca</p>
</div>
) : (
filteredCustomers.map((customer: Customer) => (
<Combobox.Option
key={customer.id}
value={customer.id}
className={({ active }) =>
`relative cursor-pointer select-none py-3 pl-10 pr-4 transition-colors ${active
? 'bg-brand-50 dark:bg-brand-900/20 text-brand-900 dark:text-brand-100'
: 'text-gray-900 dark:text-white'
}`
}
>
{({ selected, active }) => (
<>
<div className="flex items-center gap-3">
{customer.logo_url ? (
<img
src={customer.logo_url}
alt={customer.name}
className="w-8 h-8 rounded-full object-cover border border-gray-200 dark:border-gray-700"
onError={(e) => {
(e.target as HTMLImageElement).src = `https://ui-avatars.com/api/?name=${encodeURIComponent(customer.name)}&background=random`;
}}
/>
) : (
<div className="w-8 h-8 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center text-brand-700 dark:text-brand-300 text-xs font-bold">
{customer.name.substring(0, 2).toUpperCase()}
</div>
)}
<div className="flex flex-col">
<span className={`block truncate ${selected ? 'font-semibold text-brand-700 dark:text-brand-400' : 'font-medium'}`}>
{customer.name}
</span>
{customer.company && (
<span className={`block truncate text-xs ${active ? 'text-brand-600/70 dark:text-brand-400/70' : 'text-gray-500 dark:text-gray-400'}`}>
{customer.company}
</span>
)}
</div>
</div>
{selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-brand-600">
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
) : null}
</>
)}
</Combobox.Option>
))
)}
</Combobox.Options>
</Transition>
</div>
</Combobox>
{selectedCustomerId && (
<button
onClick={handleClearFilter}
className="p-2 hover:bg-red-50 dark:hover:bg-red-900/20 text-gray-400 hover:text-red-600 rounded-xl transition-all duration-200 flex-shrink-0 border border-transparent hover:border-red-100 dark:hover:border-red-900/30"
title="Limpar filtro"
>
<XMarkIcon className="w-5 h-5" />
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,545 @@
"use client";
import { useState, useEffect } from 'react';
import { useToast } from '@/components/layout/ToastContext';
import Modal from '@/components/layout/Modal';
import {
EllipsisVerticalIcon,
PlusIcon,
UserIcon,
EnvelopeIcon,
PhoneIcon,
Bars2Icon,
TagIcon,
ChatBubbleLeftRightIcon,
CalendarIcon,
ClockIcon
} from '@heroicons/react/24/outline';
interface Stage {
id: string;
name: string;
color: string;
order_index: number;
}
interface Lead {
id: string;
name: string;
email: string;
phone: string;
stage_id: string;
funnel_id: string;
notes?: string;
tags?: string[];
status?: string;
created_at?: string;
}
interface KanbanBoardProps {
funnelId: string;
campaignId?: string;
}
export default function KanbanBoard({ funnelId, campaignId }: KanbanBoardProps) {
const [stages, setStages] = useState<Stage[]>([]);
const [leads, setLeads] = useState<Lead[]>([]);
const [loading, setLoading] = useState(true);
const [draggedLeadId, setDraggedLeadId] = useState<string | null>(null);
const [dropTargetStageId, setDropTargetStageId] = useState<string | null>(null);
const [movingLeadId, setMovingLeadId] = useState<string | null>(null);
// Modal states
const [isLeadModalOpen, setIsLeadModalOpen] = useState(false);
const [selectedLead, setSelectedLead] = useState<Lead | null>(null);
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [targetStageId, setTargetStageId] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [formData, setFormData] = useState({
name: '',
email: '',
phone: '',
notes: '',
tags: ''
});
const toast = useToast();
useEffect(() => {
if (funnelId) {
fetchData();
}
}, [funnelId, campaignId]);
// Refetch quando houver alterações externas (ex: criação de etapa no modal de configurações)
useEffect(() => {
const handleRefresh = () => {
console.log('KanbanBoard: External refresh triggered');
fetchData();
};
window.addEventListener('kanban-refresh', handleRefresh);
return () => window.removeEventListener('kanban-refresh', handleRefresh);
}, []);
const fetchData = async () => {
console.log('KanbanBoard: Fetching data for funnel:', funnelId, 'campaign:', campaignId);
setLoading(true);
try {
const token = localStorage.getItem('token');
const headers = { 'Authorization': `Bearer ${token}` };
const [stagesRes, leadsRes] = await Promise.all([
fetch(`/api/crm/funnels/${funnelId}/stages`, { headers }),
campaignId
? fetch(`/api/crm/lists/${campaignId}/leads`, { headers })
: fetch(`/api/crm/leads`, { headers })
]);
if (stagesRes.ok && leadsRes.ok) {
const stagesData = await stagesRes.json();
const leadsData = await leadsRes.json();
console.log('KanbanBoard: Received stages:', stagesData.stages?.length);
console.log('KanbanBoard: Received leads:', leadsData.leads?.length);
setStages(stagesData.stages || []);
setLeads(leadsData.leads || []);
} else {
console.error('KanbanBoard: API Error', stagesRes.status, leadsRes.status);
toast.error('Erro ao carregar dados do servidor');
}
} catch (error) {
console.error('Error fetching kanban data:', error);
toast.error('Erro de conexão ao carregar monitoramento');
} finally {
setLoading(false);
}
};
const moveLead = async (leadId: string, newStageId: string) => {
setMovingLeadId(leadId);
// Optimistic update
const originalLeads = [...leads];
setLeads(prev => prev.map(l => l.id === leadId ? { ...l, stage_id: newStageId } : l));
try {
const response = await fetch(`/api/crm/leads/${leadId}/stage`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({ funnel_id: funnelId, stage_id: newStageId })
});
console.log('KanbanBoard: Move lead response:', response.status);
if (!response.ok) {
setLeads(originalLeads);
toast.error('Erro ao mover lead');
}
} catch (error) {
console.error('Error moving lead:', error);
setLeads(originalLeads);
toast.error('Erro ao mover lead');
} finally {
setMovingLeadId(null);
}
};
const handleDragStart = (e: React.DragEvent, leadId: string) => {
console.log('KanbanBoard: Drag Start', leadId);
setDraggedLeadId(leadId);
e.dataTransfer.setData('text/plain', leadId);
e.dataTransfer.effectAllowed = 'move';
// Add a slight delay to make the original item semi-transparent
const currentTarget = e.currentTarget as HTMLElement;
setTimeout(() => {
if (currentTarget) currentTarget.style.opacity = '0.4';
}, 0);
};
const handleDragEnd = (e: React.DragEvent) => {
console.log('KanbanBoard: Drag End');
const currentTarget = e.currentTarget as HTMLElement;
if (currentTarget) currentTarget.style.opacity = '1';
setDraggedLeadId(null);
setDropTargetStageId(null);
};
const handleDragOver = (e: React.DragEvent, stageId: string) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
if (dropTargetStageId !== stageId) {
setDropTargetStageId(stageId);
}
return false;
};
const handleDrop = (e: React.DragEvent, stageId: string) => {
e.preventDefault();
e.stopPropagation();
// Use state if dataTransfer is empty (fallback)
const leadId = e.dataTransfer.getData('text/plain') || draggedLeadId;
console.log('KanbanBoard: Drop', { leadId, stageId });
setDropTargetStageId(null);
if (!leadId) {
console.error('KanbanBoard: No leadId found');
return;
}
const lead = leads.find(l => l.id === leadId);
if (lead && lead.stage_id !== stageId) {
console.log('KanbanBoard: Moving lead', leadId, 'to stage', stageId);
moveLead(leadId, stageId);
} else {
console.log('KanbanBoard: Lead already in stage or not found', { lead, stageId });
}
};
const handleAddLead = (stageId: string) => {
setTargetStageId(stageId);
setFormData({
name: '',
email: '',
phone: '',
notes: '',
tags: ''
});
setIsAddModalOpen(true);
};
const handleEditLead = (lead: Lead) => {
setSelectedLead(lead);
setFormData({
name: lead.name || '',
email: lead.email || '',
phone: lead.phone || '',
notes: lead.notes || '',
tags: lead.tags?.join(', ') || ''
});
setIsLeadModalOpen(true);
};
const saveLead = async (e: React.FormEvent) => {
e.preventDefault();
setIsSaving(true);
try {
const token = localStorage.getItem('token');
const isEditing = !!selectedLead;
const url = isEditing ? `/api/crm/leads/${selectedLead.id}` : '/api/crm/leads';
const method = isEditing ? 'PUT' : 'POST';
const payload = {
...formData,
tags: formData.tags.split(',').map(t => t.trim()).filter(t => t),
funnel_id: funnelId,
stage_id: isEditing ? selectedLead.stage_id : targetStageId,
status: isEditing ? selectedLead.status : 'novo'
};
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(payload)
});
if (response.ok) {
toast.success(isEditing ? 'Lead atualizado' : 'Lead criado');
setIsAddModalOpen(false);
setIsLeadModalOpen(false);
fetchData();
} else {
toast.error('Erro ao salvar lead');
}
} catch (error) {
console.error('Error saving lead:', error);
toast.error('Erro de conexão');
} finally {
setIsSaving(false);
}
};
if (loading) {
return (
<div className="flex h-64 items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-500"></div>
</div>
);
}
return (
<div
className="flex gap-6 overflow-x-auto pb-4 h-full scrollbar-thin scrollbar-thumb-zinc-300"
onDragOver={(e) => e.preventDefault()}
>
{stages.map(stage => (
<div
key={stage.id}
className={`flex-shrink-0 w-80 flex flex-col rounded-2xl transition-all duration-200 h-full border border-zinc-200/50 ${dropTargetStageId === stage.id
? 'bg-brand-50/50 ring-2 ring-brand-500/30'
: 'bg-white'
}`}
onDragOver={(e) => handleDragOver(e, stage.id)}
onDragEnter={(e) => {
e.preventDefault();
setDropTargetStageId(stage.id);
}}
onDrop={(e) => handleDrop(e, stage.id)}
>
{/* Header da Coluna */}
<div className="p-4 flex items-center justify-between sticky top-0 z-10">
<div className="flex items-center gap-3">
<div
className="w-1.5 h-5 rounded-full"
style={{ backgroundColor: stage.color }}
></div>
<div>
<h3 className="font-bold text-zinc-900 text-xs uppercase tracking-widest">
{stage.name}
</h3>
<p className="text-[10px] text-zinc-400 font-bold">
{leads.filter(l => l.stage_id === stage.id).length}
</p>
</div>
</div>
<button className="p-1.5 text-zinc-400 hover:text-zinc-600 hover:bg-zinc-50 rounded-lg transition-colors">
<EllipsisVerticalIcon className="h-4 w-4" />
</button>
</div>
{/* Lista de Cards */}
<div className="px-3 pb-3 flex-1 overflow-y-auto space-y-3 scrollbar-thin scrollbar-thumb-zinc-200">
{leads.filter(l => l.stage_id === stage.id).map(lead => (
<div
key={lead.id}
draggable
onDragStart={(e) => handleDragStart(e, lead.id)}
onDragEnd={handleDragEnd}
onClick={() => handleEditLead(lead)}
className={`bg-white p-4 rounded-xl shadow-sm border border-zinc-200 hover:shadow-md hover:border-brand-300 transition-all duration-200 cursor-grab active:cursor-grabbing group relative select-none ${draggedLeadId === lead.id ? 'ring-2 ring-brand-500 ring-offset-2' : ''
} ${movingLeadId === lead.id ? 'opacity-50 grayscale' : ''}`}
>
{movingLeadId === lead.id && (
<div className="absolute inset-0 flex items-center justify-center bg-white/80 rounded-xl z-10">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-brand-500"></div>
</div>
)}
<div className="flex justify-between items-start mb-3">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-zinc-100 flex items-center justify-center">
<UserIcon className="w-3.5 h-3.5 text-zinc-500" />
</div>
<h4 className="font-bold text-zinc-900 text-sm leading-tight">
{lead.name || 'Sem nome'}
</h4>
</div>
<Bars2Icon className="w-4 h-4 text-zinc-300 group-hover:text-zinc-400 transition-colors" />
</div>
<div className="space-y-1.5">
{lead.email && (
<div className="flex items-center gap-2 text-[11px] text-zinc-500">
<EnvelopeIcon className="h-3 w-3" />
<span className="truncate">{lead.email}</span>
</div>
)}
{lead.phone && (
<div className="flex items-center gap-2 text-[11px] text-zinc-500">
<PhoneIcon className="h-3 w-3" />
<span>{lead.phone}</span>
</div>
)}
</div>
{lead.tags && lead.tags.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1">
{lead.tags.slice(0, 2).map((tag, i) => (
<span key={i} className="px-1.5 py-0.5 bg-zinc-100 text-zinc-600 text-[9px] font-bold rounded uppercase tracking-wider">
{tag}
</span>
))}
{lead.tags.length > 2 && (
<span className="text-[9px] font-bold text-zinc-400">+{lead.tags.length - 2}</span>
)}
</div>
)}
{/* Badge de Status (Opcional) */}
<div className="mt-4 pt-3 border-t border-zinc-100 flex items-center justify-between">
<span className="text-[9px] font-bold text-zinc-400 uppercase tracking-tighter">
#{lead.id.slice(0, 6)}
</span>
<div className="flex items-center gap-1.5">
{lead.notes && (
<ChatBubbleLeftRightIcon className="h-3 w-3 text-brand-500" />
)}
<div className="w-5 h-5 rounded-full border border-white bg-brand-100 flex items-center justify-center">
<span className="text-[7px] font-bold text-brand-600">AG</span>
</div>
</div>
</div>
</div>
))}
{leads.filter(l => l.stage_id === stage.id).length === 0 && (
<div className="py-8 flex flex-col items-center justify-center border-2 border-dashed border-zinc-200 rounded-xl">
<span className="text-[9px] font-bold text-zinc-400 uppercase tracking-widest">
Vazio
</span>
</div>
)}
</div>
{/* Footer da Coluna */}
{campaignId && (
<div className="p-3 sticky bottom-0">
<button
onClick={(e) => {
e.stopPropagation();
handleAddLead(stage.id);
}}
className="w-full py-2 text-[10px] font-bold text-zinc-400 dark:text-zinc-500 hover:text-brand-600 dark:hover:text-brand-400 hover:bg-white dark:hover:bg-zinc-800 rounded-xl flex items-center justify-center gap-2 transition-all duration-200 border border-transparent hover:border-zinc-200 dark:hover:border-zinc-700"
>
<PlusIcon className="h-3.5 w-3.5" />
NOVO LEAD
</button>
</div>
)}
</div>
))}
{/* Modal de Adicionar/Editar Lead */}
<Modal
isOpen={isAddModalOpen || isLeadModalOpen}
onClose={() => {
setIsAddModalOpen(false);
setIsLeadModalOpen(false);
setSelectedLead(null);
}}
title={isAddModalOpen ? 'Novo Lead' : 'Detalhes do Lead'}
maxWidth="lg"
>
<form onSubmit={saveLead} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-xs font-bold text-zinc-500 uppercase ml-1">Nome</label>
<div className="relative">
<UserIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-400" />
<input
type="text"
required
className="w-full pl-10 pr-4 py-2.5 bg-white border border-zinc-200 rounded-xl text-sm focus:ring-2 focus:ring-brand-500/20 outline-none"
placeholder="Nome do lead"
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
/>
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-zinc-500 uppercase ml-1">E-mail</label>
<div className="relative">
<EnvelopeIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-400" />
<input
type="email"
className="w-full pl-10 pr-4 py-2.5 bg-white border border-zinc-200 rounded-xl text-sm focus:ring-2 focus:ring-brand-500/20 outline-none"
placeholder="email@exemplo.com"
value={formData.email}
onChange={e => setFormData({ ...formData, email: e.target.value })}
/>
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-zinc-500 uppercase ml-1">Telefone</label>
<div className="relative">
<PhoneIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-400" />
<input
type="text"
className="w-full pl-10 pr-4 py-2.5 bg-white border border-zinc-200 rounded-xl text-sm focus:ring-2 focus:ring-brand-500/20 outline-none"
placeholder="(00) 00000-0000"
value={formData.phone}
onChange={e => setFormData({ ...formData, phone: e.target.value })}
/>
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-zinc-500 uppercase ml-1">Tags (separadas por vírgula)</label>
<div className="relative">
<TagIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-400" />
<input
type="text"
className="w-full pl-10 pr-4 py-2.5 bg-white border border-zinc-200 rounded-xl text-sm focus:ring-2 focus:ring-brand-500/20 outline-none"
placeholder="vendas, urgente, frio"
value={formData.tags}
onChange={e => setFormData({ ...formData, tags: e.target.value })}
/>
</div>
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-zinc-500 uppercase ml-1">Notas de Acompanhamento</label>
<div className="relative">
<ChatBubbleLeftRightIcon className="absolute left-3 top-3 h-4 w-4 text-zinc-400" />
<textarea
rows={4}
className="w-full pl-10 pr-4 py-2.5 bg-white border border-zinc-200 rounded-xl text-sm focus:ring-2 focus:ring-brand-500/20 outline-none resize-none"
placeholder="Descreva o histórico ou próximas ações..."
value={formData.notes}
onChange={e => setFormData({ ...formData, notes: e.target.value })}
/>
</div>
</div>
{selectedLead && (
<div className="p-4 bg-white rounded-xl border border-zinc-100 grid grid-cols-2 gap-4">
<div className="flex items-center gap-2 text-xs text-zinc-500">
<CalendarIcon className="h-4 w-4" />
<span>Criado em: {new Date(selectedLead.created_at || '').toLocaleDateString()}</span>
</div>
<div className="flex items-center gap-2 text-xs text-zinc-500">
<ClockIcon className="h-4 w-4" />
<span>ID: {selectedLead.id.slice(0, 8)}</span>
</div>
</div>
)}
<div className="flex justify-end gap-3 pt-4 border-t border-zinc-100 dark:border-zinc-800">
<button
type="button"
onClick={() => {
setIsAddModalOpen(false);
setIsLeadModalOpen(false);
setSelectedLead(null);
}}
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-8 py-2.5 bg-brand-600 hover:bg-brand-700 text-white text-sm font-bold rounded-xl shadow-lg shadow-brand-500/20 transition-all disabled:opacity-50 flex items-center gap-2"
>
{isSaving && <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>}
{isAddModalOpen ? 'CRIAR LEAD' : 'SALVAR ALTERAÇÕES'}
</button>
</div>
</form>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,149 @@
"use client";
import { useState, useRef, useEffect, Fragment } from 'react';
import { Combobox, Transition } from '@headlessui/react';
import { ChevronUpDownIcon, CheckIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
interface Option {
id: string;
name: string;
subtitle?: string;
}
interface SearchableSelectProps {
options: Option[];
value: string;
onChange: (value: string | null) => void;
placeholder?: string;
emptyText?: string;
label?: string;
helperText?: string;
}
export default function SearchableSelect({
options,
value,
onChange,
placeholder = 'Selecione...',
emptyText = 'Nenhum resultado encontrado',
label,
helperText,
}: SearchableSelectProps) {
const [query, setQuery] = useState('');
const selectedOption = options.find(opt => opt.id === value);
const filteredOptions =
query === ''
? options
: options.filter((option) =>
option.name.toLowerCase().includes(query.toLowerCase()) ||
option.subtitle?.toLowerCase().includes(query.toLowerCase())
);
return (
<div>
{label && (
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
{label}
</label>
)}
<Combobox value={value} onChange={onChange}>
<div className="relative">
<div className="relative w-full">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<MagnifyingGlassIcon className="w-5 h-5 text-zinc-400" />
</div>
<Combobox.Input
className="w-full pl-10 pr-10 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"
displayValue={() => selectedOption ? `${selectedOption.name}${selectedOption.subtitle ? ` (${selectedOption.subtitle})` : ''}` : ''}
onChange={(event) => setQuery(event.target.value)}
placeholder={placeholder}
/>
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-3">
<ChevronUpDownIcon
className="h-5 w-5 text-zinc-400"
aria-hidden="true"
/>
</Combobox.Button>
</div>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
afterLeave={() => setQuery('')}
>
<Combobox.Options className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg bg-white dark:bg-zinc-900 py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none border border-zinc-200 dark:border-zinc-800">
{filteredOptions.length === 0 ? (
<div className="relative cursor-default select-none px-4 py-2 text-zinc-500 dark:text-zinc-400 text-sm">
{emptyText}
</div>
) : (
<>
{value && (
<Combobox.Option
value=""
className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${active ? 'bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-400' : 'text-zinc-700 dark:text-zinc-300'
}`
}
>
{({ selected }) => (
<>
<span className={`block truncate ${selected ? 'font-semibold' : 'font-normal'}`}>
{placeholder || 'Nenhum'}
</span>
{selected && (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-brand-600 dark:text-brand-400">
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
)}
</>
)}
</Combobox.Option>
)}
{filteredOptions.map((option) => (
<Combobox.Option
key={option.id}
className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${active ? 'bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-400' : 'text-zinc-700 dark:text-zinc-300'
}`
}
value={option.id}
>
{({ selected, active }) => (
<>
<div className="flex flex-col">
<span className={`block truncate ${selected ? 'font-semibold' : 'font-normal'}`}>
{option.name}
</span>
{option.subtitle && (
<span className="text-xs text-zinc-500 dark:text-zinc-400">
{option.subtitle}
</span>
)}
</div>
{selected && (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-brand-600 dark:text-brand-400">
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
)}
</>
)}
</Combobox.Option>
))}
</>
)}
</Combobox.Options>
</Transition>
</div>
</Combobox>
{helperText && (
<p className="mt-1 text-xs text-zinc-500">
{helperText}
</p>
)}
</div>
);
}

View File

@@ -39,14 +39,14 @@ export const DashboardLayout: React.FC<DashboardLayoutProps> = ({ children, menu
{/* Conteúdo das páginas */} {/* Conteúdo das páginas */}
<div className="flex-1 overflow-auto pb-20 md:pb-0"> <div className="flex-1 overflow-auto pb-20 md:pb-0">
<div className="max-w-7xl mx-auto w-full h-full"> <div className="w-full h-full">
{children} {children}
</div> </div>
</div> </div>
</main> </main>
{/* Mobile Bottom Bar */} {/* Mobile Bottom Bar */}
<MobileBottomBar /> <MobileBottomBar menuItems={menuItems} />
</div> </div>
); );
}; };

View File

@@ -1,49 +1,91 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { import {
HomeIcon, HomeIcon,
RocketLaunchIcon, UserPlusIcon,
Squares2X2Icon RectangleStackIcon,
UsersIcon,
ListBulletIcon
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { import {
HomeIcon as HomeIconSolid, HomeIcon as HomeIconSolid,
RocketLaunchIcon as RocketIconSolid, UserPlusIcon as UserPlusIconSolid,
Squares2X2Icon as GridIconSolid RectangleStackIcon as RectangleStackIconSolid,
UsersIcon as UsersIconSolid,
ListBulletIcon as ListBulletIconSolid
} from '@heroicons/react/24/solid'; } from '@heroicons/react/24/solid';
import { MenuItem } from './SidebarRail';
export const MobileBottomBar: React.FC = () => { interface MobileBottomBarProps {
menuItems?: MenuItem[];
}
export const MobileBottomBar: React.FC<MobileBottomBarProps> = ({ menuItems }) => {
const pathname = usePathname(); const pathname = usePathname();
const [showMoreMenu, setShowMoreMenu] = useState(false);
const isActive = (path: string) => { const isActive = (path: string) => {
if (path === '/dashboard') { if (path === '/dashboard' || path === '/cliente/dashboard') {
return pathname === '/dashboard'; return pathname === path;
} }
return pathname.startsWith(path); return pathname.startsWith(path);
}; };
const navItems = [ // Mapeamento de ícones sólidos para os itens do menu
const getSolidIcon = (label: string, defaultIcon: any) => {
const map: Record<string, any> = {
'Dashboard': HomeIconSolid,
'Leads': UserPlusIconSolid,
'Listas': RectangleStackIconSolid,
'CRM': UsersIconSolid,
'Meus Leads': UserPlusIconSolid,
'Meu Perfil': UserPlusIconSolid,
};
return map[label] || defaultIcon;
};
const navItems = menuItems
? menuItems.reduce((acc: any[], item) => {
if (item.href !== '#') {
acc.push({
label: item.label,
path: item.href,
icon: item.icon,
iconSolid: getSolidIcon(item.label, item.icon)
});
} else if (item.subItems) {
// Adiciona subitens importantes se o item pai for '#'
item.subItems.forEach(sub => {
acc.push({
label: sub.label,
path: sub.href,
icon: item.icon, // Usa o ícone do pai
iconSolid: getSolidIcon(sub.label, item.icon)
});
});
}
return acc;
}, []).slice(0, 4) // Limita a 4 itens no mobile
: [
{ {
label: 'Início', label: 'Dashboard',
path: '/dashboard', path: '/dashboard',
icon: HomeIcon, icon: HomeIcon,
iconSolid: HomeIconSolid iconSolid: HomeIconSolid
}, },
{ {
label: 'CRM', label: 'Leads',
path: '/crm', path: '/crm/leads',
icon: RocketLaunchIcon, icon: UserPlusIcon,
iconSolid: RocketIconSolid iconSolid: UserPlusIconSolid
}, },
{ {
label: 'Mais', label: 'Listas',
path: '#', path: '/crm/listas',
icon: Squares2X2Icon, icon: RectangleStackIcon,
iconSolid: GridIconSolid, iconSolid: RectangleStackIconSolid
onClick: () => setShowMoreMenu(true)
} }
]; ];
@@ -56,21 +98,6 @@ export const MobileBottomBar: React.FC = () => {
const active = isActive(item.path); const active = isActive(item.path);
const Icon = active ? item.iconSolid : item.icon; const Icon = active ? item.iconSolid : item.icon;
if (item.onClick) {
return (
<button
key={item.label}
onClick={item.onClick}
className="flex flex-col items-center justify-center min-w-[70px] h-full gap-1"
>
<Icon className={`w-6 h-6 ${active ? 'text-[var(--brand-color)]' : 'text-gray-500 dark:text-gray-400'}`} />
<span className={`text-xs font-medium ${active ? 'text-[var(--brand-color)]' : 'text-gray-500 dark:text-gray-400'}`}>
{item.label}
</span>
</button>
);
}
return ( return (
<Link <Link
key={item.label} key={item.label}
@@ -86,44 +113,6 @@ export const MobileBottomBar: React.FC = () => {
})} })}
</div> </div>
</nav> </nav>
{/* More Menu Modal */}
{showMoreMenu && (
<div className="md:hidden fixed inset-0 z-[100] bg-black/50 backdrop-blur-sm" onClick={() => setShowMoreMenu(false)}>
<div
className="absolute bottom-0 left-0 right-0 bg-white dark:bg-zinc-900 rounded-t-3xl shadow-2xl max-h-[70vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="p-6">
{/* Handle bar */}
<div className="w-12 h-1.5 bg-gray-300 dark:bg-zinc-700 rounded-full mx-auto mb-6" />
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-6">
Todos os Módulos
</h2>
<div className="grid grid-cols-3 gap-4">
<Link
href="/erp"
onClick={() => setShowMoreMenu(false)}
className="flex flex-col items-center gap-3 p-4 rounded-2xl hover:bg-gray-50 dark:hover:bg-zinc-800 transition-colors"
>
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white shadow-lg">
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
</svg>
</div>
<span className="text-sm font-medium text-gray-900 dark:text-white text-center">
ERP
</span>
</Link>
{/* Add more modules here */}
</div>
</div>
</div>
</div>
)}
</> </>
); );
}; };

View File

@@ -0,0 +1,79 @@
import { Fragment } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import { XMarkIcon } from '@heroicons/react/24/outline';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl';
}
export default function Modal({ isOpen, onClose, title, children, maxWidth = 'md' }: ModalProps) {
const maxWidthClass = {
sm: 'sm:max-w-sm',
md: 'sm:max-w-md',
lg: 'sm:max-w-lg',
xl: 'sm:max-w-xl',
'2xl': 'sm:max-w-2xl',
'3xl': 'sm:max-w-3xl',
'4xl': 'sm:max-w-4xl',
'5xl': 'sm:max-w-5xl',
}[maxWidth];
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-zinc-900/75 backdrop-blur-sm transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className={`relative transform overflow-hidden rounded-2xl bg-white dark:bg-zinc-900 px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 w-full ${maxWidthClass} sm:p-6 border border-zinc-200 dark:border-zinc-800`}>
<div className="absolute right-0 top-0 hidden pr-4 pt-4 sm:block">
<button
type="button"
className="rounded-md bg-white dark:bg-zinc-900 text-zinc-400 hover:text-zinc-500 focus:outline-none"
onClick={onClose}
>
<span className="sr-only">Fechar</span>
<XMarkIcon className="h-6 w-6" aria-hidden="true" />
</button>
</div>
<div className="sm:flex sm:items-start w-full">
<div className="mt-3 text-center sm:mt-0 sm:text-left w-full">
<Dialog.Title as="h3" className="text-xl font-bold leading-6 text-zinc-900 dark:text-white mb-6">
{title}
</Dialog.Title>
<div className="mt-2">
{children}
</div>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
}

View File

@@ -0,0 +1,108 @@
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
interface PaginationProps {
currentPage: number;
totalPages: number;
totalItems: number;
itemsPerPage: number;
onPageChange: (page: number) => void;
}
export default function Pagination({
currentPage,
totalPages,
totalItems,
itemsPerPage,
onPageChange
}: PaginationProps) {
const startItem = totalItems === 0 ? 0 : (currentPage - 1) * itemsPerPage + 1;
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
const pages = [];
const maxVisiblePages = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
if (endPage - startPage < maxVisiblePages - 1) {
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
for (let i = startPage; i <= endPage; i++) {
pages.push(i);
}
return (
<div className="px-6 py-4 border-t border-zinc-200 dark:border-zinc-800 bg-zinc-50/50 dark:bg-zinc-800/50 flex flex-col sm:flex-row items-center justify-between gap-4">
<p className="text-xs text-zinc-500 dark:text-zinc-400">
Mostrando <span className="font-medium">{startItem}</span> a{' '}
<span className="font-medium">{endItem}</span> de{' '}
<span className="font-medium">{totalItems}</span> resultados
</p>
<div className="flex items-center gap-2">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1 || totalPages === 0}
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700"
>
<ChevronLeftIcon className="w-4 h-4" />
Anterior
</button>
<div className="hidden sm:flex items-center gap-1">
{startPage > 1 && (
<>
<button
onClick={() => onPageChange(1)}
className="px-3 py-1.5 text-xs font-medium rounded-md bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 transition-colors"
>
1
</button>
{startPage > 2 && (
<span className="px-2 text-zinc-400">...</span>
)}
</>
)}
{pages.map(page => (
<button
key={page}
onClick={() => onPageChange(page)}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${page === currentPage
? 'text-white shadow-sm'
: 'bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700'
}`}
style={page === currentPage ? { background: 'var(--gradient)' } : {}}
>
{page}
</button>
))}
{endPage < totalPages && (
<>
{endPage < totalPages - 1 && (
<span className="px-2 text-zinc-400">...</span>
)}
<button
onClick={() => onPageChange(totalPages)}
className="px-3 py-1.5 text-xs font-medium rounded-md bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 transition-colors"
>
{totalPages}
</button>
</>
)}
</div>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages || totalPages === 0}
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700"
>
Próximo
<ChevronRightIcon className="w-4 h-4" />
</button>
</div>
</div>
);
}

View File

@@ -57,7 +57,7 @@ export const SidebarRail: React.FC<SidebarRailProps> = ({
// Buscar perfil da agência para atualizar logo e nome // Buscar perfil da agência para atualizar logo e nome
const fetchProfile = async () => { const fetchProfile = async () => {
const token = getToken(); const token = getToken();
if (!token) return; if (!token || currentUser?.user_type === 'customer') return;
try { try {
const res = await fetch(API_ENDPOINTS.agencyProfile, { const res = await fetch(API_ENDPOINTS.agencyProfile, {

View File

@@ -6,12 +6,16 @@ import Link from 'next/link';
import { MagnifyingGlassIcon, ChevronRightIcon, HomeIcon, BellIcon, Cog6ToothIcon } from '@heroicons/react/24/outline'; import { MagnifyingGlassIcon, ChevronRightIcon, HomeIcon, BellIcon, Cog6ToothIcon } from '@heroicons/react/24/outline';
import CommandPalette from '@/components/ui/CommandPalette'; import CommandPalette from '@/components/ui/CommandPalette';
import { getUser } from '@/lib/auth'; import { getUser } from '@/lib/auth';
import { CRMCustomerFilter } from '@/components/crm/CRMCustomerFilter';
export const TopBar: React.FC = () => { export const TopBar: React.FC = () => {
const pathname = usePathname(); const pathname = usePathname();
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
const [user, setUser] = useState<any>(null); const [user, setUser] = useState<any>(null);
// Verifica se está em uma rota do CRM
const isInCRM = pathname?.startsWith('/crm') || false;
useEffect(() => { useEffect(() => {
const userData = getUser(); const userData = getUser();
setUser(userData); setUser(userData);
@@ -19,8 +23,11 @@ export const TopBar: React.FC = () => {
const generateBreadcrumbs = () => { const generateBreadcrumbs = () => {
const paths = pathname?.split('/').filter(Boolean) || []; const paths = pathname?.split('/').filter(Boolean) || [];
const isCustomer = pathname?.startsWith('/cliente');
const homePath = isCustomer ? '/cliente/dashboard' : '/dashboard';
const breadcrumbs: Array<{ name: string; href: string; icon?: React.ComponentType<{ className?: string }> }> = [ const breadcrumbs: Array<{ name: string; href: string; icon?: React.ComponentType<{ className?: string }> }> = [
{ name: 'Home', href: '/dashboard', icon: HomeIcon } { name: 'Home', href: homePath, icon: HomeIcon }
]; ];
let currentPath = ''; let currentPath = '';
paths.forEach((path, index) => { paths.forEach((path, index) => {
@@ -34,9 +41,12 @@ export const TopBar: React.FC = () => {
'financeiro': 'Financeiro', 'financeiro': 'Financeiro',
'configuracoes': 'Configurações', 'configuracoes': 'Configurações',
'novo': 'Novo', 'novo': 'Novo',
'cliente': 'Portal',
'leads': 'Leads',
'listas': 'Listas',
}; };
if (path !== 'dashboard') { // Evita duplicar Home/Dashboard se a rota for /dashboard if (path !== 'dashboard' && !(isCustomer && path === 'cliente')) { // Evita duplicar Home/Dashboard ou Portal
breadcrumbs.push({ breadcrumbs.push({
name: nameMap[path] || path.charAt(0).toUpperCase() + path.slice(1), name: nameMap[path] || path.charAt(0).toUpperCase() + path.slice(1),
href: currentPath, href: currentPath,
@@ -48,12 +58,14 @@ export const TopBar: React.FC = () => {
}; };
const breadcrumbs = generateBreadcrumbs(); const breadcrumbs = generateBreadcrumbs();
const isCustomer = pathname?.startsWith('/cliente');
const homePath = isCustomer ? '/cliente/dashboard' : '/dashboard';
return ( return (
<> <>
<div className="bg-white dark:bg-zinc-900 border-b border-gray-200 dark:border-zinc-800 px-4 md:px-6 py-3 flex items-center justify-between transition-colors"> <div className="bg-white dark:bg-zinc-900 border-b border-gray-200 dark:border-zinc-800 px-4 md:px-6 py-3 flex items-center justify-between transition-colors">
{/* Logo Mobile */} {/* Logo Mobile */}
<Link href="/dashboard" className="md:hidden flex items-center gap-2"> <Link href={homePath} className="md:hidden flex items-center gap-2">
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-white font-bold shrink-0 shadow-md overflow-hidden bg-brand-500"> <div className="w-8 h-8 rounded-lg flex items-center justify-center text-white font-bold shrink-0 shadow-md overflow-hidden bg-brand-500">
{user?.logoUrl ? ( {user?.logoUrl ? (
<img src={user.logoUrl} alt={user?.company || 'Logo'} className="w-full h-full object-cover" /> <img src={user.logoUrl} alt={user?.company || 'Logo'} className="w-full h-full object-cover" />
@@ -93,6 +105,13 @@ export const TopBar: React.FC = () => {
})} })}
</nav> </nav>
{/* CRM Customer Filter - aparece apenas em rotas CRM */}
{isInCRM && (
<div className="hidden lg:flex">
<CRMCustomerFilter />
</div>
)}
{/* Search Bar Trigger */} {/* Search Bar Trigger */}
<div className="flex items-center gap-2 md:gap-4"> <div className="flex items-center gap-2 md:gap-4">
<button <button
@@ -111,7 +130,7 @@ export const TopBar: React.FC = () => {
<span className="absolute top-2 right-2 w-2 h-2 bg-red-500 rounded-full border-2 border-white dark:border-zinc-900"></span> <span className="absolute top-2 right-2 w-2 h-2 bg-red-500 rounded-full border-2 border-white dark:border-zinc-900"></span>
</button> </button>
<Link <Link
href="/configuracoes" href={isCustomer ? "/cliente/perfil" : "/configuracoes"}
className="flex p-2 text-gray-500 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition-colors" className="flex p-2 text-gray-500 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition-colors"
> >
<Cog6ToothIcon className="w-5 h-5" /> <Cog6ToothIcon className="w-5 h-5" />

View File

@@ -0,0 +1,570 @@
"use client";
import { useEffect, useState } from 'react';
import { Button, Dialog, Input } from '@/components/ui';
import { Toaster, toast } from 'react-hot-toast';
import {
UserPlusIcon,
TrashIcon,
XMarkIcon,
CheckCircleIcon,
ExclamationTriangleIcon,
} from '@heroicons/react/24/outline';
interface Collaborator {
id: string;
email: string;
name: string;
agency_role: string;
created_at: string;
collaborator_created_at?: string;
}
interface InviteRequest {
email: string;
name: string;
}
export default function TeamManagement() {
const [collaborators, setCollaborators] = useState<Collaborator[]>([]);
const [loading, setLoading] = useState(true);
const [showInviteDialog, setShowInviteDialog] = useState(false);
const [showDirectCreateDialog, setShowDirectCreateDialog] = useState(false);
const [showActionMenu, setShowActionMenu] = useState(false);
const [inviteForm, setInviteForm] = useState<InviteRequest>({
email: '',
name: '',
});
const [inviting, setInviting] = useState(false);
const [tempPassword, setTempPassword] = useState('');
const [showPasswordDialog, setShowPasswordDialog] = useState(false);
const [passwordDialogMode, setPasswordDialogMode] = useState<'invite' | 'direct'>('invite');
const [removingId, setRemovingId] = useState<string | null>(null);
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
const [isOwner, setIsOwner] = useState(true);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Buscar colaboradores
const fetchCollaborators = async () => {
try {
setLoading(true);
const token = localStorage.getItem('token');
const response = await fetch('/api/agency/collaborators', {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
if (response.status === 403) {
// Usuário não é owner
setIsOwner(false);
setErrorMessage('Apenas o dono da agência pode gerenciar colaboradores');
setCollaborators([]);
return;
}
throw new Error('Erro ao carregar colaboradores');
}
setIsOwner(true);
setErrorMessage(null);
const data = await response.json();
setCollaborators(data || []);
} catch (error) {
console.error('Erro ao carregar colaboradores:', error);
setErrorMessage('Erro ao carregar colaboradores');
toast.error('Erro ao carregar colaboradores');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchCollaborators();
}, []);
// Fechar menu de ações ao clicar fora
useEffect(() => {
const handleClickOutside = () => setShowActionMenu(false);
if (showActionMenu) {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
}, [showActionMenu]);
// Convidar colaborador
const handleInvite = async () => {
if (!inviteForm.email || !inviteForm.name) {
toast.error('Preencha todos os campos');
return;
}
try {
setInviting(true);
const token = localStorage.getItem('token');
const response = await fetch('/api/agency/collaborators/invite', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(inviteForm),
});
if (!response.ok) {
const error = await response.text();
throw new Error(error || 'Erro ao convidar colaborador');
}
const data = await response.json();
setTempPassword(data.temporary_password);
setPasswordDialogMode('invite');
setShowPasswordDialog(true);
setShowInviteDialog(false);
setInviteForm({ email: '', name: '' });
// Recarregar colaboradores
await fetchCollaborators();
} catch (error) {
console.error('Erro ao convidar:', error);
toast.error(error instanceof Error ? error.message : 'Erro ao convidar colaborador');
} finally {
setInviting(false);
}
};
// Criar colaborador diretamente
const handleDirectCreate = async () => {
if (!inviteForm.email || !inviteForm.name) {
toast.error('Preencha todos os campos');
return;
}
try {
setInviting(true);
const token = localStorage.getItem('token');
const response = await fetch('/api/agency/collaborators/invite', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(inviteForm),
});
if (!response.ok) {
const error = await response.text();
throw new Error(error || 'Erro ao criar colaborador');
}
const data = await response.json();
setTempPassword(data.temporary_password);
setPasswordDialogMode('direct');
setShowPasswordDialog(true);
setShowDirectCreateDialog(false);
setInviteForm({ email: '', name: '' });
// Recarregar colaboradores
await fetchCollaborators();
} catch (error) {
console.error('Erro ao criar:', error);
toast.error(error instanceof Error ? error.message : 'Erro ao criar colaborador');
} finally {
setInviting(false);
}
};
// Remover colaborador
const handleRemove = async () => {
if (!removingId) return;
try {
const token = localStorage.getItem('token');
const response = await fetch(`/api/agency/collaborators/remove?id=${removingId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error('Erro ao remover colaborador');
}
toast.success('Colaborador removido com sucesso');
setShowRemoveDialog(false);
setRemovingId(null);
await fetchCollaborators();
} catch (error) {
console.error('Erro ao remover:', error);
toast.error('Erro ao remover colaborador');
}
};
const copyPassword = () => {
navigator.clipboard.writeText(tempPassword);
toast.success('Senha copiada para a área de transferência');
};
const formatDate = (dateString: string) => {
try {
return new Date(dateString).toLocaleDateString('pt-BR');
} catch {
return '-';
}
};
if (loading) {
return (
<div className="space-y-4">
<div className="h-20 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
<div className="h-20 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
</div>
);
}
return (
<>
<Toaster position="top-right" />
<div className="space-y-6">
{/* Mensagem de Erro se não for owner */}
{!isOwner && errorMessage && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 flex gap-3">
<ExclamationTriangleIcon className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-800 dark:text-red-300">
{errorMessage}
</p>
</div>
)}
{/* Cabeçalho */}
<div className="flex justify-between items-center">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Gerenciamento de Equipe
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Adicione e gerencie colaboradores com acesso ao sistema
</p>
</div>
<div className="relative">
<Button
variant="primary"
onClick={() => setShowActionMenu(!showActionMenu)}
className="flex items-center gap-2"
disabled={!isOwner}
>
<UserPlusIcon className="w-4 h-4" />
Adicionar Colaborador
</Button>
{showActionMenu && (
<div className="absolute right-0 mt-2 w-56 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-50">
<button
onClick={() => {
setShowInviteDialog(true);
setShowActionMenu(false);
}}
className="w-full text-left px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors border-b border-gray-200 dark:border-gray-700"
>
<p className="font-medium text-gray-900 dark:text-white text-sm">
Convidar por Email
</p>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-0.5">
Enviar convite por email com senha temporária
</p>
</button>
<button
onClick={() => {
setShowDirectCreateDialog(true);
setShowActionMenu(false);
}}
className="w-full text-left px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
>
<p className="font-medium text-gray-900 dark:text-white text-sm">
Criar sem Convite
</p>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-0.5">
Criar colaborador e copiar senha manualmente
</p>
</button>
</div>
)}
</div>
</div>
{/* Lista de Colaboradores */}
{collaborators.length === 0 ? (
<div className="text-center py-12 bg-gray-50 dark:bg-gray-800/50 rounded-lg border border-dashed border-gray-300 dark:border-gray-600">
<UserPlusIcon className="w-12 h-12 mx-auto text-gray-400 mb-4" />
<p className="text-gray-600 dark:text-gray-400 mb-4">
Nenhum colaborador adicionado ainda
</p>
<Button
variant="secondary"
onClick={() => setShowInviteDialog(true)}
>
Convidar o Primeiro Colaborador
</Button>
</div>
) : (
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<tr>
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900 dark:text-white">
Nome
</th>
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900 dark:text-white">
Email
</th>
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900 dark:text-white">
Função
</th>
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900 dark:text-white">
Data de Adição
</th>
<th className="px-6 py-3 text-right text-sm font-semibold text-gray-900 dark:text-white">
Ações
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{collaborators.map((collaborator) => (
<tr
key={collaborator.id}
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
>
<td className="px-6 py-4 text-sm text-gray-900 dark:text-white font-medium">
{collaborator.name}
</td>
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-400">
{collaborator.email}
</td>
<td className="px-6 py-4 text-sm">
<span className={`inline-block px-3 py-1 rounded-full text-xs font-semibold ${collaborator.agency_role === 'owner'
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
}`}>
{collaborator.agency_role === 'owner' ? 'Dono' : 'Colaborador'}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-400">
{formatDate(collaborator.collaborator_created_at || collaborator.created_at)}
</td>
<td className="px-6 py-4 text-right">
{collaborator.agency_role !== 'owner' && (
<button
onClick={() => {
setRemovingId(collaborator.id);
setShowRemoveDialog(true);
}}
className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 transition-colors"
>
<TrashIcon className="w-4 h-4" />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Informação sobre Permissões */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="flex gap-3">
<ExclamationTriangleIcon className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-800 dark:text-blue-300">
<p className="font-medium mb-1">Permissões dos Colaboradores:</p>
<ul className="list-disc list-inside space-y-0.5">
<li>Podem visualizar leads e clientes</li>
<li>Não podem editar ou remover dados</li>
<li>Permissões gerenciadas exclusivamente pelo dono</li>
</ul>
</div>
</div>
</div>
</div>
{/* Dialog: Convidar Colaborador */}
<Dialog
isOpen={showInviteDialog}
onClose={() => setShowInviteDialog(false)}
title="Convidar Colaborador"
>
<div className="space-y-4">
<Input
label="Nome"
placeholder="Nome completo do colaborador"
value={inviteForm.name}
onChange={(e) => setInviteForm({ ...inviteForm, name: e.target.value })}
disabled={inviting}
/>
<Input
label="Email"
type="email"
placeholder="email@exemplo.com"
value={inviteForm.email}
onChange={(e) => setInviteForm({ ...inviteForm, email: e.target.value })}
disabled={inviting}
/>
<div className="flex gap-3 justify-end pt-4">
<Button
variant="secondary"
onClick={() => setShowInviteDialog(false)}
disabled={inviting}
>
Cancelar
</Button>
<Button
variant="primary"
onClick={handleInvite}
disabled={inviting || !inviteForm.email || !inviteForm.name}
>
{inviting ? 'Convidando...' : 'Convidar'}
</Button>
</div>
</div>
</Dialog>
{/* Dialog: Senha Temporária */}
<Dialog
isOpen={showPasswordDialog}
onClose={() => setShowPasswordDialog(false)}
title={passwordDialogMode === 'invite' ? 'Colaborador Convidado com Sucesso' : 'Colaborador Criado com Sucesso'}
>
<div className="space-y-4">
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 flex gap-3">
<CheckCircleIcon className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0" />
<p className="text-sm text-green-800 dark:text-green-300">
{passwordDialogMode === 'invite'
? 'Colaborador criado com sucesso! Um email com a senha temporária foi enviado.'
: 'Colaborador criado com sucesso! Copie a senha abaixo e compartilhe com segurança.'}
</p>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-900 dark:text-white">
Senha Temporária
</label>
<div className="flex gap-2">
<input
type="text"
value={tempPassword}
readOnly
className="flex-1 px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white font-mono text-sm"
/>
<Button
variant="secondary"
onClick={copyPassword}
className="px-4"
>
Copiar
</Button>
</div>
</div>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 text-sm text-yellow-800 dark:text-yellow-300">
{passwordDialogMode === 'invite'
? 'O colaborador deverá alterar a senha no primeiro acesso.'
: 'Compartilhe esta senha com segurança. O colaborador deverá alterá-la no primeiro acesso.'}
</div>
<div className="flex justify-end pt-4">
<Button
variant="primary"
onClick={() => setShowPasswordDialog(false)}
>
OK
</Button>
</div>
</div>
</Dialog>
{/* Dialog: Criar Colaborador Direto */}
<Dialog
isOpen={showDirectCreateDialog}
onClose={() => setShowDirectCreateDialog(false)}
title="Criar Colaborador (Sem Email)"
>
<div className="space-y-4">
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 flex gap-3">
<ExclamationTriangleIcon className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-blue-800 dark:text-blue-300">
O colaborador será criado imediatamente. Você receberá a senha para compartilhar manualmente.
</p>
</div>
<Input
label="Nome"
placeholder="Nome completo do colaborador"
value={inviteForm.name}
onChange={(e) => setInviteForm({ ...inviteForm, name: e.target.value })}
disabled={inviting}
/>
<Input
label="Email"
type="email"
placeholder="email@exemplo.com"
value={inviteForm.email}
onChange={(e) => setInviteForm({ ...inviteForm, email: e.target.value })}
disabled={inviting}
/>
<div className="flex gap-3 justify-end pt-4">
<Button
variant="secondary"
onClick={() => setShowDirectCreateDialog(false)}
disabled={inviting}
>
Cancelar
</Button>
<Button
variant="primary"
onClick={handleDirectCreate}
disabled={inviting || !inviteForm.email || !inviteForm.name}
>
{inviting ? 'Criando...' : 'Criar Colaborador'}
</Button>
</div>
</div>
</Dialog>
{/* Dialog: Remover Colaborador */}
<Dialog
isOpen={showRemoveDialog}
onClose={() => setShowRemoveDialog(false)}
title="Remover Colaborador"
>
<div className="space-y-4">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 flex gap-3">
<ExclamationTriangleIcon className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0" />
<p className="text-sm text-red-800 dark:text-red-300">
Tem certeza que deseja remover este colaborador? Ele perderá o acesso ao sistema imediatamente.
</p>
</div>
<div className="flex gap-3 justify-end pt-4">
<Button
variant="secondary"
onClick={() => setShowRemoveDialog(false)}
>
Cancelar
</Button>
<Button
variant="primary"
onClick={handleRemove}
className="bg-red-600 hover:bg-red-700"
>
Remover
</Button>
</div>
</div>
</Dialog>
</>
);
}

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from 'react';
import { Combobox, Dialog, DialogBackdrop, DialogPanel } from '@headlessui/react'; import { Combobox, Dialog, DialogBackdrop, DialogPanel } from '@headlessui/react';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { getUser } from '@/lib/auth';
import { import {
HomeIcon, HomeIcon,
RocketLaunchIcon, RocketLaunchIcon,
@@ -16,7 +17,8 @@ import {
ShareIcon, ShareIcon,
Cog6ToothIcon, Cog6ToothIcon,
PlusIcon, PlusIcon,
ArrowRightIcon ArrowRightIcon,
UserCircleIcon
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
interface CommandPaletteProps { interface CommandPaletteProps {
@@ -76,25 +78,37 @@ export default function CommandPalette({ isOpen, setIsOpen }: CommandPaletteProp
}, [setIsOpen]); }, [setIsOpen]);
const navigation = [ const navigation = [
{ name: 'Visão Geral', href: '/dashboard', icon: HomeIcon, category: 'Navegação', solution: 'dashboard' }, // Agência
{ name: 'CRM', href: '/crm', icon: RocketLaunchIcon, category: 'Navegação', solution: 'crm' }, { name: 'Visão Geral', href: '/dashboard', icon: HomeIcon, category: 'Navegação', solution: 'dashboard', allowedTypes: ['agency_user'] },
{ name: 'ERP', href: '/erp', icon: ChartBarIcon, category: 'Navegação', solution: 'erp' }, { name: 'CRM', href: '/crm', icon: RocketLaunchIcon, category: 'Navegação', solution: 'crm', allowedTypes: ['agency_user'] },
{ name: 'Projetos', href: '/projetos', icon: BriefcaseIcon, category: 'Navegação', solution: 'projetos' }, { name: 'ERP', href: '/erp', icon: ChartBarIcon, category: 'Navegação', solution: 'erp', allowedTypes: ['agency_user'] },
{ name: 'Helpdesk', href: '/helpdesk', icon: LifebuoyIcon, category: 'Navegação', solution: 'helpdesk' }, { name: 'Projetos', href: '/projetos', icon: BriefcaseIcon, category: 'Navegação', solution: 'projetos', allowedTypes: ['agency_user'] },
{ name: 'Pagamentos', href: '/pagamentos', icon: CreditCardIcon, category: 'Navegação', solution: 'pagamentos' }, { name: 'Helpdesk', href: '/helpdesk', icon: LifebuoyIcon, category: 'Navegação', solution: 'helpdesk', allowedTypes: ['agency_user'] },
{ name: 'Contratos', href: '/contratos', icon: DocumentTextIcon, category: 'Navegação', solution: 'contratos' }, { name: 'Pagamentos', href: '/pagamentos', icon: CreditCardIcon, category: 'Navegação', solution: 'pagamentos', allowedTypes: ['agency_user'] },
{ name: 'Documentos', href: '/documentos', icon: FolderIcon, category: 'Navegação', solution: 'documentos' }, { name: 'Contratos', href: '/contratos', icon: DocumentTextIcon, category: 'Navegação', solution: 'contratos', allowedTypes: ['agency_user'] },
{ name: 'Redes Sociais', href: '/social', icon: ShareIcon, category: 'Navegação', solution: 'social' }, { name: 'Documentos', href: '/documentos', icon: FolderIcon, category: 'Navegação', solution: 'documentos', allowedTypes: ['agency_user'] },
{ name: 'Configurações', href: '/configuracoes', icon: Cog6ToothIcon, category: 'Navegação', solution: 'dashboard' }, { name: 'Redes Sociais', href: '/social', icon: ShareIcon, category: 'Navegação', solution: 'social', allowedTypes: ['agency_user'] },
// Ações { name: 'Configurações', href: '/configuracoes', icon: Cog6ToothIcon, category: 'Navegação', solution: 'dashboard', allowedTypes: ['agency_user'] },
{ name: 'Novo Projeto', href: '/projetos/novo', icon: PlusIcon, category: 'Ações', solution: 'projetos' },
{ name: 'Novo Chamado', href: '/helpdesk/novo', icon: PlusIcon, category: 'Ações', solution: 'helpdesk' }, // Cliente
{ name: 'Novo Contrato', href: '/contratos/novo', icon: PlusIcon, category: 'Ações', solution: 'contratos' }, { name: 'Dashboard', href: '/cliente/dashboard', icon: HomeIcon, category: 'Navegação', solution: 'dashboard', allowedTypes: ['customer'] },
{ name: 'Meus Leads', href: '/cliente/leads', icon: RocketLaunchIcon, category: 'Navegação', solution: 'crm', allowedTypes: ['customer'] },
{ name: 'Minhas Listas', href: '/cliente/listas', icon: FolderIcon, category: 'Navegação', solution: 'crm', allowedTypes: ['customer'] },
{ name: 'Meu Perfil', href: '/cliente/perfil', icon: UserCircleIcon, category: 'Navegação', solution: 'dashboard', allowedTypes: ['customer'] },
// Ações Agência
{ name: 'Novo Projeto', href: '/projetos/novo', icon: PlusIcon, category: 'Ações', solution: 'projetos', allowedTypes: ['agency_user'] },
{ name: 'Novo Chamado', href: '/helpdesk/novo', icon: PlusIcon, category: 'Ações', solution: 'helpdesk', allowedTypes: ['agency_user'] },
{ name: 'Novo Contrato', href: '/contratos/novo', icon: PlusIcon, category: 'Ações', solution: 'contratos', allowedTypes: ['agency_user'] },
]; ];
// Filtrar por soluções disponíveis // Filtrar por soluções disponíveis e tipo de usuário
const user = getUser();
const userType = user?.user_type || 'agency_user';
const allowedNavigation = navigation.filter(item => const allowedNavigation = navigation.filter(item =>
availableSolutions.includes(item.solution) availableSolutions.includes(item.solution) &&
(!item.allowedTypes || item.allowedTypes.includes(userType))
); );
const filteredItems = const filteredItems =

View File

@@ -0,0 +1,51 @@
"use client";
import { createContext, useContext, useState, ReactNode, useEffect } from 'react';
interface Customer {
id: string;
name: string;
email: string;
company: string;
logo_url?: string;
}
interface CRMFilterContextType {
selectedCustomerId: string | null;
setSelectedCustomerId: (id: string | null) => void;
customers: Customer[];
setCustomers: (customers: Customer[]) => void;
loading: boolean;
setLoading: (loading: boolean) => void;
}
const CRMFilterContext = createContext<CRMFilterContextType | undefined>(undefined);
export function CRMFilterProvider({ children }: { children: ReactNode }) {
const [selectedCustomerId, setSelectedCustomerId] = useState<string | null>(null);
const [customers, setCustomers] = useState<Customer[]>([]);
const [loading, setLoading] = useState(false);
return (
<CRMFilterContext.Provider
value={{
selectedCustomerId,
setSelectedCustomerId,
customers,
setCustomers,
loading,
setLoading
}}
>
{children}
</CRMFilterContext.Provider>
);
}
export function useCRMFilter() {
const context = useContext(CRMFilterContext);
if (context === undefined) {
throw new Error('useCRMFilter must be used within a CRMFilterProvider');
}
return context;
}

View File

@@ -7,6 +7,7 @@ export interface User {
email: string; email: string;
name: string; name: string;
role: string; role: string;
user_type?: 'agency_user' | 'customer' | 'superadmin';
tenantId?: string; tenantId?: string;
company?: string; company?: string;
subdomain?: string; subdomain?: string;

View File

@@ -0,0 +1,49 @@
import { headers } from 'next/headers';
export interface BrandingData {
primary_color: string;
logo_url?: string;
logo_horizontal_url?: string;
name: string;
}
export async function getBranding(): Promise<BrandingData> {
try {
const headersList = await headers();
const hostname = headersList.get('host') || '';
const subdomain = hostname.split('.')[0];
console.log(`[getBranding] Buscando branding para subdomain: ${subdomain}`);
const response = await fetch(`http://aggios-backend:8080/api/tenant/config?subdomain=${subdomain}`, {
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
console.error(`[getBranding] Erro: ${response.status}`);
return {
primary_color: '#6366f1',
name: 'Agência',
};
}
const data = await response.json();
console.log(`[getBranding] Dados recebidos:`, data);
return {
primary_color: data.primary_color || '#6366f1',
logo_url: data.logo_url,
logo_horizontal_url: data.logo_horizontal_url,
name: data.name || 'Agência',
};
} catch (error) {
console.error('[getBranding] Erro:', error);
return {
primary_color: '#6366f1',
name: 'Agência',
};
}
}

View File

@@ -0,0 +1,180 @@
'use server';
import { writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
import { headers } from 'next/headers';
export async function registerCustomer(formData: FormData) {
try {
// 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 password = formData.get('password') 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 responsibleName = formData.get('responsible_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;
console.log('[registerCustomer] Recebendo cadastro:', { personType, email, phone });
// Validar campos obrigatórios
if (!email || !phone || !password) {
return { success: false, error: 'E-mail, telefone e senha são obrigatórios' };
}
// Validar campos específicos por tipo
if (personType === 'pf') {
if (!cpf || !fullName) {
return { success: false, error: 'CPF e Nome Completo são obrigatórios para Pessoa Física' };
}
} else if (personType === 'pj') {
if (!cnpj || !companyName) {
return { success: false, error: 'CNPJ e Razão Social são obrigatórios para Pessoa Jurídica' };
}
if (!responsibleName) {
return { success: false, error: 'Nome do responsável é obrigatório para Pessoa Jurídica' };
}
}
// 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}`;
// Garantir que o diretório existe
await mkdir(uploadDir, { recursive: true });
// Salvar arquivo
await writeFile(join(uploadDir, fileName), buffer);
console.log('[registerCustomer] Logo salvo:', logoPath);
} catch (uploadError) {
console.error('[registerCustomer] Error uploading logo:', uploadError);
// Continuar sem logo em caso de erro
}
}
// Buscar tenant_id do subdomínio validado pelo middleware
const headersList = await headers();
const hostname = headersList.get('host') || '';
const subdomain = hostname.split('.')[0];
console.log('[registerCustomer] Buscando tenant ID para subdomain:', subdomain);
// Buscar tenant completo do backend (agora com o campo 'id')
const tenantResponse = await fetch(`http://aggios-backend:8080/api/tenant/config?subdomain=${subdomain}`, {
headers: { 'Content-Type': 'application/json' },
});
if (!tenantResponse.ok) {
console.error('[registerCustomer] Erro ao buscar tenant:', tenantResponse.status);
throw new Error('Erro ao identificar a agência. Tente novamente.');
}
const tenantData = await tenantResponse.json();
const tenantId = tenantData.id;
if (!tenantId) {
throw new Error('Tenant não identificado. Acesso negado.');
}
console.log('[registerCustomer] Criando cliente para tenant:', tenantId);
// 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(', ');
const response = await fetch('http://aggios-backend:8080/api/public/customers/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tenant_id: tenantId,
name: customerName,
email: email,
phone: phone,
password: password,
company: personType === 'pj' ? companyName : '',
address: fullAddress,
notes: JSON.stringify({
person_type: personType,
cpf, cnpj, full_name: fullName, responsible_name: responsibleName, company_name: companyName, trade_name: tradeName,
postal_code: postalCode, street, number, complement, neighborhood, city, state,
message, logo_path: logoPath, email, phone,
}),
tags: ['cadastro_publico', 'pendente_aprovacao'],
status: 'lead',
source: 'cadastro_publico',
}),
});
if (!response.ok) {
const errorText = await response.text();
console.error('[registerCustomer] Backend error:', errorText);
let errorMessage = 'Erro ao criar cadastro no servidor';
try {
const errorJson = JSON.parse(errorText);
if (errorJson.message) {
// Usar a mensagem clara do backend
errorMessage = errorJson.message;
} else if (errorJson.error) {
// Fallback para mensagens antigas
if (errorJson.error.includes('email') || errorJson.error.includes('duplicate_email')) {
errorMessage = 'Já existe uma conta cadastrada com este e-mail.';
} else if (errorJson.error.includes('duplicate_cpf')) {
errorMessage = 'Já existe uma conta cadastrada com este CPF.';
} else if (errorJson.error.includes('duplicate_cnpj')) {
errorMessage = 'Já existe uma conta cadastrada com este CNPJ.';
} else if (errorJson.error.includes('tenant')) {
errorMessage = 'Agência não identificada. Verifique o link de acesso.';
} else {
errorMessage = errorJson.error;
}
}
} catch (e) {
// Keep default error message
}
throw new Error(errorMessage);
}
const data = await response.json();
console.log('[registerCustomer] Cliente criado:', data.customer?.id);
return {
success: true,
message: 'Cadastro enviado com sucesso! Aguarde a aprovação da agência para receber suas credenciais de acesso.',
customer_id: data.customer?.id,
};
} catch (error: any) {
console.error('[registerCustomer] Error:', error);
return {
success: false,
error: error.message || 'Erro ao processar cadastro',
};
}
}

View File

@@ -4,6 +4,10 @@ const nextConfig: NextConfig = {
reactStrictMode: false, // Desabilitar StrictMode para evitar double render que causa removeChild reactStrictMode: false, // Desabilitar StrictMode para evitar double render que causa removeChild
experimental: { experimental: {
externalDir: true, externalDir: true,
// Aumentar limite para upload de logos (Server Actions)
serverActions: {
bodySizeLimit: '10mb',
},
}, },
async rewrites() { async rewrites() {
return { return {

View File

@@ -1,11 +1,11 @@
{ {
"name": "dash.aggios.app", "name": "agency.aggios.app",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dash.aggios.app", "name": "agency.aggios.app",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@headlessui/react": "^2.2.9", "@headlessui/react": "^2.2.9",
@@ -14,6 +14,7 @@
"lucide-react": "^0.556.0", "lucide-react": "^0.556.0",
"next": "16.0.7", "next": "16.0.7",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"papaparse": "^5.5.3",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
@@ -22,6 +23,7 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/papaparse": "^5.5.2",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
@@ -2307,6 +2309,16 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/papaparse": {
"version": "5.5.2",
"resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz",
"integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.2.7", "version": "19.2.7",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
@@ -6058,6 +6070,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/papaparse": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
"integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==",
"license": "MIT"
},
"node_modules/parent-module": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",

View File

@@ -15,6 +15,7 @@
"lucide-react": "^0.556.0", "lucide-react": "^0.556.0",
"next": "16.0.7", "next": "16.0.7",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"papaparse": "^5.5.3",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
@@ -23,6 +24,7 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/papaparse": "^5.5.2",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",

View File

@@ -0,0 +1 @@
# Este arquivo garante que a pasta seja incluída no repositório

View File

@@ -0,0 +1,70 @@
-- Tabela de leads do CRM (multi-tenant)
CREATE TABLE IF NOT EXISTS crm_leads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
-- Dados básicos
name VARCHAR(255),
email VARCHAR(255),
phone VARCHAR(50),
-- Origem do lead
source VARCHAR(50) DEFAULT 'import',
source_meta JSONB DEFAULT '{}'::jsonb,
-- Status
status VARCHAR(50) DEFAULT 'novo',
-- Informações adicionais
notes TEXT,
tags TEXT[],
-- Controle
is_active BOOLEAN DEFAULT true,
created_by UUID REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- Constraint: email deve ser único por agência (pode repetir entre agências)
CONSTRAINT unique_lead_email_per_tenant UNIQUE (tenant_id, email)
);
-- Relacionamento N:N entre leads e listas
CREATE TABLE IF NOT EXISTS crm_lead_lists (
lead_id UUID REFERENCES crm_leads(id) ON DELETE CASCADE,
list_id UUID REFERENCES crm_lists(id) ON DELETE CASCADE,
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
added_by UUID REFERENCES users(id),
PRIMARY KEY (lead_id, list_id)
);
-- Índices para performance
CREATE INDEX IF NOT EXISTS idx_crm_leads_tenant_id ON crm_leads(tenant_id);
CREATE INDEX IF NOT EXISTS idx_crm_leads_email ON crm_leads(email);
CREATE INDEX IF NOT EXISTS idx_crm_leads_phone ON crm_leads(phone);
CREATE INDEX IF NOT EXISTS idx_crm_leads_status ON crm_leads(status);
CREATE INDEX IF NOT EXISTS idx_crm_leads_is_active ON crm_leads(is_active);
CREATE INDEX IF NOT EXISTS idx_crm_leads_tags ON crm_leads USING GIN(tags);
CREATE INDEX IF NOT EXISTS idx_crm_leads_source ON crm_leads(source);
CREATE INDEX IF NOT EXISTS idx_crm_lead_lists_lead_id ON crm_lead_lists(lead_id);
CREATE INDEX IF NOT EXISTS idx_crm_lead_lists_list_id ON crm_lead_lists(list_id);
-- Trigger para atualizar updated_at (usa a função update_updated_at_column criada em 014_create_crm_tables.sql)
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_proc
WHERE proname = 'update_updated_at_column'
) THEN
IF NOT EXISTS (
SELECT 1
FROM pg_trigger
WHERE tgname = 'update_crm_leads_updated_at'
) THEN
CREATE TRIGGER update_crm_leads_updated_at BEFORE UPDATE ON crm_leads
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
END IF;
END IF;
END $$;

View File

@@ -0,0 +1,11 @@
-- Adicionar relacionamento entre leads e customers (clientes)
-- Um lead pertence a um cliente da agência
ALTER TABLE crm_leads
ADD COLUMN customer_id UUID REFERENCES crm_customers(id) ON DELETE SET NULL;
-- Índice para performance
CREATE INDEX IF NOT EXISTS idx_crm_leads_customer_id ON crm_leads(customer_id);
-- Comentário explicativo
COMMENT ON COLUMN crm_leads.customer_id IS 'Cliente (customer) ao qual este lead pertence. Permite que agências gerenciem leads de seus clientes.';

View File

@@ -0,0 +1,20 @@
-- Migration: Create CRM share tokens table
-- Description: Allows generating secure shareable links for customers to view their leads
CREATE TABLE IF NOT EXISTS crm_share_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
customer_id UUID NOT NULL REFERENCES crm_customers(id) ON DELETE CASCADE,
token VARCHAR(64) NOT NULL UNIQUE,
expires_at TIMESTAMP,
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_crm_share_tokens_token ON crm_share_tokens(token);
CREATE INDEX idx_crm_share_tokens_customer_id ON crm_share_tokens(customer_id);
CREATE INDEX idx_crm_share_tokens_tenant_id ON crm_share_tokens(tenant_id);
COMMENT ON TABLE crm_share_tokens IS 'Tokens for sharing customer lead data externally';
COMMENT ON COLUMN crm_share_tokens.token IS 'Unique secure token for accessing shared data';
COMMENT ON COLUMN crm_share_tokens.expires_at IS 'Optional expiration date (NULL = never expires)';

View File

@@ -0,0 +1,21 @@
-- Migration 018: Adicionar autenticação para clientes (Portal do Cliente)
-- Permite que clientes (CRMCustomer) façam login e vejam seus próprios leads
-- Adicionar coluna de senha para clientes
ALTER TABLE crm_customers
ADD COLUMN IF NOT EXISTS password_hash VARCHAR(255),
ADD COLUMN IF NOT EXISTS has_portal_access BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS portal_last_login TIMESTAMP,
ADD COLUMN IF NOT EXISTS portal_created_at TIMESTAMP DEFAULT NOW();
-- Criar índice para busca por email (login)
CREATE INDEX IF NOT EXISTS idx_crm_customers_email ON crm_customers(email);
-- Criar índice para clientes com acesso ao portal
CREATE INDEX IF NOT EXISTS idx_crm_customers_portal_access ON crm_customers(has_portal_access) WHERE has_portal_access = true;
-- Comentários
COMMENT ON COLUMN crm_customers.password_hash IS 'Hash bcrypt da senha para acesso ao portal do cliente';
COMMENT ON COLUMN crm_customers.has_portal_access IS 'Define se o cliente tem acesso ao portal';
COMMENT ON COLUMN crm_customers.portal_last_login IS 'Último login do cliente no portal';
COMMENT ON COLUMN crm_customers.portal_created_at IS 'Data de criação do acesso ao portal';

View File

@@ -0,0 +1,9 @@
-- Adicionar relacionamento entre campanhas (crm_lists) e clientes (crm_customers)
ALTER TABLE crm_lists
ADD COLUMN customer_id UUID REFERENCES crm_customers(id) ON DELETE CASCADE;
-- Índice para performance
CREATE INDEX IF NOT EXISTS idx_crm_lists_customer_id ON crm_lists(customer_id);
-- Comentário explicativo
COMMENT ON COLUMN crm_lists.customer_id IS 'Cliente ao qual esta campanha pertence.';

View File

@@ -0,0 +1,65 @@
-- Migration: Create CRM funnels and stages
-- Description: Allows agencies to create custom lead monitoring pipelines
CREATE TABLE IF NOT EXISTS crm_funnels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
description TEXT,
is_default BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT unique_funnel_per_tenant UNIQUE (tenant_id, name)
);
CREATE TABLE IF NOT EXISTS crm_funnel_stages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
funnel_id UUID NOT NULL REFERENCES crm_funnels(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
description TEXT,
color VARCHAR(7) DEFAULT '#3b82f6',
order_index INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Add funnel and stage to leads
ALTER TABLE crm_leads
ADD COLUMN funnel_id UUID REFERENCES crm_funnels(id) ON DELETE SET NULL,
ADD COLUMN stage_id UUID REFERENCES crm_funnel_stages(id) ON DELETE SET NULL;
-- Indices
CREATE INDEX idx_crm_funnels_tenant_id ON crm_funnels(tenant_id);
CREATE INDEX idx_crm_funnel_stages_funnel_id ON crm_funnel_stages(funnel_id);
CREATE INDEX idx_crm_leads_funnel_id ON crm_leads(funnel_id);
CREATE INDEX idx_crm_leads_stage_id ON crm_leads(stage_id);
-- Triggers for updated_at
CREATE TRIGGER update_crm_funnels_updated_at BEFORE UPDATE ON crm_funnels
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_crm_funnel_stages_updated_at BEFORE UPDATE ON crm_funnel_stages
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Function to create default funnel for a tenant
CREATE OR REPLACE FUNCTION create_default_crm_funnel(t_id UUID)
RETURNS UUID AS $$
DECLARE
f_id UUID;
BEGIN
INSERT INTO crm_funnels (tenant_id, name, description, is_default)
VALUES (t_id, 'Funil de Vendas Padrão', 'Monitoramento básico de leads', true)
RETURNING id INTO f_id;
INSERT INTO crm_funnel_stages (funnel_id, name, color, order_index) VALUES
(f_id, 'Novo', '#3b82f6', 0),
(f_id, 'Qualificado', '#10b981', 1),
(f_id, 'Proposta', '#f59e0b', 2),
(f_id, 'Negociação', '#8b5cf6', 3),
(f_id, 'Fechado', '#10b981', 4),
(f_id, 'Perdido', '#ef4444', 5);
RETURN f_id;
END;
$$ LANGUAGE plpgsql;

View File

@@ -0,0 +1,8 @@
-- Migration: Link funnels to campaigns (lists)
-- Description: Allows associating a campaign with a specific sales funnel
ALTER TABLE crm_lists
ADD COLUMN funnel_id UUID REFERENCES crm_funnels(id) ON DELETE SET NULL;
-- Index for performance
CREATE INDEX idx_crm_lists_funnel_id ON crm_lists(funnel_id);

View File

@@ -0,0 +1,26 @@
-- Script para configurar o primeiro usuário de uma agência como "owner"
-- Este script deve ser executado manualmente após criar o primeiro usuário
-- Opção 1: Se você conhece o email do usuário, use:
-- UPDATE users
-- SET agency_role = 'owner'
-- WHERE email = 'seu-email@exemplo.com' AND role = 'ADMIN_AGENCIA';
-- Opção 2: Se você quer configurar o primeiro ADMIN_AGENCIA de cada agência como owner:
UPDATE users u1
SET agency_role = 'owner'
WHERE role = 'ADMIN_AGENCIA'
AND agency_role IS NULL
AND u1.id = (
SELECT id FROM users u2
WHERE u2.tenant_id = u1.tenant_id
AND u2.role = 'ADMIN_AGENCIA'
ORDER BY u2.created_at ASC
LIMIT 1
);
-- Verificar resultado
SELECT id, email, role, agency_role, tenant_id
FROM users
WHERE role = 'ADMIN_AGENCIA'
ORDER BY tenant_id, created_at;

View File

@@ -1,137 +0,0 @@
# Scripts de Backup e Manutencao Aggios
## Estrutura
```
scripts/
├── backup-db.ps1 # Backup manual do banco
├── restore-db.ps1 # Restaurar backup mais recente
├── rebuild-safe.ps1 # Rebuild seguro (com backup automatico)
├── setup-backup-agendado.ps1 # Configurar backup automatico a cada 6h
└── reset-superadmin-password.ps1 # Gerar nova senha segura para SUPERADMIN
```
## Como Usar
### 1. Configurar Backup Automatico (EXECUTE PRIMEIRO!)
Execute como **Administrador**:
```powershell
cd g:\Projetos\aggios-app\scripts
.\setup-backup-agendado.ps1
```
Isso criara uma tarefa no Windows que fara backup **automaticamente a cada 6 horas** (00:00, 06:00, 12:00, 18:00).
### 2. Backup Manual
```powershell
cd g:\Projetos\aggios-app\scripts
.\backup-db.ps1
```
Cria um backup em `g:\Projetos\aggios-app\backups\aggios_backup_YYYY-MM-DD_HH-mm-ss.sql`
### 3. Rebuild Seguro (SEMPRE USE ESTE!)
```powershell
cd g:\Projetos\aggios-app\scripts
.\rebuild-safe.ps1
```
**O que faz:**
1. Backup automatico antes de tudo
2. Para containers (SEM `-v`)
3. Reconstroi imagens
4. Sobe tudo novamente
**DADOS NUNCA SAO APAGADOS!**
### 4. Restaurar Backup
Se algo der errado:
```powershell
cd g:\Projetos\aggios-app\scripts
.\restore-db.ps1
```
Restaura o backup mais recente (com confirmacao).
### 5. Resetar Senha do SUPERADMIN
Para gerar uma nova senha super segura:
```powershell
cd g:\Projetos\aggios-app\scripts
.\reset-superadmin-password.ps1
```
**Isso ira:**
- Gerar senha aleatoria de 28 caracteres
- Salvar em arquivo protegido (backup)
- Atualizar no banco de dados
- Exibir a nova senha na tela
**ANOTE A SENHA EXIBIDA!**
## Regras de Ouro
### PODE USAR:
-`.\rebuild-safe.ps1` - Sempre seguro
-`docker-compose down` (sem -v)
-`docker-compose up -d --build`
-`.\backup-db.ps1` - Backup manual
### NUNCA USE:
-`docker-compose down -v` - **APAGA TUDO!**
-`docker volume rm` - Apaga dados permanentemente
## Localizacao dos Backups
- **Pasta:** `g:\Projetos\aggios-app\backups/`
- **Retencao:** Ultimos 10 backups
- **Frequencia automatica:** A cada 6 horas
- **Formato:** `aggios_backup_2025-12-13_19-56-18.sql`
## Verificar Backup Agendado
1. Abra o **Agendador de Tarefas** do Windows
2. Procure por: **"Aggios - Backup Automatico DB"**
3. Verifique historico de execucoes
## Desabilitar Backup Agendado
Execute como Administrador:
```powershell
Unregister-ScheduledTask -TaskName "Aggios - Backup Automatico DB" -Confirm:$false
```
## Em Caso de Emergencia
Se perder dados acidentalmente:
1. **PARE TUDO IMEDIATAMENTE:**
```powershell
docker-compose down
```
2. **Restaure o backup:**
```powershell
cd g:\Projetos\aggios-app\scripts
.\restore-db.ps1
```
3. **Suba os containers:**
```powershell
docker-compose up -d
```
## Historico de Mudancas
- **2025-12-13:** Criacao inicial dos scripts de protecao
- Backup automatico a cada 6h
- Scripts seguros de rebuild
- Restauracao facilitada

View File

@@ -1,32 +0,0 @@
# Backup automatico do banco de dados PostgreSQL
# Execute este script ANTES de qualquer docker-compose down
$BACKUP_DIR = "g:\Projetos\aggios-app\backups"
$TIMESTAMP = Get-Date -Format "yyyy-MM-dd_HH-mm-ss"
$BACKUP_FILE = "$BACKUP_DIR\aggios_backup_$TIMESTAMP.sql"
# Cria diretorio de backup se nao existir
if (!(Test-Path $BACKUP_DIR)) {
New-Item -ItemType Directory -Path $BACKUP_DIR
}
Write-Host "Fazendo backup do banco de dados..." -ForegroundColor Yellow
# Faz o backup
docker exec aggios-postgres pg_dump -U aggios aggios_db > $BACKUP_FILE
if ($LASTEXITCODE -eq 0) {
$fileSize = (Get-Item $BACKUP_FILE).Length / 1KB
Write-Host "Backup criado com sucesso: $BACKUP_FILE ($([math]::Round($fileSize, 2)) KB)" -ForegroundColor Green
# Mantem apenas os ultimos 10 backups
Get-ChildItem $BACKUP_DIR -Filter "aggios_backup_*.sql" |
Sort-Object LastWriteTime -Descending |
Select-Object -Skip 10 |
Remove-Item -Force
Write-Host "Backups antigos limpos (mantidos ultimos 10)" -ForegroundColor Cyan
} else {
Write-Host "Erro ao criar backup!" -ForegroundColor Red
exit 1
}

View File

@@ -1,37 +0,0 @@
# Script SEGURO para rebuild - NUNCA usa -v
# Este script:
# 1. Faz backup automatico
# 2. Para os containers (SEM apagar volumes)
# 3. Reconstroi as imagens
# 4. Sobe tudo de novo
Write-Host "=======================================" -ForegroundColor Cyan
Write-Host " REBUILD SEGURO - Sem perda de dados" -ForegroundColor Cyan
Write-Host "=======================================" -ForegroundColor Cyan
Write-Host ""
# 1. Backup automatico
Write-Host "Passo 1/4: Fazendo backup..." -ForegroundColor Yellow
& "g:\Projetos\aggios-app\scripts\backup-db.ps1"
if ($LASTEXITCODE -ne 0) {
Write-Host "Erro no backup! Abortando." -ForegroundColor Red
exit 1
}
Write-Host ""
Write-Host "Passo 2/4: Parando containers..." -ForegroundColor Yellow
docker-compose down
# NAO USA -v para manter os volumes!
Write-Host ""
Write-Host "Passo 3/4: Reconstruindo imagens..." -ForegroundColor Yellow
docker-compose build
Write-Host ""
Write-Host "Passo 4/4: Subindo containers..." -ForegroundColor Yellow
docker-compose up -d
Write-Host ""
Write-Host "Rebuild concluido com seguranca!" -ForegroundColor Green
Write-Host "Status dos containers:" -ForegroundColor Cyan
docker-compose ps

View File

@@ -1,94 +0,0 @@
# Script para criar senha segura para SUPERADMIN
# Gera senha aleatoria forte e atualiza no banco
Write-Host "Gerando senha segura para SUPERADMIN..." -ForegroundColor Cyan
Write-Host ""
# Gera senha forte (16 caracteres com maiusculas, minusculas, numeros e especiais)
Add-Type -AssemblyName System.Web
$newPassword = [System.Web.Security.Membership]::GeneratePassword(20, 5)
# Garante que tem todos os tipos de caracteres
$newPassword = "Ag@" + $newPassword + "2025!"
Write-Host "Nova senha gerada: $newPassword" -ForegroundColor Green
Write-Host ""
Write-Host "IMPORTANTE: Anote esta senha em local seguro!" -ForegroundColor Yellow
Write-Host ""
# Salva em arquivo criptografado (apenas para emergencia)
$securePasswordFile = "g:\Projetos\aggios-app\backups\.superadmin_password.txt"
$newPassword | Out-File -FilePath $securePasswordFile -Force
Write-Host "Senha salva em: $securePasswordFile" -ForegroundColor Cyan
Write-Host "(Arquivo protegido - acesso restrito)" -ForegroundColor Gray
Write-Host ""
# Gera hash bcrypt usando o backend em execucao
Write-Host "Gerando hash bcrypt..." -ForegroundColor Yellow
# Cria script Go temporario para gerar o hash
$goScript = @"
package main
import (
"fmt"
"golang.org/x/crypto/bcrypt"
)
func main() {
hash, _ := bcrypt.GenerateFromPassword([]byte("$newPassword"), bcrypt.DefaultCost)
fmt.Print(string(hash))
}
"@
# Salva e executa no container
$goScript | Out-File -FilePath "g:\Projetos\aggios-app\backend\temp_hash.go" -Encoding UTF8 -Force
# Copia para o container builder e gera o hash
$hash = docker run --rm -v "g:\Projetos\aggios-app\backend:/app" -w /app golang:1.23-alpine sh -c "go run temp_hash.go"
if ($LASTEXITCODE -eq 0) {
Write-Host "Hash gerado com sucesso!" -ForegroundColor Green
# Cria SQL file para atualizar
$sqlContent = @"
DELETE FROM users WHERE email = 'admin@aggios.app';
INSERT INTO users (id, email, password_hash, first_name, last_name, role, is_active, created_at, updated_at)
VALUES (
gen_random_uuid(),
'admin@aggios.app',
'$hash',
'Super',
'Admin',
'SUPERADMIN',
true,
NOW(),
NOW()
);
SELECT 'Usuario criado com sucesso!' as status;
"@
$sqlFile = "g:\Projetos\aggios-app\backups\.update_superadmin.sql"
$sqlContent | Out-File -FilePath $sqlFile -Encoding UTF8 -Force
# Executa no banco
docker cp $sqlFile aggios-postgres:/tmp/update_admin.sql
docker exec aggios-postgres psql -U aggios aggios_db -f /tmp/update_admin.sql
# Remove arquivo temporario
Remove-Item "g:\Projetos\aggios-app\backend\temp_hash.go" -Force -ErrorAction SilentlyContinue
Remove-Item $sqlFile -Force -ErrorAction SilentlyContinue
Write-Host ""
Write-Host "========================================" -ForegroundColor Green
Write-Host " SENHA ATUALIZADA COM SUCESSO!" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Green
Write-Host ""
Write-Host "Credenciais do SUPERADMIN:" -ForegroundColor Cyan
Write-Host "Email: admin@aggios.app" -ForegroundColor White
Write-Host "Senha: $newPassword" -ForegroundColor White
Write-Host ""
Write-Host "ANOTE ESTA SENHA EM LOCAL SEGURO!" -ForegroundColor Yellow
Write-Host ""
} else {
Write-Host "Erro ao gerar hash!" -ForegroundColor Red
exit 1
}

View File

@@ -1,33 +0,0 @@
# Restaura o backup mais recente do banco de dados
$BACKUP_DIR = "g:\Projetos\aggios-app\backups"
# Encontra o backup mais recente
$latestBackup = Get-ChildItem $BACKUP_DIR -Filter "aggios_backup_*.sql" |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if (!$latestBackup) {
Write-Host "Nenhum backup encontrado em $BACKUP_DIR" -ForegroundColor Red
exit 1
}
Write-Host "Restaurando backup: $($latestBackup.Name)" -ForegroundColor Yellow
Write-Host "Data: $($latestBackup.LastWriteTime)" -ForegroundColor Cyan
# Confirma com o usuario
$confirm = Read-Host "Deseja restaurar este backup? (S/N)"
if ($confirm -ne "S" -and $confirm -ne "s") {
Write-Host "Restauracao cancelada" -ForegroundColor Yellow
exit 0
}
# Restaura o backup
Get-Content $latestBackup.FullName | docker exec -i aggios-postgres psql -U aggios aggios_db
if ($LASTEXITCODE -eq 0) {
Write-Host "Backup restaurado com sucesso!" -ForegroundColor Green
} else {
Write-Host "Erro ao restaurar backup!" -ForegroundColor Red
exit 1
}

View File

@@ -1,38 +0,0 @@
# Script para criar tarefa agendada de backup automatico
# Execute como Administrador
$scriptPath = "g:\Projetos\aggios-app\scripts\backup-db.ps1"
$taskName = "Aggios - Backup Automatico DB"
Write-Host "Configurando backup automatico..." -ForegroundColor Cyan
# Remove tarefa antiga se existir
$existingTask = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
if ($existingTask) {
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false
Write-Host "Tarefa antiga removida" -ForegroundColor Yellow
}
# Cria acao (executar o script)
$action = New-ScheduledTaskAction -Execute "PowerShell.exe" -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$scriptPath`""
# Cria trigger (a cada 6 horas)
$trigger = New-ScheduledTaskTrigger -Daily -At "00:00" -DaysInterval 1
$trigger2 = New-ScheduledTaskTrigger -Daily -At "06:00" -DaysInterval 1
$trigger3 = New-ScheduledTaskTrigger -Daily -At "12:00" -DaysInterval 1
$trigger4 = New-ScheduledTaskTrigger -Daily -At "18:00" -DaysInterval 1
# Configuracoes
$principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType S4U -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
# Registra a tarefa
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger,$trigger2,$trigger3,$trigger4 -Principal $principal -Settings $settings -Description "Backup automatico do banco de dados Aggios a cada 6 horas"
Write-Host ""
Write-Host "Backup automatico configurado!" -ForegroundColor Green
Write-Host "Frequencia: A cada 6 horas (00:00, 06:00, 12:00, 18:00)" -ForegroundColor Cyan
Write-Host "Local dos backups: g:\Projetos\aggios-app\backups" -ForegroundColor Cyan
Write-Host ""
Write-Host "Para verificar: Abra o Agendador de Tarefas do Windows" -ForegroundColor Yellow
Write-Host "Para desabilitar: Run com Administrador: Unregister-ScheduledTask -TaskName '$taskName' -Confirm:`$false" -ForegroundColor Yellow

View File

@@ -1,55 +0,0 @@
# Script para adicionar domínios locais ao arquivo hosts
# Execute como Administrador
$hostsFile = "C:\Windows\System32\drivers\etc\hosts"
$domains = @(
"127.0.0.1 dash.localhost",
"127.0.0.1 aggios.local",
"127.0.0.1 api.localhost",
"127.0.0.1 files.localhost",
"127.0.0.1 agency.localhost"
)
Write-Host "=== Configurando arquivo hosts para Aggios ===" -ForegroundColor Cyan
Write-Host ""
# Verificar se já existem as entradas
$hostsContent = Get-Content $hostsFile -ErrorAction SilentlyContinue
$needsUpdate = $false
foreach ($domain in $domains) {
$domainName = $domain.Split()[1]
if ($hostsContent -notmatch $domainName) {
Write-Host "✓ Adicionando: $domain" -ForegroundColor Green
$needsUpdate = $true
} else {
Write-Host "→ Já existe: $domainName" -ForegroundColor Yellow
}
}
if ($needsUpdate) {
Write-Host ""
Write-Host "Adicionando entradas ao arquivo hosts..." -ForegroundColor Cyan
$newContent = @()
$newContent += "`n# === Aggios Local Development ==="
$newContent += $domains
$newContent += "# === Fim Aggios ===`n"
Add-Content -Path $hostsFile -Value ($newContent -join "`n")
Write-Host ""
Write-Host "✓ Arquivo hosts atualizado com sucesso!" -ForegroundColor Green
Write-Host ""
Write-Host "Você pode agora acessar:" -ForegroundColor Cyan
Write-Host " • Dashboard: http://dash.localhost/cadastro" -ForegroundColor White
Write-Host " • API: http://api.localhost/api/health" -ForegroundColor White
Write-Host " • Institucional: http://aggios.local" -ForegroundColor White
} else {
Write-Host ""
Write-Host "✓ Todas as entradas já estão configuradas!" -ForegroundColor Green
}
Write-Host ""
Write-Host "Pressione qualquer tecla para continuar..."
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")