5 Commits

Author SHA1 Message Date
Erik Silva
adbff9bb1e fix(erp): enable erp pages and menu items 2025-12-29 17:23:59 -03:00
Erik Silva
e124a64a5d docs: adicionar solucoes alpha ERP e Documentos no README 2025-12-29 15:59:13 -03:00
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
14079 changed files with 1129273 additions and 1518 deletions

74
.agent/agent-gemini.md Normal file
View File

@@ -0,0 +1,74 @@
# Agent Gemini - Log de Evolução do Projeto Aggios
Este arquivo documenta as contribuições do Agente Code AI (Gemini) e a compreensão técnica consolidada sobre o ecossistema Aggios.
## 🚀 Visão Geral do Projeto
O **Aggios** é uma plataforma SaaS multi-tenant focada em agências, oferecendo uma suíte "all-in-one" que inclui CRM, ERP, Gestão de Projetos, entre outros.
### Stack Tecnológica
- **Frontend:** Next.js (App Router), TypeScript, Tailwind CSS, Headless UI.
- **Backend:** Go (Golang) com roteamento `gorilla/mux`.
- **Banco de Dados:** PostgreSQL (migrações SQL puras).
- **Infraestrutura:** Docker Compose (backend, agency-frontend, minio, postgres, redis).
---
## 🛠️ Contribuições do Agente (Dezembro/2025)
### 1. Módulo ERP - Finanças & Caixa
- **Gestão de Múltiplas Contas:** Implementação completa (CRUD) de contas bancárias no backend e frontend.
- **Controle de Saldo em Tempo Real:** Desenvolvimento da lógica de repositório em Go para atualizar o `current_balance` de contas baseando-se no status das transações financeiras (`paid`, `pending`).
- **Resumo Financeiro:** Refatoração dos cartões de estatísticas para exibir o "Saldo de Caixa" real (somatório de contas) em vez de apenas totais de lançamentos filtrados.
- **Dashboard ERP Real:** Dados reais, gráficos automáticos e filtros de status/data avançados.
- **Módulo de Documentos:** Implementado sistema de documentos (estilo Google Docs) com editor de texto e gestão por tenant.
### 2. UI/UX & Design System (Padrão Aggios)
- **Refinação Minimalista Flat:** Aplicação do padrão visual "Clean & Flat" na página de finanças, removendo sombras pesadas e mantendo foco no contraste e tipografia.
- **Componentização:** Utilização e refinamento de componentes em `components/ui` (Input, CustomSelect, DataTable).
- **Barra de Busca:** Implementação de busca reativa integrada ao `Input` padronizado.
### 3. Otimização e Reatividade
- **Correção de Cache da API:** Configuração de `cache: 'no-store'` nas chamadas de serviço para garantir integridade dos dados sem necessidade de recarregar a página (F5).
- **Sync de Estado:** Ajuste nos handlers do React para usar `await fetchData()` em todas as operações de escrita, garantindo que a UI reflita as mudanças do backend instantaneamente.
### 4. Novas Funcionalidades (27 de Dezembro de 2025)
- **Ações em Lote (Bulk Actions):** Implementação de seleção múltipla em transações financeiras e produtos. Adição de barra de ações flutuante para exclusão em massa e atualização de status coletiva.
- **Melhorias no Dashboard & Filtros:** Refinamento dos filtros de data, busca reativa e integração de ações em lote nos módulos de "Contas a Pagar" e "Contas a Receber".
- **Gestão de Contas Bancárias:** Refatoração da interface de contas (cards) com feedback visual de saldo e integração direta com o fluxo de caixa.
---
## 🧠 Entendimento Técnico do Sistema
### Arquitetura de Soluções
O sistema utiliza um sistema de **Solutions** vinculadas a **Planos**.
- Slugs identificados: `crm`, `erp`, `projetos`, `helpdesk`, `pagamentos`, `contratos`, `documentos`, `social`.
- O acesso é controlado via `SolutionGuard` no frontend e middleware de tenant no backend.
### Estrutura de Autenticação
- **Níveis de Acesso:** `SUPERADMIN` (Aggios), `ADMIN_AGENCIA` (Dono da agência/Tenant), `CLIENTE` (Portal do Cliente).
- **Segurança:** JWT armazenado no `localStorage` com envio no header `Authorization`.
### Padrão de Design "Aggios Pattern"
- **Cards:** Bordas sutis (`zinc-100/800`), sem sombras, `rounded-2xl` ou `[32px]`.
- **Botões:** Uso de gradientes (`var(--gradient)`) para ações primárias e visual flat para secundárias.
- **Feedback:** Uso intensivo de `react-hot-toast` para notificações de sucesso/erro.
---
## 🛠️ Diretrizes de Desenvolvimento
### 📋 Uso de Templates e Padronização
Para manter a consistência visual e técnica do ecossistema Aggios, o Agente deve seguir rigorosamente estas regras:
1. **Aggios App Pattern:** Sempre basear novas telas e funcionalidades no workflow `aggios-app-pattern.md`. Isso garante que a hierarquia visual (PageHeader -> StatsCards -> Tabs -> DataTable) seja preservada.
2. **Componentes UI Reutilizáveis:** Nunca criar elementos de interface ad-hoc se existir um componente correspondente em `components/ui`. Priorizar o uso de:
- `DataTable` para listagens.
- `Input` e `CustomSelect` para formulários e buscas.
- `StatsCard` para indicadores numéricos e financeiros.
3. **Visual Minimalista Flat:** Evitar o uso de sombras (`shadow`), utilizando bordas sutis (`border-zinc-100` / `dark:border-zinc-800`) e fundos contrastantes para separar camadas.
4. **Reatividade Garantida:** Manter o padrão de execução assíncrona com `await fetchData()` e desativação de cache da API para que os templates reflitam mudanças instantaneamente sem recarregar a página.
5. **Rebuild de Containers:** Sempre que houver mudanças estruturais no frontend (especialmente no `front-end-agency`), é necessário rodar `docker-compose up -d --build agency` para refletir as alterações no ambiente de produção/Docker.
---
*Documentado por Gemini (Agent Gemini) em 27 de Dezembro de 2025.*

View File

@@ -0,0 +1,117 @@
---
description: Padrão de Design Aggios App para Páginas de Listagem e Dashboards
---
# Padrão de Design Aggios App
Este workflow descreve como construir uma página seguindo o design system da Aggios, utilizando os componentes padronizados na pasta `components/ui`.
## 1. Estrutura Básica da Página
Toda página deve ser envolvida por um container com padding e largura máxima:
```tsx
<div className="p-6 max-w-[1600px] mx-auto space-y-6">
{/* Conteúdo aqui */}
</div>
```
## 2. Cabeçalho (`PageHeader`)
Utilize o `PageHeader` para títulos, descrições e ações globais da página.
```tsx
<PageHeader
title="Título da Página"
description="Breve descrição da funcionalidade."
primaryAction={{
label: "Novo Item",
icon: <PlusIcon className="w-4 h-4" />,
onClick: () => handleCreate()
}}
/>
```
## 3. Cartões de Estatísticas (`StatsCard`)
Para dashboards ou resumos, utilize o grid de stats:
```tsx
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
<StatsCard
title="Métrica"
value="R$ 1.000"
icon={<CurrencyDollarIcon className="w-6 h-6" />}
trend={{ value: '10%', label: 'vs ontem', type: 'up' }}
/>
</div>
```
## 4. Filtros e Pesquisa (Minimalista Flat)
Os filtros não devem ter sombras nem cores de marca no estado inicial/focus. Devem usar um visual "Clean" com contraste sólido.
```tsx
<div className="flex flex-col md:flex-row gap-4 items-center">
<div className="flex-1 w-full">
<Input
placeholder="Pesquisar..."
leftIcon={<MagnifyingGlassIcon className="w-5 h-5 text-zinc-400" />}
className="bg-white dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800 focus:border-zinc-400 dark:focus:border-zinc-500"
/>
</div>
<div className="w-full md:w-80">
<DatePicker
value={dateRange}
onChange={setDateRange}
buttonClassName="bg-white dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800 text-zinc-700 dark:text-zinc-300 hover:border-zinc-400"
/>
</div>
<div className="w-full md:w-56">
<CustomSelect
value={status}
onChange={setStatus}
options={[
{ label: 'Todos', value: 'all' },
{ label: 'Ativo', value: 'active', color: 'bg-emerald-500' },
]}
buttonClassName="bg-white dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800 hover:border-zinc-400"
/>
</div>
</div>
```
## 5. Abas e Tabelas (`Tabs` & `DataTable`)
Para organizar o conteúdo principal, utilize o componente `Tabs`. Dentro de cada aba, utilize `Card` com `noPadding` para envolver a `DataTable`.
```tsx
<Tabs
variant="pills" // ou 'underline'
items={[
{
label: 'Listagem',
icon: <TableIcon />,
content: (
<Card noPadding title="Itens" description="Gerenciamento de registros.">
<DataTable
columns={COLUMNS}
data={DATA}
pagination={{ ... }}
/>
</Card>
)
}
]}
/>
```
## Regras de Estilo e Cores
- **Botões Primários**: Sempre use `variant="primary"` e aplique o gradiente via style/classe: `style={{ background: 'var(--gradient)' }} className="shadow-lg shadow-brand-500/20"`.
- **Bordas**: Use `border-zinc-200` para light mode e `dark:border-zinc-800` para dark mode.
- **Backgrounds**: Use `bg-white` (light) e `dark:bg-zinc-900` (dark) para componentes elevados.
- **Cards & Containers (Flat Design)**:
- **Cards:** Fundo branco/zinc-900, bordas sutis (`border-zinc-200` / `dark:border-zinc-800`), **SEM SOMBRAS**.
- **Border Radius:** Usar `rounded-2xl` (16px) ou `rounded-[32px]` para containers grandes.
- **StatsCards:** Texto de valor em `font-bold` ou `font-black`, ícones em boxes coloridos com opacidade 10% no dark mode.
- **Hover:** Apenas transições de cor ou escalas sutis, evitar sombras no hover.

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"
}
]
}

106
README.md
View File

@@ -5,18 +5,74 @@ 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 Soluções Alpha (ERP e Documentos), 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
### **v2.0 - Alpha: CRM, ERP e Documentos (29/12/2025)**
- **🏢 ERP Alpha**:
- Módulo inicial de gestão empresarial integrado ao dashboard
- Estrutura base para controle financeiro e operacional
- **📄 Gestão de Documentos (Docs) Alpha**:
- Repositório centralizado de arquivos por tenant
- Organização de documentos técnicos, comerciais e operacionais
- Visualização integrada no painel da agência
- **🚀 CRM Evolução**:
- Refinamento dos fluxos de leads e funis
- Preparação para automações de vendas
### **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 +125,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 +144,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 +209,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: 2.0-crm-erp-doc (v2.0 - Soluções Alpha ERP e Documentos + CRM)

View File

@@ -60,9 +60,11 @@ func main() {
subscriptionRepo := repository.NewSubscriptionRepository(db) subscriptionRepo := repository.NewSubscriptionRepository(db)
crmRepo := repository.NewCRMRepository(db) crmRepo := repository.NewCRMRepository(db)
solutionRepo := repository.NewSolutionRepository(db) solutionRepo := repository.NewSolutionRepository(db)
erpRepo := repository.NewERPRepository(db)
docRepo := repository.NewDocumentRepository(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 +75,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 +84,9 @@ 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)
erpHandler := handlers.NewERPHandler(erpRepo)
docHandler := handlers.NewDocumentHandler(docRepo)
// Initialize upload handler // Initialize upload handler
uploadHandler, err := handlers.NewUploadHandler(cfg) uploadHandler, err := handlers.NewUploadHandler(cfg)
@@ -112,7 +118,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 +140,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 +253,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 +298,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 +310,251 @@ 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")
// ==================== ERP ROUTES (TENANT) ====================
// Finance
router.Handle("/api/erp/finance/categories", middleware.RequireAgencyUser(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
erpHandler.GetFinancialCategories(w, r)
case http.MethodPost:
erpHandler.CreateFinancialCategory(w, r)
}
}))).Methods("GET", "POST")
router.Handle("/api/erp/finance/accounts", middleware.RequireAgencyUser(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
erpHandler.GetBankAccounts(w, r)
case http.MethodPost:
erpHandler.CreateBankAccount(w, r)
}
}))).Methods("GET", "POST")
router.Handle("/api/erp/finance/accounts/{id}", middleware.RequireAgencyUser(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPut:
erpHandler.UpdateBankAccount(w, r)
case http.MethodDelete:
erpHandler.DeleteBankAccount(w, r)
}
}))).Methods("PUT", "DELETE")
router.Handle("/api/erp/finance/transactions", middleware.RequireAgencyUser(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
erpHandler.GetTransactions(w, r)
case http.MethodPost:
erpHandler.CreateTransaction(w, r)
}
}))).Methods("GET", "POST")
router.Handle("/api/erp/finance/transactions/{id}", middleware.RequireAgencyUser(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPut:
erpHandler.UpdateTransaction(w, r)
case http.MethodDelete:
erpHandler.DeleteTransaction(w, r)
}
}))).Methods("PUT", "DELETE")
// Products
router.Handle("/api/erp/products", middleware.RequireAgencyUser(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
erpHandler.GetProducts(w, r)
case http.MethodPost:
erpHandler.CreateProduct(w, r)
}
}))).Methods("GET", "POST")
router.Handle("/api/erp/products/{id}", middleware.RequireAgencyUser(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPut:
erpHandler.UpdateProduct(w, r)
case http.MethodDelete:
erpHandler.DeleteProduct(w, r)
}
}))).Methods("PUT", "DELETE")
// Orders
router.Handle("/api/erp/orders", middleware.RequireAgencyUser(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
erpHandler.GetOrders(w, r)
case http.MethodPost:
erpHandler.CreateOrder(w, r)
}
}))).Methods("GET", "POST")
router.Handle("/api/erp/orders/{id}", middleware.RequireAgencyUser(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodDelete:
erpHandler.DeleteOrder(w, r)
}
}))).Methods("DELETE")
// Entities
router.Handle("/api/erp/entities", middleware.RequireAgencyUser(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
erpHandler.GetEntities(w, r)
case http.MethodPost:
erpHandler.CreateEntity(w, r)
}
}))).Methods("GET", "POST")
router.Handle("/api/erp/entities/{id}", middleware.RequireAgencyUser(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPut, http.MethodPatch:
erpHandler.UpdateEntity(w, r)
case http.MethodDelete:
erpHandler.DeleteEntity(w, r)
}
}))).Methods("PUT", "PATCH", "DELETE")
// Documents
router.Handle("/api/documents", middleware.RequireAgencyUser(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
docHandler.List(w, r)
case http.MethodPost:
docHandler.Create(w, r)
}
}))).Methods("GET", "POST")
router.Handle("/api/documents/{id}", middleware.RequireAgencyUser(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
docHandler.Get(w, r)
case http.MethodPut:
docHandler.Update(w, r)
case http.MethodDelete:
docHandler.Delete(w, r)
}
}))).Methods("GET", "PUT", "DELETE")
router.Handle("/api/documents/{id}/subpages", middleware.RequireAgencyUser(cfg)(http.HandlerFunc(docHandler.GetSubpages))).Methods("GET")
router.Handle("/api/documents/{id}/activities", middleware.RequireAgencyUser(cfg)(http.HandlerFunc(docHandler.GetActivities))).Methods("GET")
// 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

@@ -5,7 +5,32 @@ go 1.23
require ( require (
github.com/golang-jwt/jwt/v5 v5.2.0 github.com/golang-jwt/jwt/v5 v5.2.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1
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/shopspring/decimal v1.3.1
github.com/xuri/excelize/v2 v2.8.1
golang.org/x/crypto v0.27.0 golang.org/x/crypto v0.27.0
) )
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.16.7 // indirect
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/minio/sha256-simd v1.0.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/richardlehane/mscfb v1.0.4 // indirect
github.com/richardlehane/msoleps v1.0.3 // indirect
github.com/rs/xid v1.5.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53 // indirect
github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05 // indirect
golang.org/x/net v0.21.0 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/text v0.18.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
)

View File

@@ -1,8 +1,76 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.63 h1:GbZ2oCvaUdgT5640WJOpyDhhDxvknAJU2/T3yurwcbQ=
github.com/minio/minio-go/v7 v7.0.63/go.mod h1:Q6X7Qjb7WMhvG65qKf4gUgA5XaiSox74kR1uAEjxRS4=
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/richardlehane/msoleps v1.0.3 h1:aznSZzrwYRl3rLKRT3gUk9am7T/mLNSnJINvN0AQoVM=
github.com/richardlehane/msoleps v1.0.3/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53 h1:Chd9DkqERQQuHpXjR/HSV1jLZA6uaoiwwH3vSuF3IW0=
github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.8.1 h1:pZLMEwK8ep+CLIUWpWmvW8IWE/yxqG0I1xcN6cVMGuQ=
github.com/xuri/excelize/v2 v2.8.1/go.mod h1:oli1E4C3Pa5RXg1TBXn4ENCXDV5JUMlBluUhG7c+CEE=
github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05 h1:qhbILQo1K3mphbwKh1vNm4oGezE1eF9fQWmNiIpSfI4=
github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

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,144 @@
package handlers
import (
"aggios-app/backend/internal/api/middleware"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/repository"
"encoding/json"
"net/http"
"github.com/google/uuid"
"github.com/gorilla/mux"
)
type DocumentHandler struct {
repo *repository.DocumentRepository
}
func NewDocumentHandler(repo *repository.DocumentRepository) *DocumentHandler {
return &DocumentHandler{repo: repo}
}
func (h *DocumentHandler) Create(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
userID, _ := r.Context().Value(middleware.UserIDKey).(string)
var doc domain.Document
if err := json.NewDecoder(r.Body).Decode(&doc); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
doc.ID = uuid.New()
doc.TenantID, _ = uuid.Parse(tenantID)
doc.CreatedBy, _ = uuid.Parse(userID)
doc.LastUpdatedBy, _ = uuid.Parse(userID)
if doc.Status == "" {
doc.Status = "draft"
}
if err := h.repo.Create(&doc); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(doc)
}
func (h *DocumentHandler) List(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
docs, err := h.repo.GetByTenant(tenantID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(docs)
}
func (h *DocumentHandler) Get(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
id := mux.Vars(r)["id"]
doc, err := h.repo.GetByID(id, tenantID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if doc == nil {
http.Error(w, "document not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(doc)
}
func (h *DocumentHandler) Update(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
userID, _ := r.Context().Value(middleware.UserIDKey).(string)
id := mux.Vars(r)["id"]
var doc domain.Document
if err := json.NewDecoder(r.Body).Decode(&doc); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
doc.ID, _ = uuid.Parse(id)
doc.TenantID, _ = uuid.Parse(tenantID)
doc.LastUpdatedBy, _ = uuid.Parse(userID)
if err := h.repo.Update(&doc); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(doc)
}
func (h *DocumentHandler) Delete(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
id := mux.Vars(r)["id"]
if err := h.repo.Delete(id, tenantID); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func (h *DocumentHandler) GetSubpages(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
parentID := mux.Vars(r)["id"]
docs, err := h.repo.GetSubpages(parentID, tenantID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(docs)
}
func (h *DocumentHandler) GetActivities(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
id := mux.Vars(r)["id"]
activities, err := h.repo.GetActivities(id, tenantID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(activities)
}

View File

@@ -0,0 +1,399 @@
package handlers
import (
"aggios-app/backend/internal/api/middleware"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/repository"
"encoding/json"
"log"
"net/http"
"time"
"github.com/google/uuid"
"github.com/gorilla/mux"
)
type ERPHandler struct {
repo *repository.ERPRepository
}
func NewERPHandler(repo *repository.ERPRepository) *ERPHandler {
return &ERPHandler{repo: repo}
}
// ==================== FINANCE ====================
func (h *ERPHandler) CreateFinancialCategory(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
var cat domain.FinancialCategory
if err := json.NewDecoder(r.Body).Decode(&cat); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
cat.ID = uuid.New()
cat.TenantID, _ = uuid.Parse(tenantID)
cat.IsActive = true
if err := h.repo.CreateFinancialCategory(&cat); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(cat)
}
func (h *ERPHandler) GetFinancialCategories(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
cats, err := h.repo.GetFinancialCategoriesByTenant(tenantID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(cats)
}
func (h *ERPHandler) CreateBankAccount(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
var acc domain.BankAccount
if err := json.NewDecoder(r.Body).Decode(&acc); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
acc.ID = uuid.New()
acc.TenantID, _ = uuid.Parse(tenantID)
acc.IsActive = true
if err := h.repo.CreateBankAccount(&acc); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(acc)
}
func (h *ERPHandler) GetBankAccounts(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
accs, err := h.repo.GetBankAccountsByTenant(tenantID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(accs)
}
func (h *ERPHandler) CreateTransaction(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
userID, _ := r.Context().Value(middleware.UserIDKey).(string)
var t domain.FinancialTransaction
if err := json.NewDecoder(r.Body).Decode(&t); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
t.ID = uuid.New()
t.TenantID, _ = uuid.Parse(tenantID)
t.CreatedBy, _ = uuid.Parse(userID)
if err := h.repo.CreateTransaction(&t); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(t)
}
func (h *ERPHandler) GetTransactions(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
txs, err := h.repo.GetTransactionsByTenant(tenantID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(txs)
}
// ==================== PRODUCTS ====================
func (h *ERPHandler) CreateProduct(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
var p domain.Product
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
p.ID = uuid.New()
p.TenantID, _ = uuid.Parse(tenantID)
p.IsActive = true
if err := h.repo.CreateProduct(&p); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(p)
}
func (h *ERPHandler) GetProducts(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
products, err := h.repo.GetProductsByTenant(tenantID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(products)
}
// ==================== ORDERS ====================
type createOrderRequest struct {
Order domain.Order `json:"order"`
Items []domain.OrderItem `json:"items"`
}
func (h *ERPHandler) CreateOrder(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
userID, _ := r.Context().Value(middleware.UserIDKey).(string)
var req createOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
req.Order.ID = uuid.New()
req.Order.TenantID, _ = uuid.Parse(tenantID)
req.Order.CreatedBy, _ = uuid.Parse(userID)
if req.Order.Status == "" {
req.Order.Status = "draft"
}
for i := range req.Items {
req.Items[i].ID = uuid.New()
req.Items[i].OrderID = req.Order.ID
req.Items[i].CreatedAt = time.Now()
}
if err := h.repo.CreateOrder(&req.Order, req.Items); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(req.Order)
}
func (h *ERPHandler) GetOrders(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
orders, err := h.repo.GetOrdersByTenant(tenantID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(orders)
}
// ==================== ENTITIES ====================
func (h *ERPHandler) CreateEntity(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
var e domain.Entity
if err := json.NewDecoder(r.Body).Decode(&e); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
e.ID = uuid.New()
e.TenantID, _ = uuid.Parse(tenantID)
if e.Status == "" {
e.Status = "active"
}
if err := h.repo.CreateEntity(&e); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(e)
}
func (h *ERPHandler) GetEntities(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
entityType := r.URL.Query().Get("type") // customer or supplier
entities, err := h.repo.GetEntitiesByTenant(tenantID, entityType)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(entities)
}
func (h *ERPHandler) UpdateTransaction(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
idStr := mux.Vars(r)["id"]
id, err := uuid.Parse(idStr)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
var t domain.FinancialTransaction
if err := json.NewDecoder(r.Body).Decode(&t); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
t.ID = id
t.TenantID, _ = uuid.Parse(tenantID)
if err := h.repo.UpdateTransaction(&t); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func (h *ERPHandler) DeleteTransaction(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
idStr := mux.Vars(r)["id"]
if err := h.repo.DeleteTransaction(idStr, tenantID); err != nil {
log.Printf("❌ Error deleting transaction: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func (h *ERPHandler) UpdateEntity(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
idStr := mux.Vars(r)["id"]
id, err := uuid.Parse(idStr)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
var e domain.Entity
if err := json.NewDecoder(r.Body).Decode(&e); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
e.ID = id
e.TenantID, _ = uuid.Parse(tenantID)
if err := h.repo.UpdateEntity(&e); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func (h *ERPHandler) DeleteEntity(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
idStr := mux.Vars(r)["id"]
if err := h.repo.DeleteEntity(idStr, tenantID); err != nil {
log.Printf("❌ Error deleting entity: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func (h *ERPHandler) UpdateProduct(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
idStr := mux.Vars(r)["id"]
id, err := uuid.Parse(idStr)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
var p domain.Product
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
p.ID = id
p.TenantID, _ = uuid.Parse(tenantID)
if err := h.repo.UpdateProduct(&p); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func (h *ERPHandler) DeleteProduct(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
idStr := mux.Vars(r)["id"]
if err := h.repo.DeleteProduct(idStr, tenantID); err != nil {
log.Printf("❌ Error deleting product: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func (h *ERPHandler) UpdateBankAccount(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
idStr := mux.Vars(r)["id"]
id, err := uuid.Parse(idStr)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
var a domain.BankAccount
if err := json.NewDecoder(r.Body).Decode(&a); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
a.ID = id
a.TenantID, _ = uuid.Parse(tenantID)
if err := h.repo.UpdateBankAccount(&a); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func (h *ERPHandler) DeleteBankAccount(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
idStr := mux.Vars(r)["id"]
if err := h.repo.DeleteBankAccount(idStr, tenantID); err != nil {
log.Printf("❌ Error deleting bank account: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func (h *ERPHandler) DeleteOrder(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
idStr := mux.Vars(r)["id"]
if err := h.repo.DeleteOrder(idStr, tenantID); err != nil {
log.Printf("❌ Error deleting order: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}

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,93 @@
-- Migration: 025_create_erp_tables.sql
-- Description: Create tables for Finance, Inventory, and Order management
-- Financial Categories
CREATE TABLE IF NOT EXISTS erp_financial_categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
type VARCHAR(20) NOT NULL CHECK (type IN ('income', 'expense')),
color VARCHAR(20),
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Bank Accounts
CREATE TABLE IF NOT EXISTS erp_bank_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
bank_name VARCHAR(255),
initial_balance DECIMAL(15,2) DEFAULT 0.00,
current_balance DECIMAL(15,2) DEFAULT 0.00,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Financial Transactions
CREATE TABLE IF NOT EXISTS erp_financial_transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
account_id UUID REFERENCES erp_bank_accounts(id),
category_id UUID REFERENCES erp_financial_categories(id),
description TEXT,
amount DECIMAL(15,2) NOT NULL,
type VARCHAR(20) NOT NULL CHECK (type IN ('income', 'expense')),
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'paid', 'cancelled')),
due_date DATE,
payment_date TIMESTAMP WITH TIME ZONE,
attachments TEXT[], -- URLs for proofs
created_by UUID REFERENCES users(id),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Products & Services
CREATE TABLE IF NOT EXISTS erp_products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
sku VARCHAR(100),
description TEXT,
price DECIMAL(15,2) NOT NULL,
cost_price DECIMAL(15,2),
type VARCHAR(20) DEFAULT 'product' CHECK (type IN ('product', 'service')),
stock_quantity INT DEFAULT 0,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Orders
CREATE TABLE IF NOT EXISTS erp_orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
customer_id UUID REFERENCES companies(id), -- Linked to CRM (companies)
status VARCHAR(20) NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'confirmed', 'completed', 'cancelled')),
total_amount DECIMAL(15,2) DEFAULT 0.00,
notes TEXT,
created_by UUID REFERENCES users(id),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Order Items
CREATE TABLE IF NOT EXISTS erp_order_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID NOT NULL REFERENCES erp_orders(id) ON DELETE CASCADE,
product_id UUID NOT NULL REFERENCES erp_products(id),
quantity INT NOT NULL DEFAULT 1,
unit_price DECIMAL(15,2) NOT NULL,
total_price DECIMAL(15,2) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Indexes for performance and multi-tenancy
CREATE INDEX idx_erp_fin_cat_tenant ON erp_financial_categories(tenant_id);
CREATE INDEX idx_erp_bank_acc_tenant ON erp_bank_accounts(tenant_id);
CREATE INDEX idx_erp_fin_trans_tenant ON erp_financial_transactions(tenant_id);
CREATE INDEX idx_erp_products_tenant ON erp_products(tenant_id);
CREATE INDEX idx_erp_orders_tenant ON erp_orders(tenant_id);
CREATE INDEX idx_erp_order_items_order ON erp_order_items(order_id);

View File

@@ -0,0 +1,32 @@
-- Migration: 026_create_erp_entities.sql
-- Description: Create tables for Customers and Suppliers in ERP
-- ERP Entities (Customers and Suppliers)
CREATE TABLE IF NOT EXISTS erp_entities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
document VARCHAR(20), -- CPF/CNPJ
email VARCHAR(255),
phone VARCHAR(20),
type VARCHAR(20) NOT NULL CHECK (type IN ('customer', 'supplier', 'both')),
status VARCHAR(20) DEFAULT 'active',
address TEXT,
city VARCHAR(100),
state VARCHAR(2),
zip VARCHAR(10),
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Update Financial Transactions to link with Entities
ALTER TABLE erp_financial_transactions ADD COLUMN IF NOT EXISTS entity_id UUID REFERENCES erp_entities(id);
-- Update Orders to link with Entities instead of companies (optional but more consistent for ERP)
-- Keep customer_id for now to avoid breaking existing logic, but allow entity_id
ALTER TABLE erp_orders ADD COLUMN IF NOT EXISTS entity_id UUID REFERENCES erp_entities(id);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_erp_entities_tenant ON erp_entities(tenant_id);
CREATE INDEX IF NOT EXISTS idx_erp_entities_type ON erp_entities(type);

View File

@@ -0,0 +1,4 @@
-- Migration: 027_add_payment_method_to_transactions.sql
-- Description: Add payment_method field to financial transactions
ALTER TABLE erp_financial_transactions ADD COLUMN IF NOT EXISTS payment_method VARCHAR(50);

View File

@@ -0,0 +1,5 @@
-- Migration: 028_add_crm_links_to_transactions.sql
-- Description: Add fields to link financial transactions to CRM Customers and Companies
ALTER TABLE erp_financial_transactions ADD COLUMN IF NOT EXISTS crm_customer_id UUID REFERENCES crm_customers(id) ON DELETE SET NULL;
ALTER TABLE erp_financial_transactions ADD COLUMN IF NOT EXISTS company_id UUID REFERENCES companies(id) ON DELETE SET NULL;

View File

@@ -0,0 +1,15 @@
-- Migration: 029_create_documents_table.sql
-- Description: Create table for text documents (Google Docs style)
CREATE TABLE IF NOT EXISTS documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
content TEXT,
status VARCHAR(50) DEFAULT 'draft',
created_by UUID REFERENCES users(id),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_documents_tenant_id ON documents(tenant_id);

View File

@@ -0,0 +1,22 @@
-- Migration: 030_add_subpages_and_activities_to_documents.sql
-- Description: Add parent_id for subpages and tracking columns (Fixed)
ALTER TABLE documents
ADD COLUMN IF NOT EXISTS parent_id UUID REFERENCES documents(id) ON DELETE CASCADE,
ADD COLUMN IF NOT EXISTS last_updated_by UUID REFERENCES users(id),
ADD COLUMN IF NOT EXISTS version INTEGER DEFAULT 1;
CREATE INDEX IF NOT EXISTS idx_documents_parent_id ON documents(parent_id);
-- Simple activity log table
CREATE TABLE IF NOT EXISTS document_activities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id),
action VARCHAR(50) NOT NULL, -- 'created', 'updated', 'deleted', 'status_change'
description TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_doc_activities_doc_id ON document_activities(document_id);

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

@@ -0,0 +1,32 @@
package domain
import (
"time"
"github.com/google/uuid"
)
type Document struct {
ID uuid.UUID `json:"id" db:"id"`
TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"`
ParentID *uuid.UUID `json:"parent_id" db:"parent_id"`
Title string `json:"title" db:"title"`
Content string `json:"content" db:"content"` // JSON for blocks
Status string `json:"status" db:"status"` // draft, published
CreatedBy uuid.UUID `json:"created_by" db:"created_by"`
LastUpdatedBy uuid.UUID `json:"last_updated_by" db:"last_updated_by"`
Version int `json:"version" db:"version"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type DocumentActivity struct {
ID uuid.UUID `json:"id" db:"id"`
DocumentID uuid.UUID `json:"document_id" db:"document_id"`
TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"`
UserID uuid.UUID `json:"user_id" db:"user_id"`
UserName string `json:"user_name" db:"user_name"` // For join
Action string `json:"action" db:"action"`
Description string `json:"description" db:"description"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}

View File

@@ -0,0 +1,115 @@
package domain
import (
"time"
"github.com/google/uuid"
"github.com/shopspring/decimal"
)
// FinancialCategory represents a category for income or expenses
type FinancialCategory struct {
ID uuid.UUID `json:"id" db:"id"`
TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"`
Name string `json:"name" db:"name"`
Type string `json:"type" db:"type"` // income, expense
Color string `json:"color" db:"color"`
IsActive bool `json:"is_active" db:"is_active"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// BankAccount represents a financial account in the agency
type BankAccount struct {
ID uuid.UUID `json:"id" db:"id"`
TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"`
Name string `json:"name" db:"name"`
BankName string `json:"bank_name" db:"bank_name"`
InitialBalance decimal.Decimal `json:"initial_balance" db:"initial_balance"`
CurrentBalance decimal.Decimal `json:"current_balance" db:"current_balance"`
IsActive bool `json:"is_active" db:"is_active"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// Entity represents a customer or supplier in the ERP
type Entity struct {
ID uuid.UUID `json:"id" db:"id"`
TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"`
Name string `json:"name" db:"name"`
Document string `json:"document" db:"document"`
Email string `json:"email" db:"email"`
Phone string `json:"phone" db:"phone"`
Type string `json:"type" db:"type"` // customer, supplier, both
Status string `json:"status" db:"status"`
Address string `json:"address" db:"address"`
City string `json:"city" db:"city"`
State string `json:"state" db:"state"`
Zip string `json:"zip" db:"zip"`
Notes string `json:"notes" db:"notes"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// FinancialTransaction represents a single financial movement
type FinancialTransaction struct {
ID uuid.UUID `json:"id" db:"id"`
TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"`
AccountID *uuid.UUID `json:"account_id" db:"account_id"`
CategoryID *uuid.UUID `json:"category_id" db:"category_id"`
EntityID *uuid.UUID `json:"entity_id" db:"entity_id"`
CRMCustomerID *uuid.UUID `json:"crm_customer_id" db:"crm_customer_id"`
CompanyID *uuid.UUID `json:"company_id" db:"company_id"`
Description string `json:"description" db:"description"`
Amount decimal.Decimal `json:"amount" db:"amount"`
Type string `json:"type" db:"type"` // income, expense
Status string `json:"status" db:"status"` // pending, paid, cancelled
DueDate *time.Time `json:"due_date" db:"due_date"`
PaymentDate *time.Time `json:"payment_date" db:"payment_date"`
PaymentMethod string `json:"payment_method" db:"payment_method"`
Attachments []string `json:"attachments" db:"attachments"`
CreatedBy uuid.UUID `json:"created_by" db:"created_by"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// Product represents a product or service in the catalog
type Product struct {
ID uuid.UUID `json:"id" db:"id"`
TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"`
Name string `json:"name" db:"name"`
SKU string `json:"sku" db:"sku"`
Description string `json:"description" db:"description"`
Price decimal.Decimal `json:"price" db:"price"`
CostPrice decimal.Decimal `json:"cost_price" db:"cost_price"`
Type string `json:"type" db:"type"` // product, service
StockQuantity int `json:"stock_quantity" db:"stock_quantity"`
IsActive bool `json:"is_active" db:"is_active"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// Order represents a sales or service order
type Order struct {
ID uuid.UUID `json:"id" db:"id"`
TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"`
CustomerID *uuid.UUID `json:"customer_id" db:"customer_id"`
EntityID *uuid.UUID `json:"entity_id" db:"entity_id"`
Status string `json:"status" db:"status"` // draft, confirmed, completed, cancelled
TotalAmount decimal.Decimal `json:"total_amount" db:"total_amount"`
Notes string `json:"notes" db:"notes"`
CreatedBy uuid.UUID `json:"created_by" db:"created_by"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// OrderItem represents an item within an order
type OrderItem struct {
ID uuid.UUID `json:"id" db:"id"`
OrderID uuid.UUID `json:"order_id" db:"order_id"`
ProductID uuid.UUID `json:"product_id" db:"product_id"`
Quantity int `json:"quantity" db:"quantity"`
UnitPrice decimal.Decimal `json:"unit_price" db:"unit_price"`
TotalPrice decimal.Decimal `json:"total_price" db:"total_price"`
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

@@ -0,0 +1,156 @@
package repository
import (
"aggios-app/backend/internal/domain"
"database/sql"
"fmt"
)
type DocumentRepository struct {
db *sql.DB
}
func NewDocumentRepository(db *sql.DB) *DocumentRepository {
return &DocumentRepository{db: db}
}
func (r *DocumentRepository) Create(doc *domain.Document) error {
query := `
INSERT INTO documents (id, tenant_id, parent_id, title, content, status, created_by, last_updated_by, version, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $7, 1, NOW(), NOW())
`
_, err := r.db.Exec(query, doc.ID, doc.TenantID, doc.ParentID, doc.Title, doc.Content, doc.Status, doc.CreatedBy)
if err != nil {
return err
}
return r.logActivity(doc.ID.String(), doc.TenantID.String(), doc.CreatedBy.String(), "created", "Criou o documento")
}
func (r *DocumentRepository) GetByTenant(tenantID string) ([]domain.Document, error) {
query := `
SELECT id, tenant_id, parent_id, title, content, status, created_by, last_updated_by, version, created_at, updated_at
FROM documents
WHERE tenant_id = $1 AND parent_id IS NULL
ORDER BY updated_at DESC
`
rows, err := r.db.Query(query, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var docs []domain.Document
for rows.Next() {
var doc domain.Document
if err := rows.Scan(&doc.ID, &doc.TenantID, &doc.ParentID, &doc.Title, &doc.Content, &doc.Status, &doc.CreatedBy, &doc.LastUpdatedBy, &doc.Version, &doc.CreatedAt, &doc.UpdatedAt); err != nil {
return nil, err
}
docs = append(docs, doc)
}
return docs, nil
}
func (r *DocumentRepository) GetSubpages(parentID, tenantID string) ([]domain.Document, error) {
query := `
SELECT id, tenant_id, parent_id, title, content, status, created_by, last_updated_by, version, created_at, updated_at
FROM documents
WHERE parent_id = $1 AND tenant_id = $2
ORDER BY created_at ASC
`
rows, err := r.db.Query(query, parentID, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var docs []domain.Document
for rows.Next() {
var doc domain.Document
if err := rows.Scan(&doc.ID, &doc.TenantID, &doc.ParentID, &doc.Title, &doc.Content, &doc.Status, &doc.CreatedBy, &doc.LastUpdatedBy, &doc.Version, &doc.CreatedAt, &doc.UpdatedAt); err != nil {
return nil, err
}
docs = append(docs, doc)
}
return docs, nil
}
func (r *DocumentRepository) GetByID(id, tenantID string) (*domain.Document, error) {
query := `
SELECT id, tenant_id, parent_id, title, content, status, created_by, last_updated_by, version, created_at, updated_at
FROM documents
WHERE id = $1 AND tenant_id = $2
`
var doc domain.Document
err := r.db.QueryRow(query, id, tenantID).Scan(
&doc.ID, &doc.TenantID, &doc.ParentID, &doc.Title, &doc.Content, &doc.Status, &doc.CreatedBy, &doc.LastUpdatedBy, &doc.Version, &doc.CreatedAt, &doc.UpdatedAt,
)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
return &doc, nil
}
func (r *DocumentRepository) Update(doc *domain.Document) error {
query := `
UPDATE documents
SET title = $1, content = $2, status = $3, last_updated_by = $4, version = version + 1, updated_at = NOW()
WHERE id = $5 AND tenant_id = $6
`
_, err := r.db.Exec(query, doc.Title, doc.Content, doc.Status, doc.LastUpdatedBy, doc.ID, doc.TenantID)
if err != nil {
return err
}
return r.logActivity(doc.ID.String(), doc.TenantID.String(), doc.LastUpdatedBy.String(), "updated", "Atualizou o conteúdo")
}
func (r *DocumentRepository) Delete(id, tenantID string) error {
query := "DELETE FROM documents WHERE id = $1 AND tenant_id = $2"
res, err := r.db.Exec(query, id, tenantID)
if err != nil {
return err
}
rows, _ := res.RowsAffected()
if rows == 0 {
return fmt.Errorf("document not found")
}
return nil
}
func (r *DocumentRepository) logActivity(docID, tenantID, userID, action, description string) error {
query := `
INSERT INTO document_activities (document_id, tenant_id, user_id, action, description)
VALUES ($1, $2, $3, $4, $5)
`
_, err := r.db.Exec(query, docID, tenantID, userID, action, description)
return err
}
func (r *DocumentRepository) GetActivities(docID, tenantID string) ([]domain.DocumentActivity, error) {
query := `
SELECT a.id, a.document_id, a.tenant_id, a.user_id, COALESCE(u.first_name, 'Usuário Removido') as user_name, a.action, a.description, a.created_at
FROM document_activities a
LEFT JOIN users u ON a.user_id = u.id
WHERE a.document_id = $1 AND a.tenant_id = $2
ORDER BY a.created_at DESC
LIMIT 20
`
rows, err := r.db.Query(query, docID, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var activities []domain.DocumentActivity
for rows.Next() {
var a domain.DocumentActivity
err := rows.Scan(&a.ID, &a.DocumentID, &a.TenantID, &a.UserID, &a.UserName, &a.Action, &a.Description, &a.CreatedAt)
if err != nil {
return nil, err
}
activities = append(activities, a)
}
return activities, nil
}

View File

@@ -0,0 +1,493 @@
package repository
import (
"aggios-app/backend/internal/domain"
"database/sql"
"github.com/lib/pq"
)
type ERPRepository struct {
db *sql.DB
}
func NewERPRepository(db *sql.DB) *ERPRepository {
return &ERPRepository{db: db}
}
// ==================== FINANCE: CATEGORIES ====================
func (r *ERPRepository) CreateFinancialCategory(cat *domain.FinancialCategory) error {
query := `
INSERT INTO erp_financial_categories (id, tenant_id, name, type, color, is_active)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING created_at, updated_at
`
return r.db.QueryRow(
query,
cat.ID, cat.TenantID, cat.Name, cat.Type, cat.Color, cat.IsActive,
).Scan(&cat.CreatedAt, &cat.UpdatedAt)
}
func (r *ERPRepository) GetFinancialCategoriesByTenant(tenantID string) ([]domain.FinancialCategory, error) {
query := `
SELECT id, tenant_id, name, type, color, is_active, created_at, updated_at
FROM erp_financial_categories
WHERE tenant_id = $1
ORDER BY name ASC
`
rows, err := r.db.Query(query, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var categories []domain.FinancialCategory
for rows.Next() {
var c domain.FinancialCategory
err := rows.Scan(&c.ID, &c.TenantID, &c.Name, &c.Type, &c.Color, &c.IsActive, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
return nil, err
}
categories = append(categories, c)
}
return categories, nil
}
// ==================== FINANCE: BANK ACCOUNTS ====================
func (r *ERPRepository) CreateBankAccount(acc *domain.BankAccount) error {
query := `
INSERT INTO erp_bank_accounts (id, tenant_id, name, bank_name, initial_balance, current_balance, is_active)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING created_at, updated_at
`
return r.db.QueryRow(
query,
acc.ID, acc.TenantID, acc.Name, acc.BankName, acc.InitialBalance, acc.InitialBalance, acc.IsActive,
).Scan(&acc.CreatedAt, &acc.UpdatedAt)
}
func (r *ERPRepository) GetBankAccountsByTenant(tenantID string) ([]domain.BankAccount, error) {
query := `
SELECT id, tenant_id, name, bank_name, initial_balance, current_balance, is_active, created_at, updated_at
FROM erp_bank_accounts
WHERE tenant_id = $1
ORDER BY name ASC
`
rows, err := r.db.Query(query, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var accounts []domain.BankAccount
for rows.Next() {
var a domain.BankAccount
err := rows.Scan(&a.ID, &a.TenantID, &a.Name, &a.BankName, &a.InitialBalance, &a.CurrentBalance, &a.IsActive, &a.CreatedAt, &a.UpdatedAt)
if err != nil {
return nil, err
}
accounts = append(accounts, a)
}
return accounts, nil
}
// ==================== ENTITIES: CUSTOMERS & SUPPLIERS ====================
func (r *ERPRepository) CreateEntity(e *domain.Entity) error {
query := `
INSERT INTO erp_entities (id, tenant_id, name, document, email, phone, type, status, address, city, state, zip, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING created_at, updated_at
`
return r.db.QueryRow(
query,
e.ID, e.TenantID, e.Name, e.Document, e.Email, e.Phone, e.Type, e.Status, e.Address, e.City, e.State, e.Zip, e.Notes,
).Scan(&e.CreatedAt, &e.UpdatedAt)
}
func (r *ERPRepository) GetEntitiesByTenant(tenantID string, entityType string) ([]domain.Entity, error) {
query := `
SELECT id, tenant_id, name, document, email, phone, type, status, address, city, state, zip, notes, created_at, updated_at
FROM erp_entities
WHERE tenant_id = $1
`
var args []interface{}
args = append(args, tenantID)
if entityType != "" {
query += " AND (type = $2 OR type = 'both')"
args = append(args, entityType)
}
query += " ORDER BY name ASC"
rows, err := r.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var entities []domain.Entity
for rows.Next() {
var e domain.Entity
err := rows.Scan(
&e.ID, &e.TenantID, &e.Name, &e.Document, &e.Email, &e.Phone, &e.Type, &e.Status, &e.Address, &e.City, &e.State, &e.Zip, &e.Notes, &e.CreatedAt, &e.UpdatedAt,
)
if err != nil {
return nil, err
}
entities = append(entities, e)
}
return entities, nil
}
// ==================== FINANCE: TRANSACTIONS ====================
func (r *ERPRepository) CreateTransaction(t *domain.FinancialTransaction) error {
tx, err := r.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
query := `
INSERT INTO erp_financial_transactions (
id, tenant_id, account_id, category_id, entity_id, crm_customer_id, company_id, description, amount, type, status, due_date, payment_date, payment_method, attachments, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
RETURNING created_at, updated_at
`
err = tx.QueryRow(
query,
t.ID, t.TenantID, t.AccountID, t.CategoryID, t.EntityID, t.CRMCustomerID, t.CompanyID, t.Description, t.Amount, t.Type, t.Status, t.DueDate, t.PaymentDate, t.PaymentMethod, pq.Array(t.Attachments), t.CreatedBy,
).Scan(&t.CreatedAt, &t.UpdatedAt)
if err != nil {
return err
}
// Update balance if paid
if t.Status == "paid" && t.AccountID != nil {
balanceQuery := ""
if t.Type == "income" {
balanceQuery = "UPDATE erp_bank_accounts SET current_balance = current_balance + $1 WHERE id = $2"
} else {
balanceQuery = "UPDATE erp_bank_accounts SET current_balance = current_balance - $1 WHERE id = $2"
}
_, err = tx.Exec(balanceQuery, t.Amount, t.AccountID)
if err != nil {
return err
}
}
return tx.Commit()
}
func (r *ERPRepository) GetTransactionsByTenant(tenantID string) ([]domain.FinancialTransaction, error) {
query := `
SELECT id, tenant_id, account_id, category_id, entity_id, crm_customer_id, company_id, description, amount, type, status, due_date, payment_date, payment_method, attachments, created_by, created_at, updated_at
FROM erp_financial_transactions
WHERE tenant_id = $1
ORDER BY created_at DESC
`
rows, err := r.db.Query(query, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var transactions []domain.FinancialTransaction
for rows.Next() {
var t domain.FinancialTransaction
err := rows.Scan(
&t.ID, &t.TenantID, &t.AccountID, &t.CategoryID, &t.EntityID, &t.CRMCustomerID, &t.CompanyID, &t.Description, &t.Amount, &t.Type, &t.Status, &t.DueDate, &t.PaymentDate, &t.PaymentMethod, pq.Array(&t.Attachments), &t.CreatedBy, &t.CreatedAt, &t.UpdatedAt,
)
if err != nil {
return nil, err
}
transactions = append(transactions, t)
}
return transactions, nil
}
// ==================== PRODUCTS ====================
func (r *ERPRepository) CreateProduct(p *domain.Product) error {
query := `
INSERT INTO erp_products (id, tenant_id, name, sku, description, price, cost_price, type, stock_quantity, is_active)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING created_at, updated_at
`
return r.db.QueryRow(
query,
p.ID, p.TenantID, p.Name, p.SKU, p.Description, p.Price, p.CostPrice, p.Type, p.StockQuantity, p.IsActive,
).Scan(&p.CreatedAt, &p.UpdatedAt)
}
func (r *ERPRepository) GetProductsByTenant(tenantID string) ([]domain.Product, error) {
query := `
SELECT id, tenant_id, name, sku, description, price, cost_price, type, stock_quantity, is_active, created_at, updated_at
FROM erp_products
WHERE tenant_id = $1
ORDER BY name ASC
`
rows, err := r.db.Query(query, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var products []domain.Product
for rows.Next() {
var p domain.Product
err := rows.Scan(&p.ID, &p.TenantID, &p.Name, &p.SKU, &p.Description, &p.Price, &p.CostPrice, &p.Type, &p.StockQuantity, &p.IsActive, &p.CreatedAt, &p.UpdatedAt)
if err != nil {
return nil, err
}
products = append(products, p)
}
return products, nil
}
// ==================== ORDERS ====================
func (r *ERPRepository) CreateOrder(o *domain.Order, items []domain.OrderItem) error {
tx, err := r.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
orderQuery := `
INSERT INTO erp_orders (id, tenant_id, customer_id, entity_id, status, total_amount, notes, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING created_at, updated_at
`
err = tx.QueryRow(
orderQuery,
o.ID, o.TenantID, o.CustomerID, o.EntityID, o.Status, o.TotalAmount, o.Notes, o.CreatedBy,
).Scan(&o.CreatedAt, &o.UpdatedAt)
if err != nil {
return err
}
itemQuery := `
INSERT INTO erp_order_items (id, order_id, product_id, quantity, unit_price, total_price)
VALUES ($1, $2, $3, $4, $5, $6)
`
for _, item := range items {
_, err = tx.Exec(itemQuery, item.ID, o.ID, item.ProductID, item.Quantity, item.UnitPrice, item.TotalPrice)
if err != nil {
return err
}
// Update stock if product
stockQuery := "UPDATE erp_products SET stock_quantity = stock_quantity - $1 WHERE id = $2 AND type = 'product'"
_, err = tx.Exec(stockQuery, item.Quantity, item.ProductID)
if err != nil {
return err
}
}
return tx.Commit()
}
func (r *ERPRepository) GetOrdersByTenant(tenantID string) ([]domain.Order, error) {
query := `
SELECT id, tenant_id, customer_id, status, total_amount, notes, created_by, created_at, updated_at
FROM erp_orders
WHERE tenant_id = $1
ORDER BY created_at DESC
`
rows, err := r.db.Query(query, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var orders []domain.Order
for rows.Next() {
var o domain.Order
err := rows.Scan(&o.ID, &o.TenantID, &o.CustomerID, &o.Status, &o.TotalAmount, &o.Notes, &o.CreatedBy, &o.CreatedAt, &o.UpdatedAt)
if err != nil {
return nil, err
}
orders = append(orders, o)
}
return orders, nil
}
func (r *ERPRepository) UpdateTransaction(t *domain.FinancialTransaction) error {
tx, err := r.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// Get old transaction to adjust balance
var oldT domain.FinancialTransaction
err = tx.QueryRow(`
SELECT amount, type, status, account_id
FROM erp_financial_transactions
WHERE id = $1 AND tenant_id = $2`, t.ID, t.TenantID).
Scan(&oldT.Amount, &oldT.Type, &oldT.Status, &oldT.AccountID)
if err != nil {
return err
}
// Falls back to old type if not provided in request
if t.Type == "" {
t.Type = oldT.Type
}
// Reverse old balance impact
if oldT.Status == "paid" && oldT.AccountID != nil {
balanceQuery := ""
if oldT.Type == "income" {
balanceQuery = "UPDATE erp_bank_accounts SET current_balance = current_balance - $1 WHERE id = $2"
} else {
balanceQuery = "UPDATE erp_bank_accounts SET current_balance = current_balance + $1 WHERE id = $2"
}
_, err = tx.Exec(balanceQuery, oldT.Amount, oldT.AccountID)
if err != nil {
return err
}
}
query := `
UPDATE erp_financial_transactions
SET description = $1, amount = $2, type = $3, status = $4, due_date = $5, payment_date = $6,
category_id = $7, entity_id = $8, crm_customer_id = $9, company_id = $10, account_id = $11, payment_method = $12, updated_at = NOW()
WHERE id = $13 AND tenant_id = $14
`
_, err = tx.Exec(query,
t.Description, t.Amount, t.Type, t.Status, t.DueDate, t.PaymentDate,
t.CategoryID, t.EntityID, t.CRMCustomerID, t.CompanyID, t.AccountID, t.PaymentMethod,
t.ID, t.TenantID)
if err != nil {
return err
}
// Apply new balance impact
if t.Status == "paid" && t.AccountID != nil {
balanceQuery := ""
if t.Type == "income" {
balanceQuery = "UPDATE erp_bank_accounts SET current_balance = current_balance + $1 WHERE id = $2"
} else {
balanceQuery = "UPDATE erp_bank_accounts SET current_balance = current_balance - $1 WHERE id = $2"
}
_, err = tx.Exec(balanceQuery, t.Amount, t.AccountID)
if err != nil {
return err
}
}
return tx.Commit()
}
func (r *ERPRepository) DeleteTransaction(id, tenantID string) error {
tx, err := r.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// Adjust balance before delete
var t domain.FinancialTransaction
err = tx.QueryRow(`
SELECT amount, type, status, account_id
FROM erp_financial_transactions
WHERE id = $1 AND tenant_id = $2`, id, tenantID).
Scan(&t.Amount, &t.Type, &t.Status, &t.AccountID)
if err != nil {
return err
}
if t.Status == "paid" && t.AccountID != nil {
balanceQuery := ""
if t.Type == "income" {
balanceQuery = "UPDATE erp_bank_accounts SET current_balance = current_balance - $1 WHERE id = $2"
} else {
balanceQuery = "UPDATE erp_bank_accounts SET current_balance = current_balance + $1 WHERE id = $2"
}
_, err = tx.Exec(balanceQuery, t.Amount, t.AccountID)
if err != nil {
return err
}
}
_, err = tx.Exec("DELETE FROM erp_financial_transactions WHERE id = $1 AND tenant_id = $2", id, tenantID)
if err != nil {
return err
}
return tx.Commit()
}
func (r *ERPRepository) UpdateEntity(e *domain.Entity) error {
query := `
UPDATE erp_entities
SET name = $1, document = $2, email = $3, phone = $4, type = $5, status = $6,
address = $7, city = $8, state = $9, zip = $10, notes = $11, updated_at = NOW()
WHERE id = $12 AND tenant_id = $13
`
_, err := r.db.Exec(query, e.Name, e.Document, e.Email, e.Phone, e.Type, e.Status, e.Address, e.City, e.State, e.Zip, e.Notes, e.ID, e.TenantID)
return err
}
func (r *ERPRepository) DeleteEntity(id, tenantID string) error {
_, err := r.db.Exec("DELETE FROM erp_entities WHERE id = $1 AND tenant_id = $2", id, tenantID)
return err
}
func (r *ERPRepository) UpdateProduct(p *domain.Product) error {
query := `
UPDATE erp_products
SET name = $1, sku = $2, description = $3, price = $4, cost_price = $5,
type = $6, stock_quantity = $7, is_active = $8, updated_at = NOW()
WHERE id = $9 AND tenant_id = $10
`
_, err := r.db.Exec(query, p.Name, p.SKU, p.Description, p.Price, p.CostPrice, p.Type, p.StockQuantity, p.IsActive, p.ID, p.TenantID)
return err
}
func (r *ERPRepository) DeleteProduct(id, tenantID string) error {
_, err := r.db.Exec("DELETE FROM erp_products WHERE id = $1 AND tenant_id = $2", id, tenantID)
return err
}
func (r *ERPRepository) UpdateBankAccount(a *domain.BankAccount) error {
query := `
UPDATE erp_bank_accounts
SET name = $1, bank_name = $2, initial_balance = $3, is_active = $4, updated_at = NOW()
WHERE id = $5 AND tenant_id = $6
`
_, err := r.db.Exec(query, a.Name, a.BankName, a.InitialBalance, a.IsActive, a.ID, a.TenantID)
return err
}
func (r *ERPRepository) DeleteBankAccount(id, tenantID string) error {
_, err := r.db.Exec("DELETE FROM erp_bank_accounts WHERE id = $1 AND tenant_id = $2", id, tenantID)
return err
}
func (r *ERPRepository) DeleteOrder(id, tenantID string) error {
tx, err := r.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// Deleta os itens do pedido primeiro
_, err = tx.Exec("DELETE FROM erp_order_items WHERE order_id = $1", id)
if err != nil {
return err
}
// Deleta o pedido
_, err = tx.Exec("DELETE FROM erp_orders WHERE id = $1 AND tenant_id = $2", id, tenantID)
if err != nil {
return err
}
return tx.Commit()
}

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

BIN
build_error.log Normal file

Binary file not shown.

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,67 @@
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 { getUser } from '@/lib/auth';
import { import {
HomeIcon, HomeIcon,
RocketLaunchIcon, RocketLaunchIcon,
UserPlusIcon,
RectangleStackIcon,
UsersIcon,
MegaphoneIcon,
BanknotesIcon,
CubeIcon,
ShoppingCartIcon,
ArrowDownCircleIcon,
ChartBarIcon, ChartBarIcon,
BriefcaseIcon, WalletIcon,
LifebuoyIcon, UserGroupIcon,
CreditCardIcon, ArchiveBoxIcon,
AdjustmentsHorizontalIcon,
ArrowTrendingUpIcon,
ArrowTrendingDownIcon,
DocumentTextIcon, DocumentTextIcon,
FolderIcon, ShoppingBagIcon
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: 'documentos',
label: 'Documentos',
href: '/documentos',
icon: DocumentTextIcon,
requiredSolution: 'documentos'
},
{ {
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', id: 'erp',
label: 'ERP', label: 'ERP',
href: '/erp', href: '/erp',
icon: ChartBarIcon, icon: BanknotesIcon,
requiredSolution: 'erp',
subItems: [ subItems: [
{ label: 'Dashboard', href: '/erp' }, { label: 'Visão Geral', href: '/erp', icon: ChartBarIcon },
{ label: 'Fluxo de Caixa', href: '/erp/fluxo-caixa' }, { label: 'Produtos e Estoque', href: '/erp/estoque', icon: ArchiveBoxIcon },
{ label: 'Contas a Pagar', href: '/erp/contas-pagar' }, { label: 'Pedidos e Vendas', href: '/erp/pedidos', icon: ShoppingBagIcon },
{ label: 'Contas a Receber', href: '/erp/contas-receber' }, { label: 'Caixa', href: '/erp/caixa', icon: WalletIcon },
] { label: 'Contas a Receber', href: '/erp/receber', icon: ArrowTrendingUpIcon },
}, { label: 'Contas a Pagar', href: '/erp/pagar', icon: ArrowTrendingDownIcon },
{
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' },
] ]
}, },
]; ];
@@ -146,9 +103,23 @@ export function AgencyLayoutClient({ children, colors }: AgencyLayoutClientProps
console.log('🏷️ Slugs das soluções:', solutionSlugs); console.log('🏷️ Slugs das soluções:', solutionSlugs);
// Sempre mostrar dashboard + soluções disponíveis // Sempre mostrar dashboard + soluções disponíveis
// Segurança Máxima: ERP só para ADMIN_AGENCIA
const user = getUser();
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);
// ERP restrito a administradores da agência
if (item.id === 'erp' && user?.role !== 'ADMIN_AGENCIA') {
return false;
}
const requiredSolution = (item as any).requiredSolution;
const hasSolution = solutionSlugs.includes((requiredSolution || item.id).toLowerCase());
// Temporariamente forçar a exibição de Documentos para debug
if (item.id === 'documentos') return true;
return hasSolution;
}); });
console.log('📋 Menu filtrado:', filtered.map(i => i.id)); console.log('📋 Menu filtrado:', filtered.map(i => i.id));
@@ -171,11 +142,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

@@ -1,15 +1,251 @@
'use client'; 'use client';
import React, { useState, useEffect, useMemo } from 'react';
import { SolutionGuard } from '@/components/auth/SolutionGuard'; import { SolutionGuard } from '@/components/auth/SolutionGuard';
import { PageHeader, DataTable, Card, Badge } from '@/components/ui';
import {
PlusIcon,
MagnifyingGlassIcon,
DocumentTextIcon,
PencilSquareIcon,
TrashIcon,
ArrowPathIcon,
EyeIcon,
ClockIcon
} from '@heroicons/react/24/outline';
import { docApi, Document } from '@/lib/api-docs';
import { toast } from 'react-hot-toast';
import DocumentEditor from '@/components/documentos/DocumentEditor';
import { format, parseISO } from 'date-fns';
import { ptBR } from 'date-fns/locale';
export default function DocumentosPage() { export default function DocumentosPage() {
const [documents, setDocuments] = useState<Document[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [isEditing, setIsEditing] = useState(false);
const [currentDoc, setCurrentDoc] = useState<Partial<Document> | null>(null);
// Pagination
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 8;
useEffect(() => {
fetchDocuments();
}, []);
const fetchDocuments = async () => {
try {
setLoading(true);
const data = await docApi.getDocuments();
setDocuments(data || []);
} catch (error) {
toast.error('Erro ao carregar documentos');
} finally {
setLoading(false);
}
};
const handleCreate = async () => {
try {
const newDoc = await docApi.createDocument({
title: 'Novo Documento',
content: '{"type":"doc","content":[{"type":"paragraph"}]}',
status: 'published',
parent_id: null
});
setCurrentDoc(newDoc);
setIsEditing(true);
fetchDocuments();
} catch (error) {
toast.error('Erro ao iniciar novo documento');
}
};
const handleEdit = (doc: Document) => {
setCurrentDoc(doc);
setIsEditing(true);
};
const handleSave = async (docData: Partial<Document>) => {
try {
if (docData.id) {
await docApi.updateDocument(docData.id, docData);
// toast.success('Documento atualizado!'); // Auto-save já acontece
} else {
await docApi.createDocument(docData);
toast.success('Documento criado!');
}
setIsEditing(false);
fetchDocuments();
} catch (error) {
toast.error('Erro ao salvar documento');
}
};
const handleDelete = async (id: string) => {
if (!confirm('Tem certeza que deseja excluir este documento e todas as suas subpáginas?')) return;
try {
await docApi.deleteDocument(id);
toast.success('Documento excluído!');
fetchDocuments();
} catch (error) {
toast.error('Erro ao excluir documento');
}
};
const filteredDocuments = useMemo(() => {
return documents.filter(doc =>
(doc.title || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
(doc.content || '').toLowerCase().includes(searchTerm.toLowerCase())
);
}, [documents, searchTerm]);
const paginatedDocuments = useMemo(() => {
const start = (currentPage - 1) * itemsPerPage;
return filteredDocuments.slice(start, start + itemsPerPage);
}, [filteredDocuments, currentPage]);
const columns = [
{
header: 'Documento',
accessor: (doc: Document) => (
<div className="flex items-center gap-3 py-1">
<div className="p-2.5 bg-zinc-50 dark:bg-zinc-800 rounded-xl border border-zinc-100 dark:border-zinc-700 shadow-sm">
<DocumentTextIcon className="w-5 h-5 text-zinc-500" />
</div>
<div>
<p className="font-bold text-zinc-900 dark:text-white group-hover:text-brand-500 transition-colors uppercase tracking-tight text-sm">
{doc.title || 'Sem título'}
</p>
<div className="flex items-center gap-2 mt-0.5">
<Badge variant="info" className="text-[8px] px-1.5 font-black">v{doc.version || 1}</Badge>
<span className="text-[10px] text-zinc-400 font-medium">#{doc.id.substring(0, 8)}</span>
</div>
</div>
</div>
)
},
{
header: 'Última Modificação',
accessor: (doc: Document) => (
<div className="flex items-center gap-3">
<ClockIcon className="w-4 h-4 text-zinc-300" />
<div className="flex flex-col">
<span className="text-xs font-bold text-zinc-600 dark:text-zinc-400">
{format(parseISO(doc.updated_at), "dd 'de' MMM", { locale: ptBR })}
</span>
<span className="text-[9px] text-zinc-400 uppercase font-black tracking-tighter">
às {format(parseISO(doc.updated_at), "HH:mm")}
</span>
</div>
</div>
)
},
{
header: 'Ações',
align: 'right' as const,
accessor: (doc: Document) => (
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleEdit(doc)}
className="flex items-center gap-2 px-4 py-2 text-xs font-black uppercase tracking-widest text-zinc-600 dark:text-zinc-400 hover:text-brand-500 hover:bg-brand-50 dark:hover:bg-brand-500/10 rounded-xl transition-all"
>
<PencilSquareIcon className="w-4 h-4" />
Abrir
</button>
<button
onClick={() => handleDelete(doc.id)}
className="p-2 text-zinc-300 hover:text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-500/10 rounded-xl transition-all"
title="Excluir"
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
)
}
];
return ( return (
<SolutionGuard requiredSolution="documentos"> <SolutionGuard requiredSolution="documentos">
<div className="p-6"> <div className="p-6 max-w-[1600px] mx-auto space-y-8 animate-in fade-in duration-700">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">Documentos</h1> <PageHeader
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-8 text-center"> title="Wiki & Base de Conhecimento"
<p className="text-gray-500">Gestão Eletrônica de Documentos (GED) em breve</p> description="Organize processos, manuais e documentação técnica da agência."
primaryAction={{
label: "Criar Novo",
icon: <PlusIcon className="w-5 h-5" />,
onClick: handleCreate
}}
/>
<div className="flex flex-col md:flex-row gap-4 items-center justify-between bg-white dark:bg-zinc-900/50 p-4 rounded-[28px] border border-zinc-200 dark:border-zinc-800 shadow-sm">
<div className="w-full md:w-96 relative">
<MagnifyingGlassIcon className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-zinc-300" />
<input
type="text"
placeholder="Pesquisar wiki..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-100 dark:border-zinc-800 rounded-2xl pl-12 pr-4 py-3 text-sm outline-none focus:ring-2 ring-brand-500/20 transition-all font-semibold placeholder:text-zinc-400"
/>
</div> </div>
<div className="flex items-center gap-4">
<button
onClick={fetchDocuments}
className="p-3 text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors"
>
<ArrowPathIcon className={`w-5 h-5 ${loading ? 'animate-spin' : ''}`} />
</button>
<div className="h-6 w-px bg-zinc-200 dark:border-zinc-800" />
<div className="flex items-center gap-2 px-5 py-2.5 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded-2xl text-[10px] font-black uppercase tracking-widest shadow-lg shadow-zinc-200 dark:shadow-none">
{filteredDocuments.length} Documentos
</div>
</div>
</div>
<Card noPadding allowOverflow className="border-none shadow-2xl shadow-black/5 overflow-hidden rounded-[32px]">
<DataTable
columns={columns}
data={paginatedDocuments}
isLoading={loading}
/>
{/* Pagination */}
<div className="p-6 border-t border-zinc-50 dark:border-zinc-800 flex items-center justify-between bg-zinc-50/50 dark:bg-zinc-900/50">
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">
{filteredDocuments.length} itens no total
</p>
<div className="flex gap-2">
<button
disabled={currentPage === 1}
onClick={() => setCurrentPage(p => p - 1)}
className="px-6 py-2.5 text-[10px] font-black uppercase tracking-widest bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl disabled:opacity-30 hover:bg-zinc-50 dark:hover:bg-zinc-700 transition-all shadow-sm"
>
Anterior
</button>
<button
disabled={currentPage * itemsPerPage >= filteredDocuments.length}
onClick={() => setCurrentPage(p => p + 1)}
className="px-6 py-2.5 text-[10px] font-black uppercase tracking-widest bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded-xl disabled:opacity-30 hover:bg-black dark:hover:bg-white transition-all shadow-lg active:scale-95"
>
Próximo
</button>
</div>
</div>
</Card>
{isEditing && (
<DocumentEditor
initialDocument={currentDoc}
onSave={handleSave}
onCancel={() => {
setIsEditing(false);
fetchDocuments();
}}
/>
)}
</div> </div>
</SolutionGuard> </SolutionGuard>
); );

View File

@@ -0,0 +1,211 @@
'use client';
import React, { useState, useEffect } from 'react';
import {
PlusIcon,
BanknotesIcon,
TagIcon,
CheckIcon,
XMarkIcon,
PencilSquareIcon,
TrashIcon,
BuildingLibraryIcon
} from '@heroicons/react/24/outline';
import { erpApi, FinancialCategory, BankAccount } from '@/lib/api-erp';
import { formatCurrency } from '@/lib/format';
import { toast } from 'react-hot-toast';
import {
PageHeader,
DataTable,
Input,
Card,
Tabs
} from "@/components/ui";
export default function ERPSettingsPage() {
return (
<div className="space-y-6">
<PageHeader
title="Configurações do ERP"
description="Gerencie categorias financeiras, contas bancárias e outras preferências do sistema."
/>
<Tabs
variant="pills"
items={[
{
label: 'Categorias Financeiras',
icon: <TagIcon className="w-4 h-4" />,
content: <CategorySettings />
},
{
label: 'Contas Bancárias',
icon: <BuildingLibraryIcon className="w-4 h-4" />,
content: <AccountSettings />
}
]}
/>
</div>
);
}
function CategorySettings() {
const [categories, setCategories] = useState<FinancialCategory[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
const data = await erpApi.getFinancialCategories();
setCategories(data || []);
} catch (error) {
toast.error('Erro ao carregar categorias');
} finally {
setLoading(false);
}
};
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-bold text-zinc-900 dark:text-white">Categorias</h3>
<button
className="flex items-center gap-2 px-4 py-2 text-white rounded-xl font-bold shadow-lg hover:opacity-90 transition-all text-sm"
style={{ background: 'var(--gradient)' }}
>
<PlusIcon className="w-4 h-4" />
Nova Categoria
</button>
</div>
<Card noPadding className="overflow-hidden">
<DataTable
isLoading={loading}
data={categories}
columns={[
{
header: 'Nome',
accessor: (row) => (
<div className="flex items-center gap-3">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: row.color }} />
<span className="font-bold text-zinc-900 dark:text-white">{row.name}</span>
</div>
)
},
{
header: 'Tipo',
accessor: (row) => (
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider ${row.type === 'income' ? 'bg-emerald-100 text-emerald-700' : 'bg-rose-100 text-rose-700'}`}>
{row.type === 'income' ? 'Receita' : 'Despesa'}
</span>
)
},
{
header: 'Status',
accessor: (row) => (
<span className={`text-xs font-bold ${row.is_active ? 'text-emerald-500' : 'text-zinc-400'}`}>
{row.is_active ? 'Ativo' : 'Inativo'}
</span>
)
},
{
header: '',
className: 'text-right',
accessor: () => (
<div className="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-all">
<button className="p-2 text-zinc-400 hover:text-brand-600 dark:hover:text-brand-400">
<PencilSquareIcon className="w-4 h-4" />
</button>
</div>
)
}
]}
/>
</Card>
</div>
);
}
function AccountSettings() {
const [accounts, setAccounts] = useState<BankAccount[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
const data = await erpApi.getBankAccounts();
setAccounts(data || []);
} catch (error) {
toast.error('Erro ao carregar contas');
} finally {
setLoading(false);
}
};
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-bold text-zinc-900 dark:text-white">Contas Bancárias</h3>
<button
className="flex items-center gap-2 px-4 py-2 text-white rounded-xl font-bold shadow-lg hover:opacity-90 transition-all text-sm"
style={{ background: 'var(--gradient)' }}
>
<PlusIcon className="w-4 h-4" />
Nova Conta
</button>
</div>
<Card noPadding className="overflow-hidden">
<DataTable
isLoading={loading}
data={accounts}
columns={[
{
header: 'Nome da Conta',
accessor: (row) => (
<div className="flex flex-col">
<span className="font-bold text-zinc-900 dark:text-white">{row.name}</span>
<span className="text-xs text-zinc-400 font-bold uppercase">{row.bank_name}</span>
</div>
)
},
{
header: 'Saldo Atual',
className: 'text-right',
accessor: (row) => (
<span className="font-black text-zinc-900 dark:text-white">
{formatCurrency(row.current_balance)}
</span>
)
},
{
header: 'Status',
accessor: (row) => (
<span className={`text-xs font-bold ${row.is_active ? 'text-emerald-500' : 'text-zinc-400'}`}>
{row.is_active ? 'Ativo' : 'Inativo'}
</span>
)
},
{
header: '',
className: 'text-right',
accessor: () => (
<div className="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-all">
<button className="p-2 text-zinc-400 hover:text-brand-600 dark:hover:text-brand-400">
<PencilSquareIcon className="w-4 h-4" />
</button>
</div>
)
}
]}
/>
</Card>
</div>
);
}

View File

@@ -0,0 +1,309 @@
'use client';
import React, { useState, useEffect, Fragment } from 'react';
import {
PlusIcon,
MagnifyingGlassIcon,
FunnelIcon,
ShoppingBagIcon,
CalendarIcon,
CurrencyDollarIcon,
UserIcon,
CheckCircleIcon,
ClockIcon,
XMarkIcon,
EyeIcon,
TrashIcon,
ExclamationTriangleIcon
} from '@heroicons/react/24/outline';
import { ConfirmDialog } from "@/components/ui";
import { erpApi, Order, Entity } from '@/lib/api-erp';
import { formatCurrency } from '@/lib/format';
import { useToast } from '@/components/layout/ToastContext';
import {
PageHeader,
StatsCard,
DataTable,
Input,
Card,
BulkActionBar,
} from "@/components/ui";
import { format } from 'date-fns';
export default function OrdersPage() {
const toast = useToast();
const [orders, setOrders] = useState<Order[]>([]);
const [entities, setEntities] = useState<Entity[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]);
const [confirmOpen, setConfirmOpen] = useState(false);
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false);
const [orderToDelete, setOrderToDelete] = useState<string | null>(null);
useEffect(() => {
fetchData();
}, []);
const fetchData = async (silent = false) => {
try {
if (!silent) setLoading(true);
const [ordersData, entitiesData] = await Promise.all([
erpApi.getOrders(),
erpApi.getEntities()
]);
setOrders(ordersData || []);
setEntities(entitiesData || []);
} catch (error) {
toast.error('Erro ao carregar', 'Não foi possível carregar os pedidos');
} finally {
setLoading(false);
setSelectedIds([]);
}
};
const handleBulkDelete = async () => {
if (selectedIds.length === 0) return;
setBulkConfirmOpen(true);
};
const handleConfirmBulkDelete = async () => {
if (selectedIds.length === 0) return;
const originalOrders = [...orders];
const idsToDelete = selectedIds.map(String);
// Dynamic: remove instantly
setOrders(prev => prev.filter(o => !idsToDelete.includes(String(o.id))));
const deletedCount = selectedIds.length;
try {
await Promise.all(idsToDelete.map(id => erpApi.deleteOrder(id)));
toast.success('Exclusão completa', `${deletedCount} pedidos excluídos com sucesso.`);
setTimeout(() => fetchData(true), 500);
} catch (error) {
setOrders(originalOrders);
toast.error('Erro ao excluir', 'Ocorreu um erro ao excluir alguns pedidos.');
} finally {
setBulkConfirmOpen(false);
setSelectedIds([]);
}
};
const handleDelete = (id: string) => {
setOrderToDelete(id);
setConfirmOpen(true);
};
const handleConfirmDelete = async () => {
if (!orderToDelete) return;
const originalOrders = [...orders];
const idToDelete = String(orderToDelete);
// Dynamic: remove instantly
setOrders(prev => prev.filter(o => String(o.id) !== idToDelete));
try {
await erpApi.deleteOrder(idToDelete);
toast.success('Exclusão completa', 'O pedido foi removido com sucesso.');
setTimeout(() => fetchData(true), 500);
} catch (error) {
setOrders(originalOrders);
toast.error('Erro ao excluir', 'Ocorreu um erro ao excluir o pedido.');
} finally {
setConfirmOpen(false);
setOrderToDelete(null);
}
};
const filteredOrders = orders.filter(o => {
const entityName = entities.find(e => e.id === o.entity_id)?.name || '';
const searchStr = searchTerm.toLowerCase();
return String(o.id).toLowerCase().includes(searchStr) ||
entityName.toLowerCase().includes(searchStr);
});
const totalRevenue = orders.filter(o => o.status !== 'cancelled').reduce((sum, o) => sum + Number(o.total_amount), 0);
const pendingOrders = orders.filter(o => o.status === 'confirmed').length;
const completedOrders = orders.filter(o => o.status === 'completed').length;
const columns = [
{
header: 'Pedido / Data',
accessor: (row: Order) => (
<div className="flex flex-col">
<span className="font-bold text-zinc-900 dark:text-white uppercase text-xs">#{row.id.slice(0, 8)}</span>
<div className="flex items-center gap-1 text-[10px] text-zinc-400 font-bold">
<CalendarIcon className="w-3 h-3" />
{row.created_at ? format(new Date(row.created_at), 'dd/MM/yyyy HH:mm') : '-'}
</div>
</div>
)
},
{
header: 'Cliente',
accessor: (row: Order) => (
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-zinc-100 dark:bg-zinc-800 text-zinc-500">
<UserIcon className="w-4 h-4" />
</div>
<span className="text-sm font-semibold text-zinc-900 dark:text-white">
{entities.find(e => e.id === row.entity_id)?.name || 'Consumidor Final'}
</span>
</div>
)
},
{
header: 'Status',
accessor: (row: Order) => {
const colors = {
draft: 'bg-zinc-100 text-zinc-700',
confirmed: 'bg-blue-100 text-blue-700',
completed: 'bg-emerald-100 text-emerald-700',
cancelled: 'bg-rose-100 text-rose-700'
};
const labels = {
draft: 'Rascunho',
confirmed: 'Confirmado',
completed: 'Concluído',
cancelled: 'Cancelado'
};
return (
<span className={`px-2.5 py-0.5 rounded-full text-[10px] font-black uppercase tracking-wider ${colors[row.status as keyof typeof colors]}`}>
{labels[row.status as keyof typeof labels]}
</span>
);
}
},
{
header: 'Total',
className: 'text-right',
accessor: (row: Order) => (
<span className="font-black text-zinc-900 dark:text-white">
{formatCurrency(row.total_amount)}
</span>
)
},
{
header: '',
className: 'text-right',
accessor: (row: Order) => (
<div className="flex justify-end gap-2">
<button className="p-2 text-zinc-400 hover:text-brand-600 dark:hover:text-brand-400">
<EyeIcon className="w-5 h-5" />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDelete(row.id); }}
className="p-2 text-zinc-400 hover:text-rose-600 dark:hover:text-rose-400 transition-all"
>
<TrashIcon className="w-5 h-5" />
</button>
</div>
)
}
];
return (
<div className="space-y-6">
<PageHeader
title="Pedidos & Vendas"
description="Acompanhe suas vendas, gerencie orçamentos e controle o fluxo de pedidos."
primaryAction={{
label: "Novo Pedido",
icon: <PlusIcon className="w-5 h-5" />,
onClick: () => toast.error('Funcionalidade em desenvolvimento')
}}
/>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<StatsCard
title="Receita de Vendas"
value={formatCurrency(totalRevenue)}
icon={<CurrencyDollarIcon className="w-6 h-6 text-emerald-500" />}
/>
<StatsCard
title="Pedidos Pendentes"
value={pendingOrders}
icon={<ClockIcon className="w-6 h-6 text-blue-500" />}
/>
<StatsCard
title="Pedidos Concluídos"
value={completedOrders}
icon={<CheckCircleIcon className="w-6 h-6 text-emerald-500" />}
/>
<StatsCard
title="Total de Pedidos"
value={orders.length}
icon={<ShoppingBagIcon className="w-6 h-6 text-indigo-500" />}
/>
</div>
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div className="relative w-full sm:w-96">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-400" />
<Input
placeholder="Buscar por cliente ou ID do pedido..."
className="pl-10 h-10 border-zinc-200 dark:border-zinc-800"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex gap-2 w-full sm:w-auto">
<button className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-xl text-sm font-bold text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-all">
<FunnelIcon className="w-4 h-4" />
Filtros
</button>
</div>
</div>
<Card noPadding className="overflow-hidden">
<DataTable
selectable
isLoading={loading}
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
columns={columns}
data={filteredOrders}
/>
</Card>
<ConfirmDialog
isOpen={bulkConfirmOpen}
onClose={() => setBulkConfirmOpen(false)}
onConfirm={handleConfirmBulkDelete}
title="Excluir Pedidos Selecionados"
message={`Tem certeza que deseja excluir os ${selectedIds.length} pedidos selecionados? Esta ação não pode ser desfeita.`}
confirmText="Excluir Tudo"
variant="danger"
/>
<ConfirmDialog
isOpen={confirmOpen}
onClose={() => {
setConfirmOpen(false);
setOrderToDelete(null);
}}
onConfirm={handleConfirmDelete}
title="Excluir Pedido"
message="Tem certeza que deseja excluir este pedido? Esta ação não pode ser desfeita."
confirmText="Excluir"
cancelText="Cancelar"
variant="danger"
/>
<BulkActionBar
selectedCount={selectedIds.length}
onClearSelection={() => setSelectedIds([])}
actions={[
{
label: "Excluir Selecionados",
icon: <TrashIcon className="w-5 h-5" />,
onClick: handleBulkDelete,
variant: 'danger'
}
]}
/>
</div>
);
}

View File

@@ -0,0 +1,503 @@
'use client';
import React, { useState, useEffect, Fragment } from 'react';
import {
PlusIcon,
MagnifyingGlassIcon,
FunnelIcon,
Square3Stack3DIcon as PackageIcon,
CurrencyDollarIcon,
ExclamationTriangleIcon,
TrashIcon,
PencilSquareIcon,
XMarkIcon,
CheckIcon,
TagIcon,
} from '@heroicons/react/24/outline';
import { erpApi, Product } from '@/lib/api-erp';
import { formatCurrency } from '@/lib/format';
import { useToast } from '@/components/layout/ToastContext';
import {
PageHeader,
StatsCard,
DataTable,
Input,
Card,
BulkActionBar,
ConfirmDialog,
} from "@/components/ui";
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
export default function ProductsPage() {
const toast = useToast();
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false);
const [productToDelete, setProductToDelete] = useState<string | null>(null);
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]);
const [formData, setFormData] = useState<Partial<Product>>({
name: '',
sku: '',
description: '',
price: 0,
cost_price: 0,
type: 'product',
stock_quantity: 0,
is_active: true
});
useEffect(() => {
fetchProducts();
}, []);
const fetchProducts = async (silent = false) => {
try {
if (!silent) setLoading(true);
const data = await erpApi.getProducts();
setProducts(data || []);
} catch (error) {
toast.error('Erro ao carregar', 'Não foi possível carregar os produtos');
} finally {
setLoading(false);
setSelectedIds([]);
}
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingProduct?.id) {
await erpApi.updateProduct(editingProduct.id, formData);
toast.success('Produto atualizado com sucesso!');
} else {
await erpApi.createProduct(formData);
toast.success('Produto cadastrado com sucesso!');
}
setIsModalOpen(false);
setEditingProduct(null);
resetForm();
await fetchProducts(true);
} catch (error) {
toast.error(editingProduct ? 'Erro ao atualizar produto' : 'Erro ao salvar produto');
}
};
const handleDelete = (id: string) => {
setProductToDelete(id);
setConfirmOpen(true);
};
const handleConfirmDelete = async () => {
if (!productToDelete) return;
const originalProducts = [...products];
const idToDelete = String(productToDelete);
// Dynamic: remove instantly
setProducts(prev => prev.filter(p => String(p.id) !== idToDelete));
try {
await erpApi.deleteProduct(idToDelete);
toast.success('Exclusão completa', 'O item foi removido com sucesso.');
setTimeout(() => fetchProducts(true), 500);
} catch (error) {
setProducts(originalProducts);
toast.error('Erro ao excluir', 'Ocorreu um erro ao excluir o produto.');
} finally {
setConfirmOpen(false);
setProductToDelete(null);
}
};
const handleBulkDelete = async () => {
if (selectedIds.length === 0) return;
setBulkConfirmOpen(true);
};
const handleConfirmBulkDelete = async () => {
if (selectedIds.length === 0) return;
const originalProducts = [...products];
const idsToDelete = selectedIds.map(String);
// Dynamic: remove instantly
setProducts(prev => prev.filter(p => !idsToDelete.includes(String(p.id))));
const deletedCount = selectedIds.length;
try {
await Promise.all(idsToDelete.map(id => erpApi.deleteProduct(id)));
toast.success('Exclusão completa', `${deletedCount} produtos excluídos com sucesso.`);
setTimeout(() => fetchProducts(true), 500);
} catch (error) {
setProducts(originalProducts);
toast.error('Erro ao excluir', 'Ocorreu um erro ao excluir alguns produtos.');
} finally {
setBulkConfirmOpen(false);
setSelectedIds([]);
}
};
const handleEdit = (product: Product) => {
setEditingProduct(product);
setFormData({
name: product.name,
sku: product.sku,
description: product.description,
price: Number(product.price),
cost_price: Number(product.cost_price),
type: product.type,
stock_quantity: Number(product.stock_quantity),
is_active: product.is_active
});
setIsModalOpen(true);
};
const resetForm = () => {
setFormData({
name: '',
sku: '',
description: '',
price: 0,
cost_price: 0,
type: 'product',
stock_quantity: 0,
is_active: true
});
};
const filteredProducts = products.filter(p =>
p.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(p.sku || '').toLowerCase().includes(searchTerm.toLowerCase())
);
const totalStockValue = products.reduce((sum, p) => sum + (Number(p.price) * Number(p.stock_quantity)), 0);
const lowStockItems = products.filter(p => p.type === 'product' && p.stock_quantity < 5).length;
const servicesCount = products.filter(p => p.type === 'service').length;
const columns = [
{
header: 'Produto / SKU',
accessor: (row: Product) => (
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${row.type === 'product' ? 'bg-indigo-50 text-indigo-600' : 'bg-amber-50 text-amber-600'}`}>
{row.type === 'product' ? <PackageIcon className="w-5 h-5" /> : <TagIcon className="w-5 h-5" />}
</div>
<div className="flex flex-col">
<span className="font-bold text-zinc-900 dark:text-white uppercase tracking-tight">{row.name}</span>
<span className="text-xs text-zinc-400 font-black tracking-widest">{row.sku || 'SEM SKU'}</span>
</div>
</div>
)
},
{
header: 'Tipo',
accessor: (row: Product) => (
<span className={`px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider ${row.type === 'product' ? 'bg-indigo-100 text-indigo-700' : 'bg-amber-100 text-amber-700'}`}>
{row.type === 'product' ? 'Produto' : 'Serviço'}
</span>
)
},
{
header: 'Estoque',
accessor: (row: Product) => (
row.type === 'product' ? (
<div className="flex items-center gap-2">
<span className={`font-black text-sm ${row.stock_quantity < 5 ? 'text-rose-500' : 'text-zinc-900 dark:text-white'}`}>
{row.stock_quantity}
</span>
{row.stock_quantity < 5 && (
<ExclamationTriangleIcon className="w-4 h-4 text-rose-500" />
)}
</div>
) : (
<span className="text-zinc-400 text-xs">N/A</span>
)
)
},
{
header: 'Preço de Venda',
className: 'text-right',
accessor: (row: Product) => (
<span className="font-black text-zinc-900 dark:text-white">
{formatCurrency(row.price)}
</span>
)
},
{
header: '',
className: 'text-right',
accessor: (row: Product) => (
<div className="flex justify-end gap-2">
<button
onClick={(e) => { e.stopPropagation(); handleEdit(row); }}
className="p-2 text-zinc-400 hover:text-brand-600 dark:hover:text-brand-400 transition-all"
>
<PencilSquareIcon className="w-5 h-5" />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDelete(row.id); }}
className="p-2 text-zinc-400 hover:text-rose-600 dark:hover:text-rose-400 transition-all"
>
<TrashIcon className="w-5 h-5" />
</button>
</div>
)
}
];
return (
<div className="space-y-6">
<PageHeader
title="Produtos & Estoque"
description="Controle seu inventário, gerencie preços e acompanhe a disponibilidade de itens."
primaryAction={{
label: "Novo Item",
icon: <PlusIcon className="w-5 h-5" />,
onClick: () => {
setEditingProduct(null);
resetForm();
setIsModalOpen(true);
}
}}
/>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<StatsCard
title="Total em Estoque"
value={formatCurrency(totalStockValue)}
icon={<CurrencyDollarIcon className="w-6 h-6 text-emerald-500" />}
/>
<StatsCard
title="Itens com Estoque Baixo"
value={lowStockItems}
icon={<ExclamationTriangleIcon className="w-6 h-6 text-rose-500" />}
/>
<StatsCard
title="Total de Produtos"
value={products.filter(p => p.type === 'product').length}
icon={<PackageIcon className="w-6 h-6 text-indigo-500" />}
/>
<StatsCard
title="Total de Serviços"
value={servicesCount}
icon={<TagIcon className="w-6 h-6 text-amber-500" />}
/>
</div>
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div className="relative w-full sm:w-96">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-400" />
<Input
placeholder="Buscar por nome ou SKU..."
className="pl-10 h-10 border-zinc-200 dark:border-zinc-800"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex gap-2 w-full sm:w-auto">
<button className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-xl text-sm font-bold text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-all">
<FunnelIcon className="w-4 h-4" />
Filtros
</button>
</div>
</div>
<Card noPadding className="overflow-hidden">
<DataTable
selectable
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
columns={columns}
data={filteredProducts}
isLoading={loading}
/>
</Card>
<Transition show={isModalOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={() => setIsModalOpen(false)}>
<TransitionChild
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-black/40 backdrop-blur-sm" />
</TransitionChild>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95 translate-y-4"
enterTo="opacity-100 scale-100 translate-y-0"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100 translate-y-0"
leaveTo="opacity-0 scale-95 translate-y-4"
>
<DialogPanel className="w-full max-w-2xl transform overflow-hidden rounded-[32px] bg-white dark:bg-zinc-900 p-8 text-left align-middle shadow-2xl transition-all border border-gray-100 dark:border-zinc-800">
<div className="flex justify-between items-center mb-8">
<DialogTitle as="h3" className="text-xl font-bold text-zinc-900 dark:text-white">
{editingProduct ? 'Editar Item' : 'Novo Produto/Serviço'}
</DialogTitle>
<button
onClick={() => setIsModalOpen(false)}
className="p-2 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-full transition-all"
>
<XMarkIcon className="w-6 h-6 text-zinc-400" />
</button>
</div>
<form onSubmit={handleSave} className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2">
<div className="flex gap-4">
<button
type="button"
onClick={() => setFormData({ ...formData, type: 'product' })}
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-2xl border transition-all font-bold text-sm ${formData.type === 'product' ? 'border-indigo-500 bg-indigo-50 text-indigo-600' : 'border-zinc-200 dark:border-zinc-700 text-zinc-400'}`}
>
<PackageIcon className="w-5 h-5" />
Produto
</button>
<button
type="button"
onClick={() => setFormData({ ...formData, type: 'service' })}
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-2xl border transition-all font-bold text-sm ${formData.type === 'service' ? 'border-amber-500 bg-amber-50 text-amber-600' : 'border-zinc-200 dark:border-zinc-700 text-zinc-400'}`}
>
<TagIcon className="w-5 h-5" />
Serviço
</button>
</div>
</div>
<div className="md:col-span-2">
<Input
label="Nome do Item"
required
placeholder="Ex: Teclado Mecânico RGB"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="bg-zinc-50 dark:bg-zinc-800 border-zinc-200 dark:border-zinc-700"
/>
</div>
<Input
label="SKU / Código"
placeholder="Ex: PROD-001"
value={formData.sku}
onChange={(e) => setFormData({ ...formData, sku: e.target.value })}
className="bg-zinc-50 dark:bg-zinc-800 border-zinc-200 dark:border-zinc-700"
/>
<Input
label="Estoque Inicial"
type="number"
disabled={formData.type === 'service'}
placeholder="0"
value={formData.stock_quantity}
onChange={(e) => setFormData({ ...formData, stock_quantity: Number(e.target.value) })}
className="bg-zinc-50 dark:bg-zinc-800 border-zinc-200 dark:border-zinc-700"
/>
<Input
label="Preço de Venda"
type="number"
step="0.01"
required
placeholder="0,00"
value={formData.price}
onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })}
className="bg-zinc-50 dark:bg-zinc-800 border-zinc-200 dark:border-zinc-700"
/>
<Input
label="Preço de Custo"
type="number"
step="0.01"
placeholder="0,00"
value={formData.cost_price}
onChange={(e) => setFormData({ ...formData, cost_price: Number(e.target.value) })}
className="bg-zinc-50 dark:bg-zinc-800 border-zinc-200 dark:border-zinc-700"
/>
<div className="md:col-span-2">
<label className="block text-xs font-black text-zinc-400 uppercase tracking-widest mb-2">Descrição</label>
<textarea
className="w-full px-4 py-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-2xl focus:ring-2 focus:ring-brand-500/20 outline-none transition-all placeholder:text-zinc-400 text-sm h-32 resize-none"
placeholder="Detalhes sobre o produto ou serviço..."
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
/>
</div>
<div className="md:col-span-2 pt-6 flex justify-end gap-3">
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="px-6 py-3 text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 font-bold transition-all"
>
Cancelar
</button>
<button
type="submit"
className="px-8 py-3 text-white rounded-2xl font-bold shadow-lg hover:opacity-90 transition-all flex items-center gap-2"
style={{ background: 'var(--gradient)' }}
>
<CheckIcon className="w-5 h-5" />
Salvar Item
</button>
</div>
</form>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
<ConfirmDialog
isOpen={bulkConfirmOpen}
onClose={() => setBulkConfirmOpen(false)}
onConfirm={handleConfirmBulkDelete}
title="Excluir Produtos Selecionados"
message={`Tem certeza que deseja excluir os ${selectedIds.length} produtos selecionados? Esta ação não pode ser desfeita.`}
confirmText="Excluir Tudo"
variant="danger"
/>
<ConfirmDialog
isOpen={confirmOpen}
onClose={() => {
setConfirmOpen(false);
setProductToDelete(null);
}}
onConfirm={handleConfirmDelete}
title="Excluir Item"
message="Tem certeza que deseja excluir este produto ou serviço? Esta ação não pode ser desfeita."
confirmText="Excluir"
cancelText="Cancelar"
variant="danger"
/>
<BulkActionBar
selectedCount={selectedIds.length}
onClearSelection={() => setSelectedIds([])}
actions={[
{
label: "Excluir Selecionados",
icon: <TrashIcon className="w-5 h-5" />,
onClick: handleBulkDelete,
variant: 'danger'
}
]}
/>
</div>
);
}

View File

@@ -0,0 +1,12 @@
'use client';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
import FinanceContent from '@/components/erp/FinanceContent';
export default function CaixaPage() {
return (
<SolutionGuard requiredSolution="erp">
<FinanceContent />
</SolutionGuard>
);
}

View File

@@ -0,0 +1,23 @@
'use client';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
import { AdjustmentsHorizontalIcon } from "@heroicons/react/24/outline";
import { PageHeader } from "@/components/ui";
export default function ConfiguracoesPage() {
return (
<SolutionGuard requiredSolution="erp">
<div className="p-6 max-w-[1600px] mx-auto space-y-6">
<PageHeader
title="Configurações do ERP"
description="Personalize as categorias financeiras, contas e parâmetros do sistema."
/>
<div className="flex flex-col items-center justify-center py-20 bg-white dark:bg-zinc-900 rounded-[32px] border border-zinc-200 dark:border-zinc-800">
<AdjustmentsHorizontalIcon className="w-16 h-16 text-zinc-300 mb-4" />
<h3 className="text-xl font-bold text-zinc-900 dark:text-white">Módulo em Desenvolvimento</h3>
<p className="text-zinc-500 max-w-sm text-center mt-2">Em breve você poderá configurar suas categorias, contas bancárias e fluxos operacionais aqui.</p>
</div>
</div>
</SolutionGuard>
);
}

View File

@@ -0,0 +1,358 @@
'use client';
import React, { useState, useEffect, Fragment } from 'react';
import {
PlusIcon,
MagnifyingGlassIcon,
UserIcon,
BriefcaseIcon,
TrashIcon,
PencilSquareIcon,
ArrowRightIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import { erpApi, Entity } from '@/lib/api-erp';
import { useToast } from '@/components/layout/ToastContext';
import {
StatsCard,
DataTable,
Input,
Card,
CustomSelect,
PageHeader,
BulkActionBar,
ConfirmDialog,
} from "@/components/ui";
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
import Link from 'next/link';
interface CRMCustomer {
id: string;
name: string;
email: string;
company: string;
phone: string;
}
function EntidadesContent() {
const toast = useToast();
const [entities, setEntities] = useState<Entity[]>([]);
const [crmCustomers, setCrmCustomers] = useState<CRMCustomer[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false);
const [entityToDelete, setEntityToDelete] = useState<string | null>(null);
const [editingEntity, setEditingEntity] = useState<Partial<Entity> | null>(null);
const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]);
const [formData, setFormData] = useState<Partial<Entity>>({
name: '',
type: 'supplier',
document: '',
email: '',
phone: '',
address: '',
});
useEffect(() => {
fetchAllData();
}, []);
const fetchAllData = async (silent = false) => {
try {
if (!silent) setLoading(true);
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
const [erpData, crmResp] = await Promise.all([
erpApi.getEntities(),
fetch('/api/crm/customers', {
headers: { 'Authorization': `Bearer ${token}` }
}).then(res => res.ok ? res.json() : [])
]);
setEntities(erpData || []);
setCrmCustomers(crmResp?.customers || crmResp || []);
} catch (error) {
toast.error('Erro ao carregar', 'Não foi possível carregar os dados financeiros');
} finally {
setLoading(false);
setSelectedIds([]);
}
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingEntity?.id) {
await erpApi.updateEntity(editingEntity.id, formData);
toast.success('Cadastro atualizado!');
} else {
await erpApi.createEntity(formData);
toast.success('Entidade cadastrada!');
}
setIsModalOpen(false);
setEditingEntity(null);
await fetchAllData();
} catch (error) {
toast.error('Erro ao salvar');
}
};
const handleConfirmDelete = async () => {
if (!entityToDelete) return;
const originalEntities = [...entities];
const idToDelete = String(entityToDelete);
// Dynamic: remove instantly
setEntities(prev => prev.filter(e => String(e.id) !== idToDelete));
try {
await erpApi.deleteEntity(idToDelete);
toast.success('Exclusão completa', 'A entidade foi removida com sucesso.');
setTimeout(() => fetchAllData(true), 500);
} catch (error) {
setEntities(originalEntities);
toast.error('Erro ao excluir', 'Ocorreu um erro ao excluir a entidade.');
} finally {
setConfirmOpen(false);
setEntityToDelete(null);
}
};
const handleBulkDelete = async () => {
if (selectedIds.length === 0) return;
setBulkConfirmOpen(true);
};
const handleConfirmBulkDelete = async () => {
const erpIds = selectedIds.filter(id => {
const item = combinedData.find(d => d.id === id);
return item?.source === 'ERP';
}).map(String);
if (erpIds.length === 0) return;
const originalEntities = [...entities];
// Dynamic: remove instantly
setEntities(prev => prev.filter(e => !erpIds.includes(String(e.id))));
try {
await Promise.all(erpIds.map(id => erpApi.deleteEntity(id)));
toast.success('Exclusão completa', `${erpIds.length} entidades excluídas com sucesso.`);
setTimeout(() => fetchAllData(true), 500);
} catch (error) {
setEntities(originalEntities);
toast.error('Erro ao excluir', 'Ocorreu um erro ao excluir algumas entidades.');
} finally {
setBulkConfirmOpen(false);
setSelectedIds([]);
}
};
// Combine both for searching
const combinedData = [
...crmCustomers.map(c => ({
id: c.id,
name: c.name,
email: c.email,
phone: c.phone,
source: 'CRM' as const,
type: 'Cliente (CRM)',
original: c
})),
...entities.map(e => ({
id: e.id,
name: e.name,
email: e.email,
phone: e.phone,
source: 'ERP' as const,
type: e.type === 'customer' ? 'Cliente (ERP)' : (e.type === 'supplier' ? 'Fornecedor (ERP)' : 'Ambos'),
original: e
}))
];
const filteredData = combinedData.filter(d =>
d.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(d.email || '').toLowerCase().includes(searchTerm.toLowerCase())
);
if (loading && combinedData.length === 0) return (
<div className="p-6 max-w-[1600px] mx-auto">
<div className="text-center py-20 text-zinc-500">Carregando parceiros de negócio...</div>
</div>
);
return (
<div className="p-6 max-w-[1600px] mx-auto space-y-6">
<PageHeader
title="Parceiros de Negócio"
description="Gerencie seus Clientes (CRM) e Fornecedores (ERP) em um único lugar."
primaryAction={{
label: "Novo Fornecedor",
icon: <PlusIcon className="w-5 h-5" />,
onClick: () => {
setEditingEntity(null);
setFormData({ name: '', type: 'supplier', document: '', email: '', phone: '', address: '' });
setIsModalOpen(true);
}
}}
secondaryAction={{
label: "Ir para CRM Clientes",
icon: <UserIcon className="w-5 h-5" />,
onClick: () => window.location.href = '/crm/clientes'
}}
/>
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<StatsCard
title="Clientes no CRM"
value={crmCustomers.length}
icon={<UserIcon className="w-6 h-6 text-emerald-500" />}
/>
<StatsCard
title="Fornecedores no ERP"
value={entities.filter(e => e.type === 'supplier' || e.type === 'both').length}
icon={<BriefcaseIcon className="w-6 h-6 text-purple-500" />}
/>
</div>
<Card noPadding>
<div className="p-4 border-b border-zinc-100 dark:border-zinc-800">
<div className="max-w-md">
<Input
placeholder="Pesquisar por nome ou e-mail em toda a base..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
leftIcon={<MagnifyingGlassIcon className="w-5 h-5 text-zinc-400" />}
/>
</div>
</div>
<DataTable
selectable
isLoading={loading}
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
data={filteredData}
columns={[
{
header: 'Nome / Razão Social',
accessor: (row) => (
<div className="flex flex-col">
<span className="font-bold text-zinc-900 dark:text-white">{row.name}</span>
<div className="flex items-center gap-2 mt-0.5">
<span className={`text-[10px] px-1.5 py-0.5 rounded font-black uppercase ${row.source === 'CRM' ? 'bg-emerald-50 text-emerald-700' : 'bg-purple-50 text-purple-700'}`}>
{row.source}
</span>
<span className="text-[10px] text-zinc-400 font-medium">{row.type}</span>
</div>
</div>
)
},
{
header: 'E-mail',
accessor: (row) => row.email || '-'
},
{
header: 'Telefone',
accessor: (row) => row.phone || '-'
},
{
header: '',
className: 'text-right',
accessor: (row) => (
<div className="flex justify-end gap-2">
{row.source === 'ERP' ? (
<>
<button onClick={(e) => { e.stopPropagation(); setEditingEntity(row.original as Entity); setFormData(row.original as Entity); setIsModalOpen(true); }} className="p-2 text-zinc-400 hover:text-brand-500">
<PencilSquareIcon className="w-5 h-5" />
</button>
<button onClick={(e) => { e.stopPropagation(); setEntityToDelete(row.id as string); setConfirmOpen(true); }} className="p-2 text-zinc-400 hover:text-rose-500">
<TrashIcon className="w-5 h-5" />
</button>
</>
) : (
<Link href={`/crm/clientes?id=${row.id}`} onClick={(e) => e.stopPropagation()} className="p-2 text-zinc-400 hover:text-brand-500 flex items-center gap-1 text-xs font-bold">
Ver no CRM <ArrowRightIcon className="w-4 h-4" />
</Link>
)}
</div>
)
}
]}
/>
</Card>
</div>
{/* Modal de Cadastro ERP (Fornecedores) */}
<Transition show={isModalOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={() => setIsModalOpen(false)}>
<TransitionChild 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-black/40 backdrop-blur-sm" />
</TransitionChild>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4">
<TransitionChild as={Fragment} enter="ease-out duration-300" enterFrom="opacity-0 scale-95" enterTo="opacity-100 scale-100" leave="ease-in duration-200" leaveFrom="opacity-100 scale-100" leaveTo="opacity-0 scale-95">
<DialogPanel className="w-full max-w-lg transform overflow-hidden rounded-2xl bg-white dark:bg-zinc-900 p-8 shadow-xl transition-all border border-zinc-200 dark:border-zinc-800">
<DialogTitle className="text-xl font-bold mb-6">{editingEntity ? 'Editar Fornecedor' : 'Novo Fornecedor / Outros'}</DialogTitle>
<form onSubmit={handleSave} className="space-y-4">
<Input label="Nome / Razão Social" value={formData.name} onChange={e => setFormData({ ...formData, name: e.target.value })} required />
<CustomSelect label="Tipo" options={[{ label: 'Fornecedor', value: 'supplier' }, { label: 'Cliente (ERP Avulso)', value: 'customer' }, { label: 'Ambos', value: 'both' }]} value={formData.type || 'supplier'} onChange={val => setFormData({ ...formData, type: val as any })} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input label="Documento (CNPJ/CPF)" value={formData.document} onChange={e => setFormData({ ...formData, document: e.target.value })} />
<Input label="Telefone" value={formData.phone} onChange={e => setFormData({ ...formData, phone: e.target.value })} />
</div>
<Input label="E-mail" type="email" value={formData.email} onChange={e => setFormData({ ...formData, email: e.target.value })} />
<Input label="Endereço Completo" value={formData.address} onChange={e => setFormData({ ...formData, address: e.target.value })} />
<div className="flex justify-end gap-3 mt-8 pt-4 border-t border-zinc-100 dark:border-zinc-800">
<button type="button" onClick={() => setIsModalOpen(false)} className="px-4 py-2 text-zinc-500 font-bold">Cancelar</button>
<button type="submit" className="px-8 py-2 text-white rounded-xl font-bold" style={{ background: 'var(--gradient)' }}>Salvar Cadastro</button>
</div>
</form>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
<ConfirmDialog isOpen={confirmOpen} onClose={() => setConfirmOpen(false)} onConfirm={handleConfirmDelete} title="Excluir Cadastro" message="Tem certeza? Isso pode afetar lançamentos vinculados a esta entidade no ERP." confirmText="Excluir" />
<ConfirmDialog
isOpen={bulkConfirmOpen}
onClose={() => setBulkConfirmOpen(false)}
onConfirm={handleConfirmBulkDelete}
title="Excluir Itens Selecionados"
message={`Tem certeza que deseja excluir as entidades selecionadas? Esta ação não pode ser desfeita.`}
confirmText="Excluir Tudo"
variant="danger"
/>
<BulkActionBar
selectedCount={selectedIds.length}
onClearSelection={() => setSelectedIds([])}
actions={[
{
label: "Excluir Selecionados",
icon: <TrashIcon className="w-5 h-5" />,
onClick: handleBulkDelete,
variant: 'danger'
}
]}
/>
</div>
);
}
export default function EntidadesPage() {
return (
<SolutionGuard requiredSolution="erp">
<EntidadesContent />
</SolutionGuard>
);
}

View File

@@ -0,0 +1,14 @@
'use client';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
import ProductsPage from '../ProductsPage';
export default function EstoquePage() {
return (
<SolutionGuard requiredSolution="erp">
<div className="p-6 max-w-[1600px] mx-auto">
<ProductsPage />
</div>
</SolutionGuard>
);
}

View File

@@ -0,0 +1,12 @@
'use client';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
import FinanceContent from '@/components/erp/FinanceContent';
export default function ContasPagarPage() {
return (
<SolutionGuard requiredSolution="erp">
<FinanceContent type="pagar" />
</SolutionGuard>
);
}

View File

@@ -1,16 +1,315 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react';
import {
AreaChart, Area, PieChart, Pie, Cell, ResponsiveContainer, CartesianGrid, XAxis, YAxis, Tooltip, Legend
} from 'recharts';
import {
ArrowTrendingUpIcon,
ArrowTrendingDownIcon,
CubeIcon,
CurrencyDollarIcon,
CreditCardIcon,
ClockIcon,
} from "@heroicons/react/24/outline";
import { erpApi, FinancialTransaction, Order, FinancialCategory, Entity } from '@/lib/api-erp';
import { formatCurrency } from '@/lib/format';
import { PageHeader, StatsCard, Card } from "@/components/ui";
import { SolutionGuard } from '@/components/auth/SolutionGuard'; import { SolutionGuard } from '@/components/auth/SolutionGuard';
const COLORS = ['#8b5cf6', '#ec4899', '#f43f5e', '#f59e0b', '#10b981', '#3b82f6'];
function ERPDashboardContent() {
const [transactions, setTransactions] = useState<FinancialTransaction[]>([]);
const [orders, setOrders] = useState<Order[]>([]);
const [categories, setCategories] = useState<FinancialCategory[]>([]);
const [entities, setEntities] = useState<Entity[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const [txData, orderData, categoriesData, entitiesData] = await Promise.all([
erpApi.getTransactions(),
erpApi.getOrders(),
erpApi.getFinancialCategories(),
erpApi.getEntities()
]);
setTransactions(Array.isArray(txData) ? txData : []);
setOrders(Array.isArray(orderData) ? orderData : []);
setCategories(Array.isArray(categoriesData) ? categoriesData : []);
setEntities(Array.isArray(entitiesData) ? entitiesData : []);
} catch (error) {
console.error('Error fetching dashboard data:', error);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const paidTransactions = (transactions || []).filter(t => t.status === 'paid');
const totalIncome = paidTransactions
.filter(t => t.type === 'income')
.reduce((sum, t) => sum + Number(t.amount || 0), 0);
const totalExpense = paidTransactions
.filter(t => t.type === 'expense')
.reduce((sum, t) => sum + Number(t.amount || 0), 0);
const pendingIncome = (transactions || [])
.filter(t => t.type === 'income' && t.status === 'pending')
.reduce((sum, t) => sum + Number(t.amount || 0), 0);
const pendingExpense = (transactions || [])
.filter(t => t.type === 'expense' && t.status === 'pending')
.reduce((sum, t) => sum + Number(t.amount || 0), 0);
const balance = totalIncome - totalExpense;
// Process chart data (Income vs Expense by Month)
const getChartData = () => {
const months = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez'];
const currentYear = new Date().getFullYear();
const data = months.map((month, index) => {
const monthTransactions = paidTransactions.filter(t => {
const date = new Date(t.payment_date || t.due_date || '');
return date.getMonth() === index && date.getFullYear() === currentYear;
});
const income = monthTransactions
.filter(t => t.type === 'income')
.reduce((sum, t) => sum + Number(t.amount || 0), 0);
const expense = monthTransactions
.filter(t => t.type === 'expense')
.reduce((sum, t) => sum + Number(t.amount || 0), 0);
return { name: month, income, expense };
});
const currentMonthIndex = new Date().getMonth();
// Mostrar pelo menos os últimos 6 meses ou o ano todo se for o caso
return data.slice(Math.max(0, currentMonthIndex - 5), currentMonthIndex + 1);
};
// Process category data (Expenses by Category)
const getCategoryData = () => {
const expenseTransactions = paidTransactions.filter(t => t.type === 'expense');
const breakdown: Record<string, number> = {};
expenseTransactions.forEach(t => {
const category = categories.find(c => c.id === t.category_id)?.name || 'Outros';
breakdown[category] = (breakdown[category] || 0) + Number(t.amount || 0);
});
return Object.entries(breakdown)
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value)
.slice(0, 6);
};
const chartData = getChartData();
const categoryData = getCategoryData();
if (loading) return (
<div className="p-6 max-w-[1600px] mx-auto">
<div className="flex items-center justify-center h-[600px]">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-500"></div>
</div>
</div>
);
return (
<div className="p-6 max-w-[1600px] mx-auto space-y-6">
<PageHeader
title="Dashboard ERP"
description="Visão geral financeira e operacional em tempo real"
/>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<StatsCard
title="Receitas pagas"
value={formatCurrency(totalIncome)}
icon={<ArrowTrendingUpIcon className="w-6 h-6 text-emerald-500" />}
trend={{ value: formatCurrency(pendingIncome), label: 'pendente', type: 'up' }}
/>
<StatsCard
title="Despesas pagas"
value={formatCurrency(totalExpense)}
icon={<ArrowTrendingDownIcon className="w-6 h-6 text-rose-500" />}
trend={{ value: formatCurrency(pendingExpense), label: 'pendente', type: 'down' }}
/>
<StatsCard
title="Saldo em Caixa"
value={formatCurrency(balance)}
icon={<CurrencyDollarIcon className="w-6 h-6 text-brand-500" />}
/>
<StatsCard
title="Pedidos (Mês)"
value={(orders?.length || 0).toString()}
icon={<CubeIcon className="w-6 h-6 text-purple-500" />}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<Card title="Evolução Financeira" description="Diferença entre entradas e saídas pagas nos últimos meses.">
<div className="h-[350px] w-full mt-4">
{chartData.some(d => d.income > 0 || d.expense > 0) ? (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient id="colorIncome" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.1} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorExpense" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.1} />
<stop offset="95%" stopColor="#ef4444" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#88888820" />
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: '#888' }} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: '#888' }} tickFormatter={(val) => `R$${val}`} />
<Tooltip
contentStyle={{
borderRadius: '16px',
border: 'none',
boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)',
backgroundColor: 'rgba(255, 255, 255, 0.9)'
}}
formatter={(value: any) => formatCurrency(value || 0)}
/>
<Legend verticalAlign="top" height={36} />
<Area
name="Receitas"
type="monotone"
dataKey="income"
stroke="#10b981"
fillOpacity={1}
fill="url(#colorIncome)"
strokeWidth={3}
/>
<Area
name="Despesas"
type="monotone"
dataKey="expense"
stroke="#ef4444"
fillOpacity={1}
fill="url(#colorExpense)"
strokeWidth={3}
/>
</AreaChart>
</ResponsiveContainer>
) : (
<div className="flex items-center justify-center h-full text-zinc-400 text-sm italic">
Ainda não dados financeiros suficientes para exibir o gráfico.
</div>
)}
</div>
</Card>
</div>
<div className="lg:col-span-1">
<Card title="Despesas por Categoria" description="Distribuição dos gastos pagos.">
<div className="h-[350px] w-full mt-4">
{categoryData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={categoryData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
paddingAngle={5}
dataKey="value"
>
{categoryData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip
contentStyle={{
borderRadius: '16px',
border: 'none',
boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)'
}}
formatter={(value: any) => formatCurrency(value || 0)}
/>
<Legend />
</PieChart>
</ResponsiveContainer>
) : (
<div className="flex items-center justify-center h-full text-zinc-400 text-sm italic">
Ainda não despesas pagas registradas.
</div>
)}
</div>
</Card>
</div>
<div className="lg:col-span-3">
<Card title="Transações Recentes" description="Últimos lançamentos financeiros registrados no sistema.">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-zinc-100 dark:border-zinc-800">
<th className="py-4 font-semibold text-zinc-900 dark:text-white">Descrição</th>
<th className="py-4 font-semibold text-zinc-900 dark:text-white">Categoria</th>
<th className="py-4 font-semibold text-zinc-900 dark:text-white">Data</th>
<th className="py-4 font-semibold text-zinc-900 dark:text-white text-right">Valor</th>
<th className="py-4 font-semibold text-zinc-900 dark:text-white text-right">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-50 dark:divide-zinc-900">
{transactions.slice(0, 5).map((t) => (
<tr key={t.id} className="hover:bg-zinc-50 dark:hover:bg-zinc-800/50 transition-colors">
<td className="py-4 text-zinc-600 dark:text-zinc-400">{t.description}</td>
<td className="py-4 text-zinc-600 dark:text-zinc-400">
{categories.find(c => c.id === t.category_id)?.name || 'Outros'}
</td>
<td className="py-4 text-zinc-600 dark:text-zinc-400">
{new Date(t.payment_date || t.due_date || '').toLocaleDateString('pt-BR')}
</td>
<td className={`py-4 text-right font-medium ${t.type === 'income' ? 'text-emerald-600' : 'text-rose-600'}`}>
{t.type === 'income' ? '+' : '-'} {formatCurrency(t.amount)}
</td>
<td className="py-4 text-right">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${t.status === 'paid' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/20 dark:text-emerald-400' :
t.status === 'pending' ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/20 dark:text-amber-400' :
'bg-zinc-100 text-zinc-700 dark:bg-zinc-900/20 dark:text-zinc-400'
}`}>
{t.status === 'paid' ? 'Pago' : t.status === 'pending' ? 'Pendente' : 'Cancelado'}
</span>
</td>
</tr>
))}
{transactions.length === 0 && (
<tr>
<td colSpan={5} className="py-8 text-center text-zinc-400 italic">
Nenhuma transação encontrada.
</td>
</tr>
)}
</tbody>
</table>
</div>
</Card>
</div>
</div>
</div>
);
}
export default function ERPPage() { export default function ERPPage() {
return ( return (
<SolutionGuard requiredSolution="erp"> <SolutionGuard requiredSolution="erp">
<div className="p-6"> <ERPDashboardContent />
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">ERP</h1>
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-8 text-center">
<p className="text-gray-500">Sistema Integrado de Gestão Empresarial em breve</p>
</div>
</div>
</SolutionGuard> </SolutionGuard>
); );
} }

View File

@@ -0,0 +1,14 @@
'use client';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
import OrdersPage from '../OrdersPage';
export default function PedidosPage() {
return (
<SolutionGuard requiredSolution="erp">
<div className="p-6 max-w-[1600px] mx-auto">
<OrdersPage />
</div>
</SolutionGuard>
);
}

View File

@@ -0,0 +1,12 @@
'use client';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
import FinanceContent from '@/components/erp/FinanceContent';
export default function ContasReceberPage() {
return (
<SolutionGuard requiredSolution="erp">
<FinanceContent type="receber" />
</SolutionGuard>
);
}

View File

@@ -0,0 +1,198 @@
"use client";
import { useState } from 'react';
import {
CalendarIcon,
MagnifyingGlassIcon,
PlusIcon,
FunnelIcon,
ArrowPathIcon,
EllipsisVerticalIcon
} from "@heroicons/react/24/outline";
import { Button, Input, Select, PageHeader, Card, StatsCard, Tabs, DatePicker, CustomSelect } from "@/components/ui";
import {
UsersIcon,
CurrencyDollarIcon,
BriefcaseIcon as BriefcaseSolidIcon,
ArrowTrendingUpIcon,
TableCellsIcon,
ChartPieIcon,
Cog6ToothIcon as CogIcon
} from "@heroicons/react/24/outline";
export default function TestPage() {
const [searchTerm, setSearchTerm] = useState('');
const [dateRange, setDateRange] = useState<{ start: Date | null; end: Date | null }>({ start: null, end: null });
const [status, setStatus] = useState('all');
// Dados fictícios para a lista
const items = [
{ id: 1, name: 'Projeto Alpha', client: 'Empresa A', date: '2023-10-01', status: 'Ativo', amount: 'R$ 1.500,00' },
{ id: 2, name: 'Serviço Beta', client: 'Empresa B', date: '2023-10-05', status: 'Pendente', amount: 'R$ 2.300,00' },
{ id: 3, name: 'Consultoria Gamma', client: 'Empresa C', date: '2023-10-10', status: 'Concluído', amount: 'R$ 800,00' },
{ id: 4, name: 'Design Delta', client: 'Empresa D', date: '2023-10-12', status: 'Ativo', amount: 'R$ 4.200,00' },
];
return (
<div className="p-6 max-w-[1600px] mx-auto space-y-6">
<PageHeader
title="Página de Teste"
description="Área de desenvolvimento e homologação de novos componentes do padrão Aggios."
primaryAction={{
label: "Novo Item",
icon: <PlusIcon className="w-4 h-4" />,
onClick: () => console.log('Novo Item')
}}
/>
{/* Stats Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
<StatsCard
title="Total de Clientes"
value="1.240"
icon={<UsersIcon className="w-6 h-6" />}
trend={{ value: '12%', label: 'vs mês passado', type: 'up' }}
/>
<StatsCard
title="Receita Mensal"
value="R$ 45.200"
icon={<CurrencyDollarIcon className="w-6 h-6" />}
trend={{ value: '8.4%', label: 'vs mês passado', type: 'up' }}
/>
<StatsCard
title="Projetos Ativos"
value="42"
icon={<BriefcaseSolidIcon className="w-6 h-6" />}
trend={{ value: '2', label: 'novos esta semana', type: 'neutral' }}
/>
<StatsCard
title="Taxa de Conversão"
value="18.5%"
icon={<ArrowTrendingUpIcon className="w-6 h-6" />}
trend={{ value: '2.1%', label: 'vs mês passado', type: 'down' }}
/>
</div>
{/* Filters Area: Clean Visual (Solid contrast) */}
<div className="flex flex-col md:flex-row gap-4 items-center">
<div className="flex-1 w-full">
<Input
placeholder="Pesquisar registros..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
leftIcon={<MagnifyingGlassIcon className="w-5 h-5 text-zinc-400" />}
className="bg-white dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800 focus:border-zinc-400 dark:focus:border-zinc-500"
/>
</div>
<div className="w-full md:w-80">
<DatePicker
value={dateRange}
onChange={setDateRange}
buttonClassName="bg-white dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800 text-zinc-700 dark:text-zinc-300 hover:border-zinc-400"
/>
</div>
<div className="w-full md:w-56">
<CustomSelect
value={status}
onChange={setStatus}
options={[
{ label: 'Todos os Status', value: 'all' },
{ label: 'Ativo', value: 'active', color: 'bg-emerald-500' },
{ label: 'Pendente', value: 'pending', color: 'bg-amber-500' },
{ label: 'Concluído', value: 'done', color: 'bg-blue-500' },
]}
buttonClassName="bg-white dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800 hover:border-zinc-400"
/>
</div>
</div>
{/* Content Tabs */}
<Tabs
items={[
{
label: 'Visão Geral',
icon: <TableCellsIcon />,
content: (
<Card noPadding title="Itens Recentes" description="Lista de últimos itens cadastrados no sistema.">
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="bg-zinc-50/50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-800 text-left">
<th className="px-6 py-4 text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Item</th>
<th className="px-6 py-4 text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Cliente</th>
<th className="px-6 py-4 text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Data</th>
<th className="px-6 py-4 text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Valor</th>
<th className="px-6 py-4 text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider text-right">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
{items.map((item) => (
<tr key={item.id} className="hover:bg-zinc-50 dark:hover:bg-zinc-800/50 transition-colors group">
<td className="px-6 py-4">
<div className="font-medium text-zinc-900 dark:text-white">{item.name}</div>
<div className="text-xs text-zinc-500">ID: #{item.id}</div>
</td>
<td className="px-6 py-4 text-sm text-zinc-600 dark:text-zinc-300">{item.client}</td>
<td className="px-6 py-4 text-sm text-zinc-600 dark:text-zinc-300">
<div className="flex items-center gap-2">
<CalendarIcon className="w-4 h-4 text-zinc-400" />
{new Date(item.date).toLocaleDateString('pt-BR')}
</div>
</td>
<td className="px-6 py-4 text-sm font-semibold text-zinc-900 dark:text-white">{item.amount}</td>
<td className="px-6 py-4 text-right">
<button className="p-2 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800 text-zinc-400 hover:text-zinc-600 transition-colors">
<EllipsisVerticalIcon className="w-5 h-5" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="p-4 bg-zinc-50/30 dark:bg-zinc-900/30 border-t border-zinc-200 dark:border-zinc-800 flex items-center justify-between">
<span className="text-xs text-zinc-500 italic">Exibindo {items.length} resultados encontrados.</span>
<div className="flex gap-2">
<Button variant="outline" size="sm">Anterior</Button>
<Button variant="outline" size="sm">Próximo</Button>
</div>
</div>
</Card>
)
},
{
label: 'Relatórios',
icon: <ChartPieIcon />,
content: (
<Card title="Analytics" description="Visualize o desempenho dos seus itens em tempo real.">
<div className="flex items-center justify-center h-48 border-2 border-dashed border-zinc-200 dark:border-zinc-800 rounded-xl">
<p className="text-zinc-400 text-sm font-medium">Gráficos e métricas detalhadas serão exibidos aqui.</p>
</div>
</Card>
)
},
{
label: 'Configurações',
icon: <CogIcon />,
content: (
<Card title="Preferências" description="Ajuste as configurações deste módulo de teste.">
<div className="space-y-4">
<div className="flex items-center justify-between p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-xl">
<div>
<p className="text-sm font-bold text-zinc-900 dark:text-white">Notificações por E-mail</p>
<p className="text-xs text-zinc-500">Receba alertas automáticos sobre novos itens.</p>
</div>
<div className="w-10 h-6 bg-brand-500 rounded-full relative">
<div className="absolute right-1 top-1 w-4 h-4 bg-white rounded-full"></div>
</div>
</div>
</div>
</Card>
)
}
]}
/>
</div>
);
}

View File

@@ -9,6 +9,7 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ path
try { try {
const response = await fetch(`http://backend:8080/api/${path}${req.nextUrl.search}`, { const response = await fetch(`http://backend:8080/api/${path}${req.nextUrl.search}`, {
method: "GET", method: "GET",
cache: 'no-store',
headers: { headers: {
"Authorization": token || "", "Authorization": token || "",
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -78,3 +79,32 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ pat
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
} }
} }
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path: pathArray } = await params;
const path = pathArray?.join("/") || "";
const token = req.headers.get("authorization");
const host = req.headers.get("host");
try {
const response = await fetch(`http://backend:8080/api/${path}${req.nextUrl.search}`, {
method: "DELETE",
headers: {
"Authorization": token || "",
"Content-Type": "application/json",
"X-Forwarded-Host": host || "",
"X-Original-Host": host || "",
},
});
if (response.status === 204) {
return new NextResponse(null, { status: 204 });
}
const data = await response.json().catch(() => ({}));
return NextResponse.json(data, { status: response.status });
} catch (error) {
console.error("API proxy error:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}

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,127 @@
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}`, {
cache: 'no-store',
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,67 @@
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`, {
cache: 'no-store',
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>
);
}

Binary file not shown.

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,199 @@
'use client';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Document, docApi } from '@/lib/api-docs';
import {
XMarkIcon,
CheckIcon,
ArrowLeftIcon,
Bars3BottomLeftIcon,
HashtagIcon,
CloudArrowUpIcon,
SparklesIcon
} from "@heroicons/react/24/outline";
import NotionEditor from './NotionEditor';
import DocumentSidebar from './DocumentSidebar';
import { toast } from 'react-hot-toast';
interface DocumentEditorProps {
initialDocument: Partial<Document> | null;
onSave: (doc: Partial<Document>) => void;
onCancel: () => void;
}
export default function DocumentEditor({ initialDocument, onSave, onCancel }: DocumentEditorProps) {
const [document, setDocument] = useState<Partial<Document> | null>(initialDocument);
const [title, setTitle] = useState(initialDocument?.title || '');
const [content, setContent] = useState(initialDocument?.content || '[]');
const [showSidebar, setShowSidebar] = useState(true);
const [saving, setSaving] = useState(false);
// Refs para controle fino de salvamento
const saveTimeout = useRef<NodeJS.Timeout | null>(null);
const lastSaved = useRef({ title: initialDocument?.title || '', content: initialDocument?.content || '[]' });
useEffect(() => {
if (initialDocument) {
setDocument(initialDocument);
setTitle(initialDocument.title || '');
setContent(initialDocument.content || '[]');
lastSaved.current = {
title: initialDocument.title || '',
content: initialDocument.content || '[]'
};
}
}, [initialDocument]);
// Função de Auto-Save Robusta
const autoSave = useCallback(async (newTitle: string, newContent: string) => {
if (!document?.id) return;
setSaving(true);
console.log('💾 Inactivity detected. Saving document...', document.id);
try {
await docApi.updateDocument(document.id, {
title: newTitle,
content: newContent,
status: 'published'
});
// Atualiza o ref do último salvo para evitar loop
lastSaved.current = { title: newTitle, content: newContent };
console.log('✅ Document saved successfully');
} catch (e) {
console.error('❌ Auto-save failed', e);
toast.error('Erro ao salvar automaticamente');
} finally {
// Delay visual para o feedback de "Salvo"
setTimeout(() => setSaving(false), 800);
}
}, [document?.id]);
// Trigger de auto-save com debounce de 1 segundo
useEffect(() => {
if (!document?.id) return;
// Verifica se houve mudança real em relação ao último salvo
if (title === lastSaved.current.title && content === lastSaved.current.content) {
return;
}
if (saveTimeout.current) clearTimeout(saveTimeout.current);
saveTimeout.current = setTimeout(() => {
autoSave(title, content);
}, 1000); // Salva após 1 segundo de inatividade
return () => {
if (saveTimeout.current) clearTimeout(saveTimeout.current);
};
}, [title, content, document?.id, autoSave]);
const navigateToDoc = async (doc: Document) => {
// Antes de navegar, salva o atual se necessário
if (title !== lastSaved.current.title || content !== lastSaved.current.content) {
await autoSave(title, content);
}
setDocument(doc);
setTitle(doc.title);
setContent(doc.content);
lastSaved.current = { title: doc.title, content: doc.content };
toast.success(`Abrindo: ${doc.title || 'Untitled'}`, { duration: 1000, position: 'bottom-center' });
};
return (
<div className="fixed inset-0 z-[60] bg-white dark:bg-zinc-950 flex flex-col">
{/* Header Clean */}
<header className="h-16 border-b border-zinc-200 dark:border-zinc-800 px-6 flex items-center justify-between bg-white dark:bg-zinc-950 z-20 shrink-0">
<div className="flex items-center gap-4 flex-1">
<button
onClick={async () => {
if (title !== lastSaved.current.title || content !== lastSaved.current.content) {
await autoSave(title, content);
}
onCancel();
}}
className="p-2 text-zinc-400 hover:text-zinc-900 dark:hover:text-white rounded-xl hover:bg-zinc-100 dark:hover:bg-zinc-900 transition-all"
title="Back to list"
>
<ArrowLeftIcon className="w-5 h-5" />
</button>
<button
onClick={() => setShowSidebar(!showSidebar)}
className={`p-2 rounded-xl transition-all ${showSidebar ? 'text-brand-500 bg-brand-50/50 dark:bg-brand-500/10' : 'text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-900'}`}
title={showSidebar ? "Hide Navigation" : "Show Navigation"}
>
<Bars3BottomLeftIcon className="w-5 h-5" />
</button>
<div className="h-4 w-px bg-zinc-200 dark:bg-zinc-800 mx-2" />
<div className="flex items-center gap-2 flex-1">
<HashtagIcon className="w-4 h-4 text-zinc-300" />
<input
type="text"
placeholder="Untitled Document"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="text-lg font-bold bg-transparent border-none outline-none text-zinc-900 dark:text-white placeholder:text-zinc-300 dark:placeholder:text-zinc-600 w-full max-w-xl"
/>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-zinc-50 dark:bg-zinc-900 border border-zinc-100 dark:border-zinc-800">
{saving ? (
<div className="flex items-center gap-2 text-brand-500">
<div className="w-1.5 h-1.5 bg-brand-500 rounded-full animate-pulse" />
<span className="text-[10px] font-black uppercase tracking-widest leading-none">Saving...</span>
</div>
) : (
<div className="flex items-center gap-2 text-emerald-500">
<CheckIcon className="w-3.5 h-3.5" />
<span className="text-[10px] font-black uppercase tracking-widest text-zinc-500 dark:text-zinc-400 leading-none">Sync Saved</span>
</div>
)}
</div>
</div>
</header>
<div className="flex-1 flex overflow-hidden">
{/* Lateral Sidebar (Navegação) */}
{showSidebar && document?.id && (
<DocumentSidebar
key={document.id} // Re-render sidebar on doc change to ensure fresh data
documentId={document.id}
onNavigate={navigateToDoc}
/>
)}
{/* Área do Editor */}
<main className="flex-1 overflow-y-auto bg-white dark:bg-zinc-950 flex flex-col items-center custom-scrollbar">
<div className="w-full max-w-[850px] px-12 md:px-24 py-16 animate-in slide-in-from-bottom-2 duration-500">
{/* Title Hero Display */}
<div className="mb-14 group">
<h1 className="text-5xl font-black text-zinc-900 dark:text-white tracking-tighter leading-tight">
{title || 'Untitled'}
</h1>
<div className="mt-6 flex items-center gap-4 text-zinc-400">
<SparklesIcon className="w-4 h-4 text-brand-500" />
<div className="flex items-center gap-2">
<div className="w-5 h-5 rounded-full bg-brand-500 flex items-center justify-center text-[10px] font-bold text-white uppercase">Me</div>
<span className="text-[10px] font-black uppercase tracking-widest">Editing Mode Real-time Sync</span>
</div>
</div>
</div>
<NotionEditor
documentId={document?.id}
initialContent={content}
onChange={setContent}
/>
</div>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,86 @@
'use client';
import React from 'react';
import { Document } from '@/lib/api-docs';
import {
DocumentTextIcon,
PencilSquareIcon,
TrashIcon,
CalendarIcon
} from "@heroicons/react/24/outline";
import { format, parseISO } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { Card } from "@/components/ui";
interface DocumentListProps {
documents: Document[];
onEdit: (doc: Document) => void;
onDelete: (id: string) => void;
}
export default function DocumentList({ documents, onEdit, onDelete }: DocumentListProps) {
if (documents.length === 0) {
return (
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-200 dark:border-zinc-800 p-12 text-center">
<DocumentTextIcon className="w-12 h-12 text-zinc-300 mx-auto mb-4" />
<h3 className="text-lg font-bold text-zinc-900 dark:text-white">Nenhum documento ainda</h3>
<p className="text-zinc-500 max-w-xs mx-auto mt-2">
Comece criando seu primeiro documento de texto para sua agência.
</p>
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{documents.map((doc) => (
<Card
key={doc.id}
className="group hover:shadow-xl transition-all border-2 border-transparent hover:border-brand-500/20"
>
<div className="flex flex-col h-full">
<div className="flex justify-between items-start mb-4">
<div className="p-3 bg-brand-50 dark:bg-brand-500/10 rounded-xl">
<DocumentTextIcon className="w-6 h-6 text-brand-600 dark:text-brand-400" />
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-all">
<button
onClick={() => onEdit(doc)}
className="p-2 text-zinc-400 hover:text-brand-500 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg transition-colors"
>
<PencilSquareIcon className="w-4 h-4" />
</button>
<button
onClick={() => onDelete(doc.id)}
className="p-2 text-zinc-400 hover:text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-500/10 rounded-lg transition-colors"
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
</div>
<h3 className="text-base font-bold text-zinc-900 dark:text-white mb-2 line-clamp-1">
{doc.title || 'Documento sem título'}
</h3>
<p className="text-sm text-zinc-500 line-clamp-3 mb-6 flex-1">
{doc.content ? doc.content.replace(/<[^>]*>/g, '').substring(0, 150) : 'Sem conteúdo...'}
</p>
<div className="flex items-center gap-2 pt-4 border-t border-zinc-100 dark:border-zinc-800">
<CalendarIcon className="w-3.5 h-3.5 text-zinc-400" />
<span className="text-[10px] font-bold text-zinc-400 uppercase tracking-wider">
{format(parseISO(doc.updated_at), "dd 'de' MMMM", { locale: ptBR })}
</span>
{doc.status === 'draft' && (
<span className="ml-auto text-[10px] font-black uppercase tracking-widest text-amber-500 bg-amber-50 dark:bg-amber-500/10 px-2 py-0.5 rounded-full">
Rascunho
</span>
)}
</div>
</div>
</Card>
))}
</div>
);
}

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