feat: redesign superadmin agencies list, implement flat design, add date filters, and fix UI bugs
This commit is contained in:
14
.claude/settings.local.json
Normal file
14
.claude/settings.local.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(docker-compose up:*)",
|
||||||
|
"Bash(docker-compose ps:*)",
|
||||||
|
"Bash(grep:*)",
|
||||||
|
"Bash(cat:*)",
|
||||||
|
"Bash(docker logs:*)",
|
||||||
|
"Bash(docker exec:*)",
|
||||||
|
"Bash(npx tsc:*)",
|
||||||
|
"Bash(docker-compose restart:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
37
.vscode/settings.json
vendored
37
.vscode/settings.json
vendored
@@ -1 +1,36 @@
|
|||||||
{}
|
{
|
||||||
|
// ============================================
|
||||||
|
// CONFIGURAÇÕES TAILWIND CSS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
"tailwindCSS.validate": false, // DESATIVA validação para remover avisos chatos
|
||||||
|
"tailwindCSS.showPixelEquivalents": false,
|
||||||
|
|
||||||
|
// ⚠️ ATENÇÃO: AVISOS "suggestCanonicalClasses" SÃO BUGS DO PLUGIN
|
||||||
|
// O Tailwind CSS IntelliSense está bugado e sugere sintaxe ERRADA.
|
||||||
|
//
|
||||||
|
// ✅ Sintaxe CORRETA (Tailwind v4):
|
||||||
|
// - [var(--brand-color)] ← Use isso!
|
||||||
|
// - bg-gradient-to-r ← Use isso!
|
||||||
|
//
|
||||||
|
// ❌ Sintaxe ERRADA (sugestão bugada):
|
||||||
|
// - (--brand-color) ← NÃO funciona!
|
||||||
|
// - bg-linear-to-r ← NÃO funciona!
|
||||||
|
//
|
||||||
|
// Por isso desativamos a validação acima (tailwindCSS.validate: false)
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CONFIGURAÇÕES CSS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
"css.validate": true,
|
||||||
|
"css.lint.unknownAtRules": "ignore",
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// MELHORIAS NO EDITOR
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
"editor.quickSuggestions": {
|
||||||
|
"strings": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
174
1. docs/mind-projeto-simples.md
Normal file
174
1. docs/mind-projeto-simples.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# Arquitetura Multi-tenant - Modelo de Negócio Aggios
|
||||||
|
|
||||||
|
## Visão Geral da Plataforma
|
||||||
|
|
||||||
|
A plataforma Aggios utiliza uma arquitetura multi-tenant em três camadas principais:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ aggios.app (Site Institucional) │
|
||||||
|
│ - Marketing │
|
||||||
|
│ - Cadastro de novas agências │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ dash.aggios.app (SuperAdmin) │
|
||||||
|
│ - Você (dono da plataforma) │
|
||||||
|
│ - Gerencia TODAS as agências │
|
||||||
|
│ - Vê analytics globais │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────┴───────────┐
|
||||||
|
▼ ▼
|
||||||
|
┌──────────────────┐ ┌──────────────────┐
|
||||||
|
│ idealpages. │ │ outraagencia. │
|
||||||
|
│ aggios.app │ │ aggios.app │
|
||||||
|
├──────────────────┤ ├──────────────────┤
|
||||||
|
│ Painel da │ │ Painel da │
|
||||||
|
│ IdeaPages │ │ Outra Agência │
|
||||||
|
│ │ │ │
|
||||||
|
│ • CRM │ │ • CRM │
|
||||||
|
│ • ERP │ │ • ERP │
|
||||||
|
│ • Projetos │ │ • Projetos │
|
||||||
|
│ • White Label │ │ • White Label │
|
||||||
|
│ (seu logo) │ │ (logo deles) │
|
||||||
|
└──────────────────┘ └──────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
Clientes da Clientes da
|
||||||
|
IdeaPages Outra Agência
|
||||||
|
```
|
||||||
|
|
||||||
|
## Como Funciona na Prática
|
||||||
|
|
||||||
|
### 1. Sua Agência (Exemplo: IdeaPages)
|
||||||
|
- **URL**: `idealpages.aggios.app`
|
||||||
|
- **White Label**: Logo e cores da IdeaPages
|
||||||
|
- **Clientes**: Cadastrados DENTRO da agência IdeaPages
|
||||||
|
- **Isolamento**: Cada cliente é isolado por tenant_id (multi-tenant)
|
||||||
|
|
||||||
|
### 2. Quando um Cliente Precisa do CRM
|
||||||
|
|
||||||
|
**Você SEMPRE manda a URL da sua agência**, não aggios.app!
|
||||||
|
|
||||||
|
- Cliente cria conta em `idealpages.aggios.app`
|
||||||
|
- Cliente acessa `idealpages.aggios.app` com login próprio
|
||||||
|
- Cliente vê **SEU logo** (IdeaPages)
|
||||||
|
- Cliente vê **SEU white label**
|
||||||
|
- Cliente só vê os dados DELE (isolamento por tenant)
|
||||||
|
|
||||||
|
### 3. Estrutura de Clientes
|
||||||
|
|
||||||
|
```
|
||||||
|
IdeaPages (você - agência)
|
||||||
|
├── Cliente 1 (Empresa ABC)
|
||||||
|
│ ├── Vê: Logo IdeaPages
|
||||||
|
│ ├── Acessa: idealpages.aggios.app
|
||||||
|
│ └── Usa: CRM, ERP, Projetos (dados isolados)
|
||||||
|
│
|
||||||
|
├── Cliente 2 (Tech Solutions)
|
||||||
|
│ ├── Vê: Logo IdeaPages
|
||||||
|
│ ├── Acessa: idealpages.aggios.app
|
||||||
|
│ └── Usa: CRM, ERP, Projetos (dados isolados)
|
||||||
|
│
|
||||||
|
└── Cliente 3 (Marketing Pro)
|
||||||
|
├── Vê: Logo IdeaPages
|
||||||
|
├── Acessa: idealpages.aggios.app
|
||||||
|
└── Usa: CRM, ERP, Projetos (dados isolados)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefícios para a Agência
|
||||||
|
|
||||||
|
✅ **White Label Completo**: Cliente vê sua marca, não "Aggios"
|
||||||
|
✅ **Controle Total**: Você gerencia todos os seus clientes
|
||||||
|
✅ **Isolamento de Dados**: Cada cliente só vê os próprios dados
|
||||||
|
✅ **Escalável**: Adicione quantos clientes quiser na mesma agência
|
||||||
|
✅ **Identidade Visual**: Logo e cores personalizadas por agência
|
||||||
|
|
||||||
|
## Fluxo de Trabalho
|
||||||
|
|
||||||
|
1. **Agência se cadastra** → Cria subdomínio (ex: idealpages.aggios.app)
|
||||||
|
2. **Agência personaliza** → Upload de logo, cores, identidade visual
|
||||||
|
3. **Agência adiciona clientes** → Cada cliente recebe credenciais
|
||||||
|
4. **Cliente acessa** → idealpages.aggios.app (vê marca da agência)
|
||||||
|
5. **Cliente usa módulos** → CRM, ERP, Projetos (dados isolados)
|
||||||
|
|
||||||
|
## Resposta Direta
|
||||||
|
|
||||||
|
**Pergunta**: "Cliente precisa do CRM, mando aggios.app ou idealpages.aggios.app?"
|
||||||
|
|
||||||
|
**Resposta**: **`idealpages.aggios.app`** ✅
|
||||||
|
|
||||||
|
O cliente SEMPRE acessa o painel da sua agência, onde verá sua marca e terá acesso aos módulos que você liberar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sistema de Links de Cadastro Personalizados
|
||||||
|
|
||||||
|
### Visão Geral
|
||||||
|
|
||||||
|
Sistema que permite ao SuperAdmin criar links de cadastro customizados, escolhendo:
|
||||||
|
- **Campos do formulário**: Quais informações coletar
|
||||||
|
- **Módulos habilitados**: Quais funcionalidades o cliente terá acesso
|
||||||
|
- **Branding**: Logo e cores personalizadas
|
||||||
|
|
||||||
|
### Fluxo de Uso
|
||||||
|
|
||||||
|
1. **SuperAdmin** acessa `dash.aggios.app/superadmin/signup-templates`
|
||||||
|
2. **Cria template** selecionando:
|
||||||
|
- Campos: email, senha, subdomínio, CNPJ, telefone, etc.
|
||||||
|
- Módulos: CRM, ERP, PROJECTS, FINANCIAL, etc.
|
||||||
|
- Slug: URL amigável (ex: `crm-rapido`)
|
||||||
|
3. **Compartilha link**: `aggios.app/cadastro/crm-rapido`
|
||||||
|
4. **Cliente acessa** e vê formulário personalizado
|
||||||
|
5. **Após cadastro**, tenant criado com módulos específicos
|
||||||
|
|
||||||
|
### Exemplo Real: DH Projects
|
||||||
|
|
||||||
|
```
|
||||||
|
Template: "CRM Rápido"
|
||||||
|
Slug: crm-rapido
|
||||||
|
Campos: email, senha, subdomínio, nome da empresa
|
||||||
|
Módulos: CRM
|
||||||
|
|
||||||
|
Link gerado: aggios.app/cadastro/crm-rapido
|
||||||
|
|
||||||
|
Cliente preenche:
|
||||||
|
- Email: contato@dhprojects.com
|
||||||
|
- Senha: ********
|
||||||
|
- Subdomínio: dhprojects
|
||||||
|
- Empresa: DH Projects
|
||||||
|
|
||||||
|
Resultado:
|
||||||
|
✅ Tenant criado: dhprojects.aggios.app
|
||||||
|
✅ Módulo CRM habilitado
|
||||||
|
✅ Outros módulos desabilitados
|
||||||
|
```
|
||||||
|
|
||||||
|
### Estrutura Técnica
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- Tabela: `signup_templates`
|
||||||
|
- Repository: `SignupTemplateRepository`
|
||||||
|
- Handlers: `/api/admin/signup-templates` (CRUD)
|
||||||
|
- Handler público: `/api/signup-templates/slug/{slug}` (renderiza form)
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- Gerenciamento: `dash.aggios.app/superadmin/signup-templates`
|
||||||
|
- Cadastro público: `aggios.app/cadastro/{slug}`
|
||||||
|
|
||||||
|
**Campos Disponíveis:**
|
||||||
|
- email, password, subdomain (obrigatórios)
|
||||||
|
- company_name, cnpj, phone, address, city, state, zipcode (opcionais)
|
||||||
|
|
||||||
|
**Módulos Disponíveis:**
|
||||||
|
- CRM, ERP, PROJECTS, FINANCIAL, INVENTORY, HR
|
||||||
|
|
||||||
|
### Benefícios
|
||||||
|
|
||||||
|
✅ Cadastro rápido para clientes específicos
|
||||||
|
✅ Coleta apenas informações necessárias
|
||||||
|
✅ Habilita somente módulos contratados
|
||||||
|
✅ Reduz fricção no onboarding
|
||||||
|
✅ Personalização por caso de uso
|
||||||
149
1. docs/nova-interface.md
Normal file
149
1. docs/nova-interface.md
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
# System Instruction: Arquitetura de Layout com Sidebar Expansível
|
||||||
|
|
||||||
|
**Role:** Senior React Developer & UI Specialist
|
||||||
|
**Tech Stack:** React, Tailwind CSS (Sem bibliotecas de ícones ou fontes externas).
|
||||||
|
|
||||||
|
**Objetivo:**
|
||||||
|
Implementar um sistema de layout "Dashboard" composto por um **Menu Lateral (Sidebar)** que expande e colapsa suavemente e uma área de conteúdo principal.
|
||||||
|
|
||||||
|
**Requisitos Críticos de Animação:**
|
||||||
|
1. A transição de largura da sidebar deve ser suave (transition-all duration-300).
|
||||||
|
2. O texto dos botões **não deve quebrar** ou desaparecer bruscamente. Use a técnica de transição de `max-width` e `opacity` para que o texto deslize suavemente para fora.
|
||||||
|
3. Não utilize bibliotecas de animação (Framer Motion, etc), apenas Tailwind CSS puro.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Componente: `DashboardLayout.tsx` (Container Principal)
|
||||||
|
|
||||||
|
Este componente deve gerenciar o estado global do menu (aberto/fechado) para evitar "prop drilling" desnecessário.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { SidebarRail } from './SidebarRail';
|
||||||
|
|
||||||
|
interface DashboardLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DashboardLayout: React.FC<DashboardLayoutProps> = ({ children }) => {
|
||||||
|
// Estado centralizado do layout
|
||||||
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
|
const [activeTab, setActiveTab] = useState('home');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-full bg-gray-900 text-slate-900 overflow-hidden p-3 gap-3">
|
||||||
|
{/* Sidebar controla seu próprio estado visual via props */}
|
||||||
|
<SidebarRail
|
||||||
|
activeTab={activeTab}
|
||||||
|
onTabChange={setActiveTab}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
onToggle={() => setIsExpanded(!isExpanded)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Área de Conteúdo (Children) */}
|
||||||
|
<main className="flex-1 h-full min-w-0 overflow-hidden flex flex-col bg-white rounded-3xl shadow-xl relative">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Componente: `SidebarRail.tsx` (Lógica de Animação)
|
||||||
|
|
||||||
|
Aqui reside a lógica visual. Substitua os ícones por `<span>Icon</span>` ou SVGs genéricos para manter o código agnóstico.
|
||||||
|
|
||||||
|
**Pontos de atenção no código abaixo:**
|
||||||
|
* `w-[220px]` vs `w-[72px]`: Define a largura física.
|
||||||
|
* `max-w-[150px]` vs `max-w-0`: Define a animação do texto.
|
||||||
|
* `whitespace-nowrap`: Impede que o texto pule de linha enquanto fecha.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface SidebarRailProps {
|
||||||
|
activeTab: string;
|
||||||
|
onTabChange: (tab: string) => void;
|
||||||
|
isExpanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SidebarRail: React.FC<SidebarRailProps> = ({ activeTab, onTabChange, isExpanded, onToggle }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
h-full bg-zinc-900 rounded-3xl flex flex-col py-6 gap-4 text-gray-400 shrink-0 border border-white/10 shadow-xl
|
||||||
|
transition-[width] duration-300 ease-[cubic-bezier(0.25,0.1,0.25,1)] px-3
|
||||||
|
${isExpanded ? 'w-[220px]' : 'w-[72px]'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Header / Toggle */}
|
||||||
|
<div className={`flex items-center w-full relative transition-all duration-300 mb-4 ${isExpanded ? 'justify-between px-1' : 'justify-center'}`}>
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center text-white font-bold shrink-0 z-10">
|
||||||
|
Logo
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Título com animação de opacidade e largura */}
|
||||||
|
<div className={`overflow-hidden transition-all duration-300 ease-in-out whitespace-nowrap absolute left-14 ${isExpanded ? 'opacity-100 max-w-[100px]' : 'opacity-0 max-w-0'}`}>
|
||||||
|
<span className="font-bold text-white text-lg">App Name</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navegação */}
|
||||||
|
<div className="flex flex-col gap-2 w-full">
|
||||||
|
<RailButton
|
||||||
|
label="Dashboard"
|
||||||
|
active={activeTab === 'home'}
|
||||||
|
onClick={() => onTabChange('home')}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
/>
|
||||||
|
<RailButton
|
||||||
|
label="Settings"
|
||||||
|
active={activeTab === 'settings'}
|
||||||
|
onClick={() => onTabChange('settings')}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer / Toggle Button */}
|
||||||
|
<div className="mt-auto">
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className="w-full p-2 rounded-xl hover:bg-white/10 text-gray-400 hover:text-white transition-colors flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{/* Ícone de Toggle Genérico */}
|
||||||
|
<span>{isExpanded ? '<<' : '>>'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Subcomponente do Botão (Essencial para a animação do texto)
|
||||||
|
const RailButton = ({ label, active, onClick, isExpanded }: any) => (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`
|
||||||
|
flex items-center p-2.5 rounded-xl transition-all duration-300 group relative overflow-hidden
|
||||||
|
${active ? 'bg-white/10 text-white' : 'hover:bg-white/5 hover:text-gray-200'}
|
||||||
|
${isExpanded ? '' : 'justify-center'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Placeholder do Ícone */}
|
||||||
|
<div className="shrink-0 flex items-center justify-center w-6 h-6 bg-gray-700/50 rounded text-[10px]">Icon</div>
|
||||||
|
|
||||||
|
{/* Lógica Mágica do Texto: Max-Width Transition */}
|
||||||
|
<div className={`
|
||||||
|
overflow-hidden whitespace-nowrap transition-all duration-300 ease-in-out
|
||||||
|
${isExpanded ? 'max-w-[150px] opacity-100 ml-3' : 'max-w-0 opacity-0 ml-0'}
|
||||||
|
`}>
|
||||||
|
<span className="font-medium text-sm">{label}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Indicador de Ativo (Barra lateral pequena quando fechado) */}
|
||||||
|
{active && !isExpanded && (
|
||||||
|
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-3 bg-white rounded-r-full -ml-3" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
```
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
_ "github.com/lib/pq"
|
_ "github.com/lib/pq"
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
"aggios-app/backend/internal/api/handlers"
|
"aggios-app/backend/internal/api/handlers"
|
||||||
"aggios-app/backend/internal/api/middleware"
|
"aggios-app/backend/internal/api/middleware"
|
||||||
@@ -53,6 +54,8 @@ func main() {
|
|||||||
userRepo := repository.NewUserRepository(db)
|
userRepo := repository.NewUserRepository(db)
|
||||||
tenantRepo := repository.NewTenantRepository(db)
|
tenantRepo := repository.NewTenantRepository(db)
|
||||||
companyRepo := repository.NewCompanyRepository(db)
|
companyRepo := repository.NewCompanyRepository(db)
|
||||||
|
signupTemplateRepo := repository.NewSignupTemplateRepository(db)
|
||||||
|
agencyTemplateRepo := repository.NewAgencyTemplateRepository(db)
|
||||||
|
|
||||||
// Initialize services
|
// Initialize services
|
||||||
authService := service.NewAuthService(userRepo, tenantRepo, cfg)
|
authService := service.NewAuthService(userRepo, tenantRepo, cfg)
|
||||||
@@ -67,6 +70,14 @@ func main() {
|
|||||||
agencyHandler := handlers.NewAgencyRegistrationHandler(agencyService, cfg)
|
agencyHandler := handlers.NewAgencyRegistrationHandler(agencyService, cfg)
|
||||||
tenantHandler := handlers.NewTenantHandler(tenantService)
|
tenantHandler := handlers.NewTenantHandler(tenantService)
|
||||||
companyHandler := handlers.NewCompanyHandler(companyService)
|
companyHandler := handlers.NewCompanyHandler(companyService)
|
||||||
|
signupTemplateHandler := handlers.NewSignupTemplateHandler(signupTemplateRepo, userRepo, tenantRepo, agencyService)
|
||||||
|
agencyTemplateHandler := handlers.NewAgencyTemplateHandler(agencyTemplateRepo, agencyService, userRepo, tenantRepo)
|
||||||
|
|
||||||
|
// Initialize upload handler
|
||||||
|
uploadHandler, err := handlers.NewUploadHandler(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("❌ Erro ao inicializar upload handler: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Create middleware chain
|
// Create middleware chain
|
||||||
tenantDetector := middleware.TenantDetector(tenantRepo)
|
tenantDetector := middleware.TenantDetector(tenantRepo)
|
||||||
@@ -76,44 +87,95 @@ func main() {
|
|||||||
authMiddleware := middleware.Auth(cfg)
|
authMiddleware := middleware.Auth(cfg)
|
||||||
|
|
||||||
// Setup routes
|
// Setup routes
|
||||||
mux := http.NewServeMux()
|
router := mux.NewRouter()
|
||||||
|
|
||||||
// Health check (no auth)
|
// Serve static files (uploads)
|
||||||
mux.HandleFunc("/health", healthHandler.Check)
|
fs := http.FileServer(http.Dir("./uploads"))
|
||||||
mux.HandleFunc("/api/health", healthHandler.Check)
|
router.PathPrefix("/uploads/").Handler(http.StripPrefix("/uploads", fs))
|
||||||
|
|
||||||
// Auth routes (public with rate limiting)
|
// ==================== PUBLIC ROUTES ====================
|
||||||
mux.HandleFunc("/api/auth/login", authHandler.Login)
|
|
||||||
|
|
||||||
// Protected auth routes
|
// Health check
|
||||||
mux.Handle("/api/auth/change-password", authMiddleware(http.HandlerFunc(authHandler.ChangePassword)))
|
router.HandleFunc("/health", healthHandler.Check)
|
||||||
|
router.HandleFunc("/api/health", healthHandler.Check)
|
||||||
|
|
||||||
// Agency management (SUPERADMIN only)
|
// Auth
|
||||||
mux.HandleFunc("/api/admin/agencies/register", agencyHandler.RegisterAgency)
|
router.HandleFunc("/api/auth/login", authHandler.Login)
|
||||||
mux.HandleFunc("/api/admin/agencies", tenantHandler.ListAll)
|
router.HandleFunc("/api/auth/register", agencyHandler.PublicRegister).Methods("POST")
|
||||||
mux.HandleFunc("/api/admin/agencies/", agencyHandler.HandleAgency)
|
|
||||||
mux.HandleFunc("/api/tenant/check", tenantHandler.CheckExists)
|
|
||||||
|
|
||||||
// Client registration (ADMIN_AGENCIA only - requires auth)
|
// Public agency template registration (for creating new agencies)
|
||||||
mux.Handle("/api/agencies/clients/register", authMiddleware(http.HandlerFunc(agencyHandler.RegisterClient)))
|
router.HandleFunc("/api/agency-templates", agencyTemplateHandler.GetTemplateBySlug).Methods("GET")
|
||||||
|
router.HandleFunc("/api/agency-signup/register", agencyTemplateHandler.PublicRegisterAgency).Methods("POST")
|
||||||
|
|
||||||
|
// Public client signup via templates
|
||||||
|
router.HandleFunc("/api/signup-templates/slug/{slug}", signupTemplateHandler.GetTemplateBySlug).Methods("GET")
|
||||||
|
router.HandleFunc("/api/signup/register", signupTemplateHandler.PublicRegister).Methods("POST")
|
||||||
|
|
||||||
|
// File upload (public for signup, will also work with auth)
|
||||||
|
router.HandleFunc("/api/upload", uploadHandler.Upload).Methods("POST")
|
||||||
|
|
||||||
|
// Tenant check (public)
|
||||||
|
router.HandleFunc("/api/tenant/check", tenantHandler.CheckExists).Methods("GET")
|
||||||
|
|
||||||
|
// Hash generator (dev only - remove in production)
|
||||||
|
router.HandleFunc("/api/hash", handlers.GenerateHash).Methods("POST")
|
||||||
|
|
||||||
|
// ==================== PROTECTED ROUTES ====================
|
||||||
|
|
||||||
|
// Auth (protected)
|
||||||
|
router.Handle("/api/auth/change-password", authMiddleware(http.HandlerFunc(authHandler.ChangePassword))).Methods("POST")
|
||||||
|
|
||||||
|
// SUPERADMIN: Agency management
|
||||||
|
router.HandleFunc("/api/admin/agencies/register", agencyHandler.RegisterAgency).Methods("POST")
|
||||||
|
router.HandleFunc("/api/admin/agencies", tenantHandler.ListAll).Methods("GET")
|
||||||
|
router.HandleFunc("/api/admin/agencies/{id}", agencyHandler.HandleAgency).Methods("GET", "PATCH", "DELETE")
|
||||||
|
|
||||||
|
// SUPERADMIN: Agency template management
|
||||||
|
router.Handle("/api/admin/agency-templates", authMiddleware(http.HandlerFunc(agencyTemplateHandler.ListTemplates))).Methods("GET")
|
||||||
|
router.Handle("/api/admin/agency-templates", authMiddleware(http.HandlerFunc(agencyTemplateHandler.CreateTemplate))).Methods("POST")
|
||||||
|
|
||||||
|
// SUPERADMIN: Client signup template management
|
||||||
|
router.Handle("/api/admin/signup-templates", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
signupTemplateHandler.ListTemplates(w, r)
|
||||||
|
} else if r.Method == http.MethodPost {
|
||||||
|
signupTemplateHandler.CreateTemplate(w, r)
|
||||||
|
}
|
||||||
|
}))).Methods("GET", "POST")
|
||||||
|
|
||||||
|
router.Handle("/api/admin/signup-templates/{id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
signupTemplateHandler.GetTemplateByID(w, r)
|
||||||
|
case http.MethodPut, http.MethodPatch:
|
||||||
|
signupTemplateHandler.UpdateTemplate(w, r)
|
||||||
|
case http.MethodDelete:
|
||||||
|
signupTemplateHandler.DeleteTemplate(w, r)
|
||||||
|
}
|
||||||
|
}))).Methods("GET", "PUT", "PATCH", "DELETE")
|
||||||
|
|
||||||
|
// ADMIN_AGENCIA: Client registration
|
||||||
|
router.Handle("/api/agencies/clients/register", authMiddleware(http.HandlerFunc(agencyHandler.RegisterClient))).Methods("POST")
|
||||||
|
|
||||||
// Agency profile routes (protected)
|
// Agency profile routes (protected)
|
||||||
mux.Handle("/api/agency/profile", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
router.Handle("/api/agency/profile", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method == http.MethodGet {
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
agencyProfileHandler.GetProfile(w, r)
|
agencyProfileHandler.GetProfile(w, r)
|
||||||
} else if r.Method == http.MethodPut || r.Method == http.MethodPatch {
|
case http.MethodPut, http.MethodPatch:
|
||||||
agencyProfileHandler.UpdateProfile(w, r)
|
agencyProfileHandler.UpdateProfile(w, r)
|
||||||
} else {
|
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
}
|
}
|
||||||
})))
|
}))).Methods("GET", "PUT", "PATCH")
|
||||||
|
|
||||||
// Protected routes (require authentication)
|
// Agency logo upload (protected)
|
||||||
mux.Handle("/api/companies", authMiddleware(http.HandlerFunc(companyHandler.List)))
|
router.Handle("/api/agency/logo", authMiddleware(http.HandlerFunc(agencyProfileHandler.UploadLogo))).Methods("POST")
|
||||||
mux.Handle("/api/companies/create", authMiddleware(http.HandlerFunc(companyHandler.Create)))
|
|
||||||
|
|
||||||
// Apply global middlewares: tenant -> cors -> security -> rateLimit -> mux
|
// Company routes (protected)
|
||||||
handler := tenantDetector(corsMiddleware(securityMiddleware(rateLimitMiddleware(mux))))
|
router.Handle("/api/companies", authMiddleware(http.HandlerFunc(companyHandler.List))).Methods("GET")
|
||||||
|
router.Handle("/api/companies/create", authMiddleware(http.HandlerFunc(companyHandler.Create))).Methods("POST")
|
||||||
|
|
||||||
|
// Apply global middlewares: tenant -> cors -> security -> rateLimit -> router
|
||||||
|
handler := tenantDetector(corsMiddleware(securityMiddleware(rateLimitMiddleware(router))))
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
addr := fmt.Sprintf(":%s", cfg.Server.Port)
|
addr := fmt.Sprintf(":%s", cfg.Server.Port)
|
||||||
|
|||||||
@@ -6,5 +6,6 @@ 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/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
|
github.com/minio/minio-go/v7 v7.0.63
|
||||||
golang.org/x/crypto v0.27.0
|
golang.org/x/crypto v0.27.0
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"aggios-app/backend/internal/config"
|
"aggios-app/backend/internal/config"
|
||||||
@@ -13,6 +12,7 @@ import (
|
|||||||
"aggios-app/backend/internal/service"
|
"aggios-app/backend/internal/service"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/gorilla/mux"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,6 +45,8 @@ func (h *AgencyRegistrationHandler) RegisterAgency(w http.ResponseWriter, r *htt
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("📥 Registering agency: %s (subdomain: %s)", req.AgencyName, req.Subdomain)
|
log.Printf("📥 Registering agency: %s (subdomain: %s)", req.AgencyName, req.Subdomain)
|
||||||
|
log.Printf("📊 Payload received: RazaoSocial=%s, Phone=%s, City=%s, State=%s, Neighborhood=%s, TeamSize=%s, PrimaryColor=%s, SecondaryColor=%s",
|
||||||
|
req.RazaoSocial, req.Phone, req.City, req.State, req.Neighborhood, req.TeamSize, req.PrimaryColor, req.SecondaryColor)
|
||||||
|
|
||||||
tenant, admin, err := h.agencyService.RegisterAgency(req)
|
tenant, admin, err := h.agencyService.RegisterAgency(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -104,6 +106,112 @@ func (h *AgencyRegistrationHandler) RegisterAgency(w http.ResponseWriter, r *htt
|
|||||||
json.NewEncoder(w).Encode(response)
|
json.NewEncoder(w).Encode(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PublicRegister handles public agency registration
|
||||||
|
func (h *AgencyRegistrationHandler) PublicRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req domain.PublicRegisterAgencyRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
log.Printf("❌ Error decoding request: %v", err)
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("📥 Public Registering agency: %s (subdomain: %s)", req.CompanyName, req.Subdomain)
|
||||||
|
log.Printf("📦 Full Payload: %+v", req)
|
||||||
|
|
||||||
|
// Map to internal request
|
||||||
|
phone := ""
|
||||||
|
if len(req.Contacts) > 0 {
|
||||||
|
phone = req.Contacts[0].Whatsapp
|
||||||
|
}
|
||||||
|
|
||||||
|
internalReq := domain.RegisterAgencyRequest{
|
||||||
|
AgencyName: req.CompanyName,
|
||||||
|
Subdomain: req.Subdomain,
|
||||||
|
CNPJ: req.CNPJ,
|
||||||
|
RazaoSocial: req.RazaoSocial,
|
||||||
|
Description: req.Description,
|
||||||
|
Website: req.Website,
|
||||||
|
Industry: req.Industry,
|
||||||
|
Phone: phone,
|
||||||
|
TeamSize: req.TeamSize,
|
||||||
|
CEP: req.CEP,
|
||||||
|
State: req.State,
|
||||||
|
City: req.City,
|
||||||
|
Neighborhood: req.Neighborhood,
|
||||||
|
Street: req.Street,
|
||||||
|
Number: req.Number,
|
||||||
|
Complement: req.Complement,
|
||||||
|
PrimaryColor: req.PrimaryColor,
|
||||||
|
SecondaryColor: req.SecondaryColor,
|
||||||
|
LogoURL: req.LogoURL,
|
||||||
|
AdminEmail: req.Email,
|
||||||
|
AdminPassword: req.Password,
|
||||||
|
AdminName: req.FullName,
|
||||||
|
}
|
||||||
|
|
||||||
|
tenant, admin, err := h.agencyService.RegisterAgency(internalReq)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("❌ Error registering agency: %v", err)
|
||||||
|
switch err {
|
||||||
|
case service.ErrSubdomainTaken:
|
||||||
|
http.Error(w, err.Error(), http.StatusConflict)
|
||||||
|
case service.ErrEmailAlreadyExists:
|
||||||
|
http.Error(w, err.Error(), http.StatusConflict)
|
||||||
|
case service.ErrWeakPassword:
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
default:
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("✅ Agency created: %s (ID: %s)", tenant.Name, tenant.ID)
|
||||||
|
|
||||||
|
// Generate JWT token for the new admin
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"user_id": admin.ID.String(),
|
||||||
|
"email": admin.Email,
|
||||||
|
"role": admin.Role,
|
||||||
|
"tenant_id": tenant.ID.String(),
|
||||||
|
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days
|
||||||
|
"iat": time.Now().Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
tokenString, err := token.SignedString([]byte(h.cfg.JWT.Secret))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol := "http://"
|
||||||
|
if h.cfg.App.Environment == "production" {
|
||||||
|
protocol = "https://"
|
||||||
|
}
|
||||||
|
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"token": tokenString,
|
||||||
|
"id": admin.ID,
|
||||||
|
"email": admin.Email,
|
||||||
|
"name": admin.Name,
|
||||||
|
"role": admin.Role,
|
||||||
|
"tenantId": tenant.ID,
|
||||||
|
"company": tenant.Name,
|
||||||
|
"subdomain": tenant.Subdomain,
|
||||||
|
"message": "Agency registered successfully",
|
||||||
|
"access_url": protocol + tenant.Domain,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
// RegisterClient handles client registration (ADMIN_AGENCIA only)
|
// RegisterClient handles client registration (ADMIN_AGENCIA only)
|
||||||
func (h *AgencyRegistrationHandler) RegisterClient(w http.ResponseWriter, r *http.Request) {
|
func (h *AgencyRegistrationHandler) RegisterClient(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
@@ -147,9 +255,10 @@ func (h *AgencyRegistrationHandler) HandleAgency(w http.ResponseWriter, r *http.
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
agencyID := strings.TrimPrefix(r.URL.Path, "/api/admin/agencies/")
|
vars := mux.Vars(r)
|
||||||
if agencyID == "" || agencyID == r.URL.Path {
|
agencyID := vars["id"]
|
||||||
http.NotFound(w, r)
|
if agencyID == "" {
|
||||||
|
http.Error(w, "Missing agency ID", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,6 +283,27 @@ func (h *AgencyRegistrationHandler) HandleAgency(w http.ResponseWriter, r *http.
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(details)
|
json.NewEncoder(w).Encode(details)
|
||||||
|
|
||||||
|
case http.MethodPatch:
|
||||||
|
var updateData map[string]interface{}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&updateData); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if isActive, ok := updateData["is_active"].(bool); ok {
|
||||||
|
if err := h.agencyService.UpdateAgencyStatus(id, isActive); err != nil {
|
||||||
|
if errors.Is(err, service.ErrTenantNotFound) {
|
||||||
|
http.Error(w, "Agency not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"message": "Status updated"})
|
||||||
|
|
||||||
case http.MethodDelete:
|
case http.MethodDelete:
|
||||||
if err := h.agencyService.DeleteAgency(id); err != nil {
|
if err := h.agencyService.DeleteAgency(id); err != nil {
|
||||||
if errors.Is(err, service.ErrTenantNotFound) {
|
if errors.Is(err, service.ErrTenantNotFound) {
|
||||||
|
|||||||
225
backend/internal/api/handlers/agency_logo.go
Normal file
225
backend/internal/api/handlers/agency_logo.go
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"aggios-app/backend/internal/api/middleware"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/minio/minio-go/v7"
|
||||||
|
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UploadLogo handles logo file uploads
|
||||||
|
func (h *AgencyHandler) UploadLogo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Only accept POST
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Logo upload request received from tenant")
|
||||||
|
|
||||||
|
// Get tenant ID from context
|
||||||
|
tenantIDVal := r.Context().Value(middleware.TenantIDKey)
|
||||||
|
if tenantIDVal == nil {
|
||||||
|
log.Printf("No tenant ID in context")
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get as uuid.UUID first, if that fails try string and parse
|
||||||
|
var tenantID uuid.UUID
|
||||||
|
var ok bool
|
||||||
|
|
||||||
|
tenantID, ok = tenantIDVal.(uuid.UUID)
|
||||||
|
if !ok {
|
||||||
|
// Try as string
|
||||||
|
tenantIDStr, isString := tenantIDVal.(string)
|
||||||
|
if !isString {
|
||||||
|
log.Printf("Invalid tenant ID type: %T", tenantIDVal)
|
||||||
|
http.Error(w, "Invalid tenant ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
tenantID, err = uuid.Parse(tenantIDStr)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to parse tenant ID: %v", err)
|
||||||
|
http.Error(w, "Invalid tenant ID format", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Processing logo upload for tenant: %s", tenantID)
|
||||||
|
|
||||||
|
// 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 contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/svg+xml" && contentType != "image/jpg" {
|
||||||
|
http.Error(w, "Only PNG, JPG or SVG files are allowed", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get logo type (logo or horizontal)
|
||||||
|
logoType := r.FormValue("type")
|
||||||
|
if logoType != "logo" && logoType != "horizontal" {
|
||||||
|
logoType = "logo"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current logo URL from database to delete old file
|
||||||
|
var currentLogoURL string
|
||||||
|
var queryErr error
|
||||||
|
if logoType == "horizontal" {
|
||||||
|
queryErr = h.tenantRepo.DB().QueryRow("SELECT logo_horizontal_url FROM tenants WHERE id = $1", tenantID).Scan(¤tLogoURL)
|
||||||
|
} else {
|
||||||
|
queryErr = h.tenantRepo.DB().QueryRow("SELECT logo_url FROM tenants WHERE id = $1", tenantID).Scan(¤tLogoURL)
|
||||||
|
}
|
||||||
|
if queryErr != nil && queryErr.Error() != "sql: no rows in result set" {
|
||||||
|
log.Printf("Warning: Failed to get current logo URL: %v", queryErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize MinIO client
|
||||||
|
minioClient, err := minio.New("aggios-minio:9000", &minio.Options{
|
||||||
|
Creds: credentials.NewStaticV4("minioadmin", "M1n10_S3cur3_P@ss_2025!", ""),
|
||||||
|
Secure: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to create MinIO client: %v", err)
|
||||||
|
http.Error(w, "Storage service unavailable", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure bucket exists
|
||||||
|
bucketName := "aggios-logos"
|
||||||
|
ctx := context.Background()
|
||||||
|
exists, err := minioClient.BucketExists(ctx, bucketName)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to check bucket: %v", err)
|
||||||
|
http.Error(w, "Storage error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
err = minioClient.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to create bucket: %v", err)
|
||||||
|
http.Error(w, "Storage error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Set bucket policy to public-read
|
||||||
|
policy := fmt.Sprintf(`{
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Principal": {"AWS": ["*"]},
|
||||||
|
"Action": ["s3:GetObject"],
|
||||||
|
"Resource": ["arn:aws:s3:::%s/*"]
|
||||||
|
}]
|
||||||
|
}`, bucketName)
|
||||||
|
err = minioClient.SetBucketPolicy(ctx, bucketName, policy)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: Failed to set bucket policy: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read file content
|
||||||
|
fileBytes, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to read file", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique filename
|
||||||
|
ext := filepath.Ext(header.Filename)
|
||||||
|
filename := fmt.Sprintf("tenants/%s/%s-%d%s", tenantID, logoType, time.Now().Unix(), ext)
|
||||||
|
|
||||||
|
// Upload to MinIO
|
||||||
|
_, err = minioClient.PutObject(
|
||||||
|
ctx,
|
||||||
|
bucketName,
|
||||||
|
filename,
|
||||||
|
bytes.NewReader(fileBytes),
|
||||||
|
int64(len(fileBytes)),
|
||||||
|
minio.PutObjectOptions{
|
||||||
|
ContentType: contentType,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to upload to MinIO: %v", err)
|
||||||
|
http.Error(w, "Failed to save file", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate public URL
|
||||||
|
logoURL := fmt.Sprintf("http://localhost:9000/%s/%s", bucketName, filename)
|
||||||
|
|
||||||
|
log.Printf("Logo uploaded successfully: %s", logoURL)
|
||||||
|
|
||||||
|
// Delete old logo file from MinIO if exists
|
||||||
|
if currentLogoURL != "" && currentLogoURL != "https://via.placeholder.com/150" {
|
||||||
|
// Extract object key from URL
|
||||||
|
// Example: http://localhost:9000/aggios-logos/tenants/uuid/logo-123.png -> tenants/uuid/logo-123.png
|
||||||
|
oldFilename := ""
|
||||||
|
if len(currentLogoURL) > 0 {
|
||||||
|
// Split by bucket name
|
||||||
|
if idx := len("http://localhost:9000/aggios-logos/"); idx < len(currentLogoURL) {
|
||||||
|
oldFilename = currentLogoURL[idx:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if oldFilename != "" {
|
||||||
|
err = minioClient.RemoveObject(ctx, bucketName, oldFilename, minio.RemoveObjectOptions{})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: Failed to delete old logo %s: %v", oldFilename, err)
|
||||||
|
// Don't fail the request if deletion fails
|
||||||
|
} else {
|
||||||
|
log.Printf("Old logo deleted successfully: %s", oldFilename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update tenant record in database
|
||||||
|
var err2 error
|
||||||
|
if logoType == "horizontal" {
|
||||||
|
_, err2 = h.tenantRepo.DB().Exec("UPDATE tenants SET logo_horizontal_url = $1, updated_at = NOW() WHERE id = $2", logoURL, tenantID)
|
||||||
|
} else {
|
||||||
|
_, err2 = h.tenantRepo.DB().Exec("UPDATE tenants SET logo_url = $1, updated_at = NOW() WHERE id = $2", logoURL, tenantID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err2 != nil {
|
||||||
|
log.Printf("Failed to update logo: %v", err2)
|
||||||
|
http.Error(w, "Failed to update database", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return success response
|
||||||
|
response := map[string]string{
|
||||||
|
"logo_url": logoURL,
|
||||||
|
"message": "Logo uploaded successfully",
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
@@ -29,12 +29,20 @@ type AgencyProfileResponse struct {
|
|||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
Website string `json:"website"`
|
Website string `json:"website"`
|
||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
|
Neighborhood string `json:"neighborhood"`
|
||||||
|
Number string `json:"number"`
|
||||||
|
Complement string `json:"complement"`
|
||||||
City string `json:"city"`
|
City string `json:"city"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
Zip string `json:"zip"`
|
Zip string `json:"zip"`
|
||||||
RazaoSocial string `json:"razao_social"`
|
RazaoSocial string `json:"razao_social"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Industry string `json:"industry"`
|
Industry string `json:"industry"`
|
||||||
|
TeamSize string `json:"team_size"`
|
||||||
|
PrimaryColor string `json:"primary_color"`
|
||||||
|
SecondaryColor string `json:"secondary_color"`
|
||||||
|
LogoURL string `json:"logo_url"`
|
||||||
|
LogoHorizontalURL string `json:"logo_horizontal_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateAgencyProfileRequest struct {
|
type UpdateAgencyProfileRequest struct {
|
||||||
@@ -44,12 +52,20 @@ type UpdateAgencyProfileRequest struct {
|
|||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
Website string `json:"website"`
|
Website string `json:"website"`
|
||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
|
Neighborhood string `json:"neighborhood"`
|
||||||
|
Number string `json:"number"`
|
||||||
|
Complement string `json:"complement"`
|
||||||
City string `json:"city"`
|
City string `json:"city"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
Zip string `json:"zip"`
|
Zip string `json:"zip"`
|
||||||
RazaoSocial string `json:"razao_social"`
|
RazaoSocial string `json:"razao_social"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Industry string `json:"industry"`
|
Industry string `json:"industry"`
|
||||||
|
TeamSize string `json:"team_size"`
|
||||||
|
PrimaryColor string `json:"primary_color"`
|
||||||
|
SecondaryColor string `json:"secondary_color"`
|
||||||
|
LogoURL string `json:"logo_url"`
|
||||||
|
LogoHorizontalURL string `json:"logo_horizontal_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetProfile returns the current agency profile
|
// GetProfile returns the current agency profile
|
||||||
@@ -61,10 +77,8 @@ func (h *AgencyHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Get tenant from context (set by auth middleware)
|
// Get tenant from context (set by auth middleware)
|
||||||
tenantID := r.Context().Value(middleware.TenantIDKey)
|
tenantID := r.Context().Value(middleware.TenantIDKey)
|
||||||
log.Printf("DEBUG GetProfile: tenantID from context = %v (type: %T)", tenantID, tenantID)
|
|
||||||
|
|
||||||
if tenantID == nil {
|
if tenantID == nil {
|
||||||
log.Printf("DEBUG GetProfile: tenantID is nil from auth middleware")
|
|
||||||
http.Error(w, "Tenant not found in context", http.StatusUnauthorized)
|
http.Error(w, "Tenant not found in context", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -87,6 +101,10 @@ func (h *AgencyHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("🔍 GetProfile for tenant %s: Found %s", tid, tenant.Name)
|
||||||
|
log.Printf("📄 Tenant Data: Address=%s, Number=%s, TeamSize=%s, RazaoSocial=%s",
|
||||||
|
tenant.Address, tenant.Number, tenant.TeamSize, tenant.RazaoSocial)
|
||||||
|
|
||||||
response := AgencyProfileResponse{
|
response := AgencyProfileResponse{
|
||||||
ID: tenant.ID.String(),
|
ID: tenant.ID.String(),
|
||||||
Name: tenant.Name,
|
Name: tenant.Name,
|
||||||
@@ -95,12 +113,20 @@ func (h *AgencyHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
Phone: tenant.Phone,
|
Phone: tenant.Phone,
|
||||||
Website: tenant.Website,
|
Website: tenant.Website,
|
||||||
Address: tenant.Address,
|
Address: tenant.Address,
|
||||||
|
Neighborhood: tenant.Neighborhood,
|
||||||
|
Number: tenant.Number,
|
||||||
|
Complement: tenant.Complement,
|
||||||
City: tenant.City,
|
City: tenant.City,
|
||||||
State: tenant.State,
|
State: tenant.State,
|
||||||
Zip: tenant.Zip,
|
Zip: tenant.Zip,
|
||||||
RazaoSocial: tenant.RazaoSocial,
|
RazaoSocial: tenant.RazaoSocial,
|
||||||
Description: tenant.Description,
|
Description: tenant.Description,
|
||||||
Industry: tenant.Industry,
|
Industry: tenant.Industry,
|
||||||
|
TeamSize: tenant.TeamSize,
|
||||||
|
PrimaryColor: tenant.PrimaryColor,
|
||||||
|
SecondaryColor: tenant.SecondaryColor,
|
||||||
|
LogoURL: tenant.LogoURL,
|
||||||
|
LogoHorizontalURL: tenant.LogoHorizontalURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@@ -143,11 +169,19 @@ func (h *AgencyHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
"phone": req.Phone,
|
"phone": req.Phone,
|
||||||
"website": req.Website,
|
"website": req.Website,
|
||||||
"address": req.Address,
|
"address": req.Address,
|
||||||
|
"neighborhood": req.Neighborhood,
|
||||||
|
"number": req.Number,
|
||||||
|
"complement": req.Complement,
|
||||||
"city": req.City,
|
"city": req.City,
|
||||||
"state": req.State,
|
"state": req.State,
|
||||||
"zip": req.Zip,
|
"zip": req.Zip,
|
||||||
"description": req.Description,
|
"description": req.Description,
|
||||||
"industry": req.Industry,
|
"industry": req.Industry,
|
||||||
|
"team_size": req.TeamSize,
|
||||||
|
"primary_color": req.PrimaryColor,
|
||||||
|
"secondary_color": req.SecondaryColor,
|
||||||
|
"logo_url": req.LogoURL,
|
||||||
|
"logo_horizontal_url": req.LogoHorizontalURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update in database
|
// Update in database
|
||||||
@@ -171,14 +205,23 @@ func (h *AgencyHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
Phone: tenant.Phone,
|
Phone: tenant.Phone,
|
||||||
Website: tenant.Website,
|
Website: tenant.Website,
|
||||||
Address: tenant.Address,
|
Address: tenant.Address,
|
||||||
|
Neighborhood: tenant.Neighborhood,
|
||||||
|
Number: tenant.Number,
|
||||||
|
Complement: tenant.Complement,
|
||||||
City: tenant.City,
|
City: tenant.City,
|
||||||
State: tenant.State,
|
State: tenant.State,
|
||||||
Zip: tenant.Zip,
|
Zip: tenant.Zip,
|
||||||
RazaoSocial: tenant.RazaoSocial,
|
RazaoSocial: tenant.RazaoSocial,
|
||||||
Description: tenant.Description,
|
Description: tenant.Description,
|
||||||
Industry: tenant.Industry,
|
Industry: tenant.Industry,
|
||||||
|
TeamSize: tenant.TeamSize,
|
||||||
|
PrimaryColor: tenant.PrimaryColor,
|
||||||
|
SecondaryColor: tenant.SecondaryColor,
|
||||||
|
LogoURL: tenant.LogoURL,
|
||||||
|
LogoHorizontalURL: tenant.LogoHorizontalURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(response)
|
json.NewEncoder(w).Encode(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
239
backend/internal/api/handlers/agency_template_handler.go
Normal file
239
backend/internal/api/handlers/agency_template_handler.go
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"aggios-app/backend/internal/domain"
|
||||||
|
"aggios-app/backend/internal/repository"
|
||||||
|
"aggios-app/backend/internal/service"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AgencyTemplateHandler struct {
|
||||||
|
templateRepo *repository.AgencyTemplateRepository
|
||||||
|
agencyService *service.AgencyService
|
||||||
|
userRepo *repository.UserRepository
|
||||||
|
tenantRepo *repository.TenantRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAgencyTemplateHandler(
|
||||||
|
templateRepo *repository.AgencyTemplateRepository,
|
||||||
|
agencyService *service.AgencyService,
|
||||||
|
userRepo *repository.UserRepository,
|
||||||
|
tenantRepo *repository.TenantRepository,
|
||||||
|
) *AgencyTemplateHandler {
|
||||||
|
return &AgencyTemplateHandler{
|
||||||
|
templateRepo: templateRepo,
|
||||||
|
agencyService: agencyService,
|
||||||
|
userRepo: userRepo,
|
||||||
|
tenantRepo: tenantRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTemplateBySlug - Public endpoint to get template details
|
||||||
|
func (h *AgencyTemplateHandler) GetTemplateBySlug(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := r.URL.Query().Get("slug")
|
||||||
|
if slug == "" {
|
||||||
|
http.Error(w, "Missing slug parameter", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
template, err := h.templateRepo.FindBySlug(slug)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Template not found: %v", err)
|
||||||
|
http.Error(w, "Template not found or expired", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(template)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicRegisterAgency - Public endpoint for agency registration via template
|
||||||
|
func (h *AgencyTemplateHandler) PublicRegisterAgency(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req domain.AgencyRegistrationViaTemplate
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Validar template
|
||||||
|
template, err := h.templateRepo.FindBySlug(req.TemplateSlug)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Template error: %v", err)
|
||||||
|
http.Error(w, "Invalid or expired template", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Validar campos obrigatórios
|
||||||
|
if req.AgencyName == "" || req.Subdomain == "" || req.AdminEmail == "" || req.AdminPassword == "" {
|
||||||
|
http.Error(w, "Missing required fields", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Validar senha
|
||||||
|
if len(req.AdminPassword) < 8 {
|
||||||
|
http.Error(w, "Password must be at least 8 characters", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Verificar se email já existe
|
||||||
|
existingUser, _ := h.userRepo.FindByEmail(req.AdminEmail)
|
||||||
|
if existingUser != nil {
|
||||||
|
http.Error(w, "Email already registered", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Verificar se subdomain já existe
|
||||||
|
existingTenant, _ := h.tenantRepo.FindBySubdomain(req.Subdomain)
|
||||||
|
if existingTenant != nil {
|
||||||
|
http.Error(w, "Subdomain already taken", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Hash da senha
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.AdminPassword), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error hashing password: %v", err)
|
||||||
|
http.Error(w, "Error processing password", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Criar tenant (agência)
|
||||||
|
tenant := &domain.Tenant{
|
||||||
|
Name: req.AgencyName,
|
||||||
|
Domain: req.Subdomain + ".aggios.app",
|
||||||
|
Subdomain: req.Subdomain,
|
||||||
|
CNPJ: req.CNPJ,
|
||||||
|
RazaoSocial: req.RazaoSocial,
|
||||||
|
Website: req.Website,
|
||||||
|
Phone: req.Phone,
|
||||||
|
Description: req.Description,
|
||||||
|
Industry: req.Industry,
|
||||||
|
TeamSize: req.TeamSize,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Endereço (se fornecido)
|
||||||
|
if req.Address != nil {
|
||||||
|
tenant.Address = req.Address["street"]
|
||||||
|
tenant.Number = req.Address["number"]
|
||||||
|
tenant.Complement = req.Address["complement"]
|
||||||
|
tenant.Neighborhood = req.Address["neighborhood"]
|
||||||
|
tenant.City = req.Address["city"]
|
||||||
|
tenant.State = req.Address["state"]
|
||||||
|
tenant.Zip = req.Address["cep"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Personalização do template
|
||||||
|
if template.CustomPrimaryColor.Valid {
|
||||||
|
tenant.PrimaryColor = template.CustomPrimaryColor.String
|
||||||
|
}
|
||||||
|
if template.CustomLogoURL.Valid {
|
||||||
|
tenant.LogoURL = template.CustomLogoURL.String
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.tenantRepo.Create(tenant); err != nil {
|
||||||
|
log.Printf("Error creating tenant: %v", err)
|
||||||
|
http.Error(w, "Error creating agency", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Criar usuário admin da agência
|
||||||
|
user := &domain.User{
|
||||||
|
Email: req.AdminEmail,
|
||||||
|
Password: string(hashedPassword),
|
||||||
|
Name: req.AdminName,
|
||||||
|
Role: "ADMIN_AGENCIA",
|
||||||
|
TenantID: &tenant.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.userRepo.Create(user); err != nil {
|
||||||
|
log.Printf("Error creating user: %v", err)
|
||||||
|
http.Error(w, "Error creating admin user", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Incrementar contador de uso do template
|
||||||
|
if err := h.templateRepo.IncrementUsageCount(template.ID.String()); err != nil {
|
||||||
|
log.Printf("Warning: failed to increment usage count: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Preparar resposta com redirect
|
||||||
|
redirectURL := template.RedirectURL.String
|
||||||
|
if redirectURL == "" {
|
||||||
|
redirectURL = "http://" + req.Subdomain + ".localhost/login"
|
||||||
|
}
|
||||||
|
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"message": template.SuccessMessage.String,
|
||||||
|
"tenant_id": tenant.ID,
|
||||||
|
"user_id": user.ID,
|
||||||
|
"redirect_url": redirectURL,
|
||||||
|
"subdomain": req.Subdomain,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTemplate - SUPERADMIN only
|
||||||
|
func (h *AgencyTemplateHandler) CreateTemplate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req domain.CreateAgencyTemplateRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
formFieldsJSON, _ := repository.FormFieldsToJSON(req.FormFields)
|
||||||
|
modulesJSON, _ := json.Marshal(req.AvailableModules)
|
||||||
|
|
||||||
|
template := &domain.AgencySignupTemplate{
|
||||||
|
Name: req.Name,
|
||||||
|
Slug: req.Slug,
|
||||||
|
Description: req.Description,
|
||||||
|
FormFields: formFieldsJSON,
|
||||||
|
AvailableModules: modulesJSON,
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.CustomPrimaryColor != "" {
|
||||||
|
template.CustomPrimaryColor.Valid = true
|
||||||
|
template.CustomPrimaryColor.String = req.CustomPrimaryColor
|
||||||
|
}
|
||||||
|
if req.CustomLogoURL != "" {
|
||||||
|
template.CustomLogoURL.Valid = true
|
||||||
|
template.CustomLogoURL.String = req.CustomLogoURL
|
||||||
|
}
|
||||||
|
if req.RedirectURL != "" {
|
||||||
|
template.RedirectURL.Valid = true
|
||||||
|
template.RedirectURL.String = req.RedirectURL
|
||||||
|
}
|
||||||
|
if req.SuccessMessage != "" {
|
||||||
|
template.SuccessMessage.Valid = true
|
||||||
|
template.SuccessMessage.String = req.SuccessMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.templateRepo.Create(template); err != nil {
|
||||||
|
log.Printf("Error creating template: %v", err)
|
||||||
|
http.Error(w, "Error creating template", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(template)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTemplates - SUPERADMIN only
|
||||||
|
func (h *AgencyTemplateHandler) ListTemplates(w http.ResponseWriter, r *http.Request) {
|
||||||
|
templates, err := h.templateRepo.List()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Error fetching templates", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(templates)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -55,28 +56,38 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Login handles user login
|
// Login handles user login
|
||||||
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log.Printf("🔐 LOGIN HANDLER CALLED - Method: %s", r.Method)
|
||||||
|
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
|
log.Printf("❌ Method not allowed: %s", r.Method)
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
bodyBytes, err := io.ReadAll(r.Body)
|
bodyBytes, err := io.ReadAll(r.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("❌ Failed to read body: %v", err)
|
||||||
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer r.Body.Close()
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
log.Printf("📥 Raw body: %s", string(bodyBytes))
|
||||||
|
|
||||||
// Trim whitespace to avoid decode errors caused by BOM or stray chars
|
// Trim whitespace to avoid decode errors caused by BOM or stray chars
|
||||||
sanitized := strings.TrimSpace(string(bodyBytes))
|
sanitized := strings.TrimSpace(string(bodyBytes))
|
||||||
var req domain.LoginRequest
|
var req domain.LoginRequest
|
||||||
if err := json.Unmarshal([]byte(sanitized), &req); err != nil {
|
if err := json.Unmarshal([]byte(sanitized), &req); err != nil {
|
||||||
|
log.Printf("❌ JSON parse error: %v", err)
|
||||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("📧 Login attempt for email: %s", req.Email)
|
||||||
|
|
||||||
response, err := h.authService.Login(req)
|
response, err := h.authService.Login(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("❌ authService.Login error: %v", err)
|
||||||
if err == service.ErrInvalidCredentials {
|
if err == service.ErrInvalidCredentials {
|
||||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||||
} else {
|
} else {
|
||||||
@@ -85,6 +96,7 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("✅ Login successful for %s, role=%s", response.User.Email, response.User.Role)
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(response)
|
json.NewEncoder(w).Encode(response)
|
||||||
}
|
}
|
||||||
|
|||||||
38
backend/internal/api/handlers/hash.go
Normal file
38
backend/internal/api/handlers/hash.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HashRequest struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HashResponse struct {
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateHash(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req HashRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to generate hash", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(HashResponse{Hash: string(hash)})
|
||||||
|
}
|
||||||
180
backend/internal/api/handlers/signup_template.go
Normal file
180
backend/internal/api/handlers/signup_template.go
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"aggios-app/backend/internal/api/middleware"
|
||||||
|
"aggios-app/backend/internal/domain"
|
||||||
|
"aggios-app/backend/internal/repository"
|
||||||
|
"aggios-app/backend/internal/service"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SignupTemplateHandler struct {
|
||||||
|
repo *repository.SignupTemplateRepository
|
||||||
|
userRepo *repository.UserRepository
|
||||||
|
tenantRepo *repository.TenantRepository
|
||||||
|
agencyService *service.AgencyService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSignupTemplateHandler(
|
||||||
|
repo *repository.SignupTemplateRepository,
|
||||||
|
userRepo *repository.UserRepository,
|
||||||
|
tenantRepo *repository.TenantRepository,
|
||||||
|
agencyService *service.AgencyService,
|
||||||
|
) *SignupTemplateHandler {
|
||||||
|
return &SignupTemplateHandler{
|
||||||
|
repo: repo,
|
||||||
|
userRepo: userRepo,
|
||||||
|
tenantRepo: tenantRepo,
|
||||||
|
agencyService: agencyService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTemplate cria um novo template (SuperAdmin)
|
||||||
|
func (h *SignupTemplateHandler) CreateTemplate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var template domain.SignupTemplate
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&template); err != nil {
|
||||||
|
log.Printf("Error decoding request body: %v", err)
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pegar user_id do contexto (do middleware de autenticação)
|
||||||
|
userIDStr, ok := r.Context().Value(middleware.UserIDKey).(string)
|
||||||
|
if !ok || userIDStr == "" {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := uuid.Parse(userIDStr)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error parsing user_id: %v", err)
|
||||||
|
http.Error(w, "Invalid user ID", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
template.CreatedBy = userID
|
||||||
|
template.IsActive = true
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := h.repo.Create(ctx, &template); err != nil {
|
||||||
|
log.Printf("Error creating signup template: %v", err)
|
||||||
|
http.Error(w, "Error creating template", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(template)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTemplates lista todos os templates (SuperAdmin)
|
||||||
|
func (h *SignupTemplateHandler) ListTemplates(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := context.Background()
|
||||||
|
templates, err := h.repo.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error listing signup templates: %v", err)
|
||||||
|
http.Error(w, "Error listing templates", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(templates)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTemplateBySlug retorna um template pelo slug (público)
|
||||||
|
func (h *SignupTemplateHandler) GetTemplateBySlug(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
slug := vars["slug"]
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
template, err := h.repo.FindBySlug(ctx, slug)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error finding signup template by slug %s: %v", slug, err)
|
||||||
|
http.Error(w, "Template not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(template)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTemplateByID retorna um template pelo ID (SuperAdmin)
|
||||||
|
func (h *SignupTemplateHandler) GetTemplateByID(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
idStr := vars["id"]
|
||||||
|
|
||||||
|
id, err := uuid.Parse(idStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid template ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
template, err := h.repo.FindByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error finding signup template by ID %s: %v", idStr, err)
|
||||||
|
http.Error(w, "Template not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(template)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTemplate atualiza um template (SuperAdmin)
|
||||||
|
func (h *SignupTemplateHandler) UpdateTemplate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
idStr := vars["id"]
|
||||||
|
|
||||||
|
id, err := uuid.Parse(idStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid template ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var template domain.SignupTemplate
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&template); err != nil {
|
||||||
|
log.Printf("Error decoding request body: %v", err)
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
template.ID = id
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := h.repo.Update(ctx, &template); err != nil {
|
||||||
|
log.Printf("Error updating signup template: %v", err)
|
||||||
|
http.Error(w, "Error updating template", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(template)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTemplate deleta um template (SuperAdmin)
|
||||||
|
func (h *SignupTemplateHandler) DeleteTemplate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
idStr := vars["id"]
|
||||||
|
|
||||||
|
id, err := uuid.Parse(idStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid template ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := h.repo.Delete(ctx, id); err != nil {
|
||||||
|
log.Printf("Error deleting signup template: %v", err)
|
||||||
|
http.Error(w, "Error deleting template", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
121
backend/internal/api/handlers/signup_template_register.go
Normal file
121
backend/internal/api/handlers/signup_template_register.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"aggios-app/backend/internal/domain"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PublicSignupRequest representa o cadastro público via template
|
||||||
|
type PublicSignupRequest struct {
|
||||||
|
TemplateSlug string `json:"template_slug"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Subdomain string `json:"subdomain"`
|
||||||
|
CompanyName string `json:"company_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicRegister handles public registration via template
|
||||||
|
func (h *SignupTemplateHandler) PublicRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req PublicSignupRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
log.Printf("Error decoding request body: %v", err)
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 1. Buscar o template
|
||||||
|
template, err := h.repo.FindBySlug(ctx, req.TemplateSlug)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error finding template: %v", err)
|
||||||
|
http.Error(w, "Template not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Incrementar usage_count
|
||||||
|
if err := h.repo.IncrementUsageCount(ctx, template.ID); err != nil {
|
||||||
|
log.Printf("Error incrementing usage count: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Verificar se email já existe
|
||||||
|
emailExists, err := h.userRepo.EmailExists(req.Email)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error checking email: %v", err)
|
||||||
|
http.Error(w, "Error processing registration", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if emailExists {
|
||||||
|
http.Error(w, "Email already registered", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Verificar se subdomain já existe (se fornecido)
|
||||||
|
if req.Subdomain != "" {
|
||||||
|
exists, err := h.tenantRepo.SubdomainExists(req.Subdomain)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error checking subdomain: %v", err)
|
||||||
|
http.Error(w, "Error processing registration", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
http.Error(w, "Subdomain already taken", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Hash da senha
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error hashing password: %v", err)
|
||||||
|
http.Error(w, "Error processing registration", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Criar tenant (empresa/cliente)
|
||||||
|
tenant := &domain.Tenant{
|
||||||
|
Name: req.CompanyName,
|
||||||
|
Domain: req.Subdomain + ".aggios.app",
|
||||||
|
Subdomain: req.Subdomain,
|
||||||
|
Description: "Registered via " + template.Name,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.tenantRepo.Create(tenant); err != nil {
|
||||||
|
log.Printf("Error creating tenant: %v", err)
|
||||||
|
http.Error(w, "Error creating account", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Criar usuário admin do tenant
|
||||||
|
user := &domain.User{
|
||||||
|
Email: req.Email,
|
||||||
|
Password: string(hashedPassword),
|
||||||
|
Name: req.Name,
|
||||||
|
Role: "CLIENTE",
|
||||||
|
TenantID: &tenant.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.userRepo.Create(user); err != nil {
|
||||||
|
log.Printf("Error creating user: %v", err)
|
||||||
|
http.Error(w, "Error creating user", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Resposta de sucesso
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"message": template.SuccessMessage,
|
||||||
|
"tenant_id": tenant.ID,
|
||||||
|
"user_id": user.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
130
backend/internal/api/handlers/upload.go
Normal file
130
backend/internal/api/handlers/upload.go
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"aggios-app/backend/internal/api/middleware"
|
||||||
|
"aggios-app/backend/internal/config"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/minio/minio-go/v7"
|
||||||
|
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UploadHandler handles file upload endpoints
|
||||||
|
type UploadHandler struct {
|
||||||
|
minioClient *minio.Client
|
||||||
|
cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUploadHandler creates a new upload handler
|
||||||
|
func NewUploadHandler(cfg *config.Config) (*UploadHandler, error) {
|
||||||
|
// 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 {
|
||||||
|
return nil, fmt.Errorf("failed to create MinIO client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure bucket exists
|
||||||
|
ctx := context.Background()
|
||||||
|
bucketName := cfg.Minio.BucketName
|
||||||
|
exists, err := minioClient.BucketExists(ctx, bucketName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to check bucket existence: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
err = minioClient.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create bucket: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &UploadHandler{
|
||||||
|
minioClient: minioClient,
|
||||||
|
cfg: cfg,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadResponse represents the upload response
|
||||||
|
type UploadResponse struct {
|
||||||
|
FileID string `json:"file_id"`
|
||||||
|
FileName string `json:"file_name"`
|
||||||
|
FileURL string `json:"file_url"`
|
||||||
|
FileSize int64 `json:"file_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload handles file upload
|
||||||
|
func (h *UploadHandler) Upload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get user ID from context (optional for signup flow)
|
||||||
|
userIDStr, _ := r.Context().Value(middleware.UserIDKey).(string)
|
||||||
|
|
||||||
|
// Use temp tenant for unauthenticated uploads (signup flow)
|
||||||
|
tenantID := uuid.MustParse("00000000-0000-0000-0000-000000000000")
|
||||||
|
if userIDStr != "" {
|
||||||
|
// TODO: Query database to get tenant_id from user_id when authenticated
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse multipart form (max 10MB)
|
||||||
|
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
||||||
|
http.Error(w, "File too large (max 10MB)", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file from form
|
||||||
|
file, header, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to read file", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Validate file type (images only)
|
||||||
|
contentType := header.Header.Get("Content-Type")
|
||||||
|
if !strings.HasPrefix(contentType, "image/") {
|
||||||
|
http.Error(w, "Only images are allowed", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique file ID
|
||||||
|
fileID := uuid.New()
|
||||||
|
ext := filepath.Ext(header.Filename)
|
||||||
|
objectName := fmt.Sprintf("tenants/%s/logos/%s%s", tenantID.String(), fileID.String(), ext)
|
||||||
|
|
||||||
|
// Upload to MinIO
|
||||||
|
ctx := context.Background()
|
||||||
|
_, err = h.minioClient.PutObject(ctx, h.cfg.Minio.BucketName, objectName, file, header.Size, minio.PutObjectOptions{
|
||||||
|
ContentType: contentType,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to upload file", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate public URL (replace internal hostname with localhost for browser access)
|
||||||
|
fileURL := fmt.Sprintf("http://localhost:9000/%s/%s", h.cfg.Minio.BucketName, objectName)
|
||||||
|
|
||||||
|
// Return response
|
||||||
|
response := UploadResponse{
|
||||||
|
FileID: fileID.String(),
|
||||||
|
FileName: header.Filename,
|
||||||
|
FileURL: fileURL,
|
||||||
|
FileSize: header.Size,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
@@ -46,11 +46,27 @@ func Auth(cfg *config.Config) func(http.Handler) http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID := claims["user_id"].(string)
|
// Verificar se user_id existe e é do tipo correto
|
||||||
tenantID := claims["tenant_id"].(string)
|
userIDClaim, ok := claims["user_id"]
|
||||||
|
if !ok || userIDClaim == nil {
|
||||||
|
http.Error(w, "Missing user_id in token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, ok := userIDClaim.(string)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Invalid user_id format in token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// tenant_id pode ser nil para SuperAdmin
|
||||||
|
var tenantID string
|
||||||
|
if tenantIDClaim, ok := claims["tenant_id"]; ok && tenantIDClaim != nil {
|
||||||
|
tenantID, _ = tenantIDClaim.(string)
|
||||||
|
}
|
||||||
|
|
||||||
ctx := context.WithValue(r.Context(), UserIDKey, userID)
|
ctx := context.WithValue(r.Context(), UserIDKey, userID)
|
||||||
ctx = context.WithValue(ctx, TenantIDKey, tenantID)
|
ctx = context.WithValue(ctx, TenantIDKey, tenantID)
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,7 @@ type Config struct {
|
|||||||
JWT JWTConfig
|
JWT JWTConfig
|
||||||
Security SecurityConfig
|
Security SecurityConfig
|
||||||
App AppConfig
|
App AppConfig
|
||||||
|
Minio MinioConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
// AppConfig holds application-level settings
|
// AppConfig holds application-level settings
|
||||||
@@ -45,6 +46,15 @@ type SecurityConfig struct {
|
|||||||
PasswordMinLength int
|
PasswordMinLength int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MinioConfig holds MinIO configuration
|
||||||
|
type MinioConfig struct {
|
||||||
|
Endpoint string
|
||||||
|
RootUser string
|
||||||
|
RootPassword string
|
||||||
|
UseSSL bool
|
||||||
|
BucketName string
|
||||||
|
}
|
||||||
|
|
||||||
// Load loads configuration from environment variables
|
// Load loads configuration from environment variables
|
||||||
func Load() *Config {
|
func Load() *Config {
|
||||||
env := getEnvOrDefault("APP_ENV", "development")
|
env := getEnvOrDefault("APP_ENV", "development")
|
||||||
@@ -90,6 +100,13 @@ func Load() *Config {
|
|||||||
MaxAttemptsPerMin: maxAttempts,
|
MaxAttemptsPerMin: maxAttempts,
|
||||||
PasswordMinLength: 8,
|
PasswordMinLength: 8,
|
||||||
},
|
},
|
||||||
|
Minio: MinioConfig{
|
||||||
|
Endpoint: getEnvOrDefault("MINIO_ENDPOINT", "minio:9000"),
|
||||||
|
RootUser: getEnvOrDefault("MINIO_ROOT_USER", "minioadmin"),
|
||||||
|
RootPassword: getEnvOrDefault("MINIO_ROOT_PASSWORD", "changeme"),
|
||||||
|
UseSSL: getEnvOrDefault("MINIO_USE_SSL", "false") == "true",
|
||||||
|
BucketName: getEnvOrDefault("MINIO_BUCKET_NAME", "aggios"),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
66
backend/internal/domain/agency_template.go
Normal file
66
backend/internal/domain/agency_template.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AgencySignupTemplate represents a signup template for agencies (SuperAdmin → Agency)
|
||||||
|
type AgencySignupTemplate struct {
|
||||||
|
ID uuid.UUID `json:"id" db:"id"`
|
||||||
|
Name string `json:"name" db:"name"`
|
||||||
|
Slug string `json:"slug" db:"slug"`
|
||||||
|
Description string `json:"description" db:"description"`
|
||||||
|
FormFields []byte `json:"form_fields" db:"form_fields"` // JSONB
|
||||||
|
AvailableModules []byte `json:"available_modules" db:"available_modules"` // JSONB
|
||||||
|
CustomPrimaryColor sql.NullString `json:"custom_primary_color" db:"custom_primary_color"`
|
||||||
|
CustomLogoURL sql.NullString `json:"custom_logo_url" db:"custom_logo_url"`
|
||||||
|
RedirectURL sql.NullString `json:"redirect_url" db:"redirect_url"`
|
||||||
|
SuccessMessage sql.NullString `json:"success_message" db:"success_message"`
|
||||||
|
IsActive bool `json:"is_active" db:"is_active"`
|
||||||
|
UsageCount int `json:"usage_count" db:"usage_count"`
|
||||||
|
MaxUses sql.NullInt64 `json:"max_uses" db:"max_uses"`
|
||||||
|
ExpiresAt sql.NullTime `json:"expires_at" db:"expires_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAgencyTemplateRequest for creating a new agency template
|
||||||
|
type CreateAgencyTemplateRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
FormFields []string `json:"form_fields"`
|
||||||
|
AvailableModules []string `json:"available_modules"`
|
||||||
|
CustomPrimaryColor string `json:"custom_primary_color"`
|
||||||
|
CustomLogoURL string `json:"custom_logo_url"`
|
||||||
|
RedirectURL string `json:"redirect_url"`
|
||||||
|
SuccessMessage string `json:"success_message"`
|
||||||
|
MaxUses int `json:"max_uses"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgencyRegistrationViaTemplate for public registration via template
|
||||||
|
type AgencyRegistrationViaTemplate struct {
|
||||||
|
TemplateSlug string `json:"template_slug"`
|
||||||
|
|
||||||
|
// Agency info
|
||||||
|
AgencyName string `json:"agencyName"`
|
||||||
|
Subdomain string `json:"subdomain"`
|
||||||
|
CNPJ string `json:"cnpj"`
|
||||||
|
RazaoSocial string `json:"razaoSocial"`
|
||||||
|
Website string `json:"website"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
AdminEmail string `json:"adminEmail"`
|
||||||
|
AdminPassword string `json:"adminPassword"`
|
||||||
|
AdminName string `json:"adminName"`
|
||||||
|
|
||||||
|
// Optional fields
|
||||||
|
Description string `json:"description"`
|
||||||
|
Industry string `json:"industry"`
|
||||||
|
TeamSize string `json:"teamSize"`
|
||||||
|
Address map[string]string `json:"address"`
|
||||||
|
}
|
||||||
35
backend/internal/domain/signup_template.go
Normal file
35
backend/internal/domain/signup_template.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FormField representa um campo do formulário de cadastro
|
||||||
|
type FormField struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Type string `json:"type"` // email, password, text, tel, etc
|
||||||
|
Required bool `json:"required"`
|
||||||
|
Order int `json:"order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignupTemplate representa um template de cadastro personalizado
|
||||||
|
type SignupTemplate struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
FormFields []FormField `json:"form_fields"`
|
||||||
|
EnabledModules []string `json:"enabled_modules"` // ["CRM", "ERP", "PROJECTS"]
|
||||||
|
RedirectURL string `json:"redirect_url,omitempty"`
|
||||||
|
SuccessMessage string `json:"success_message,omitempty"`
|
||||||
|
CustomLogoURL string `json:"custom_logo_url,omitempty"`
|
||||||
|
CustomPrimaryColor string `json:"custom_primary_color,omitempty"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
UsageCount int `json:"usage_count"`
|
||||||
|
CreatedBy uuid.UUID `json:"created_by"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
@@ -18,11 +18,19 @@ type Tenant struct {
|
|||||||
Phone string `json:"phone,omitempty" db:"phone"`
|
Phone string `json:"phone,omitempty" db:"phone"`
|
||||||
Website string `json:"website,omitempty" db:"website"`
|
Website string `json:"website,omitempty" db:"website"`
|
||||||
Address string `json:"address,omitempty" db:"address"`
|
Address string `json:"address,omitempty" db:"address"`
|
||||||
|
Neighborhood string `json:"neighborhood,omitempty" db:"neighborhood"`
|
||||||
|
Number string `json:"number,omitempty" db:"number"`
|
||||||
|
Complement string `json:"complement,omitempty" db:"complement"`
|
||||||
City string `json:"city,omitempty" db:"city"`
|
City string `json:"city,omitempty" db:"city"`
|
||||||
State string `json:"state,omitempty" db:"state"`
|
State string `json:"state,omitempty" db:"state"`
|
||||||
Zip string `json:"zip,omitempty" db:"zip"`
|
Zip string `json:"zip,omitempty" db:"zip"`
|
||||||
Description string `json:"description,omitempty" db:"description"`
|
Description string `json:"description,omitempty" db:"description"`
|
||||||
Industry string `json:"industry,omitempty" db:"industry"`
|
Industry string `json:"industry,omitempty" db:"industry"`
|
||||||
|
TeamSize string `json:"team_size,omitempty" db:"team_size"`
|
||||||
|
PrimaryColor string `json:"primary_color,omitempty" db:"primary_color"`
|
||||||
|
SecondaryColor string `json:"secondary_color,omitempty" db:"secondary_color"`
|
||||||
|
LogoURL string `json:"logo_url,omitempty" db:"logo_url"`
|
||||||
|
LogoHorizontalURL string `json:"logo_horizontal_url,omitempty" db:"logo_horizontal_url"`
|
||||||
IsActive bool `json:"is_active" db:"is_active"`
|
IsActive bool `json:"is_active" db:"is_active"`
|
||||||
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"`
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ type RegisterAgencyRequest struct {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Website string `json:"website"`
|
Website string `json:"website"`
|
||||||
Industry string `json:"industry"`
|
Industry string `json:"industry"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
TeamSize string `json:"teamSize"`
|
||||||
|
|
||||||
// Endereço
|
// Endereço
|
||||||
CEP string `json:"cep"`
|
CEP string `json:"cep"`
|
||||||
@@ -46,12 +48,59 @@ type RegisterAgencyRequest struct {
|
|||||||
Number string `json:"number"`
|
Number string `json:"number"`
|
||||||
Complement string `json:"complement"`
|
Complement string `json:"complement"`
|
||||||
|
|
||||||
|
// Personalização
|
||||||
|
PrimaryColor string `json:"primaryColor"`
|
||||||
|
SecondaryColor string `json:"secondaryColor"`
|
||||||
|
LogoURL string `json:"logoUrl"`
|
||||||
|
LogoHorizontalURL string `json:"logoHorizontalUrl"`
|
||||||
|
|
||||||
// Admin da Agência
|
// Admin da Agência
|
||||||
AdminEmail string `json:"adminEmail"`
|
AdminEmail string `json:"adminEmail"`
|
||||||
AdminPassword string `json:"adminPassword"`
|
AdminPassword string `json:"adminPassword"`
|
||||||
AdminName string `json:"adminName"`
|
AdminName string `json:"adminName"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PublicRegisterAgencyRequest represents the public signup payload
|
||||||
|
type PublicRegisterAgencyRequest struct {
|
||||||
|
// User
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
FullName string `json:"fullName"`
|
||||||
|
Newsletter bool `json:"newsletter"`
|
||||||
|
|
||||||
|
// Company
|
||||||
|
CompanyName string `json:"companyName"`
|
||||||
|
CNPJ string `json:"cnpj"`
|
||||||
|
RazaoSocial string `json:"razaoSocial"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Website string `json:"website"`
|
||||||
|
Industry string `json:"industry"`
|
||||||
|
TeamSize string `json:"teamSize"`
|
||||||
|
|
||||||
|
// Address
|
||||||
|
CEP string `json:"cep"`
|
||||||
|
State string `json:"state"`
|
||||||
|
City string `json:"city"`
|
||||||
|
Neighborhood string `json:"neighborhood"`
|
||||||
|
Street string `json:"street"`
|
||||||
|
Number string `json:"number"`
|
||||||
|
Complement string `json:"complement"`
|
||||||
|
|
||||||
|
// Contacts (simplified for now, taking the first one as phone if available)
|
||||||
|
Contacts []struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Whatsapp string `json:"whatsapp"`
|
||||||
|
} `json:"contacts"`
|
||||||
|
|
||||||
|
// Domain
|
||||||
|
Subdomain string `json:"subdomain"`
|
||||||
|
|
||||||
|
// Branding
|
||||||
|
PrimaryColor string `json:"primaryColor"`
|
||||||
|
SecondaryColor string `json:"secondaryColor"`
|
||||||
|
LogoURL string `json:"logoUrl"`
|
||||||
|
}
|
||||||
|
|
||||||
// RegisterClientRequest represents client registration (ADMIN_AGENCIA only)
|
// RegisterClientRequest represents client registration (ADMIN_AGENCIA only)
|
||||||
type RegisterClientRequest struct {
|
type RegisterClientRequest struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
|||||||
168
backend/internal/repository/agency_template_repository.go
Normal file
168
backend/internal/repository/agency_template_repository.go
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"aggios-app/backend/internal/domain"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AgencyTemplateRepository struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAgencyTemplateRepository(db *sql.DB) *AgencyTemplateRepository {
|
||||||
|
return &AgencyTemplateRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AgencyTemplateRepository) Create(template *domain.AgencySignupTemplate) error {
|
||||||
|
query := `
|
||||||
|
INSERT INTO agency_signup_templates (
|
||||||
|
name, slug, description, form_fields, available_modules,
|
||||||
|
custom_primary_color, custom_logo_url, redirect_url, success_message, max_uses
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
RETURNING id, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
return r.db.QueryRow(
|
||||||
|
query,
|
||||||
|
template.Name,
|
||||||
|
template.Slug,
|
||||||
|
template.Description,
|
||||||
|
template.FormFields,
|
||||||
|
template.AvailableModules,
|
||||||
|
template.CustomPrimaryColor,
|
||||||
|
template.CustomLogoURL,
|
||||||
|
template.RedirectURL,
|
||||||
|
template.SuccessMessage,
|
||||||
|
template.MaxUses,
|
||||||
|
).Scan(&template.ID, &template.CreatedAt, &template.UpdatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AgencyTemplateRepository) FindBySlug(slug string) (*domain.AgencySignupTemplate, error) {
|
||||||
|
var template domain.AgencySignupTemplate
|
||||||
|
query := `
|
||||||
|
SELECT id, name, slug, description, form_fields, available_modules,
|
||||||
|
custom_primary_color, custom_logo_url, redirect_url, success_message,
|
||||||
|
is_active, usage_count, max_uses, expires_at, created_at, updated_at
|
||||||
|
FROM agency_signup_templates
|
||||||
|
WHERE slug = $1 AND is_active = true
|
||||||
|
`
|
||||||
|
|
||||||
|
err := r.db.QueryRow(query, slug).Scan(
|
||||||
|
&template.ID, &template.Name, &template.Slug, &template.Description,
|
||||||
|
&template.FormFields, &template.AvailableModules,
|
||||||
|
&template.CustomPrimaryColor, &template.CustomLogoURL,
|
||||||
|
&template.RedirectURL, &template.SuccessMessage,
|
||||||
|
&template.IsActive, &template.UsageCount, &template.MaxUses,
|
||||||
|
&template.ExpiresAt, &template.CreatedAt, &template.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar se expirou
|
||||||
|
if template.ExpiresAt.Valid && template.ExpiresAt.Time.Before(sql.NullTime{}.Time) {
|
||||||
|
return nil, fmt.Errorf("template expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar limite de usos
|
||||||
|
if template.MaxUses.Valid && template.UsageCount >= int(template.MaxUses.Int64) {
|
||||||
|
return nil, fmt.Errorf("template usage limit reached")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &template, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AgencyTemplateRepository) List() ([]domain.AgencySignupTemplate, error) {
|
||||||
|
var templates []domain.AgencySignupTemplate
|
||||||
|
query := `
|
||||||
|
SELECT id, name, slug, description, form_fields, available_modules,
|
||||||
|
custom_primary_color, custom_logo_url, redirect_url, success_message,
|
||||||
|
is_active, usage_count, max_uses, expires_at, created_at, updated_at
|
||||||
|
FROM agency_signup_templates
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := r.db.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var t domain.AgencySignupTemplate
|
||||||
|
if err := rows.Scan(
|
||||||
|
&t.ID, &t.Name, &t.Slug, &t.Description,
|
||||||
|
&t.FormFields, &t.AvailableModules,
|
||||||
|
&t.CustomPrimaryColor, &t.CustomLogoURL,
|
||||||
|
&t.RedirectURL, &t.SuccessMessage,
|
||||||
|
&t.IsActive, &t.UsageCount, &t.MaxUses,
|
||||||
|
&t.ExpiresAt, &t.CreatedAt, &t.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
templates = append(templates, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
return templates, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AgencyTemplateRepository) IncrementUsageCount(id string) error {
|
||||||
|
query := `UPDATE agency_signup_templates SET usage_count = usage_count + 1 WHERE id = $1`
|
||||||
|
_, err := r.db.Exec(query, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AgencyTemplateRepository) Update(template *domain.AgencySignupTemplate) error {
|
||||||
|
query := `
|
||||||
|
UPDATE agency_signup_templates
|
||||||
|
SET name = $1, description = $2, form_fields = $3, available_modules = $4,
|
||||||
|
custom_primary_color = $5, custom_logo_url = $6, redirect_url = $7,
|
||||||
|
success_message = $8, is_active = $9, max_uses = $10, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $11
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err := r.db.Exec(
|
||||||
|
query,
|
||||||
|
template.Name,
|
||||||
|
template.Description,
|
||||||
|
template.FormFields,
|
||||||
|
template.AvailableModules,
|
||||||
|
template.CustomPrimaryColor,
|
||||||
|
template.CustomLogoURL,
|
||||||
|
template.RedirectURL,
|
||||||
|
template.SuccessMessage,
|
||||||
|
template.IsActive,
|
||||||
|
template.MaxUses,
|
||||||
|
template.ID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AgencyTemplateRepository) Delete(id string) error {
|
||||||
|
query := `DELETE FROM agency_signup_templates WHERE id = $1`
|
||||||
|
_, err := r.db.Exec(query, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Convert form fields to JSON
|
||||||
|
func FormFieldsToJSON(fields []string) ([]byte, error) {
|
||||||
|
type FormField struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Required bool `json:"required"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var formFields []FormField
|
||||||
|
for _, field := range fields {
|
||||||
|
formFields = append(formFields, FormField{
|
||||||
|
Name: field,
|
||||||
|
Required: field == "agencyName" || field == "subdomain" || field == "adminEmail" || field == "adminPassword",
|
||||||
|
Enabled: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Marshal(formFields)
|
||||||
|
}
|
||||||
280
backend/internal/repository/signup_template_repository.go
Normal file
280
backend/internal/repository/signup_template_repository.go
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"aggios-app/backend/internal/domain"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SignupTemplateRepository struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSignupTemplateRepository(db *sql.DB) *SignupTemplateRepository {
|
||||||
|
return &SignupTemplateRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create cria um novo template de cadastro
|
||||||
|
func (r *SignupTemplateRepository) Create(ctx context.Context, template *domain.SignupTemplate) error {
|
||||||
|
formFieldsJSON, err := json.Marshal(template.FormFields)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error marshaling form_fields: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
modulesJSON, err := json.Marshal(template.EnabledModules)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error marshaling enabled_modules: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
INSERT INTO signup_templates (
|
||||||
|
name, description, slug, form_fields, enabled_modules,
|
||||||
|
redirect_url, success_message, custom_logo_url, custom_primary_color,
|
||||||
|
is_active, created_by
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
|
RETURNING id, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
err = r.db.QueryRowContext(
|
||||||
|
ctx, query,
|
||||||
|
template.Name, template.Description, template.Slug,
|
||||||
|
formFieldsJSON, modulesJSON,
|
||||||
|
template.RedirectURL, template.SuccessMessage,
|
||||||
|
template.CustomLogoURL, template.CustomPrimaryColor,
|
||||||
|
template.IsActive, template.CreatedBy,
|
||||||
|
).Scan(&template.ID, &template.CreatedAt, &template.UpdatedAt)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error creating signup template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindBySlug busca um template pelo slug
|
||||||
|
func (r *SignupTemplateRepository) FindBySlug(ctx context.Context, slug string) (*domain.SignupTemplate, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, name, description, slug, form_fields, enabled_modules,
|
||||||
|
redirect_url, success_message, custom_logo_url, custom_primary_color,
|
||||||
|
is_active, usage_count, created_by, created_at, updated_at
|
||||||
|
FROM signup_templates
|
||||||
|
WHERE slug = $1 AND is_active = true
|
||||||
|
`
|
||||||
|
|
||||||
|
var template domain.SignupTemplate
|
||||||
|
var formFieldsJSON, modulesJSON []byte
|
||||||
|
var redirectURL, successMessage, customLogoURL, customPrimaryColor sql.NullString
|
||||||
|
|
||||||
|
err := r.db.QueryRowContext(ctx, query, slug).Scan(
|
||||||
|
&template.ID, &template.Name, &template.Description, &template.Slug,
|
||||||
|
&formFieldsJSON, &modulesJSON,
|
||||||
|
&redirectURL, &successMessage,
|
||||||
|
&customLogoURL, &customPrimaryColor,
|
||||||
|
&template.IsActive, &template.UsageCount, &template.CreatedBy,
|
||||||
|
&template.CreatedAt, &template.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if redirectURL.Valid {
|
||||||
|
template.RedirectURL = redirectURL.String
|
||||||
|
}
|
||||||
|
if successMessage.Valid {
|
||||||
|
template.SuccessMessage = successMessage.String
|
||||||
|
}
|
||||||
|
if customLogoURL.Valid {
|
||||||
|
template.CustomLogoURL = customLogoURL.String
|
||||||
|
}
|
||||||
|
if customPrimaryColor.Valid {
|
||||||
|
template.CustomPrimaryColor = customPrimaryColor.String
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, fmt.Errorf("signup template not found")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error finding signup template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(formFieldsJSON, &template.FormFields); err != nil {
|
||||||
|
return nil, fmt.Errorf("error unmarshaling form_fields: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(modulesJSON, &template.EnabledModules); err != nil {
|
||||||
|
return nil, fmt.Errorf("error unmarshaling enabled_modules: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &template, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByID busca um template pelo ID
|
||||||
|
func (r *SignupTemplateRepository) FindByID(ctx context.Context, id uuid.UUID) (*domain.SignupTemplate, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, name, description, slug, form_fields, enabled_modules,
|
||||||
|
redirect_url, success_message, custom_logo_url, custom_primary_color,
|
||||||
|
is_active, usage_count, created_by, created_at, updated_at
|
||||||
|
FROM signup_templates
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
var template domain.SignupTemplate
|
||||||
|
var formFieldsJSON, modulesJSON []byte
|
||||||
|
var redirectURL, successMessage, customLogoURL, customPrimaryColor sql.NullString
|
||||||
|
|
||||||
|
err := r.db.QueryRowContext(ctx, query, id).Scan(
|
||||||
|
&template.ID, &template.Name, &template.Description, &template.Slug,
|
||||||
|
&formFieldsJSON, &modulesJSON,
|
||||||
|
&redirectURL, &successMessage,
|
||||||
|
&customLogoURL, &customPrimaryColor,
|
||||||
|
&template.IsActive, &template.UsageCount, &template.CreatedBy,
|
||||||
|
&template.CreatedAt, &template.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if redirectURL.Valid {
|
||||||
|
template.RedirectURL = redirectURL.String
|
||||||
|
}
|
||||||
|
if successMessage.Valid {
|
||||||
|
template.SuccessMessage = successMessage.String
|
||||||
|
}
|
||||||
|
if customLogoURL.Valid {
|
||||||
|
template.CustomLogoURL = customLogoURL.String
|
||||||
|
}
|
||||||
|
if customPrimaryColor.Valid {
|
||||||
|
template.CustomPrimaryColor = customPrimaryColor.String
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, fmt.Errorf("signup template not found")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error finding signup template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(formFieldsJSON, &template.FormFields); err != nil {
|
||||||
|
return nil, fmt.Errorf("error unmarshaling form_fields: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(modulesJSON, &template.EnabledModules); err != nil {
|
||||||
|
return nil, fmt.Errorf("error unmarshaling enabled_modules: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &template, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List lista todos os templates
|
||||||
|
func (r *SignupTemplateRepository) List(ctx context.Context) ([]*domain.SignupTemplate, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, name, description, slug, form_fields, enabled_modules,
|
||||||
|
redirect_url, success_message, custom_logo_url, custom_primary_color,
|
||||||
|
is_active, usage_count, created_by, created_at, updated_at
|
||||||
|
FROM signup_templates
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := r.db.QueryContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error listing signup templates: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var templates []*domain.SignupTemplate
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var template domain.SignupTemplate
|
||||||
|
var formFieldsJSON, modulesJSON []byte
|
||||||
|
var redirectURL, successMessage, customLogoURL, customPrimaryColor sql.NullString
|
||||||
|
|
||||||
|
err := rows.Scan(
|
||||||
|
&template.ID, &template.Name, &template.Description, &template.Slug,
|
||||||
|
&formFieldsJSON, &modulesJSON,
|
||||||
|
&redirectURL, &successMessage,
|
||||||
|
&customLogoURL, &customPrimaryColor,
|
||||||
|
&template.IsActive, &template.UsageCount, &template.CreatedBy,
|
||||||
|
&template.CreatedAt, &template.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error scanning signup template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if redirectURL.Valid {
|
||||||
|
template.RedirectURL = redirectURL.String
|
||||||
|
}
|
||||||
|
if successMessage.Valid {
|
||||||
|
template.SuccessMessage = successMessage.String
|
||||||
|
}
|
||||||
|
if customLogoURL.Valid {
|
||||||
|
template.CustomLogoURL = customLogoURL.String
|
||||||
|
}
|
||||||
|
if customPrimaryColor.Valid {
|
||||||
|
template.CustomPrimaryColor = customPrimaryColor.String
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(formFieldsJSON, &template.FormFields); err != nil {
|
||||||
|
return nil, fmt.Errorf("error unmarshaling form_fields: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(modulesJSON, &template.EnabledModules); err != nil {
|
||||||
|
return nil, fmt.Errorf("error unmarshaling enabled_modules: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
templates = append(templates, &template)
|
||||||
|
}
|
||||||
|
|
||||||
|
return templates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrementUsageCount incrementa o contador de uso
|
||||||
|
func (r *SignupTemplateRepository) IncrementUsageCount(ctx context.Context, id uuid.UUID) error {
|
||||||
|
query := `UPDATE signup_templates SET usage_count = usage_count + 1 WHERE id = $1`
|
||||||
|
_, err := r.db.ExecContext(ctx, query, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update atualiza um template
|
||||||
|
func (r *SignupTemplateRepository) Update(ctx context.Context, template *domain.SignupTemplate) error {
|
||||||
|
formFieldsJSON, err := json.Marshal(template.FormFields)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error marshaling form_fields: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
modulesJSON, err := json.Marshal(template.EnabledModules)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error marshaling enabled_modules: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
UPDATE signup_templates SET
|
||||||
|
name = $1, description = $2, slug = $3, form_fields = $4, enabled_modules = $5,
|
||||||
|
redirect_url = $6, success_message = $7, custom_logo_url = $8, custom_primary_color = $9,
|
||||||
|
is_active = $10
|
||||||
|
WHERE id = $11
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err = r.db.ExecContext(
|
||||||
|
ctx, query,
|
||||||
|
template.Name, template.Description, template.Slug,
|
||||||
|
formFieldsJSON, modulesJSON,
|
||||||
|
template.RedirectURL, template.SuccessMessage,
|
||||||
|
template.CustomLogoURL, template.CustomPrimaryColor,
|
||||||
|
template.IsActive, template.ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error updating signup template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete deleta um template
|
||||||
|
func (r *SignupTemplateRepository) Delete(ctx context.Context, id uuid.UUID) error {
|
||||||
|
query := `DELETE FROM signup_templates WHERE id = $1`
|
||||||
|
_, err := r.db.ExecContext(ctx, query, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error deleting signup template: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -19,14 +19,21 @@ func NewTenantRepository(db *sql.DB) *TenantRepository {
|
|||||||
return &TenantRepository{db: db}
|
return &TenantRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DB returns the underlying database connection
|
||||||
|
func (r *TenantRepository) DB() *sql.DB {
|
||||||
|
return r.db
|
||||||
|
}
|
||||||
|
|
||||||
// Create creates a new tenant
|
// Create creates a new tenant
|
||||||
func (r *TenantRepository) Create(tenant *domain.Tenant) error {
|
func (r *TenantRepository) Create(tenant *domain.Tenant) error {
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO tenants (
|
INSERT INTO tenants (
|
||||||
id, name, domain, subdomain, cnpj, razao_social, email, website,
|
id, name, domain, subdomain, cnpj, razao_social, email, phone, website,
|
||||||
address, city, state, zip, description, industry, created_at, updated_at
|
address, neighborhood, number, complement, city, state, zip,
|
||||||
|
description, industry, team_size, primary_color, secondary_color,
|
||||||
|
logo_url, logo_horizontal_url, created_at, updated_at
|
||||||
)
|
)
|
||||||
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, $18, $19, $20, $21, $22, $23, $24, $25)
|
||||||
RETURNING id, created_at, updated_at
|
RETURNING id, created_at, updated_at
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -44,13 +51,22 @@ func (r *TenantRepository) Create(tenant *domain.Tenant) error {
|
|||||||
tenant.CNPJ,
|
tenant.CNPJ,
|
||||||
tenant.RazaoSocial,
|
tenant.RazaoSocial,
|
||||||
tenant.Email,
|
tenant.Email,
|
||||||
|
tenant.Phone,
|
||||||
tenant.Website,
|
tenant.Website,
|
||||||
tenant.Address,
|
tenant.Address,
|
||||||
|
tenant.Neighborhood,
|
||||||
|
tenant.Number,
|
||||||
|
tenant.Complement,
|
||||||
tenant.City,
|
tenant.City,
|
||||||
tenant.State,
|
tenant.State,
|
||||||
tenant.Zip,
|
tenant.Zip,
|
||||||
tenant.Description,
|
tenant.Description,
|
||||||
tenant.Industry,
|
tenant.Industry,
|
||||||
|
tenant.TeamSize,
|
||||||
|
tenant.PrimaryColor,
|
||||||
|
tenant.SecondaryColor,
|
||||||
|
tenant.LogoURL,
|
||||||
|
tenant.LogoHorizontalURL,
|
||||||
tenant.CreatedAt,
|
tenant.CreatedAt,
|
||||||
tenant.UpdatedAt,
|
tenant.UpdatedAt,
|
||||||
).Scan(&tenant.ID, &tenant.CreatedAt, &tenant.UpdatedAt)
|
).Scan(&tenant.ID, &tenant.CreatedAt, &tenant.UpdatedAt)
|
||||||
@@ -60,13 +76,15 @@ func (r *TenantRepository) Create(tenant *domain.Tenant) error {
|
|||||||
func (r *TenantRepository) FindByID(id uuid.UUID) (*domain.Tenant, error) {
|
func (r *TenantRepository) FindByID(id uuid.UUID) (*domain.Tenant, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, name, domain, subdomain, cnpj, razao_social, email, phone, website,
|
SELECT id, name, domain, subdomain, cnpj, razao_social, email, phone, website,
|
||||||
address, city, state, zip, description, industry, is_active, created_at, updated_at
|
address, neighborhood, number, complement, city, state, zip, description, industry, team_size,
|
||||||
|
primary_color, secondary_color, logo_url, logo_horizontal_url,
|
||||||
|
is_active, created_at, updated_at
|
||||||
FROM tenants
|
FROM tenants
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
tenant := &domain.Tenant{}
|
tenant := &domain.Tenant{}
|
||||||
var cnpj, razaoSocial, email, phone, website, address, city, state, zip, description, industry sql.NullString
|
var cnpj, razaoSocial, email, phone, website, address, neighborhood, number, complement, city, state, zip, description, industry, teamSize, primaryColor, secondaryColor, logoURL, logoHorizontalURL sql.NullString
|
||||||
|
|
||||||
err := r.db.QueryRow(query, id).Scan(
|
err := r.db.QueryRow(query, id).Scan(
|
||||||
&tenant.ID,
|
&tenant.ID,
|
||||||
@@ -79,11 +97,19 @@ func (r *TenantRepository) FindByID(id uuid.UUID) (*domain.Tenant, error) {
|
|||||||
&phone,
|
&phone,
|
||||||
&website,
|
&website,
|
||||||
&address,
|
&address,
|
||||||
|
&neighborhood,
|
||||||
|
&number,
|
||||||
|
&complement,
|
||||||
&city,
|
&city,
|
||||||
&state,
|
&state,
|
||||||
&zip,
|
&zip,
|
||||||
&description,
|
&description,
|
||||||
&industry,
|
&industry,
|
||||||
|
&teamSize,
|
||||||
|
&primaryColor,
|
||||||
|
&secondaryColor,
|
||||||
|
&logoURL,
|
||||||
|
&logoHorizontalURL,
|
||||||
&tenant.IsActive,
|
&tenant.IsActive,
|
||||||
&tenant.CreatedAt,
|
&tenant.CreatedAt,
|
||||||
&tenant.UpdatedAt,
|
&tenant.UpdatedAt,
|
||||||
@@ -116,6 +142,15 @@ func (r *TenantRepository) FindByID(id uuid.UUID) (*domain.Tenant, error) {
|
|||||||
if address.Valid {
|
if address.Valid {
|
||||||
tenant.Address = address.String
|
tenant.Address = address.String
|
||||||
}
|
}
|
||||||
|
if neighborhood.Valid {
|
||||||
|
tenant.Neighborhood = neighborhood.String
|
||||||
|
}
|
||||||
|
if number.Valid {
|
||||||
|
tenant.Number = number.String
|
||||||
|
}
|
||||||
|
if complement.Valid {
|
||||||
|
tenant.Complement = complement.String
|
||||||
|
}
|
||||||
if city.Valid {
|
if city.Valid {
|
||||||
tenant.City = city.String
|
tenant.City = city.String
|
||||||
}
|
}
|
||||||
@@ -131,6 +166,21 @@ func (r *TenantRepository) FindByID(id uuid.UUID) (*domain.Tenant, error) {
|
|||||||
if industry.Valid {
|
if industry.Valid {
|
||||||
tenant.Industry = industry.String
|
tenant.Industry = industry.String
|
||||||
}
|
}
|
||||||
|
if teamSize.Valid {
|
||||||
|
tenant.TeamSize = teamSize.String
|
||||||
|
}
|
||||||
|
if primaryColor.Valid {
|
||||||
|
tenant.PrimaryColor = primaryColor.String
|
||||||
|
}
|
||||||
|
if secondaryColor.Valid {
|
||||||
|
tenant.SecondaryColor = secondaryColor.String
|
||||||
|
}
|
||||||
|
if logoURL.Valid {
|
||||||
|
tenant.LogoURL = logoURL.String
|
||||||
|
}
|
||||||
|
if logoHorizontalURL.Valid {
|
||||||
|
tenant.LogoHorizontalURL = logoHorizontalURL.String
|
||||||
|
}
|
||||||
|
|
||||||
return tenant, nil
|
return tenant, nil
|
||||||
}
|
}
|
||||||
@@ -171,7 +221,7 @@ func (r *TenantRepository) SubdomainExists(subdomain string) (bool, error) {
|
|||||||
// FindAll returns all tenants
|
// FindAll returns all tenants
|
||||||
func (r *TenantRepository) FindAll() ([]*domain.Tenant, error) {
|
func (r *TenantRepository) FindAll() ([]*domain.Tenant, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, name, domain, subdomain, is_active, created_at, updated_at
|
SELECT id, name, domain, subdomain, email, phone, cnpj, logo_url, is_active, created_at, updated_at
|
||||||
FROM tenants
|
FROM tenants
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
`
|
`
|
||||||
@@ -185,11 +235,17 @@ func (r *TenantRepository) FindAll() ([]*domain.Tenant, error) {
|
|||||||
var tenants []*domain.Tenant
|
var tenants []*domain.Tenant
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
tenant := &domain.Tenant{}
|
tenant := &domain.Tenant{}
|
||||||
|
var email, phone, cnpj, logoURL sql.NullString
|
||||||
|
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&tenant.ID,
|
&tenant.ID,
|
||||||
&tenant.Name,
|
&tenant.Name,
|
||||||
&tenant.Domain,
|
&tenant.Domain,
|
||||||
&tenant.Subdomain,
|
&tenant.Subdomain,
|
||||||
|
&email,
|
||||||
|
&phone,
|
||||||
|
&cnpj,
|
||||||
|
&logoURL,
|
||||||
&tenant.IsActive,
|
&tenant.IsActive,
|
||||||
&tenant.CreatedAt,
|
&tenant.CreatedAt,
|
||||||
&tenant.UpdatedAt,
|
&tenant.UpdatedAt,
|
||||||
@@ -197,6 +253,20 @@ func (r *TenantRepository) FindAll() ([]*domain.Tenant, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if email.Valid {
|
||||||
|
tenant.Email = email.String
|
||||||
|
}
|
||||||
|
if phone.Valid {
|
||||||
|
tenant.Phone = phone.String
|
||||||
|
}
|
||||||
|
if cnpj.Valid {
|
||||||
|
tenant.CNPJ = cnpj.String
|
||||||
|
}
|
||||||
|
if logoURL.Valid {
|
||||||
|
tenant.LogoURL = logoURL.String
|
||||||
|
}
|
||||||
|
|
||||||
tenants = append(tenants, tenant)
|
tenants = append(tenants, tenant)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +279,21 @@ func (r *TenantRepository) FindAll() ([]*domain.Tenant, error) {
|
|||||||
|
|
||||||
// Delete removes a tenant (and cascades to related data)
|
// Delete removes a tenant (and cascades to related data)
|
||||||
func (r *TenantRepository) Delete(id uuid.UUID) error {
|
func (r *TenantRepository) Delete(id uuid.UUID) error {
|
||||||
result, err := r.db.Exec(`DELETE FROM tenants WHERE id = $1`, id)
|
// Start transaction
|
||||||
|
tx, err := r.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// Delete all users associated with this tenant first
|
||||||
|
_, err = tx.Exec(`DELETE FROM users WHERE tenant_id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the tenant
|
||||||
|
result, err := tx.Exec(`DELETE FROM tenants WHERE id = $1`, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -223,7 +307,8 @@ func (r *TenantRepository) Delete(id uuid.UUID) error {
|
|||||||
return sql.ErrNoRows
|
return sql.ErrNoRows
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
// Commit transaction
|
||||||
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateProfile updates tenant profile information
|
// UpdateProfile updates tenant profile information
|
||||||
@@ -237,13 +322,21 @@ func (r *TenantRepository) UpdateProfile(id uuid.UUID, updates map[string]interf
|
|||||||
phone = COALESCE($5, phone),
|
phone = COALESCE($5, phone),
|
||||||
website = COALESCE($6, website),
|
website = COALESCE($6, website),
|
||||||
address = COALESCE($7, address),
|
address = COALESCE($7, address),
|
||||||
city = COALESCE($8, city),
|
neighborhood = COALESCE($8, neighborhood),
|
||||||
state = COALESCE($9, state),
|
number = COALESCE($9, number),
|
||||||
zip = COALESCE($10, zip),
|
complement = COALESCE($10, complement),
|
||||||
description = COALESCE($11, description),
|
city = COALESCE($11, city),
|
||||||
industry = COALESCE($12, industry),
|
state = COALESCE($12, state),
|
||||||
updated_at = $13
|
zip = COALESCE($13, zip),
|
||||||
WHERE id = $14
|
description = COALESCE($14, description),
|
||||||
|
industry = COALESCE($15, industry),
|
||||||
|
team_size = COALESCE($16, team_size),
|
||||||
|
primary_color = COALESCE($17, primary_color),
|
||||||
|
secondary_color = COALESCE($18, secondary_color),
|
||||||
|
logo_url = COALESCE($19, logo_url),
|
||||||
|
logo_horizontal_url = COALESCE($20, logo_horizontal_url),
|
||||||
|
updated_at = $21
|
||||||
|
WHERE id = $22
|
||||||
`
|
`
|
||||||
|
|
||||||
_, err := r.db.Exec(
|
_, err := r.db.Exec(
|
||||||
@@ -255,14 +348,29 @@ func (r *TenantRepository) UpdateProfile(id uuid.UUID, updates map[string]interf
|
|||||||
updates["phone"],
|
updates["phone"],
|
||||||
updates["website"],
|
updates["website"],
|
||||||
updates["address"],
|
updates["address"],
|
||||||
|
updates["neighborhood"],
|
||||||
|
updates["number"],
|
||||||
|
updates["complement"],
|
||||||
updates["city"],
|
updates["city"],
|
||||||
updates["state"],
|
updates["state"],
|
||||||
updates["zip"],
|
updates["zip"],
|
||||||
updates["description"],
|
updates["description"],
|
||||||
updates["industry"],
|
updates["industry"],
|
||||||
|
updates["team_size"],
|
||||||
|
updates["primary_color"],
|
||||||
|
updates["secondary_color"],
|
||||||
|
updates["logo_url"],
|
||||||
|
updates["logo_horizontal_url"],
|
||||||
time.Now(),
|
time.Now(),
|
||||||
id,
|
id,
|
||||||
)
|
)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateStatus updates the is_active status of a tenant
|
||||||
|
func (r *TenantRepository) UpdateStatus(id uuid.UUID, isActive bool) error {
|
||||||
|
query := `UPDATE tenants SET is_active = $1, updated_at = $2 WHERE id = $3`
|
||||||
|
_, err := r.db.Exec(query, isActive, time.Now(), id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"aggios-app/backend/internal/domain"
|
"aggios-app/backend/internal/domain"
|
||||||
@@ -53,6 +54,8 @@ func (r *UserRepository) Create(user *domain.User) error {
|
|||||||
|
|
||||||
// FindByEmail finds a user by email
|
// FindByEmail finds a user by email
|
||||||
func (r *UserRepository) FindByEmail(email string) (*domain.User, error) {
|
func (r *UserRepository) FindByEmail(email string) (*domain.User, error) {
|
||||||
|
log.Printf("🔍 FindByEmail called with: %s", email)
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
SELECT id, tenant_id, email, password_hash, first_name, role, created_at, updated_at
|
SELECT id, tenant_id, email, password_hash, first_name, role, created_at, updated_at
|
||||||
FROM users
|
FROM users
|
||||||
@@ -72,10 +75,16 @@ func (r *UserRepository) FindByEmail(email string) (*domain.User, error) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
|
log.Printf("❌ User not found: %s", email)
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("❌ DB error finding user %s: %v", email, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return user, err
|
log.Printf("✅ Found user: %s, role: %s", user.Email, user.Role)
|
||||||
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindByID finds a user by ID
|
// FindByID finds a user by ID
|
||||||
|
|||||||
@@ -60,9 +60,6 @@ func (s *AgencyService) RegisterAgency(req domain.RegisterAgencyRequest) (*domai
|
|||||||
if req.Complement != "" {
|
if req.Complement != "" {
|
||||||
address += " - " + req.Complement
|
address += " - " + req.Complement
|
||||||
}
|
}
|
||||||
if req.Neighborhood != "" {
|
|
||||||
address += " - " + req.Neighborhood
|
|
||||||
}
|
|
||||||
|
|
||||||
tenant := &domain.Tenant{
|
tenant := &domain.Tenant{
|
||||||
Name: req.AgencyName,
|
Name: req.AgencyName,
|
||||||
@@ -71,13 +68,22 @@ func (s *AgencyService) RegisterAgency(req domain.RegisterAgencyRequest) (*domai
|
|||||||
CNPJ: req.CNPJ,
|
CNPJ: req.CNPJ,
|
||||||
RazaoSocial: req.RazaoSocial,
|
RazaoSocial: req.RazaoSocial,
|
||||||
Email: req.AdminEmail,
|
Email: req.AdminEmail,
|
||||||
|
Phone: req.Phone,
|
||||||
Website: req.Website,
|
Website: req.Website,
|
||||||
Address: address,
|
Address: address,
|
||||||
|
Neighborhood: req.Neighborhood,
|
||||||
|
Number: req.Number,
|
||||||
|
Complement: req.Complement,
|
||||||
City: req.City,
|
City: req.City,
|
||||||
State: req.State,
|
State: req.State,
|
||||||
Zip: req.CEP,
|
Zip: req.CEP,
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
Industry: req.Industry,
|
Industry: req.Industry,
|
||||||
|
TeamSize: req.TeamSize,
|
||||||
|
PrimaryColor: req.PrimaryColor,
|
||||||
|
SecondaryColor: req.SecondaryColor,
|
||||||
|
LogoURL: req.LogoURL,
|
||||||
|
LogoHorizontalURL: req.LogoHorizontalURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.tenantRepo.Create(tenant); err != nil {
|
if err := s.tenantRepo.Create(tenant); err != nil {
|
||||||
@@ -189,3 +195,16 @@ func (s *AgencyService) DeleteAgency(id uuid.UUID) error {
|
|||||||
|
|
||||||
return s.tenantRepo.Delete(id)
|
return s.tenantRepo.Delete(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateAgencyStatus updates the is_active status of a tenant
|
||||||
|
func (s *AgencyService) UpdateAgencyStatus(id uuid.UUID, isActive bool) error {
|
||||||
|
tenant, err := s.tenantRepo.FindByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tenant == nil {
|
||||||
|
return ErrTenantNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.tenantRepo.UpdateStatus(id, isActive)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"aggios-app/backend/internal/config"
|
"aggios-app/backend/internal/config"
|
||||||
@@ -78,14 +79,20 @@ func (s *AuthService) Login(req domain.LoginRequest) (*domain.LoginResponse, err
|
|||||||
// Find user by email
|
// Find user by email
|
||||||
user, err := s.userRepo.FindByEmail(req.Email)
|
user, err := s.userRepo.FindByEmail(req.Email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("❌ DB error finding user %s: %v", req.Email, err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if user == nil {
|
if user == nil {
|
||||||
|
log.Printf("❌ User not found: %s", req.Email)
|
||||||
return nil, ErrInvalidCredentials
|
return nil, ErrInvalidCredentials
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("🔍 Attempting login for %s with password_hash: %.10s...", req.Email, user.Password)
|
||||||
|
log.Printf("🔍 Provided password length: %d", len(req.Password))
|
||||||
|
|
||||||
// Verify password
|
// Verify password
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
|
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
|
||||||
|
log.Printf("❌ Password mismatch for %s: %v", req.Email, err)
|
||||||
return nil, ErrInvalidCredentials
|
return nil, ErrInvalidCredentials
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -167,6 +167,30 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- aggios-network
|
- aggios-network
|
||||||
|
|
||||||
|
# Frontend - Agency (tenant-only)
|
||||||
|
agency:
|
||||||
|
build:
|
||||||
|
context: ./front-end-agency
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: aggios-agency
|
||||||
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.agency.rule=Host(`agency.aggios.local`) || Host(`agency.localhost`) || HostRegexp(`^.+\\.localhost$`)"
|
||||||
|
- "traefik.http.routers.agency.entrypoints=web"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- NEXT_PUBLIC_API_URL=http://api.localhost
|
||||||
|
- API_INTERNAL_URL=http://backend:8080
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000" ]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
networks:
|
||||||
|
- aggios-network
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
driver: local
|
driver: local
|
||||||
|
|||||||
41
front-end-agency/.gitignore
vendored
Normal file
41
front-end-agency/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
41
front-end-agency/Dockerfile
Normal file
41
front-end-agency/Dockerfile
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build Next.js
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
|
||||||
|
# Install only production dependencies
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
# Copy built app from builder
|
||||||
|
COPY --from=builder /app/.next ./.next
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD node -e "require('http').get('http://localhost:3000', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})"
|
||||||
|
|
||||||
|
# Start app
|
||||||
|
CMD ["npm", "start"]
|
||||||
36
front-end-agency/README.md
Normal file
36
front-end-agency/README.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
1105
front-end-agency/app/(agency)/configuracoes/page.tsx
Normal file
1105
front-end-agency/app/(agency)/configuracoes/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, Fragment } from 'react';
|
import { useEffect, useState, Fragment } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { Menu, Transition } from '@headlessui/react';
|
import { Menu, Transition } from '@headlessui/react';
|
||||||
import {
|
import {
|
||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
|
|
||||||
const ThemeToggle = dynamic(() => import('@/components/ThemeToggle'), { ssr: false });
|
const ThemeToggle = dynamic(() => import('@/components/ThemeToggle'), { ssr: false });
|
||||||
const ThemeTester = dynamic(() => import('@/components/ThemeTester'), { ssr: false });
|
const ThemeTester = dynamic(() => import('@/components/ThemeTester'), { ssr: false });
|
||||||
|
const DynamicFavicon = dynamic(() => import('@/components/DynamicFavicon'), { ssr: false });
|
||||||
|
|
||||||
const DEFAULT_GRADIENT = 'linear-gradient(135deg, #ff3a05, #ff0080)';
|
const DEFAULT_GRADIENT = 'linear-gradient(135deg, #ff3a05, #ff0080)';
|
||||||
|
|
||||||
@@ -63,8 +64,10 @@ export default function AgencyLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
const [user, setUser] = useState<any>(null);
|
const [user, setUser] = useState<any>(null);
|
||||||
const [agencyName, setAgencyName] = useState('');
|
const [agencyName, setAgencyName] = useState('');
|
||||||
|
const [agencyLogo, setAgencyLogo] = useState('');
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
const [activeSubmenu, setActiveSubmenu] = useState<number | null>(null);
|
const [activeSubmenu, setActiveSubmenu] = useState<number | null>(null);
|
||||||
@@ -87,7 +90,6 @@ export default function AgencyLayout({
|
|||||||
router.push('/login');
|
router.push('/login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedUser = JSON.parse(userData);
|
const parsedUser = JSON.parse(userData);
|
||||||
setUser(parsedUser);
|
setUser(parsedUser);
|
||||||
|
|
||||||
@@ -98,10 +100,33 @@ export default function AgencyLayout({
|
|||||||
|
|
||||||
const hostname = window.location.hostname;
|
const hostname = window.location.hostname;
|
||||||
const hostSubdomain = hostname.split('.')[0] || 'default';
|
const hostSubdomain = hostname.split('.')[0] || 'default';
|
||||||
const themeKey = parsedUser?.subdomain || parsedUser?.tenantId || hostSubdomain;
|
const themeKey = parsedUser?.subdomain || parsedUser?.tenantId || parsedUser?.tenant_id || hostSubdomain;
|
||||||
|
|
||||||
setAgencyName(parsedUser?.subdomain || hostSubdomain);
|
setAgencyName(parsedUser?.subdomain || hostSubdomain);
|
||||||
|
|
||||||
|
// Buscar logo da agência
|
||||||
|
const fetchAgencyLogo = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/agency/profile', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.logo_url) {
|
||||||
|
setAgencyLogo(data.logo_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar logo da agência:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchAgencyLogo();
|
||||||
|
|
||||||
const storedGradient = localStorage.getItem(`agency-theme:${themeKey}`);
|
const storedGradient = localStorage.getItem(`agency-theme:${themeKey}`);
|
||||||
setGradientVariables(storedGradient || DEFAULT_GRADIENT);
|
setGradientVariables(storedGradient || DEFAULT_GRADIENT);
|
||||||
|
|
||||||
@@ -126,6 +151,17 @@ export default function AgencyLayout({
|
|||||||
};
|
};
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hostname = window.location.hostname;
|
||||||
|
const hostSubdomain = hostname.split('.')[0] || 'default';
|
||||||
|
const userData = localStorage.getItem('user');
|
||||||
|
const parsedUser = userData ? JSON.parse(userData) : null;
|
||||||
|
const themeKey = parsedUser?.subdomain || parsedUser?.tenantId || parsedUser?.tenant_id || hostSubdomain;
|
||||||
|
const storedGradient = localStorage.getItem(`agency-theme:${themeKey}`) || DEFAULT_GRADIENT;
|
||||||
|
|
||||||
|
setGradientVariables(storedGradient);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -219,6 +255,9 @@ export default function AgencyLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-gray-50 dark:bg-gray-950">
|
<div className="flex h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
|
{/* Favicon Dinâmico */}
|
||||||
|
<DynamicFavicon logoUrl={agencyLogo} />
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<aside className={`${activeSubmenu !== null ? 'w-20' : (sidebarOpen ? 'w-64' : 'w-20')} transition-all duration-300 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col`}>
|
<aside className={`${activeSubmenu !== null ? 'w-20' : (sidebarOpen ? 'w-64' : 'w-20')} transition-all duration-300 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col`}>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
@@ -226,7 +265,11 @@ export default function AgencyLayout({
|
|||||||
{(sidebarOpen && activeSubmenu === null) ? (
|
{(sidebarOpen && activeSubmenu === null) ? (
|
||||||
<div className="flex items-center justify-between px-4 w-full">
|
<div className="flex items-center justify-between px-4 w-full">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
|
{agencyLogo ? (
|
||||||
|
<img src={agencyLogo} alt="Logo" className="w-8 h-8 rounded-lg object-contain shrink-0" />
|
||||||
|
) : (
|
||||||
<div className="w-8 h-8 rounded-lg shrink-0" style={{ background: 'var(--gradient-primary)' }}></div>
|
<div className="w-8 h-8 rounded-lg shrink-0" style={{ background: 'var(--gradient-primary)' }}></div>
|
||||||
|
)}
|
||||||
<span className="font-bold text-lg dark:text-white capitalize">{agencyName}</span>
|
<span className="font-bold text-lg dark:text-white capitalize">{agencyName}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -236,9 +279,15 @@ export default function AgencyLayout({
|
|||||||
<XMarkIcon className="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
<XMarkIcon className="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{agencyLogo ? (
|
||||||
|
<img src={agencyLogo} alt="Logo" className="w-8 h-8 rounded-lg object-contain" />
|
||||||
) : (
|
) : (
|
||||||
<div className="w-8 h-8 rounded-lg" style={{ background: 'var(--gradient-primary)' }}></div>
|
<div className="w-8 h-8 rounded-lg" style={{ background: 'var(--gradient-primary)' }}></div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Menu */}
|
{/* Menu */}
|
||||||
@@ -34,8 +34,60 @@ export default function CadastroPage() {
|
|||||||
const [primaryColor, setPrimaryColor] = useState("#ff3a05");
|
const [primaryColor, setPrimaryColor] = useState("#ff3a05");
|
||||||
const [secondaryColor, setSecondaryColor] = useState("#ff0080");
|
const [secondaryColor, setSecondaryColor] = useState("#ff0080");
|
||||||
const [logoUrl, setLogoUrl] = useState<string>("");
|
const [logoUrl, setLogoUrl] = useState<string>("");
|
||||||
|
const [logoHorizontalUrl, setLogoHorizontalUrl] = useState<string>("");
|
||||||
|
const [uploadingLogo, setUploadingLogo] = useState(false);
|
||||||
|
const [uploadingLogoHorizontal, setUploadingLogoHorizontal] = useState(false);
|
||||||
const [showPreviewMobile, setShowPreviewMobile] = useState(false);
|
const [showPreviewMobile, setShowPreviewMobile] = useState(false);
|
||||||
|
|
||||||
|
// Função para upload de logo
|
||||||
|
const handleLogoUpload = async (file: File, isHorizontal: boolean = false) => {
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
toast.error('Arquivo muito grande. Máximo: 10MB');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHorizontal) {
|
||||||
|
setUploadingLogoHorizontal(true);
|
||||||
|
} else {
|
||||||
|
setUploadingLogo(true);
|
||||||
|
}
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const headers: HeadersInit = {};
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Upload failed');
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (isHorizontal) {
|
||||||
|
setLogoHorizontalUrl(data.file_url);
|
||||||
|
} else {
|
||||||
|
setLogoUrl(data.file_url);
|
||||||
|
}
|
||||||
|
toast.success('Logo enviado com sucesso!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro no upload:', error);
|
||||||
|
toast.error('Falha ao enviar logo. Tente novamente.');
|
||||||
|
} finally {
|
||||||
|
if (isHorizontal) {
|
||||||
|
setUploadingLogoHorizontal(false);
|
||||||
|
} else {
|
||||||
|
setUploadingLogo(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Carregar dados do localStorage ao montar
|
// Carregar dados do localStorage ao montar
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const saved = localStorage.getItem('cadastroFormData');
|
const saved = localStorage.getItem('cadastroFormData');
|
||||||
@@ -314,6 +366,12 @@ export default function CadastroPage() {
|
|||||||
number: formData.number,
|
number: formData.number,
|
||||||
complement: formData.complement,
|
complement: formData.complement,
|
||||||
|
|
||||||
|
// Personalização
|
||||||
|
primaryColor: formData.primaryColor,
|
||||||
|
secondaryColor: formData.secondaryColor,
|
||||||
|
logoUrl: logoUrl,
|
||||||
|
logoHorizontalUrl: logoHorizontalUrl,
|
||||||
|
|
||||||
// Admin
|
// Admin
|
||||||
adminEmail: formData.email,
|
adminEmail: formData.email,
|
||||||
adminPassword: password,
|
adminPassword: password,
|
||||||
@@ -334,12 +392,20 @@ export default function CadastroPage() {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let errorMessage = 'Erro ao criar conta';
|
let errorMessage = 'Erro ao criar conta';
|
||||||
try {
|
try {
|
||||||
const error = await response.json();
|
|
||||||
errorMessage = error.message || error.error || errorMessage;
|
|
||||||
} catch (e) {
|
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
if (text) errorMessage = text;
|
// Tentar parsear como JSON primeiro
|
||||||
|
try {
|
||||||
|
const error = JSON.parse(text);
|
||||||
|
errorMessage = error.message || error.error || text;
|
||||||
|
} catch {
|
||||||
|
// Se não for JSON, usar o texto direto
|
||||||
|
errorMessage = text || errorMessage;
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Erro ao ler resposta
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.error(errorMessage, { id: 'register' });
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -573,16 +639,30 @@ export default function CadastroPage() {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!data.erro) {
|
if (!data.erro) {
|
||||||
setCepData({
|
const nextCep = {
|
||||||
state: data.uf || "",
|
state: data.uf || "",
|
||||||
city: data.localidade || "",
|
city: data.localidade || "",
|
||||||
neighborhood: data.bairro || "",
|
neighborhood: data.bairro || "",
|
||||||
street: data.logradouro || ""
|
street: data.logradouro || ""
|
||||||
});
|
};
|
||||||
|
setCepData(nextCep);
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
state: nextCep.state,
|
||||||
|
city: nextCep.city,
|
||||||
|
neighborhood: nextCep.neighborhood,
|
||||||
|
street: nextCep.street,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
toast.error('CEP não encontrado. Verifique o número.');
|
||||||
|
setCepData({ state: "", city: "", neighborhood: "", street: "" });
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
toast.error('Não foi possível consultar o CEP agora.');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erro ao buscar CEP:", error);
|
console.error("Erro ao buscar CEP:", error);
|
||||||
|
toast.error('Erro ao buscar CEP. Tente novamente.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingCep(false);
|
setLoadingCep(false);
|
||||||
}
|
}
|
||||||
@@ -851,7 +931,7 @@ export default function CadastroPage() {
|
|||||||
<textarea
|
<textarea
|
||||||
name="description"
|
name="description"
|
||||||
placeholder="Apresente sua empresa em poucas palavras (máx 300 caracteres)"
|
placeholder="Apresente sua empresa em poucas palavras (máx 300 caracteres)"
|
||||||
className="w-full px-3.5 py-3 text-[14px] font-normal border rounded-md bg-white placeholder:text-zinc-500 border-zinc-200 outline-none ring-0 shadow-none focus:shadow-none resize-none focus:border-[var(--brand-color)]"
|
className="w-full px-3.5 py-3 text-[14px] font-normal border rounded-md bg-white placeholder:text-zinc-500 border-zinc-200 outline-none ring-0 shadow-none focus:shadow-none resize-none focus:border-(--brand-color)"
|
||||||
rows={4}
|
rows={4}
|
||||||
maxLength={300}
|
maxLength={300}
|
||||||
value={formData.description || ''}
|
value={formData.description || ''}
|
||||||
@@ -941,7 +1021,15 @@ export default function CadastroPage() {
|
|||||||
|
|
||||||
// Se campo vazio, limpar dados
|
// Se campo vazio, limpar dados
|
||||||
if (numbers.length === 0) {
|
if (numbers.length === 0) {
|
||||||
setCepData({ state: "", city: "", neighborhood: "", street: "" });
|
const emptyCep = { state: "", city: "", neighborhood: "", street: "" };
|
||||||
|
setCepData(emptyCep);
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
state: "",
|
||||||
|
city: "",
|
||||||
|
neighborhood: "",
|
||||||
|
street: "",
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
// Se CEP completo, buscar dados
|
// Se CEP completo, buscar dados
|
||||||
else if (numbers.length === 8) {
|
else if (numbers.length === 8) {
|
||||||
@@ -1166,10 +1254,10 @@ export default function CadastroPage() {
|
|||||||
|
|
||||||
{/* Formulário (oculto quando preview ativo no mobile) */}
|
{/* Formulário (oculto quando preview ativo no mobile) */}
|
||||||
<div className={showPreviewMobile ? 'hidden lg:block space-y-4' : 'block space-y-4'}>
|
<div className={showPreviewMobile ? 'hidden lg:block space-y-4' : 'block space-y-4'}>
|
||||||
{/* Upload de Logo */}
|
{/* Upload de Logo Quadrado (Obrigatório) */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-[#000000] mb-3">
|
<label className="block text-sm font-medium text-[#000000] mb-3">
|
||||||
Logo da Empresa <span className="text-[#7D7D7D]">(opcional)</span>
|
Logo Quadrado <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
{/* Preview do Logo */}
|
{/* Preview do Logo */}
|
||||||
@@ -1188,22 +1276,28 @@ export default function CadastroPage() {
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
const reader = new FileReader();
|
handleLogoUpload(file, false);
|
||||||
reader.onloadend = () => {
|
|
||||||
setLogoUrl(reader.result as string);
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
id="logo-upload"
|
id="logo-upload"
|
||||||
|
disabled={uploadingLogo}
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
htmlFor="logo-upload"
|
htmlFor="logo-upload"
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 border border-zinc-200 rounded-md text-sm font-medium text-zinc-900 hover:bg-zinc-50 transition-colors cursor-pointer"
|
className={`inline-flex items-center gap-2 px-4 py-2 border border-zinc-200 rounded-md text-sm font-medium text-zinc-900 transition-colors ${uploadingLogo ? 'opacity-50 cursor-not-allowed' : 'hover:bg-zinc-50 cursor-pointer'}`}
|
||||||
>
|
>
|
||||||
|
{uploadingLogo ? (
|
||||||
|
<>
|
||||||
|
<i className="ri-loader-4-line animate-spin" />
|
||||||
|
Enviando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<i className="ri-upload-2-line" />
|
<i className="ri-upload-2-line" />
|
||||||
Escolher arquivo
|
Escolher arquivo
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
{logoUrl && (
|
{logoUrl && (
|
||||||
<button
|
<button
|
||||||
@@ -1222,6 +1316,68 @@ export default function CadastroPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Upload de Logo Horizontal (Opcional) */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#000000] mb-3">
|
||||||
|
Logo Horizontal <span className="text-[#7D7D7D]">(opcional)</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
{/* Preview do Logo Horizontal */}
|
||||||
|
<div className="w-32 h-20 rounded-lg border-2 border-dashed border-[#E5E5E5] flex items-center justify-center overflow-hidden bg-[#F5F5F5]">
|
||||||
|
{logoHorizontalUrl ? (
|
||||||
|
<img src={logoHorizontalUrl} alt="Logo horizontal preview" className="w-full h-full object-contain" />
|
||||||
|
) : (
|
||||||
|
<i className="ri-image-line text-3xl text-[#7D7D7D]" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Input de Upload */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
handleLogoUpload(file, true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="hidden"
|
||||||
|
id="logo-horizontal-upload"
|
||||||
|
disabled={uploadingLogoHorizontal}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="logo-horizontal-upload"
|
||||||
|
className={`inline-flex items-center gap-2 px-4 py-2 border border-zinc-200 rounded-md text-sm font-medium text-zinc-900 transition-colors ${uploadingLogoHorizontal ? 'opacity-50 cursor-not-allowed' : 'hover:bg-zinc-50 cursor-pointer'}`}
|
||||||
|
>
|
||||||
|
{uploadingLogoHorizontal ? (
|
||||||
|
<>
|
||||||
|
<i className="ri-loader-4-line animate-spin" />
|
||||||
|
Enviando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<i className="ri-upload-2-line" />
|
||||||
|
Escolher arquivo
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
{logoHorizontalUrl && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLogoHorizontalUrl('')}
|
||||||
|
className="ml-2 text-sm hover:underline font-medium"
|
||||||
|
style={{ color: 'var(--brand-color)' }}
|
||||||
|
>
|
||||||
|
Remover
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-zinc-600 mt-2">
|
||||||
|
PNG, JPG ou SVG. Formato horizontal. Ex: 400x100px
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Cores do Painel */}
|
{/* Cores do Painel */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{/* Cor Primária */}
|
{/* Cor Primária */}
|
||||||
25
front-end-agency/app/LayoutWrapper.tsx
Normal file
25
front-end-agency/app/LayoutWrapper.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
const DEFAULT_GRADIENT = 'linear-gradient(135deg, #ff3a05, #ff0080)';
|
||||||
|
|
||||||
|
const setGradientVariables = (gradient: string) => {
|
||||||
|
document.documentElement.style.setProperty('--gradient-primary', gradient);
|
||||||
|
document.documentElement.style.setProperty('--gradient', gradient);
|
||||||
|
document.documentElement.style.setProperty('--gradient-text', gradient.replace('90deg', 'to right'));
|
||||||
|
document.documentElement.style.setProperty('--color-gradient-brand', gradient.replace('90deg', 'to right'));
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LayoutWrapper({ children }: { children: ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Em toda troca de rota, volta para o tema padrão; layouts específicos (ex.: agência) aplicam o próprio na sequência
|
||||||
|
setGradientVariables(DEFAULT_GRADIENT);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
80
front-end-agency/app/api/[...path]/route.ts
Normal file
80
front-end-agency/app/api/[...path]/route.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function GET(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: "GET",
|
||||||
|
headers: {
|
||||||
|
"Authorization": token || "",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Forwarded-Host": host || "",
|
||||||
|
"X-Original-Host": host || "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API proxy error:", error);
|
||||||
|
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(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");
|
||||||
|
const body = await req.json();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://backend:8080/api/${path}${req.nextUrl.search}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Authorization": token || "",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Forwarded-Host": host || "",
|
||||||
|
"X-Original-Host": host || "",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API proxy error:", error);
|
||||||
|
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(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");
|
||||||
|
const body = await req.json();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://backend:8080/api/${path}${req.nextUrl.search}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": token || "",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Forwarded-Host": host || "",
|
||||||
|
"X-Original-Host": host || "",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API proxy error:", error);
|
||||||
|
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
50
front-end-agency/app/api/agency/logo/route.ts
Normal file
50
front-end-agency/app/api/agency/logo/route.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.API_INTERNAL_URL || 'http://aggios-backend:8080';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const authorization = request.headers.get('authorization');
|
||||||
|
|
||||||
|
if (!authorization) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get form data from request
|
||||||
|
const formData = await request.formData();
|
||||||
|
|
||||||
|
console.log('Forwarding logo upload to backend:', BACKEND_URL);
|
||||||
|
|
||||||
|
// Forward to backend
|
||||||
|
const response = await fetch(`${BACKEND_URL}/api/agency/logo`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': authorization,
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Backend response status:', response.status);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('Backend error:', errorText);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: errorText || 'Failed to upload logo' },
|
||||||
|
{ status: response.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logo upload error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error: ' + (error instanceof Error ? error.message : String(error)) },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
front-end-agency/app/api/auth/login/route.ts
Normal file
29
front-end-agency/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
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('Login error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Erro ao processar login' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
front-end-agency/app/favicon.ico
Normal file
BIN
front-end-agency/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
183
front-end-agency/app/globals.css
Normal file
183
front-end-agency/app/globals.css
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
@config "../tailwind.config.js";
|
||||||
|
|
||||||
|
@import "tailwindcss";
|
||||||
|
@import "./tokens.css";
|
||||||
|
|
||||||
|
@variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
a,
|
||||||
|
button,
|
||||||
|
[role="button"],
|
||||||
|
input[type="submit"],
|
||||||
|
input[type="reset"],
|
||||||
|
input[type="button"],
|
||||||
|
label[for] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--color-surface-muted);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
transition: background-color 0.25s ease, color 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background-color: var(--color-brand-500);
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Seleção em campos de formulário usa o gradiente padrão da marca */
|
||||||
|
input::selection,
|
||||||
|
textarea::selection,
|
||||||
|
select::selection {
|
||||||
|
background: var(--color-gradient-brand);
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface-card {
|
||||||
|
background-color: var(--color-surface-card);
|
||||||
|
border: 1px solid var(--color-border-strong);
|
||||||
|
box-shadow: 0 20px 80px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-panel {
|
||||||
|
background: linear-gradient(120deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.05));
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(15, 23, 42, 0.25);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-text {
|
||||||
|
background: var(--color-gradient-brand);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
49
front-end-agency/app/layout.tsx
Normal file
49
front-end-agency/app/layout.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Inter, Open_Sans, Fira_Code } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
import LayoutWrapper from "./LayoutWrapper";
|
||||||
|
import { ThemeProvider } from "next-themes";
|
||||||
|
|
||||||
|
const inter = Inter({
|
||||||
|
variable: "--font-inter",
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["400", "500", "600", "700"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const openSans = Open_Sans({
|
||||||
|
variable: "--font-open-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["600", "700"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const firaCode = Fira_Code({
|
||||||
|
variable: "--font-fira-code",
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["400", "600"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Aggios - Dashboard",
|
||||||
|
description: "Plataforma SaaS para agências digitais",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="pt-BR" suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/remixicon@4.3.0/fonts/remixicon.css" />
|
||||||
|
</head>
|
||||||
|
<body className={`${inter.variable} ${openSans.variable} ${firaCode.variable} antialiased`}>
|
||||||
|
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
|
||||||
|
<LayoutWrapper>
|
||||||
|
{children}
|
||||||
|
</LayoutWrapper>
|
||||||
|
</ThemeProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
286
front-end-agency/app/login/page.tsx
Normal file
286
front-end-agency/app/login/page.tsx
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button, Input, Checkbox } from "@/components/ui";
|
||||||
|
import toast, { Toaster } from 'react-hot-toast';
|
||||||
|
import { saveAuth, isAuthenticated } from '@/lib/auth';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
const ThemeToggle = dynamic(() => import('@/components/ThemeToggle'), { ssr: false });
|
||||||
|
|
||||||
|
const DEFAULT_GRADIENT = 'linear-gradient(135deg, #ff3a05, #ff0080)';
|
||||||
|
|
||||||
|
const setGradientVariables = (gradient: string) => {
|
||||||
|
document.documentElement.style.setProperty('--gradient-primary', gradient);
|
||||||
|
document.documentElement.style.setProperty('--gradient', gradient);
|
||||||
|
document.documentElement.style.setProperty('--gradient-text', gradient.replace('90deg', 'to right'));
|
||||||
|
document.documentElement.style.setProperty('--color-gradient-brand', gradient.replace('90deg', 'to right'));
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isSuperAdmin, setIsSuperAdmin] = useState(false);
|
||||||
|
const [subdomain, setSubdomain] = useState<string>('');
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
rememberMe: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const hostname = window.location.hostname;
|
||||||
|
const sub = hostname.split('.')[0];
|
||||||
|
const superAdmin = sub === 'dash';
|
||||||
|
setSubdomain(sub);
|
||||||
|
setIsSuperAdmin(superAdmin);
|
||||||
|
|
||||||
|
// Aplicar tema: dash sempre padrão; tenants aplicam o salvo ou vindo via query param
|
||||||
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
|
const themeParam = searchParams.get('theme');
|
||||||
|
|
||||||
|
if (superAdmin) {
|
||||||
|
setGradientVariables(DEFAULT_GRADIENT);
|
||||||
|
} else {
|
||||||
|
const stored = localStorage.getItem(`agency-theme:${sub}`);
|
||||||
|
const gradient = themeParam || stored || DEFAULT_GRADIENT;
|
||||||
|
setGradientVariables(gradient);
|
||||||
|
|
||||||
|
if (themeParam) {
|
||||||
|
localStorage.setItem(`agency-theme:${sub}`, gradient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAuthenticated()) {
|
||||||
|
const target = superAdmin ? '/superadmin' : '/dashboard';
|
||||||
|
window.location.href = target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!formData.email) {
|
||||||
|
toast.error('Por favor, insira seu email');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||||
|
toast.error('Por favor, insira um email válido');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.password) {
|
||||||
|
toast.error('Por favor, insira sua senha');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: formData.email,
|
||||||
|
password: formData.password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.message || 'Credenciais inválidas');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
saveAuth(data.token, data.user);
|
||||||
|
|
||||||
|
console.log('Login successful:', data.user);
|
||||||
|
|
||||||
|
toast.success('Login realizado com sucesso! Redirecionando...');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const target = isSuperAdmin ? '/superadmin' : '/dashboard';
|
||||||
|
window.location.href = target;
|
||||||
|
}, 1000);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Erro ao fazer login. Verifique suas credenciais.');
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toaster
|
||||||
|
position="top-center"
|
||||||
|
toastOptions={{
|
||||||
|
duration: 5000,
|
||||||
|
style: {
|
||||||
|
background: '#FFFFFF',
|
||||||
|
color: '#000000',
|
||||||
|
padding: '16px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid #E5E5E5',
|
||||||
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
icon: '⚠️',
|
||||||
|
style: {
|
||||||
|
background: '#ef4444',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
border: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
icon: '✓',
|
||||||
|
style: {
|
||||||
|
background: '#10B981',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
border: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
{/* Lado Esquerdo - Formulário */}
|
||||||
|
<div className="w-full lg:w-1/2 flex items-center justify-center px-6 sm:px-12 py-12">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
{/* Logo mobile */}
|
||||||
|
<div className="lg:hidden text-center mb-8">
|
||||||
|
<div className="inline-block px-6 py-3 rounded-2xl" style={{ background: 'var(--gradient-primary)' }}>
|
||||||
|
<h1 className="text-3xl font-bold text-white">
|
||||||
|
{isSuperAdmin ? 'aggios' : subdomain}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Theme Toggle */}
|
||||||
|
<div className="flex justify-end mb-4">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-[28px] font-bold text-[#000000] dark:text-white">
|
||||||
|
{isSuperAdmin ? 'Painel Administrativo' : 'Bem-vindo de volta'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-[14px] text-[#7D7D7D] dark:text-gray-400 mt-2">
|
||||||
|
{isSuperAdmin
|
||||||
|
? 'Acesso exclusivo para administradores Aggios'
|
||||||
|
: 'Entre com suas credenciais para acessar o painel'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
placeholder="seu@email.com"
|
||||||
|
leftIcon="ri-mail-line"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Senha"
|
||||||
|
type="password"
|
||||||
|
placeholder="Digite sua senha"
|
||||||
|
leftIcon="ri-lock-line"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Checkbox
|
||||||
|
id="rememberMe"
|
||||||
|
label="Lembrar de mim"
|
||||||
|
checked={formData.rememberMe}
|
||||||
|
onChange={(e) => setFormData({ ...formData, rememberMe: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
href="/recuperar-senha"
|
||||||
|
className="text-[14px] font-medium hover:opacity-80 transition-opacity"
|
||||||
|
style={{ background: 'var(--gradient-primary)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}
|
||||||
|
>
|
||||||
|
Esqueceu a senha?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Entrando...' : 'Entrar'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Link para cadastro - apenas para agências */}
|
||||||
|
{!isSuperAdmin && (
|
||||||
|
<p className="text-center text-[14px] text-[#7D7D7D] dark:text-gray-400">
|
||||||
|
Ainda não tem conta?{' '}
|
||||||
|
<a
|
||||||
|
href="http://dash.localhost/cadastro"
|
||||||
|
className="font-medium hover:opacity-80 transition-opacity"
|
||||||
|
style={{ background: 'var(--gradient-primary)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}
|
||||||
|
>
|
||||||
|
Cadastre sua agência
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lado Direito - Branding */}
|
||||||
|
<div className="hidden lg:flex lg:w-1/2 relative overflow-hidden" style={{ background: 'var(--gradient-primary)' }}>
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center p-12 text-white">
|
||||||
|
<div className="max-w-md text-center">
|
||||||
|
<h1 className="text-5xl font-bold mb-6">
|
||||||
|
{isSuperAdmin ? 'aggios' : subdomain}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl opacity-90 mb-8">
|
||||||
|
{isSuperAdmin
|
||||||
|
? 'Gerencie todas as agências em um só lugar'
|
||||||
|
: 'Gerencie seus clientes com eficiência'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-6 text-left">
|
||||||
|
<div>
|
||||||
|
<i className="ri-shield-check-line text-3xl mb-2"></i>
|
||||||
|
<h3 className="font-semibold mb-1">Seguro</h3>
|
||||||
|
<p className="text-sm opacity-80">Proteção de dados</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<i className="ri-speed-line text-3xl mb-2"></i>
|
||||||
|
<h3 className="font-semibold mb-1">Rápido</h3>
|
||||||
|
<p className="text-sm opacity-80">Performance otimizada</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<i className="ri-team-line text-3xl mb-2"></i>
|
||||||
|
<h3 className="font-semibold mb-1">Colaborativo</h3>
|
||||||
|
<p className="text-sm opacity-80">Trabalho em equipe</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<i className="ri-line-chart-line text-3xl mb-2"></i>
|
||||||
|
<h3 className="font-semibold mb-1">Insights</h3>
|
||||||
|
<p className="text-sm opacity-80">Relatórios detalhados</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
front-end-agency/app/not-found.tsx
Normal file
146
front-end-agency/app/not-found.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
{/* Lado Esquerdo - Conteúdo 404 */}
|
||||||
|
<div className="w-full lg:w-1/2 flex items-center justify-center px-6 sm:px-12 py-12">
|
||||||
|
<div className="w-full max-w-md text-center">
|
||||||
|
{/* Logo mobile */}
|
||||||
|
<div className="lg:hidden mb-8">
|
||||||
|
<div className="inline-block px-6 py-3 rounded-2xl bg-linear-to-r from-brand-500 to-brand-700">
|
||||||
|
<h1 className="text-3xl font-bold text-white">aggios</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 404 Number */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-[120px] font-bold leading-none gradient-text">
|
||||||
|
404
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-[28px] font-bold text-[#000000] mb-2">
|
||||||
|
Página não encontrada
|
||||||
|
</h2>
|
||||||
|
<p className="text-[14px] text-[#7D7D7D] leading-relaxed">
|
||||||
|
Desculpe, a página que você está procurando não existe ou foi movida.
|
||||||
|
Verifique a URL ou volte para a página inicial.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
className="w-full"
|
||||||
|
size="lg"
|
||||||
|
leftIcon="ri-login-box-line"
|
||||||
|
onClick={() => window.location.href = '/login'}
|
||||||
|
>
|
||||||
|
Fazer login
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
size="lg"
|
||||||
|
leftIcon="ri-user-add-line"
|
||||||
|
onClick={() => window.location.href = '/cadastro'}
|
||||||
|
>
|
||||||
|
Criar conta
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Help Section */}
|
||||||
|
<div className="mt-8 p-5 bg-[#F5F5F5] rounded-lg text-left">
|
||||||
|
<h4 className="text-[13px] font-semibold text-[#000000] mb-3 flex items-center gap-2">
|
||||||
|
<i className="ri-questionnaire-line text-[16px] gradient-text" />
|
||||||
|
Precisa de ajuda?
|
||||||
|
</h4>
|
||||||
|
<ul className="text-[13px] text-[#7D7D7D] space-y-2">
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<i className="ri-arrow-right-s-line text-[16px] gradient-text mt-0.5" />
|
||||||
|
<span>Verifique se a URL está correta</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<i className="ri-arrow-right-s-line text-[16px] gradient-text mt-0.5" />
|
||||||
|
<span>Tente buscar no menu principal</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<i className="ri-arrow-right-s-line text-[16px] gradient-text mt-0.5" />
|
||||||
|
<span>Entre em contato com o suporte se o problema persistir</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lado Direito - Branding */}
|
||||||
|
<div className="hidden lg:flex lg:w-1/2 relative overflow-hidden" style={{ background: 'var(--gradient-primary)' }}>
|
||||||
|
<div className="relative z-10 flex flex-col justify-center items-center w-full p-12 text-white">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="inline-block px-6 py-3 rounded-2xl bg-white/10 backdrop-blur-sm border border-white/20">
|
||||||
|
<h1 className="text-5xl font-bold tracking-tight bg-linear-to-r from-white to-white/80 bg-clip-text text-transparent">
|
||||||
|
aggios
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conteúdo */}
|
||||||
|
<div className="max-w-lg text-center">
|
||||||
|
<div className="w-20 h-20 rounded-2xl bg-white/20 flex items-center justify-center mb-6 mx-auto">
|
||||||
|
<i className="ri-compass-3-line text-4xl" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-4xl font-bold mb-4">Perdido? Estamos aqui!</h2>
|
||||||
|
<p className="text-white/80 text-lg mb-8">
|
||||||
|
Mesmo que esta página não exista, temos muitas outras funcionalidades incríveis
|
||||||
|
esperando por você no Aggios.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<div className="space-y-4 text-left">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-white/20 flex items-center justify-center shrink-0 mt-0.5">
|
||||||
|
<i className="ri-dashboard-line text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold mb-1">Dashboard Completo</h4>
|
||||||
|
<p className="text-white/70 text-sm">Visualize todos os seus projetos e métricas</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-white/20 flex items-center justify-center shrink-0 mt-0.5">
|
||||||
|
<i className="ri-team-line text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold mb-1">Gestão de Equipe</h4>
|
||||||
|
<p className="text-white/70 text-sm">Organize e acompanhe sua equipe</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-white/20 flex items-center justify-center shrink-0 mt-0.5">
|
||||||
|
<i className="ri-customer-service-line text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold mb-1">Suporte 24/7</h4>
|
||||||
|
<p className="text-white/70 text-sm">Estamos sempre disponíveis para ajudar</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Círculos decorativos */}
|
||||||
|
<div className="absolute top-0 right-0 w-96 h-96 bg-white/5 rounded-full blur-3xl" />
|
||||||
|
<div className="absolute bottom-0 left-0 w-96 h-96 bg-white/5 rounded-full blur-3xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
front-end-agency/app/page.tsx
Normal file
5
front-end-agency/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
54
front-end-agency/app/tokens.css
Normal file
54
front-end-agency/app/tokens.css
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
@layer theme {
|
||||||
|
:root {
|
||||||
|
/* Gradientes */
|
||||||
|
--gradient: linear-gradient(135deg, #ff3a05, #ff0080);
|
||||||
|
--gradient-text: linear-gradient(to right, #ff3a05, #ff0080);
|
||||||
|
--gradient-primary: linear-gradient(135deg, #ff3a05, #ff0080);
|
||||||
|
--color-gradient-brand: linear-gradient(135deg, #ff3a05, #ff0080);
|
||||||
|
|
||||||
|
/* Cores sólidas de marca (usadas em textos/bordas) */
|
||||||
|
--brand-color: #ff3a05;
|
||||||
|
--brand-color-strong: #ff0080;
|
||||||
|
|
||||||
|
/* Superfícies e tipografia */
|
||||||
|
--color-surface-light: #ffffff;
|
||||||
|
--color-surface-dark: #0a0a0a;
|
||||||
|
--color-surface-muted: #f5f7fb;
|
||||||
|
--color-surface-card: #ffffff;
|
||||||
|
--color-border-strong: rgba(15, 23, 42, 0.08);
|
||||||
|
--color-text-primary: #0f172a;
|
||||||
|
--color-text-secondary: #475569;
|
||||||
|
--color-text-inverse: #f8fafc;
|
||||||
|
--color-gray-50: #f9fafb;
|
||||||
|
--color-gray-100: #f3f4f6;
|
||||||
|
--color-gray-200: #e5e7eb;
|
||||||
|
--color-gray-300: #d1d5db;
|
||||||
|
--color-gray-400: #9ca3af;
|
||||||
|
--color-gray-500: #6b7280;
|
||||||
|
--color-gray-600: #4b5563;
|
||||||
|
--color-gray-700: #374151;
|
||||||
|
--color-gray-800: #1f2937;
|
||||||
|
--color-gray-900: #111827;
|
||||||
|
--color-gray-950: #030712;
|
||||||
|
|
||||||
|
/* Espaçamento */
|
||||||
|
--spacing-xs: 0.25rem;
|
||||||
|
--spacing-sm: 0.5rem;
|
||||||
|
--spacing-md: 1rem;
|
||||||
|
--spacing-lg: 1.5rem;
|
||||||
|
--spacing-xl: 2rem;
|
||||||
|
--spacing-2xl: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
/* Invertendo superfícies e texto para dark mode */
|
||||||
|
--color-surface-light: #020617;
|
||||||
|
--color-surface-dark: #f8fafc;
|
||||||
|
--color-surface-muted: #0b1220;
|
||||||
|
--color-surface-card: #0f172a;
|
||||||
|
--color-border-strong: rgba(148, 163, 184, 0.25);
|
||||||
|
--color-text-primary: #f8fafc;
|
||||||
|
--color-text-secondary: #cbd5f5;
|
||||||
|
--color-text-inverse: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
front-end-agency/components/DynamicFavicon.tsx
Normal file
33
front-end-agency/components/DynamicFavicon.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
interface DynamicFaviconProps {
|
||||||
|
logoUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DynamicFavicon({ logoUrl }: DynamicFaviconProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!logoUrl) return;
|
||||||
|
|
||||||
|
// Remove favicons antigos
|
||||||
|
const existingLinks = document.querySelectorAll("link[rel*='icon']");
|
||||||
|
existingLinks.forEach(link => link.remove());
|
||||||
|
|
||||||
|
// Adiciona novo favicon
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.type = 'image/x-icon';
|
||||||
|
link.rel = 'shortcut icon';
|
||||||
|
link.href = logoUrl;
|
||||||
|
document.getElementsByTagName('head')[0].appendChild(link);
|
||||||
|
|
||||||
|
// Adiciona Apple touch icon
|
||||||
|
const appleLink = document.createElement('link');
|
||||||
|
appleLink.rel = 'apple-touch-icon';
|
||||||
|
appleLink.href = logoUrl;
|
||||||
|
document.getElementsByTagName('head')[0].appendChild(appleLink);
|
||||||
|
|
||||||
|
}, [logoUrl]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
100
front-end-agency/components/ThemeTester.tsx
Normal file
100
front-end-agency/components/ThemeTester.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { SwatchIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
const themePresets = [
|
||||||
|
{
|
||||||
|
name: 'Azul (Marca)',
|
||||||
|
gradient: 'linear-gradient(135deg, #0ea5e9, #0284c7)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Azul/Roxo',
|
||||||
|
gradient: 'linear-gradient(135deg, #0066FF, #9333EA)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Verde/Esmeralda',
|
||||||
|
gradient: 'linear-gradient(135deg, #10B981, #059669)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Ciano/Azul',
|
||||||
|
gradient: 'linear-gradient(135deg, #06B6D4, #3B82F6)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Rosa/Roxo',
|
||||||
|
gradient: 'linear-gradient(135deg, #EC4899, #A855F7)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Vermelho/Laranja',
|
||||||
|
gradient: 'linear-gradient(135deg, #EF4444, #F97316)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Índigo/Violeta',
|
||||||
|
gradient: 'linear-gradient(135deg, #6366F1, #8B5CF6)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Âmbar/Amarelo',
|
||||||
|
gradient: 'linear-gradient(135deg, #F59E0B, #EAB308)',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ThemeTester() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const applyTheme = (gradient: string) => {
|
||||||
|
document.documentElement.style.setProperty('--gradient-primary', gradient);
|
||||||
|
document.documentElement.style.setProperty('--gradient', gradient);
|
||||||
|
document.documentElement.style.setProperty('--gradient-text', gradient);
|
||||||
|
document.documentElement.style.setProperty('--color-gradient-brand', gradient);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-50">
|
||||||
|
{/* Botão flutuante */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="w-14 h-14 rounded-full shadow-lg flex items-center justify-center transition-all hover:scale-110"
|
||||||
|
style={{ background: 'var(--gradient-primary)' }}
|
||||||
|
title="Testar Temas"
|
||||||
|
>
|
||||||
|
<SwatchIcon className="w-6 h-6 text-white" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Painel de temas */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute bottom-16 right-0 w-80 bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white">Testar Gradientes</h3>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Clique para aplicar temporariamente
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 max-h-96 overflow-y-auto space-y-2">
|
||||||
|
{themePresets.map((theme) => (
|
||||||
|
<button
|
||||||
|
key={theme.name}
|
||||||
|
onClick={() => applyTheme(theme.gradient)}
|
||||||
|
className="w-full flex items-center space-x-3 p-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors group"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-12 h-12 rounded-lg shrink-0"
|
||||||
|
style={{ background: theme.gradient }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-white text-left">
|
||||||
|
{theme.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 text-center">
|
||||||
|
💡 Recarregue a página para voltar ao tema original
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
front-end-agency/components/ThemeToggle.tsx
Normal file
37
front-end-agency/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { MoonIcon, SunIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
export default function ThemeToggle() {
|
||||||
|
const { resolvedTheme, setTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return <div className="w-9 h-9 rounded-lg bg-gray-100 dark:bg-gray-800" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDark = resolvedTheme === 'dark';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTheme(isDark ? 'light' : 'dark')}
|
||||||
|
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||||
|
aria-label={isDark ? 'Ativar tema claro' : 'Ativar tema escuro'}
|
||||||
|
title={isDark ? 'Alterar para modo claro' : 'Alterar para modo escuro'}
|
||||||
|
>
|
||||||
|
{isDark ? (
|
||||||
|
<SunIcon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<MoonIcon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
153
front-end-agency/components/cadastro/DashboardPreview.tsx
Normal file
153
front-end-agency/components/cadastro/DashboardPreview.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
interface DashboardPreviewProps {
|
||||||
|
companyName: string;
|
||||||
|
subdomain: string;
|
||||||
|
primaryColor: string;
|
||||||
|
secondaryColor: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPreview({
|
||||||
|
companyName,
|
||||||
|
subdomain,
|
||||||
|
primaryColor,
|
||||||
|
secondaryColor,
|
||||||
|
logoUrl
|
||||||
|
}: DashboardPreviewProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg border-2 border-[#E5E5E5] overflow-hidden shadow-lg">
|
||||||
|
{/* Header do Preview */}
|
||||||
|
<div className="bg-[#F5F5F5] px-3 py-2 border-b border-[#E5E5E5] flex items-center gap-2">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-[#FF5F57]" />
|
||||||
|
<div className="w-3 h-3 rounded-full bg-[#FFBD2E]" />
|
||||||
|
<div className="w-3 h-3 rounded-full bg-[#28CA42]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-center">
|
||||||
|
<span className="text-xs text-[#7D7D7D]">
|
||||||
|
{subdomain || 'seu-dominio'}.aggios.app
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conteúdo do Preview - Dashboard */}
|
||||||
|
<div className="aspect-video bg-[#F8F9FA] relative overflow-hidden">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div
|
||||||
|
className="absolute left-0 top-0 bottom-0 w-16 flex flex-col items-center py-4 gap-3"
|
||||||
|
style={{ backgroundColor: primaryColor }}
|
||||||
|
>
|
||||||
|
{/* Logo/Initial */}
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-white/20 flex items-center justify-center text-white font-bold text-sm overflow-hidden">
|
||||||
|
{logoUrl ? (
|
||||||
|
<img src={logoUrl} alt="Logo" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span>{(companyName || 'E')[0].toUpperCase()}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Menu Icons */}
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
|
||||||
|
<i className="ri-dashboard-line text-white text-lg" />
|
||||||
|
</div>
|
||||||
|
<div className="w-10 h-10 rounded-lg flex items-center justify-center text-white/60">
|
||||||
|
<i className="ri-folder-line text-lg" />
|
||||||
|
</div>
|
||||||
|
<div className="w-10 h-10 rounded-lg flex items-center justify-center text-white/60">
|
||||||
|
<i className="ri-team-line text-lg" />
|
||||||
|
</div>
|
||||||
|
<div className="w-10 h-10 rounded-lg flex items-center justify-center text-white/60">
|
||||||
|
<i className="ri-settings-3-line text-lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="ml-16 p-4">
|
||||||
|
{/* Top Bar */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-bold text-[#000000]">
|
||||||
|
{companyName || 'Sua Empresa'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-[#7D7D7D]">Dashboard</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-[#E5E5E5]" />
|
||||||
|
<div className="w-6 h-6 rounded-full bg-[#E5E5E5]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-3 gap-2 mb-3">
|
||||||
|
<div className="bg-white rounded-lg p-2 border border-[#E5E5E5]">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 rounded flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: `${primaryColor}20` }}
|
||||||
|
>
|
||||||
|
<i className="ri-folder-line text-xs" style={{ color: primaryColor }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-[#7D7D7D]">Projetos</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-bold" style={{ color: primaryColor }}>24</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg p-2 border border-[#E5E5E5]">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 rounded flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: secondaryColor ? `${secondaryColor}20` : '#10B98120' }}
|
||||||
|
>
|
||||||
|
<i className="ri-team-line text-xs" style={{ color: secondaryColor || '#10B981' }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-[#7D7D7D]">Clientes</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-bold" style={{ color: secondaryColor || '#10B981' }}>15</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg p-2 border border-[#E5E5E5]">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<div className="w-6 h-6 rounded flex items-center justify-center bg-[#7D7D7D]/10">
|
||||||
|
<i className="ri-money-dollar-circle-line text-xs text-[#7D7D7D]" />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-[#7D7D7D]">Receita</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-bold text-[#7D7D7D]">R$ 45k</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart Area */}
|
||||||
|
<div className="bg-white rounded-lg p-3 border border-[#E5E5E5]">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xs font-semibold text-[#000000]">Desempenho</span>
|
||||||
|
<button
|
||||||
|
className="px-2 py-0.5 rounded text-[10px] text-white"
|
||||||
|
style={{ backgroundColor: primaryColor }}
|
||||||
|
>
|
||||||
|
Este mês
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-1 h-16">
|
||||||
|
{[40, 70, 45, 80, 60, 90, 75].map((height, i) => (
|
||||||
|
<div key={i} className="flex-1 flex flex-col justify-end">
|
||||||
|
<div
|
||||||
|
className="w-full rounded-t transition-all"
|
||||||
|
style={{
|
||||||
|
height: `${height}%`,
|
||||||
|
backgroundColor: i === 6 ? primaryColor : `${primaryColor}40`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Preview */}
|
||||||
|
<div className="bg-[#F5F5F5] px-3 py-2 text-center border-t border-[#E5E5E5]">
|
||||||
|
<p className="text-[10px] text-[#7D7D7D]">
|
||||||
|
Preview do seu painel • As cores e layout podem ser ajustados
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
234
front-end-agency/components/cadastro/DynamicBranding.tsx
Normal file
234
front-end-agency/components/cadastro/DynamicBranding.tsx
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import DashboardPreview from "./DashboardPreview";
|
||||||
|
|
||||||
|
interface DynamicBrandingProps {
|
||||||
|
currentStep: number;
|
||||||
|
companyName?: string;
|
||||||
|
subdomain?: string;
|
||||||
|
primaryColor?: string;
|
||||||
|
secondaryColor?: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DynamicBranding({
|
||||||
|
currentStep,
|
||||||
|
companyName = '',
|
||||||
|
subdomain = '',
|
||||||
|
primaryColor = '#0ea5e9',
|
||||||
|
secondaryColor = '#0284c7',
|
||||||
|
logoUrl = ''
|
||||||
|
}: DynamicBrandingProps) {
|
||||||
|
const [activeTestimonial, setActiveTestimonial] = useState(0);
|
||||||
|
|
||||||
|
const testimonials = [
|
||||||
|
{
|
||||||
|
text: "Com o Aggios, nossa produtividade aumentou 40%. Gestão de projetos nunca foi tão simples!",
|
||||||
|
author: "Maria Silva",
|
||||||
|
company: "DigitalWorks",
|
||||||
|
avatar: "MS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Reduzi 60% do tempo gasto com controle financeiro. Tudo centralizado em um só lugar.",
|
||||||
|
author: "João Santos",
|
||||||
|
company: "TechHub",
|
||||||
|
avatar: "JS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "A melhor decisão para nossa agência. Dashboard intuitivo e relatórios incríveis!",
|
||||||
|
author: "Ana Costa",
|
||||||
|
company: "CreativeFlow",
|
||||||
|
avatar: "AC"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const stepContent = [
|
||||||
|
{
|
||||||
|
icon: "ri-user-heart-line",
|
||||||
|
title: "Bem-vindo ao Aggios!",
|
||||||
|
description: "Vamos criar sua conta em poucos passos",
|
||||||
|
benefits: [
|
||||||
|
"✓ Acesso completo ao painel",
|
||||||
|
"✓ Gestão ilimitada de projetos",
|
||||||
|
"✓ Suporte prioritário"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "ri-building-line",
|
||||||
|
title: "Configure sua Empresa",
|
||||||
|
description: "Personalize de acordo com seu negócio",
|
||||||
|
benefits: [
|
||||||
|
"✓ Dashboard personalizado",
|
||||||
|
"✓ Gestão de equipe e clientes",
|
||||||
|
"✓ Controle financeiro integrado"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "ri-map-pin-line",
|
||||||
|
title: "Quase lá!",
|
||||||
|
description: "Informações de localização e contato",
|
||||||
|
benefits: [
|
||||||
|
"✓ Multi-contatos configuráveis",
|
||||||
|
"✓ Integração com WhatsApp",
|
||||||
|
"✓ Notificações em tempo real"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "ri-global-line",
|
||||||
|
title: "Seu Domínio Exclusivo",
|
||||||
|
description: "Escolha como acessar seu painel",
|
||||||
|
benefits: [
|
||||||
|
"✓ Subdomínio personalizado",
|
||||||
|
"✓ SSL incluído gratuitamente",
|
||||||
|
"✓ Domínio próprio (opcional)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "ri-palette-line",
|
||||||
|
title: "Personalize as Cores",
|
||||||
|
description: "Deixe com a cara da sua empresa",
|
||||||
|
benefits: [
|
||||||
|
"✓ Preview em tempo real",
|
||||||
|
"✓ Paleta de cores customizada",
|
||||||
|
"✓ Identidade visual única"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const content = stepContent[currentStep - 1] || stepContent[0];
|
||||||
|
|
||||||
|
// Auto-rotate testimonials
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setActiveTestimonial((prev) => (prev + 1) % testimonials.length);
|
||||||
|
}, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [testimonials.length]);
|
||||||
|
|
||||||
|
// Se for etapa 5, mostrar preview do dashboard
|
||||||
|
if (currentStep === 5) {
|
||||||
|
return (
|
||||||
|
<div className="relative z-10 flex flex-col justify-center items-center w-full p-12">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="inline-block px-6 py-3 rounded-2xl bg-white/10 backdrop-blur-sm border border-white/20">
|
||||||
|
<h1 className="text-5xl font-bold tracking-tight bg-linear-to-r from-white to-white/80 bg-clip-text text-transparent">
|
||||||
|
aggios
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conteúdo */}
|
||||||
|
<div className="max-w-lg text-center">
|
||||||
|
<h2 className="text-3xl font-bold mb-2 text-white">Preview do seu Painel</h2>
|
||||||
|
<p className="text-white/80 text-lg">Veja como ficará seu dashboard personalizado</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<div className="w-full max-w-3xl">
|
||||||
|
<DashboardPreview
|
||||||
|
companyName={companyName}
|
||||||
|
subdomain={subdomain}
|
||||||
|
primaryColor={primaryColor}
|
||||||
|
secondaryColor={secondaryColor}
|
||||||
|
logoUrl={logoUrl}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<p className="text-white/70 text-sm">
|
||||||
|
As cores e configurações são atualizadas em tempo real
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Decorative circles */}
|
||||||
|
<div className="absolute -bottom-32 -left-32 w-96 h-96 rounded-full bg-white/5" />
|
||||||
|
<div className="absolute -top-16 -right-16 w-64 h-64 rounded-full bg-white/5" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative z-10 flex flex-col justify-between w-full p-12 text-white">
|
||||||
|
{/* Logo e Conteúdo da Etapa */}
|
||||||
|
<div className="flex flex-col justify-center flex-1">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="inline-block px-6 py-3 rounded-2xl bg-white/10 backdrop-blur-sm border border-white/20">
|
||||||
|
<h1 className="text-5xl font-bold tracking-tight bg-linear-to-r from-white to-white/80 bg-clip-text text-transparent">
|
||||||
|
aggios
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ícone e Título da Etapa */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-white/20 flex items-center justify-center mb-4">
|
||||||
|
<i className={`${content.icon} text-3xl`} />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-bold mb-2">{content.title}</h2>
|
||||||
|
<p className="text-white/80 text-lg">{content.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Benefícios */}
|
||||||
|
<div className="space-y-3 mb-8">
|
||||||
|
{content.benefits.map((benefit, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center gap-3 text-white/90 animate-fade-in"
|
||||||
|
style={{ animationDelay: `${index * 100}ms` }}
|
||||||
|
>
|
||||||
|
<span className="text-lg">{benefit}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Carrossel de Depoimentos */}
|
||||||
|
<div className="relative">
|
||||||
|
<div className="bg-white/10 backdrop-blur-sm rounded-2xl p-6 border border-white/20">
|
||||||
|
<div className="mb-4">
|
||||||
|
<i className="ri-double-quotes-l text-3xl text-white/40" />
|
||||||
|
</div>
|
||||||
|
<p className="text-white/95 mb-4 min-h-[60px]">
|
||||||
|
{testimonials[activeTestimonial].text}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center font-semibold">
|
||||||
|
{testimonials[activeTestimonial].avatar}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-white">
|
||||||
|
{testimonials[activeTestimonial].author}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-white/70">
|
||||||
|
{testimonials[activeTestimonial].company}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Indicadores */}
|
||||||
|
<div className="flex gap-2 justify-center mt-4">
|
||||||
|
{testimonials.map((_, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => setActiveTestimonial(index)}
|
||||||
|
className={`h-1.5 rounded-full transition-all ${index === activeTestimonial
|
||||||
|
? "w-8 bg-white"
|
||||||
|
: "w-1.5 bg-white/40 hover:bg-white/60"
|
||||||
|
}`}
|
||||||
|
aria-label={`Ir para depoimento ${index + 1}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Decorative circles */}
|
||||||
|
<div className="absolute -bottom-32 -left-32 w-96 h-96 rounded-full bg-white/5" />
|
||||||
|
<div className="absolute -top-16 -right-16 w-64 h-64 rounded-full bg-white/5" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
front-end-agency/components/ui/Button.tsx
Normal file
71
front-end-agency/components/ui/Button.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ButtonHTMLAttributes, forwardRef } from "react";
|
||||||
|
|
||||||
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: "primary" | "secondary" | "outline" | "ghost";
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
isLoading?: boolean;
|
||||||
|
leftIcon?: string;
|
||||||
|
rightIcon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
children,
|
||||||
|
variant = "primary",
|
||||||
|
size = "md",
|
||||||
|
isLoading = false,
|
||||||
|
leftIcon,
|
||||||
|
rightIcon,
|
||||||
|
className = "",
|
||||||
|
disabled,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const baseStyles =
|
||||||
|
"inline-flex items-center justify-center font-medium rounded-[6px] transition-opacity focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer";
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
primary: "text-white hover:opacity-90 active:opacity-80",
|
||||||
|
secondary:
|
||||||
|
"bg-[#E5E5E5] dark:bg-gray-700 text-[#000000] dark:text-white hover:opacity-90 active:opacity-80",
|
||||||
|
outline:
|
||||||
|
"border border-[#E5E5E5] dark:border-gray-600 text-[#000000] dark:text-white hover:bg-[#E5E5E5]/10 dark:hover:bg-gray-700/50 active:bg-[#E5E5E5]/20 dark:active:bg-gray-700",
|
||||||
|
ghost: "text-[#000000] dark:text-white hover:bg-[#E5E5E5]/20 dark:hover:bg-gray-700/30 active:bg-[#E5E5E5]/30 dark:active:bg-gray-700/50",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: "h-9 px-3 text-[13px]",
|
||||||
|
md: "h-10 px-4 text-[14px]",
|
||||||
|
lg: "h-12 px-6 text-[14px]",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
|
||||||
|
style={variant === 'primary' ? { background: 'var(--gradient-primary)' } : undefined}
|
||||||
|
disabled={disabled || isLoading}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{isLoading && (
|
||||||
|
<i className="ri-loader-4-line animate-spin mr-2 text-[20px]" />
|
||||||
|
)}
|
||||||
|
{!isLoading && leftIcon && (
|
||||||
|
<i className={`${leftIcon} mr-2 text-[20px]`} />
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
{!isLoading && rightIcon && (
|
||||||
|
<i className={`${rightIcon} ml-2 text-[20px]`} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
export default Button;
|
||||||
69
front-end-agency/components/ui/Checkbox.tsx
Normal file
69
front-end-agency/components/ui/Checkbox.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { InputHTMLAttributes, forwardRef, useState } from "react";
|
||||||
|
|
||||||
|
interface CheckboxProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label?: string | React.ReactNode;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
|
||||||
|
({ label, error, className = "", onChange, checked: controlledChecked, ...props }, ref) => {
|
||||||
|
const [isChecked, setIsChecked] = useState(controlledChecked || false);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setIsChecked(e.target.checked);
|
||||||
|
if (onChange) {
|
||||||
|
onChange(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checked = controlledChecked !== undefined ? controlledChecked : isChecked;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<label className="flex items-start gap-3 cursor-pointer group">
|
||||||
|
<div className="relative flex items-center justify-center mt-0.5">
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="checkbox"
|
||||||
|
className={`
|
||||||
|
appearance-none w-[18px] h-[18px] border rounded-sm
|
||||||
|
border-zinc-200 dark:border-gray-600 bg-white dark:bg-gray-700
|
||||||
|
checked:border-brand-500
|
||||||
|
focus:outline-none focus:border-brand-500
|
||||||
|
transition-colors cursor-pointer
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
background: checked ? 'var(--gradient-primary)' : undefined,
|
||||||
|
}}
|
||||||
|
checked={checked}
|
||||||
|
onChange={handleChange}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<i
|
||||||
|
className={`ri-check-line absolute text-white text-[14px] pointer-events-none transition-opacity ${checked ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{label && (
|
||||||
|
<span className="text-[14px] text-zinc-900 dark:text-white select-none">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
{error && (
|
||||||
|
<p className="mt-1 text-[13px] text-red-500 flex items-center gap-1">
|
||||||
|
<i className="ri-error-warning-line" />
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Checkbox.displayName = "Checkbox";
|
||||||
|
|
||||||
|
export default Checkbox;
|
||||||
95
front-end-agency/components/ui/Dialog.tsx
Normal file
95
front-end-agency/components/ui/Dialog.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { Fragment } from 'react';
|
||||||
|
import { Dialog as HeadlessDialog, Transition } from '@headlessui/react';
|
||||||
|
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
interface DialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
showClose?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'max-w-md',
|
||||||
|
md: 'max-w-lg',
|
||||||
|
lg: 'max-w-2xl',
|
||||||
|
xl: 'max-w-4xl',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Dialog({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
size = 'md',
|
||||||
|
showClose = true,
|
||||||
|
}: DialogProps) {
|
||||||
|
return (
|
||||||
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
|
<HeadlessDialog as="div" className="relative z-50" onClose={onClose}>
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm" />
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-center justify-center p-4">
|
||||||
|
<Transition.Child
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<HeadlessDialog.Panel
|
||||||
|
className={`w-full ${sizeClasses[size]} transform rounded-2xl bg-white dark:bg-gray-800 p-6 text-left align-middle shadow-xl transition-all border border-gray-200 dark:border-gray-700`}
|
||||||
|
>
|
||||||
|
{title && (
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<HeadlessDialog.Title
|
||||||
|
as="h3"
|
||||||
|
className="text-lg font-semibold text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</HeadlessDialog.Title>
|
||||||
|
{showClose && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="w-5 h-5 text-gray-500 dark:text-gray-400" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</HeadlessDialog.Panel>
|
||||||
|
</Transition.Child>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</HeadlessDialog>
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Componente auxiliar para o corpo do dialog
|
||||||
|
Dialog.Body = function DialogBody({ children, className = '' }: { children: React.ReactNode; className?: string }) {
|
||||||
|
return <div className={`text-sm text-gray-600 dark:text-gray-300 ${className}`}>{children}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Componente auxiliar para o rodapé do dialog
|
||||||
|
Dialog.Footer = function DialogFooter({ children, className = '' }: { children: React.ReactNode; className?: string }) {
|
||||||
|
return <div className={`mt-6 flex items-center justify-end space-x-3 ${className}`}>{children}</div>;
|
||||||
|
};
|
||||||
105
front-end-agency/components/ui/Input.tsx
Normal file
105
front-end-agency/components/ui/Input.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { InputHTMLAttributes, forwardRef, useState } from "react";
|
||||||
|
|
||||||
|
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
helperText?: string;
|
||||||
|
leftIcon?: string;
|
||||||
|
rightIcon?: string;
|
||||||
|
onRightIconClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
helperText,
|
||||||
|
leftIcon,
|
||||||
|
rightIcon,
|
||||||
|
onRightIconClick,
|
||||||
|
className = "",
|
||||||
|
type,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const isPassword = type === "password";
|
||||||
|
|
||||||
|
const inputType = isPassword ? (showPassword ? "text" : "password") : type;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{label && (
|
||||||
|
<label className="block text-[13px] font-semibold text-zinc-900 dark:text-white mb-2">
|
||||||
|
{label}
|
||||||
|
{props.required && <span className="text-brand-500 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="relative">
|
||||||
|
{leftIcon && (
|
||||||
|
<i
|
||||||
|
className={`${leftIcon} absolute left-3.5 top-1/2 -translate-y-1/2 text-[#7D7D7D] dark:text-gray-400 text-[20px]`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type={inputType}
|
||||||
|
className={`
|
||||||
|
w-full px-3.5 py-3 text-[14px] font-normal
|
||||||
|
border rounded-md bg-white dark:bg-gray-700 dark:text-white
|
||||||
|
placeholder:text-zinc-500 dark:placeholder:text-gray-400
|
||||||
|
transition-all
|
||||||
|
${leftIcon ? "pl-11" : ""}
|
||||||
|
${isPassword || rightIcon ? "pr-11" : ""}
|
||||||
|
${error
|
||||||
|
? "border-red-500 focus:border-red-500"
|
||||||
|
: "border-zinc-200 dark:border-gray-600 focus:border-brand-500"
|
||||||
|
}
|
||||||
|
outline-none ring-0 focus:ring-0 shadow-none focus:shadow-none
|
||||||
|
disabled:bg-zinc-100 disabled:cursor-not-allowed
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{isPassword && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-900 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className={`${showPassword ? "ri-eye-off-line" : "ri-eye-line"} text-[20px]`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!isPassword && rightIcon && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRightIconClick}
|
||||||
|
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-900 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<i className={`${rightIcon} text-[20px]`} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="mt-1 text-[13px] text-red-500 flex items-center gap-1">
|
||||||
|
<i className="ri-error-warning-line" />
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{helperText && !error && (
|
||||||
|
<p className="mt-1 text-[13px] text-zinc-500">{helperText}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Input.displayName = "Input";
|
||||||
|
|
||||||
|
export default Input;
|
||||||
211
front-end-agency/components/ui/SearchableSelect.tsx
Normal file
211
front-end-agency/components/ui/SearchableSelect.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SelectHTMLAttributes, forwardRef, useState, useRef, useEffect } from "react";
|
||||||
|
|
||||||
|
interface SelectOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchableSelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, 'onChange'> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
helperText?: string;
|
||||||
|
leftIcon?: string;
|
||||||
|
options: SelectOption[];
|
||||||
|
placeholder?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
value?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SearchableSelect = forwardRef<HTMLSelectElement, SearchableSelectProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
helperText,
|
||||||
|
leftIcon,
|
||||||
|
options,
|
||||||
|
placeholder,
|
||||||
|
className = "",
|
||||||
|
onChange,
|
||||||
|
value,
|
||||||
|
required,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [selectedOption, setSelectedOption] = useState<SelectOption | null>(
|
||||||
|
options.find(opt => opt.value === value) || null
|
||||||
|
);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const filteredOptions = options.filter(option =>
|
||||||
|
option.label.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && searchInputRef.current) {
|
||||||
|
searchInputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (value) {
|
||||||
|
const option = options.find(opt => opt.value === value);
|
||||||
|
if (option) {
|
||||||
|
setSelectedOption(option);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [value, options]);
|
||||||
|
|
||||||
|
const handleSelect = (option: SelectOption) => {
|
||||||
|
setSelectedOption(option);
|
||||||
|
setIsOpen(false);
|
||||||
|
setSearchTerm("");
|
||||||
|
if (onChange) {
|
||||||
|
onChange(option.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{/* Hidden select for form compatibility */}
|
||||||
|
<select
|
||||||
|
ref={ref}
|
||||||
|
value={selectedOption?.value || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const option = options.find(opt => opt.value === e.target.value);
|
||||||
|
if (option) handleSelect(option);
|
||||||
|
}}
|
||||||
|
className="hidden"
|
||||||
|
required={required}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
{placeholder || "Selecione uma opção"}
|
||||||
|
</option>
|
||||||
|
{options.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{label && (
|
||||||
|
<label className="block text-[13px] font-semibold text-zinc-900 dark:text-white mb-2">
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-brand-500 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={containerRef} className="relative">
|
||||||
|
{leftIcon && (
|
||||||
|
<i
|
||||||
|
className={`${leftIcon} absolute left-3.5 top-1/2 -translate-y-1/2 text-zinc-500 dark:text-zinc-400 text-[20px] pointer-events-none z-10`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Custom trigger */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className={`
|
||||||
|
w-full px-3.5 py-3 text-[14px] font-normal
|
||||||
|
border rounded-md bg-white dark:bg-zinc-800
|
||||||
|
text-zinc-900 dark:text-white text-left
|
||||||
|
transition-all
|
||||||
|
cursor-pointer
|
||||||
|
${leftIcon ? "pl-11" : ""}
|
||||||
|
pr-11
|
||||||
|
${error
|
||||||
|
? "border-red-500 focus:border-red-500"
|
||||||
|
: "border-zinc-200 dark:border-zinc-700 focus:border-brand-500"
|
||||||
|
}
|
||||||
|
outline-none ring-0 focus:ring-0 shadow-none focus:shadow-none
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{selectedOption ? selectedOption.label : (
|
||||||
|
<span className="text-zinc-500 dark:text-zinc-400">{placeholder || "Selecione uma opção"}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<i className={`ri-arrow-${isOpen ? 'up' : 'down'}-s-line absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-500 dark:text-zinc-400 text-[20px] pointer-events-none transition-transform`} />
|
||||||
|
|
||||||
|
{/* Dropdown */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute z-50 w-full mt-2 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-md shadow-lg max-h-[300px] overflow-hidden">
|
||||||
|
{/* Search input */}
|
||||||
|
<div className="p-2 border-b border-zinc-200 dark:border-zinc-700">
|
||||||
|
<div className="relative">
|
||||||
|
<i className="ri-search-line absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500 dark:text-zinc-400 text-[16px]" />
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
type="text"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
placeholder="Buscar..."
|
||||||
|
className="w-full pl-9 pr-3 py-2 text-[14px] border border-zinc-200 dark:border-zinc-700 rounded-md outline-none focus:border-brand-500 shadow-none bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder:text-zinc-500 dark:placeholder:text-zinc-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Options list */}
|
||||||
|
<div className="overflow-y-auto max-h-60">
|
||||||
|
{filteredOptions.length > 0 ? (
|
||||||
|
filteredOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelect(option)}
|
||||||
|
className={`
|
||||||
|
w-full px-4 py-2.5 text-left text-[14px] transition-colors
|
||||||
|
hover:bg-zinc-100 dark:hover:bg-zinc-700 cursor-pointer
|
||||||
|
${selectedOption?.value === option.value ? 'bg-brand-500/10 text-brand-600 font-medium' : 'text-zinc-900 dark:text-white'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="px-4 py-8 text-center text-zinc-500 dark:text-zinc-400 text-[14px]">
|
||||||
|
Nenhum resultado encontrado
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{helperText && !error && (
|
||||||
|
<p className="mt-1.5 text-[12px] text-zinc-600 dark:text-zinc-400">{helperText}</p>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p className="mt-1 text-[13px] text-red-500 flex items-center gap-1">
|
||||||
|
<i className="ri-error-warning-line" />
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
SearchableSelect.displayName = "SearchableSelect";
|
||||||
|
|
||||||
|
export default SearchableSelect;
|
||||||
89
front-end-agency/components/ui/Select.tsx
Normal file
89
front-end-agency/components/ui/Select.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SelectHTMLAttributes, forwardRef } from "react";
|
||||||
|
|
||||||
|
interface SelectOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
helperText?: string;
|
||||||
|
leftIcon?: string;
|
||||||
|
options: SelectOption[];
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
helperText,
|
||||||
|
leftIcon,
|
||||||
|
options,
|
||||||
|
placeholder,
|
||||||
|
className = "",
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{label && (
|
||||||
|
<label className="block text-[13px] font-semibold text-zinc-900 mb-2">
|
||||||
|
{label}
|
||||||
|
{props.required && <span className="text-brand-500 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="relative">
|
||||||
|
{leftIcon && (
|
||||||
|
<i
|
||||||
|
className={`${leftIcon} absolute left-3.5 top-1/2 -translate-y-1/2 text-[#7D7D7D] text-[20px] pointer-events-none z-10`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
ref={ref}
|
||||||
|
className={`
|
||||||
|
w-full px-3.5 py-3 text-[14px] font-normal
|
||||||
|
border rounded-md bg-white
|
||||||
|
text-zinc-900
|
||||||
|
transition-all appearance-none
|
||||||
|
cursor-pointer
|
||||||
|
${leftIcon ? "pl-11" : ""}
|
||||||
|
pr-11
|
||||||
|
${error
|
||||||
|
? "border-red-500 focus:border-red-500"
|
||||||
|
: "border-zinc-200 focus:border-brand-500"
|
||||||
|
}
|
||||||
|
outline-none ring-0 focus:ring-0 shadow-none focus:shadow-none
|
||||||
|
disabled:bg-zinc-100 disabled:cursor-not-allowed
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
{placeholder || "Selecione uma opção"}
|
||||||
|
</option>
|
||||||
|
{options.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<i className="ri-arrow-down-s-line absolute right-3.5 top-1/2 -translate-y-1/2 text-[#7D7D7D] text-[20px] pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
{helperText && !error && (
|
||||||
|
<p className="mt-1.5 text-[12px] text-zinc-500">{helperText}</p>
|
||||||
|
)}
|
||||||
|
{error && <p className="mt-1.5 text-[12px] text-red-500">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Select.displayName = "Select";
|
||||||
|
|
||||||
|
export default Select;
|
||||||
6
front-end-agency/components/ui/index.ts
Normal file
6
front-end-agency/components/ui/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export { default as Button } from "./Button";
|
||||||
|
export { default as Input } from "./Input";
|
||||||
|
export { default as Checkbox } from "./Checkbox";
|
||||||
|
export { default as Select } from "./Select";
|
||||||
|
export { default as SearchableSelect } from "./SearchableSelect";
|
||||||
|
export { default as Dialog } from "./Dialog";
|
||||||
18
front-end-agency/eslint.config.mjs
Normal file
18
front-end-agency/eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
56
front-end-agency/lib/api.ts
Normal file
56
front-end-agency/lib/api.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* API Configuration - URLs e funções de requisição
|
||||||
|
*/
|
||||||
|
|
||||||
|
// URL base da API - pode ser alterada por variável de ambiente
|
||||||
|
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://api.localhost';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoints da API
|
||||||
|
*/
|
||||||
|
export const API_ENDPOINTS = {
|
||||||
|
// Auth
|
||||||
|
register: `${API_BASE_URL}/api/auth/register`,
|
||||||
|
login: `${API_BASE_URL}/api/auth/login`,
|
||||||
|
logout: `${API_BASE_URL}/api/auth/logout`,
|
||||||
|
refresh: `${API_BASE_URL}/api/auth/refresh`,
|
||||||
|
me: `${API_BASE_URL}/api/me`,
|
||||||
|
|
||||||
|
// Admin / Agencies
|
||||||
|
adminAgencyRegister: `${API_BASE_URL}/api/admin/agencies/register`,
|
||||||
|
|
||||||
|
// Health
|
||||||
|
health: `${API_BASE_URL}/health`,
|
||||||
|
apiHealth: `${API_BASE_URL}/api/health`,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper para fetch com tratamento de erros
|
||||||
|
*/
|
||||||
|
export async function apiRequest<T = any>(
|
||||||
|
url: string,
|
||||||
|
options?: RequestInit
|
||||||
|
): Promise<T> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options?.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.message || `Erro ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error('Erro desconhecido na requisição');
|
||||||
|
}
|
||||||
|
}
|
||||||
79
front-end-agency/lib/auth.ts
Normal file
79
front-end-agency/lib/auth.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Auth utilities - Gerenciamento de autenticação no cliente
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
tenantId?: string;
|
||||||
|
company?: string;
|
||||||
|
subdomain?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'token';
|
||||||
|
const USER_KEY = 'user';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Salva token e dados do usuário no localStorage
|
||||||
|
*/
|
||||||
|
export function saveAuth(token: string, user: User): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retorna o token JWT armazenado
|
||||||
|
*/
|
||||||
|
export function getToken(): string | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
return localStorage.getItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retorna os dados do usuário armazenados
|
||||||
|
*/
|
||||||
|
export function getUser(): User | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
|
||||||
|
const userStr = localStorage.getItem(USER_KEY);
|
||||||
|
if (!userStr) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(userStr);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica se o usuário está autenticado
|
||||||
|
*/
|
||||||
|
export function isAuthenticated(): boolean {
|
||||||
|
return !!getToken() && !!getUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove token e dados do usuário (logout)
|
||||||
|
*/
|
||||||
|
export function clearAuth(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
localStorage.removeItem(USER_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retorna headers com Authorization para requisições autenticadas
|
||||||
|
*/
|
||||||
|
export function getAuthHeaders(): HeadersInit {
|
||||||
|
const token = getToken();
|
||||||
|
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
48
front-end-agency/middleware.ts
Normal file
48
front-end-agency/middleware.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
export async function middleware(request: NextRequest) {
|
||||||
|
const hostname = request.headers.get('host') || '';
|
||||||
|
const url = request.nextUrl;
|
||||||
|
|
||||||
|
const apiBase = process.env.API_INTERNAL_URL || 'http://backend:8080';
|
||||||
|
|
||||||
|
// Extrair subdomínio
|
||||||
|
const subdomain = hostname.split('.')[0];
|
||||||
|
|
||||||
|
// Validar subdomínio de agência ({subdomain}.localhost)
|
||||||
|
if (hostname.includes('.')) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiBase}/api/tenant/check?subdomain=${subdomain}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
const baseHost = hostname.split('.').slice(1).join('.') || hostname;
|
||||||
|
const redirectUrl = new URL(url.toString());
|
||||||
|
redirectUrl.hostname = baseHost;
|
||||||
|
redirectUrl.pathname = '/';
|
||||||
|
return NextResponse.redirect(redirectUrl);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const baseHost = hostname.split('.').slice(1).join('.') || hostname;
|
||||||
|
const redirectUrl = new URL(url.toString());
|
||||||
|
redirectUrl.hostname = baseHost;
|
||||||
|
redirectUrl.pathname = '/';
|
||||||
|
return NextResponse.redirect(redirectUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permitir acesso normal
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
/*
|
||||||
|
* Match all request paths except for the ones starting with:
|
||||||
|
* - api (API routes)
|
||||||
|
* - _next/static (static files)
|
||||||
|
* - _next/image (image optimization files)
|
||||||
|
* - favicon.ico (favicon file)
|
||||||
|
*/
|
||||||
|
'/((?!api|_next/static|_next/image|favicon.ico).*)',
|
||||||
|
],
|
||||||
|
};
|
||||||
32
front-end-agency/next.config.ts
Normal file
32
front-end-agency/next.config.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
experimental: {
|
||||||
|
externalDir: true,
|
||||||
|
},
|
||||||
|
async rewrites() {
|
||||||
|
return {
|
||||||
|
beforeFiles: [
|
||||||
|
{
|
||||||
|
source: "/api/:path*",
|
||||||
|
destination: "http://backend:8080/api/:path*",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
headers: async () => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/api/:path*",
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
key: "X-Forwarded-For",
|
||||||
|
value: "127.0.0.1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
7524
front-end-agency/package-lock.json
generated
Normal file
7524
front-end-agency/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
front-end-agency/package.json
Normal file
33
front-end-agency/package.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "agency.aggios.app",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@headlessui/react": "^2.2.9",
|
||||||
|
"@heroicons/react": "^2.2.0",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"lucide-react": "^0.556.0",
|
||||||
|
"next": "16.0.7",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"react": "19.2.0",
|
||||||
|
"react-dom": "19.2.0",
|
||||||
|
"react-hot-toast": "^2.6.0",
|
||||||
|
"remixicon": "^4.7.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.0.7",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
front-end-agency/postcss.config.mjs
Normal file
7
front-end-agency/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
1
front-end-agency/public/file.svg
Normal file
1
front-end-agency/public/file.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
1
front-end-agency/public/globe.svg
Normal file
1
front-end-agency/public/globe.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
1
front-end-agency/public/next.svg
Normal file
1
front-end-agency/public/next.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
1
front-end-agency/public/vercel.svg
Normal file
1
front-end-agency/public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
1
front-end-agency/public/window.svg
Normal file
1
front-end-agency/public/window.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
12
front-end-agency/tailwind.config.js
Normal file
12
front-end-agency/tailwind.config.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
const sharedPreset = require("./tailwind.preset.js");
|
||||||
|
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
const config = {
|
||||||
|
presets: [sharedPreset],
|
||||||
|
content: [
|
||||||
|
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = config;
|
||||||
35
front-end-agency/tailwind.preset.js
Normal file
35
front-end-agency/tailwind.preset.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
darkMode: "class",
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['var(--font-inter)', 'ui-sans-serif', 'system-ui', 'sans-serif'],
|
||||||
|
mono: ['var(--font-fira-code)', 'ui-monospace', 'SFMono-Regular', 'monospace'],
|
||||||
|
heading: ['var(--font-open-sans)', 'ui-sans-serif', 'system-ui', 'sans-serif'],
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
50: '#fff4ef',
|
||||||
|
100: '#ffe8df',
|
||||||
|
200: '#ffd0c0',
|
||||||
|
300: '#ffb093',
|
||||||
|
400: '#ff8a66',
|
||||||
|
500: '#ff3a05',
|
||||||
|
600: '#ff1f45',
|
||||||
|
700: '#ff0080',
|
||||||
|
800: '#d10069',
|
||||||
|
900: '#9e0050',
|
||||||
|
950: '#4b0028',
|
||||||
|
},
|
||||||
|
surface: {
|
||||||
|
light: '#ffffff',
|
||||||
|
dark: '#0a0a0a',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
glow: '0 0 20px rgba(255, 58, 5, 0.25)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
34
front-end-agency/tsconfig.json
Normal file
34
front-end-agency/tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -1,782 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { Tab } from '@headlessui/react';
|
|
||||||
import { Dialog } from '@/components/ui';
|
|
||||||
import {
|
|
||||||
BuildingOfficeIcon,
|
|
||||||
SwatchIcon,
|
|
||||||
PhotoIcon,
|
|
||||||
UserGroupIcon,
|
|
||||||
ShieldCheckIcon,
|
|
||||||
BellIcon,
|
|
||||||
} from '@heroicons/react/24/outline';
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ name: 'Dados da Agência', icon: BuildingOfficeIcon },
|
|
||||||
{ name: 'Personalização', icon: SwatchIcon },
|
|
||||||
{ name: 'Logo e Marca', icon: PhotoIcon },
|
|
||||||
{ name: 'Equipe', icon: UserGroupIcon },
|
|
||||||
{ name: 'Segurança', icon: ShieldCheckIcon },
|
|
||||||
{ name: 'Notificações', icon: BellIcon },
|
|
||||||
];
|
|
||||||
|
|
||||||
const themePresets = [
|
|
||||||
{ name: 'Marca', gradient: 'linear-gradient(135deg, #ff3a05, #ff0080)', colors: ['#ff3a05', '#ff0080'] },
|
|
||||||
{ name: 'Azul/Roxo', gradient: 'linear-gradient(135deg, #0066FF, #9333EA)', colors: ['#0066FF', '#9333EA'] },
|
|
||||||
{ name: 'Verde/Esmeralda', gradient: 'linear-gradient(135deg, #10B981, #059669)', colors: ['#10B981', '#059669'] },
|
|
||||||
{ name: 'Ciano/Azul', gradient: 'linear-gradient(135deg, #06B6D4, #3B82F6)', colors: ['#06B6D4', '#3B82F6'] },
|
|
||||||
{ name: 'Rosa/Roxo', gradient: 'linear-gradient(135deg, #EC4899, #A855F7)', colors: ['#EC4899', '#A855F7'] },
|
|
||||||
{ name: 'Vermelho/Laranja', gradient: 'linear-gradient(135deg, #EF4444, #F97316)', colors: ['#EF4444', '#F97316'] },
|
|
||||||
];
|
|
||||||
|
|
||||||
const DEFAULT_GRADIENT = 'linear-gradient(135deg, #ff3a05, #ff0080)';
|
|
||||||
const THEME_STORAGE_PREFIX = 'agency-theme:';
|
|
||||||
|
|
||||||
const setThemeVariables = (gradient: string) => {
|
|
||||||
document.documentElement.style.setProperty('--gradient-primary', gradient);
|
|
||||||
document.documentElement.style.setProperty('--gradient', gradient);
|
|
||||||
document.documentElement.style.setProperty('--gradient-text', gradient.replace('90deg', 'to right'));
|
|
||||||
document.documentElement.style.setProperty('--color-gradient-brand', gradient.replace('90deg', 'to right'));
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ConfiguracoesPage() {
|
|
||||||
const [selectedTab, setSelectedTab] = useState(0);
|
|
||||||
const [selectedTheme, setSelectedTheme] = useState(0);
|
|
||||||
const [activeGradient, setActiveGradient] = useState(DEFAULT_GRADIENT);
|
|
||||||
const [themeKey, setThemeKey] = useState('default');
|
|
||||||
const [customColor1, setCustomColor1] = useState('#ff3a05');
|
|
||||||
const [customColor2, setCustomColor2] = useState('#ff0080');
|
|
||||||
const [showSuccessDialog, setShowSuccessDialog] = useState(false);
|
|
||||||
const [successMessage, setSuccessMessage] = useState('');
|
|
||||||
const [showSupportDialog, setShowSupportDialog] = useState(false);
|
|
||||||
const [supportMessage, setSupportMessage] = useState('Para alterar estes dados, contate o suporte.');
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
// Dados da agência (buscados da API)
|
|
||||||
const [agencyData, setAgencyData] = useState({
|
|
||||||
name: '',
|
|
||||||
cnpj: '',
|
|
||||||
email: '',
|
|
||||||
phone: '',
|
|
||||||
website: '',
|
|
||||||
address: '',
|
|
||||||
city: '',
|
|
||||||
state: '',
|
|
||||||
zip: '',
|
|
||||||
razaoSocial: '',
|
|
||||||
description: '',
|
|
||||||
industry: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Dados para alteração de senha
|
|
||||||
const [passwordData, setPasswordData] = useState({
|
|
||||||
currentPassword: '',
|
|
||||||
newPassword: '',
|
|
||||||
confirmPassword: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Buscar dados da agência da API e inicializar tema salvo
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchAgencyData = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
const userData = localStorage.getItem('user');
|
|
||||||
|
|
||||||
if (!token || !userData) {
|
|
||||||
console.error('Usuário não autenticado');
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedUser = JSON.parse(userData);
|
|
||||||
const hostname = window.location.hostname;
|
|
||||||
const hostSubdomain = hostname.split('.')[0] || 'default';
|
|
||||||
const key = parsedUser?.subdomain || parsedUser?.tenantId || hostSubdomain;
|
|
||||||
|
|
||||||
setThemeKey(key);
|
|
||||||
|
|
||||||
const savedGradient = localStorage.getItem(`${THEME_STORAGE_PREFIX}${key}`) || DEFAULT_GRADIENT;
|
|
||||||
setActiveGradient(savedGradient);
|
|
||||||
setThemeVariables(savedGradient);
|
|
||||||
|
|
||||||
const presetIndex = themePresets.findIndex((theme) => theme.gradient === savedGradient);
|
|
||||||
if (presetIndex >= 0) {
|
|
||||||
setSelectedTheme(presetIndex);
|
|
||||||
setCustomColor1(themePresets[presetIndex].colors[0]);
|
|
||||||
setCustomColor2(themePresets[presetIndex].colors[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Buscar dados da API
|
|
||||||
const response = await fetch('/api/agency/profile', {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
setAgencyData({
|
|
||||||
name: data.name || '',
|
|
||||||
cnpj: data.cnpj || '',
|
|
||||||
email: data.email || '',
|
|
||||||
phone: data.phone || '',
|
|
||||||
website: data.website || '',
|
|
||||||
address: data.address || '',
|
|
||||||
city: data.city || '',
|
|
||||||
state: data.state || '',
|
|
||||||
zip: data.zip || '',
|
|
||||||
razaoSocial: data.razao_social || '',
|
|
||||||
description: data.description || '',
|
|
||||||
industry: data.industry || '',
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.error('Erro ao buscar dados:', response.status);
|
|
||||||
// Fallback para localStorage se API falhar
|
|
||||||
const savedData = localStorage.getItem('cadastroData');
|
|
||||||
if (savedData) {
|
|
||||||
const data = JSON.parse(savedData);
|
|
||||||
const user = JSON.parse(userData);
|
|
||||||
setAgencyData({
|
|
||||||
name: data.formData?.companyName || '',
|
|
||||||
cnpj: data.formData?.cnpj || '',
|
|
||||||
email: data.formData?.email || user.email || '',
|
|
||||||
phone: data.contacts?.[0]?.phone || '',
|
|
||||||
website: data.formData?.website || '',
|
|
||||||
address: `${data.cepData?.logradouro || ''}, ${data.formData?.number || ''}`,
|
|
||||||
city: data.cepData?.localidade || '',
|
|
||||||
state: data.cepData?.uf || '',
|
|
||||||
zip: data.formData?.cep || '',
|
|
||||||
razaoSocial: data.cnpjData?.razaoSocial || '',
|
|
||||||
description: data.formData?.description || '',
|
|
||||||
industry: data.formData?.industry || '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erro ao buscar dados da agência:', error);
|
|
||||||
setSuccessMessage('Erro ao carregar dados da agência.');
|
|
||||||
setShowSuccessDialog(true);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchAgencyData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const applyTheme = (gradient: string) => {
|
|
||||||
setActiveGradient(gradient);
|
|
||||||
setThemeVariables(gradient);
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyCustomTheme = () => {
|
|
||||||
const gradient = `linear-gradient(90deg, ${customColor1}, ${customColor2})`;
|
|
||||||
setSelectedTheme(-1);
|
|
||||||
applyTheme(gradient);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveAgency = async () => {
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
if (!token) {
|
|
||||||
setSuccessMessage('Você precisa estar autenticado.');
|
|
||||||
setShowSuccessDialog(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch('/api/agency/profile', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: agencyData.name,
|
|
||||||
cnpj: agencyData.cnpj,
|
|
||||||
email: agencyData.email,
|
|
||||||
phone: agencyData.phone,
|
|
||||||
website: agencyData.website,
|
|
||||||
address: agencyData.address,
|
|
||||||
city: agencyData.city,
|
|
||||||
state: agencyData.state,
|
|
||||||
zip: agencyData.zip,
|
|
||||||
razao_social: agencyData.razaoSocial,
|
|
||||||
description: agencyData.description,
|
|
||||||
industry: agencyData.industry,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
setSuccessMessage('Dados da agência salvos com sucesso!');
|
|
||||||
} else {
|
|
||||||
setSuccessMessage('Erro ao salvar dados. Tente novamente.');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erro ao salvar:', error);
|
|
||||||
setSuccessMessage('Erro ao salvar dados. Verifique sua conexão.');
|
|
||||||
}
|
|
||||||
setShowSuccessDialog(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveTheme = () => {
|
|
||||||
const gradientToSave = selectedTheme >= 0
|
|
||||||
? themePresets[selectedTheme].gradient
|
|
||||||
: activeGradient;
|
|
||||||
|
|
||||||
applyTheme(gradientToSave);
|
|
||||||
if (themeKey) {
|
|
||||||
localStorage.setItem(`${THEME_STORAGE_PREFIX}${themeKey}`, gradientToSave);
|
|
||||||
}
|
|
||||||
|
|
||||||
setSuccessMessage('Tema salvo com sucesso!');
|
|
||||||
setShowSuccessDialog(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChangePassword = async () => {
|
|
||||||
// Validações
|
|
||||||
if (!passwordData.currentPassword) {
|
|
||||||
setSuccessMessage('Por favor, informe sua senha atual.');
|
|
||||||
setShowSuccessDialog(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!passwordData.newPassword || passwordData.newPassword.length < 8) {
|
|
||||||
setSuccessMessage('A nova senha deve ter pelo menos 8 caracteres.');
|
|
||||||
setShowSuccessDialog(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (passwordData.newPassword !== passwordData.confirmPassword) {
|
|
||||||
setSuccessMessage('As senhas não coincidem.');
|
|
||||||
setShowSuccessDialog(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
if (!token) {
|
|
||||||
setSuccessMessage('Você precisa estar autenticado.');
|
|
||||||
setShowSuccessDialog(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch('/api/auth/change-password', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
currentPassword: passwordData.currentPassword,
|
|
||||||
newPassword: passwordData.newPassword,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
setPasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
|
||||||
setSuccessMessage('Senha alterada com sucesso!');
|
|
||||||
} else {
|
|
||||||
const error = await response.text();
|
|
||||||
setSuccessMessage(error || 'Erro ao alterar senha. Verifique sua senha atual.');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erro ao alterar senha:', error);
|
|
||||||
setSuccessMessage('Erro ao alterar senha. Verifique sua conexão.');
|
|
||||||
}
|
|
||||||
setShowSuccessDialog(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6 max-w-7xl mx-auto">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
|
||||||
Configurações
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
|
||||||
Gerencie as configurações da sua agência
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Loading State */}
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900 dark:border-gray-100"></div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Tabs */}
|
|
||||||
<Tab.Group selectedIndex={selectedTab} onChange={setSelectedTab}>
|
|
||||||
<Tab.List className="flex space-x-1 rounded-xl bg-gray-100 dark:bg-gray-800 p-1 mb-8">
|
|
||||||
{tabs.map((tab) => {
|
|
||||||
const Icon = tab.icon;
|
|
||||||
return (
|
|
||||||
<Tab
|
|
||||||
key={tab.name}
|
|
||||||
className={({ selected }) =>
|
|
||||||
`w-full flex items-center justify-center space-x-2 rounded-lg py-2.5 text-sm font-medium leading-5 transition-all
|
|
||||||
${selected
|
|
||||||
? 'bg-white dark:bg-gray-900 text-gray-900 dark:text-white shadow'
|
|
||||||
: 'text-gray-600 dark:text-gray-400 hover:bg-white/50 dark:hover:bg-gray-700/50 hover:text-gray-900 dark:hover:text-white'
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon className="w-5 h-5" />
|
|
||||||
<span className="hidden sm:inline">{tab.name}</span>
|
|
||||||
</Tab>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Tab.List>
|
|
||||||
|
|
||||||
<Tab.Panels>
|
|
||||||
{/* Tab 1: Dados da Agência */}
|
|
||||||
<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">
|
|
||||||
Informações da Agência
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Nome da Agência
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={agencyData.name}
|
|
||||||
onChange={(e) => setAgencyData({ ...agencyData, name: e.target.value })}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center justify-between">
|
|
||||||
<span>CNPJ</span>
|
|
||||||
<span className="text-xs text-gray-500">Alteração via suporte</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={agencyData.cnpj}
|
|
||||||
readOnly
|
|
||||||
onClick={() => {
|
|
||||||
setSupportMessage('Para alterar CNPJ, contate o suporte.');
|
|
||||||
setShowSupportDialog(true);
|
|
||||||
}}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center justify-between">
|
|
||||||
<span>E-mail (acesso)</span>
|
|
||||||
<span className="text-xs text-gray-500">Alteração via suporte</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={agencyData.email}
|
|
||||||
readOnly
|
|
||||||
onClick={() => {
|
|
||||||
setSupportMessage('Para alterar o e-mail de acesso, contate o suporte.');
|
|
||||||
setShowSupportDialog(true);
|
|
||||||
}}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Telefone / WhatsApp
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="tel"
|
|
||||||
value={agencyData.phone}
|
|
||||||
onChange={(e) => setAgencyData({ ...agencyData, phone: e.target.value })}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Website
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={agencyData.website}
|
|
||||||
onChange={(e) => setAgencyData({ ...agencyData, website: e.target.value })}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
CEP
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={agencyData.zip}
|
|
||||||
onChange={(e) => setAgencyData({ ...agencyData, zip: e.target.value })}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Endereço
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={agencyData.address}
|
|
||||||
onChange={(e) => setAgencyData({ ...agencyData, address: e.target.value })}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Cidade
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={agencyData.city}
|
|
||||||
onChange={(e) => setAgencyData({ ...agencyData, city: e.target.value })}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Estado
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={agencyData.state}
|
|
||||||
onChange={(e) => setAgencyData({ ...agencyData, state: e.target.value })}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 flex justify-end">
|
|
||||||
<button
|
|
||||||
onClick={handleSaveAgency}
|
|
||||||
className="px-6 py-2 rounded-lg text-white font-medium transition-all hover:scale-105"
|
|
||||||
style={{ background: 'var(--gradient-primary)' }}
|
|
||||||
>
|
|
||||||
Salvar Alterações
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Tab.Panel>
|
|
||||||
|
|
||||||
{/* Tab 2: Personalização */}
|
|
||||||
<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">
|
|
||||||
Personalização do Dashboard
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{/* Temas Pré-definidos */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">
|
|
||||||
Temas Pré-definidos
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
|
||||||
{themePresets.map((theme, idx) => (
|
|
||||||
<button
|
|
||||||
key={theme.name}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedTheme(idx);
|
|
||||||
applyTheme(theme.gradient);
|
|
||||||
}}
|
|
||||||
className={`p-4 rounded-xl border-2 transition-all hover:scale-105 ${selectedTheme === idx
|
|
||||||
? 'border-gray-900 dark:border-gray-100'
|
|
||||||
: 'border-gray-200 dark:border-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="w-full h-24 rounded-lg mb-3"
|
|
||||||
style={{ background: theme.gradient }}
|
|
||||||
/>
|
|
||||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
|
||||||
{theme.name}
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Cores Customizadas */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">
|
|
||||||
Cores Personalizadas
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-gray-600 dark:text-gray-400 mb-2">
|
|
||||||
Cor Primária
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={customColor1}
|
|
||||||
onChange={(e) => setCustomColor1(e.target.value)}
|
|
||||||
className="w-20 h-20 rounded-lg cursor-pointer border-2 border-gray-300 dark:border-gray-600"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-gray-600 dark:text-gray-400 mb-2">
|
|
||||||
Cor Secundária
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={customColor2}
|
|
||||||
onChange={(e) => setCustomColor2(e.target.value)}
|
|
||||||
className="w-20 h-20 rounded-lg cursor-pointer border-2 border-gray-300 dark:border-gray-600"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<label className="block text-xs text-gray-600 dark:text-gray-400 mb-2">
|
|
||||||
Preview
|
|
||||||
</label>
|
|
||||||
<div
|
|
||||||
className="h-20 rounded-lg"
|
|
||||||
style={{ background: `linear-gradient(90deg, ${customColor1}, ${customColor2})` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={applyCustomTheme}
|
|
||||||
className="px-4 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-lg font-medium hover:scale-105 transition-all"
|
|
||||||
>
|
|
||||||
Aplicar
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 flex justify-end">
|
|
||||||
<button
|
|
||||||
onClick={handleSaveTheme}
|
|
||||||
className="px-6 py-2 rounded-lg text-white font-medium transition-all hover:scale-105"
|
|
||||||
style={{ background: 'var(--gradient-primary)' }}
|
|
||||||
>
|
|
||||||
Salvar Tema
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Tab.Panel>
|
|
||||||
|
|
||||||
{/* Tab 3: Logo e Marca */}
|
|
||||||
<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">
|
|
||||||
Logo e Identidade Visual
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">
|
|
||||||
Logo Principal
|
|
||||||
</label>
|
|
||||||
<div className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl p-8 text-center">
|
|
||||||
<PhotoIcon className="w-12 h-12 mx-auto text-gray-400 mb-3" />
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
|
||||||
Arraste e solte sua logo aqui ou clique para fazer upload
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-500">
|
|
||||||
PNG, JPG ou SVG (máx. 2MB)
|
|
||||||
</p>
|
|
||||||
<button className="mt-4 px-4 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-lg text-sm font-medium hover:scale-105 transition-all">
|
|
||||||
Selecionar Arquivo
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">
|
|
||||||
Favicon
|
|
||||||
</label>
|
|
||||||
<div className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl p-8 text-center">
|
|
||||||
<PhotoIcon className="w-12 h-12 mx-auto text-gray-400 mb-3" />
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
|
||||||
Upload do favicon (ícone da aba do navegador)
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-500">
|
|
||||||
ICO ou PNG 32x32 pixels
|
|
||||||
</p>
|
|
||||||
<button className="mt-4 px-4 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-lg text-sm font-medium hover:scale-105 transition-all">
|
|
||||||
Selecionar Arquivo
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 flex justify-end">
|
|
||||||
<button
|
|
||||||
className="px-6 py-2 rounded-lg text-white font-medium transition-all hover:scale-105"
|
|
||||||
style={{ background: 'var(--gradient-primary)' }}
|
|
||||||
>
|
|
||||||
Salvar Alterações
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Tab.Panel>
|
|
||||||
|
|
||||||
{/* Tab 4: Equipe */}
|
|
||||||
<Tab.Panel className="rounded-xl bg-white dark:bg-gray-800 p-6 border border-gray-200 dark:border-gray-700">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">
|
|
||||||
Gerenciamento de Equipe
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<UserGroupIcon className="w-16 h-16 mx-auto text-gray-300 dark:text-gray-600 mb-4" />
|
|
||||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
|
||||||
Em breve: gerenciamento completo de usuários e permissões
|
|
||||||
</p>
|
|
||||||
<button className="px-6 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-lg font-medium hover:scale-105 transition-all">
|
|
||||||
Convidar Membro
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Tab.Panel>
|
|
||||||
|
|
||||||
{/* Tab 5: Segurança */}
|
|
||||||
<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">
|
|
||||||
Segurança e Privacidade
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{/* Alteração de Senha */}
|
|
||||||
<div className="max-w-2xl">
|
|
||||||
<h3 className="text-md font-medium text-gray-900 dark:text-white mb-4">
|
|
||||||
Alterar Senha
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Senha Atual
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={passwordData.currentPassword}
|
|
||||||
onChange={(e) => setPasswordData({ ...passwordData, currentPassword: e.target.value })}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none"
|
|
||||||
placeholder="Digite sua senha atual"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Nova Senha
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={passwordData.newPassword}
|
|
||||||
onChange={(e) => setPasswordData({ ...passwordData, newPassword: e.target.value })}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none"
|
|
||||||
placeholder="Digite a nova senha (mínimo 8 caracteres)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Confirmar Nova Senha
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={passwordData.confirmPassword}
|
|
||||||
onChange={(e) => setPasswordData({ ...passwordData, confirmPassword: e.target.value })}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none"
|
|
||||||
placeholder="Digite a nova senha novamente"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-4">
|
|
||||||
<button
|
|
||||||
onClick={handleChangePassword}
|
|
||||||
className="px-6 py-2 rounded-lg text-white font-medium transition-all hover:scale-105"
|
|
||||||
style={{ background: 'var(--gradient-primary)' }}
|
|
||||||
>
|
|
||||||
Alterar Senha
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Recursos Futuros */}
|
|
||||||
<div className="mt-8 pt-8 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<h3 className="text-md font-medium text-gray-900 dark:text-white mb-4">
|
|
||||||
Recursos em Desenvolvimento
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<ShieldCheckIcon className="w-5 h-5" />
|
|
||||||
<span>Autenticação em duas etapas (2FA)</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<ShieldCheckIcon className="w-5 h-5" />
|
|
||||||
<span>Histórico de acessos</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<ShieldCheckIcon className="w-5 h-5" />
|
|
||||||
<span>Dispositivos conectados</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Tab.Panel>
|
|
||||||
|
|
||||||
{/* Tab 6: Notificações */}
|
|
||||||
<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">
|
|
||||||
Preferências de Notificações
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<BellIcon 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">
|
|
||||||
Em breve: configuração de notificações por e-mail, push e mais
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Tab.Panel>
|
|
||||||
</Tab.Panels>
|
|
||||||
</Tab.Group>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Dialog de Sucesso */}
|
|
||||||
<Dialog
|
|
||||||
isOpen={showSuccessDialog}
|
|
||||||
onClose={() => setShowSuccessDialog(false)}
|
|
||||||
title="Sucesso"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<Dialog.Body>
|
|
||||||
<p className="text-center py-4">{successMessage}</p>
|
|
||||||
</Dialog.Body>
|
|
||||||
<Dialog.Footer>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowSuccessDialog(false)}
|
|
||||||
className="px-6 py-2 rounded-lg text-white font-medium transition-all hover:scale-105"
|
|
||||||
style={{ background: 'var(--gradient-primary)' }}
|
|
||||||
>
|
|
||||||
OK
|
|
||||||
</button>
|
|
||||||
</Dialog.Footer>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Dialog de Suporte */}
|
|
||||||
<Dialog
|
|
||||||
isOpen={showSupportDialog}
|
|
||||||
onClose={() => setShowSupportDialog(false)}
|
|
||||||
title="Contatar suporte"
|
|
||||||
>
|
|
||||||
<Dialog.Body>
|
|
||||||
<p className="text-sm text-gray-700 dark:text-gray-200">{supportMessage}</p>
|
|
||||||
<p className="mt-3 text-sm text-gray-500">Envie um e-mail para suporte@aggios.app ou abra um chamado para ajuste desses dados.</p>
|
|
||||||
</Dialog.Body>
|
|
||||||
<Dialog.Footer>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowSupportDialog(false)}
|
|
||||||
className="px-4 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-lg font-medium hover:scale-105 transition-all"
|
|
||||||
>
|
|
||||||
Fechar
|
|
||||||
</button>
|
|
||||||
</Dialog.Footer>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -17,7 +17,7 @@ export default function LayoutWrapper({ children }: { children: ReactNode }) {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Em toda troca de rota, volta para o tema padrão; layouts específicos (ex.: agência) aplicam o próprio na sequência
|
// Reseta tema padrão em toda troca de rota
|
||||||
setGradientVariables(DEFAULT_GRADIENT);
|
setGradientVariables(DEFAULT_GRADIENT);
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,13 @@ export async function POST(request: NextRequest) {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const text = await response.text();
|
||||||
|
let data: any;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch (e) {
|
||||||
|
data = { error: text };
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return NextResponse.json(data, { status: response.status });
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
|||||||
267
front-end-dash.aggios.app/app/cadastro/[slug]/page.tsx
Normal file
267
front-end-dash.aggios.app/app/cadastro/[slug]/page.tsx
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Input from '@/components/ui/Input';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import { CheckCircleIcon } from '@heroicons/react/24/solid';
|
||||||
|
|
||||||
|
interface FormField {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type: string;
|
||||||
|
required: boolean;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SignupTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
slug: string;
|
||||||
|
form_fields: FormField[];
|
||||||
|
enabled_modules: string[];
|
||||||
|
redirect_url?: string;
|
||||||
|
success_message?: string;
|
||||||
|
custom_logo_url?: string;
|
||||||
|
custom_primary_color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CustomSignupPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [template, setTemplate] = useState<SignupTemplate | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [formData, setFormData] = useState<Record<string, string>>({});
|
||||||
|
const [slug, setSlug] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
params.then(p => {
|
||||||
|
setSlug(p.slug);
|
||||||
|
});
|
||||||
|
}, [params]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (slug) {
|
||||||
|
loadTemplate();
|
||||||
|
}
|
||||||
|
}, [slug]);
|
||||||
|
|
||||||
|
const loadTemplate = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/signup-templates/slug/${slug}`);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setTemplate(data);
|
||||||
|
|
||||||
|
// Inicializar formData com campos vazios
|
||||||
|
const initialData: Record<string, string> = {};
|
||||||
|
data.form_fields.forEach((field: FormField) => {
|
||||||
|
initialData[field.name] = '';
|
||||||
|
});
|
||||||
|
setFormData(initialData);
|
||||||
|
} else {
|
||||||
|
setError('Template de cadastro não encontrado');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Erro ao carregar formulário de cadastro');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSubmitting(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Registro público via template
|
||||||
|
const payload = {
|
||||||
|
template_slug: slug,
|
||||||
|
email: formData.email,
|
||||||
|
password: formData.password,
|
||||||
|
name: formData.company_name || formData.subdomain || 'Cliente',
|
||||||
|
subdomain: formData.subdomain,
|
||||||
|
company_name: formData.company_name,
|
||||||
|
...formData, // Incluir todos os campos adicionais
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch('/api/signup/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setSuccess(true);
|
||||||
|
|
||||||
|
// Redirecionar após 2 segundos
|
||||||
|
setTimeout(() => {
|
||||||
|
if (template?.redirect_url) {
|
||||||
|
window.location.href = template.redirect_url;
|
||||||
|
} else {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
const data = await response.json();
|
||||||
|
setError(data.error || 'Erro ao realizar cadastro');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Erro ao processar cadastro');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (fieldName: string, value: string) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[fieldName]: value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900 dark:border-white"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !template) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950 p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-lg p-8 max-w-md w-full text-center border border-gray-200 dark:border-gray-800">
|
||||||
|
<div className="w-16 h-16 bg-red-100 dark:bg-red-900 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<span className="text-3xl">⚠️</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
Link Inválido
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => router.push('/')}>
|
||||||
|
Voltar para Início
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950 p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-lg p-8 max-w-md w-full text-center border border-gray-200 dark:border-gray-800">
|
||||||
|
<div className="w-16 h-16 bg-green-100 dark:bg-green-900 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<CheckCircleIcon className="w-10 h-10 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
Cadastro Realizado!
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
{template?.success_message || 'Seu cadastro foi realizado com sucesso. Redirecionando...'}
|
||||||
|
</p>
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 dark:border-white mx-auto"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedFields = [...(template?.form_fields || [])].sort((a, b) => a.order - b.order);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950 p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-lg p-8 max-w-md w-full border border-gray-200 dark:border-gray-800">
|
||||||
|
{/* Logo personalizado */}
|
||||||
|
{template?.custom_logo_url && (
|
||||||
|
<div className="flex justify-center mb-6">
|
||||||
|
<img
|
||||||
|
src={template.custom_logo_url}
|
||||||
|
alt="Logo"
|
||||||
|
className="h-12 object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cabeçalho */}
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
{template?.name}
|
||||||
|
</h1>
|
||||||
|
{template?.description && (
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{template.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Módulos incluídos */}
|
||||||
|
{template && template.enabled_modules.length > 0 && (
|
||||||
|
<div className="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||||
|
<p className="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">
|
||||||
|
Módulos incluídos:
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{template.enabled_modules.map((module) => (
|
||||||
|
<span
|
||||||
|
key={module}
|
||||||
|
className="px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded text-xs font-medium"
|
||||||
|
>
|
||||||
|
{module}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Formulário */}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{sortedFields.map((field) => (
|
||||||
|
<Input
|
||||||
|
key={field.name}
|
||||||
|
label={field.label}
|
||||||
|
type={field.type}
|
||||||
|
value={formData[field.name] || ''}
|
||||||
|
onChange={(e) => handleInputChange(field.name, e.target.value)}
|
||||||
|
required={field.required}
|
||||||
|
placeholder={`Digite ${field.label.toLowerCase()}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={submitting}
|
||||||
|
style={template?.custom_primary_color ? {
|
||||||
|
background: template.custom_primary_color
|
||||||
|
} : undefined}
|
||||||
|
>
|
||||||
|
{submitting ? 'Cadastrando...' : 'Criar Conta'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Link para login */}
|
||||||
|
<p className="mt-6 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Já tem uma conta?{' '}
|
||||||
|
<a href="/login" className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400">
|
||||||
|
Fazer login
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1400
front-end-dash.aggios.app/app/cadastro/page.tsx
Normal file
1400
front-end-dash.aggios.app/app/cadastro/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "./tokens.css";
|
@import "./tokens.css";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
@@ -47,7 +47,17 @@ html.dark {
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
|
font-family: var(--font-arimo), ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
a,
|
||||||
|
button,
|
||||||
|
[role="button"],
|
||||||
|
input[type="submit"],
|
||||||
|
input[type="reset"],
|
||||||
|
input[type="button"],
|
||||||
|
label[for] {
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Inter, Open_Sans, Fira_Code } from "next/font/google";
|
import { Open_Sans, Fira_Code, Arimo } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import LayoutWrapper from "./LayoutWrapper";
|
import LayoutWrapper from "./LayoutWrapper";
|
||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
|
|
||||||
const inter = Inter({
|
const arimo = Arimo({
|
||||||
variable: "--font-inter",
|
variable: "--font-arimo",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
weight: ["400", "500", "600", "700"],
|
weight: ["400", "500", "600", "700"],
|
||||||
});
|
});
|
||||||
@@ -24,7 +24,7 @@ const firaCode = Fira_Code({
|
|||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Aggios - Dashboard",
|
title: "Aggios - Dashboard",
|
||||||
description: "Plataforma SaaS para agências digitais",
|
description: "Painel administrativo SuperAdmin",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -37,7 +37,7 @@ export default function RootLayout({
|
|||||||
<head>
|
<head>
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/remixicon@4.3.0/fonts/remixicon.css" />
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/remixicon@4.3.0/fonts/remixicon.css" />
|
||||||
</head>
|
</head>
|
||||||
<body className={`${inter.variable} ${openSans.variable} ${firaCode.variable} antialiased`}>
|
<body className={`${arimo.variable} ${openSans.variable} ${firaCode.variable} antialiased`}>
|
||||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
|
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
|
||||||
<LayoutWrapper>
|
<LayoutWrapper>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -30,31 +30,20 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const hostname = window.location.hostname;
|
setIsSuperAdmin(true);
|
||||||
const sub = hostname.split('.')[0];
|
|
||||||
const superAdmin = sub === 'dash';
|
|
||||||
setSubdomain(sub);
|
|
||||||
setIsSuperAdmin(superAdmin);
|
|
||||||
|
|
||||||
// Aplicar tema: dash sempre padrão; tenants aplicam o salvo ou vindo via query param
|
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
|
||||||
const themeParam = searchParams.get('theme');
|
|
||||||
|
|
||||||
if (superAdmin) {
|
|
||||||
setGradientVariables(DEFAULT_GRADIENT);
|
setGradientVariables(DEFAULT_GRADIENT);
|
||||||
} else {
|
|
||||||
const stored = localStorage.getItem(`agency-theme:${sub}`);
|
|
||||||
const gradient = themeParam || stored || DEFAULT_GRADIENT;
|
|
||||||
setGradientVariables(gradient);
|
|
||||||
|
|
||||||
if (themeParam) {
|
|
||||||
localStorage.setItem(`agency-theme:${sub}`, gradient);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isAuthenticated()) {
|
if (isAuthenticated()) {
|
||||||
const target = superAdmin ? '/superadmin' : '/dashboard';
|
const userData = localStorage.getItem('user');
|
||||||
window.location.href = target;
|
if (userData) {
|
||||||
|
const user = JSON.parse(userData);
|
||||||
|
if (user.role === 'SUPERADMIN') {
|
||||||
|
window.location.href = '/superadmin';
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@@ -224,20 +213,6 @@ export default function LoginPage() {
|
|||||||
>
|
>
|
||||||
{isLoading ? 'Entrando...' : 'Entrar'}
|
{isLoading ? 'Entrando...' : 'Entrar'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Link para cadastro - apenas para agências */}
|
|
||||||
{!isSuperAdmin && (
|
|
||||||
<p className="text-center text-[14px] text-[#7D7D7D] dark:text-gray-400">
|
|
||||||
Ainda não tem conta?{' '}
|
|
||||||
<a
|
|
||||||
href="http://dash.localhost/cadastro"
|
|
||||||
className="font-medium hover:opacity-80 transition-opacity"
|
|
||||||
style={{ background: 'var(--gradient-primary)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}
|
|
||||||
>
|
|
||||||
Cadastre sua agência
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -247,13 +222,10 @@ export default function LoginPage() {
|
|||||||
<div className="absolute inset-0 flex flex-col items-center justify-center p-12 text-white">
|
<div className="absolute inset-0 flex flex-col items-center justify-center p-12 text-white">
|
||||||
<div className="max-w-md text-center">
|
<div className="max-w-md text-center">
|
||||||
<h1 className="text-5xl font-bold mb-6">
|
<h1 className="text-5xl font-bold mb-6">
|
||||||
{isSuperAdmin ? 'aggios' : subdomain}
|
aggios
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl opacity-90 mb-8">
|
<p className="text-xl opacity-90 mb-8">
|
||||||
{isSuperAdmin
|
Gerencie todas as agências em um só lugar
|
||||||
? 'Gerencie todas as agências em um só lugar'
|
|
||||||
: 'Gerencie seus clientes com eficiência'
|
|
||||||
}
|
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-6 text-left">
|
<div className="grid grid-cols-2 gap-6 text-left">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
342
front-end-dash.aggios.app/app/superadmin/agencies/[id]/page.tsx
Normal file
342
front-end-dash.aggios.app/app/superadmin/agencies/[id]/page.tsx
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { BuildingOfficeIcon, ArrowLeftIcon, PaintBrushIcon, MapPinIcon } from '@heroicons/react/24/outline';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
|
||||||
|
interface AgencyTenant {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
subdomain: string;
|
||||||
|
domain: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
website: string;
|
||||||
|
cnpj: string;
|
||||||
|
razao_social: string;
|
||||||
|
description: string;
|
||||||
|
industry: string;
|
||||||
|
team_size: string;
|
||||||
|
address: string;
|
||||||
|
neighborhood: string;
|
||||||
|
number: string;
|
||||||
|
complement: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
zip: string;
|
||||||
|
primary_color: string;
|
||||||
|
secondary_color: string;
|
||||||
|
logo_url: string;
|
||||||
|
logo_horizontal_url: string;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgencyDetails {
|
||||||
|
tenant: AgencyTenant;
|
||||||
|
admin?: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
access_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AgencyDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const [details, setDetails] = useState<AgencyDetails | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (params.id) {
|
||||||
|
fetchAgency(params.id as string);
|
||||||
|
}
|
||||||
|
}, [params.id]);
|
||||||
|
|
||||||
|
const fetchAgency = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/admin/agencies/${id}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
// Handle both flat (legacy) and nested (new) responses
|
||||||
|
if (data.tenant) {
|
||||||
|
setDetails(data);
|
||||||
|
} else {
|
||||||
|
// Fallback for legacy flat response
|
||||||
|
setDetails({
|
||||||
|
tenant: data,
|
||||||
|
access_url: `http://${data.subdomain}.localhost`, // Fallback URL
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching agency:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!details || !details.tenant) {
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded relative" role="alert">
|
||||||
|
<strong className="font-bold">Erro!</strong>
|
||||||
|
<span className="block sm:inline"> Agência não encontrada.</span>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/superadmin/agencies"
|
||||||
|
className="mt-4 inline-flex items-center gap-2 text-gray-600 hover:text-gray-900"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="w-4 h-4" />
|
||||||
|
Voltar para Agências
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tenant } = details;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-7xl mx-auto">
|
||||||
|
<div className="mb-8">
|
||||||
|
<Link
|
||||||
|
href="/superadmin/agencies"
|
||||||
|
className="inline-flex items-center gap-2 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200 mb-6 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="w-4 h-4" />
|
||||||
|
Voltar para Agências
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="h-16 w-16 rounded-xl bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 flex items-center justify-center p-2">
|
||||||
|
{tenant.logo_url ? (
|
||||||
|
<img src={tenant.logo_url} alt={tenant.name} className="max-h-full max-w-full object-contain" />
|
||||||
|
) : (
|
||||||
|
<BuildingOfficeIcon className="w-8 h-8 text-gray-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{tenant.name}</h1>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<a
|
||||||
|
href={details.access_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{tenant.subdomain}.aggios.app
|
||||||
|
<ArrowLeftIcon className="w-3 h-3 rotate-135" />
|
||||||
|
</a>
|
||||||
|
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||||
|
<span className={`px-2 py-0.5 inline-flex text-xs font-medium rounded-full ${tenant.is_active
|
||||||
|
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
|
||||||
|
: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'
|
||||||
|
}`}>
|
||||||
|
{tenant.is_active ? 'Ativa' : 'Inativa'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Link
|
||||||
|
href={`/superadmin/agencies/${tenant.id}/edit`}
|
||||||
|
className="px-4 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors font-medium text-sm"
|
||||||
|
>
|
||||||
|
Editar Dados
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium text-sm"
|
||||||
|
>
|
||||||
|
Acessar Painel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Coluna Esquerda (2/3) */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Informações Básicas */}
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-800/50">
|
||||||
|
<h2 className="text-base font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<BuildingOfficeIcon className="w-5 h-5 text-gray-500" />
|
||||||
|
Dados da Empresa
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Razão Social</dt>
|
||||||
|
<dd className="mt-1 text-sm font-medium text-gray-900 dark:text-white">{tenant.razao_social || '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">CNPJ</dt>
|
||||||
|
<dd className="mt-1 text-sm font-medium text-gray-900 dark:text-white">{tenant.cnpj || '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Setor</dt>
|
||||||
|
<dd className="mt-1 text-sm text-gray-900 dark:text-white">{tenant.industry || '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Tamanho da Equipe</dt>
|
||||||
|
<dd className="mt-1 text-sm text-gray-900 dark:text-white">{tenant.team_size || '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Descrição</dt>
|
||||||
|
<dd className="mt-1 text-sm text-gray-900 dark:text-white">{tenant.description || '-'}</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Endereço */}
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-800/50">
|
||||||
|
<h2 className="text-base font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<MapPinIcon className="w-5 h-5 text-gray-500" />
|
||||||
|
Localização
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Endereço</dt>
|
||||||
|
<dd className="mt-1 text-sm text-gray-900 dark:text-white">
|
||||||
|
{tenant.address ? (
|
||||||
|
<>
|
||||||
|
{tenant.address}
|
||||||
|
{tenant.number ? `, ${tenant.number}` : ''}
|
||||||
|
{tenant.complement ? ` - ${tenant.complement}` : ''}
|
||||||
|
</>
|
||||||
|
) : '-'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Bairro</dt>
|
||||||
|
<dd className="mt-1 text-sm text-gray-900 dark:text-white">{tenant.neighborhood || '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Cidade / UF</dt>
|
||||||
|
<dd className="mt-1 text-sm text-gray-900 dark:text-white">
|
||||||
|
{tenant.city && tenant.state ? `${tenant.city} - ${tenant.state}` : '-'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">CEP</dt>
|
||||||
|
<dd className="mt-1 text-sm text-gray-900 dark:text-white">{tenant.zip || '-'}</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Coluna Direita (1/3) */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Branding */}
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-800/50">
|
||||||
|
<h2 className="text-base font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<PaintBrushIcon className="w-5 h-5 text-gray-500" />
|
||||||
|
Identidade Visual
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Cores da Marca</dt>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div
|
||||||
|
className="w-12 h-12 rounded-lg border border-gray-200 dark:border-gray-700 mb-1"
|
||||||
|
style={{ backgroundColor: tenant.primary_color || '#000000' }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-mono text-gray-500">{tenant.primary_color || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div
|
||||||
|
className="w-12 h-12 rounded-lg border border-gray-200 dark:border-gray-700 mb-1"
|
||||||
|
style={{ backgroundColor: tenant.secondary_color || '#ffffff' }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-mono text-gray-500">{tenant.secondary_color || '-'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tenant.logo_horizontal_url && (
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Logo Horizontal</dt>
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700 flex justify-center">
|
||||||
|
<img src={tenant.logo_horizontal_url} alt="Logo Horizontal" className="max-h-12 max-w-full object-contain" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contato */}
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-800/50">
|
||||||
|
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
|
||||||
|
Contato
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Email</dt>
|
||||||
|
<dd className="mt-1 text-sm text-gray-900 dark:text-white break-all">{tenant.email || '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Telefone</dt>
|
||||||
|
<dd className="mt-1 text-sm text-gray-900 dark:text-white">{tenant.phone || '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Website</dt>
|
||||||
|
<dd className="mt-1">
|
||||||
|
{tenant.website ? (
|
||||||
|
<a
|
||||||
|
href={tenant.website}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm text-blue-600 dark:text-blue-400 hover:underline break-all"
|
||||||
|
>
|
||||||
|
{tenant.website}
|
||||||
|
</a>
|
||||||
|
) : '-'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadados */}
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-6">
|
||||||
|
<dl className="space-y-3">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-sm text-gray-500 dark:text-gray-400">Criada em</dt>
|
||||||
|
<dd className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{new Date(tenant.created_at).toLocaleDateString('pt-BR')}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-sm text-gray-500 dark:text-gray-400">Última atualização</dt>
|
||||||
|
<dd className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{new Date(tenant.updated_at).toLocaleDateString('pt-BR')}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
364
front-end-dash.aggios.app/app/superadmin/agencies/new/page.tsx
Normal file
364
front-end-dash.aggios.app/app/superadmin/agencies/new/page.tsx
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function NewAgencyPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
// Agência
|
||||||
|
agencyName: '',
|
||||||
|
subdomain: '',
|
||||||
|
cnpj: '',
|
||||||
|
razaoSocial: '',
|
||||||
|
description: '',
|
||||||
|
website: '',
|
||||||
|
industry: '',
|
||||||
|
phone: '',
|
||||||
|
teamSize: '',
|
||||||
|
|
||||||
|
// Endereço
|
||||||
|
cep: '',
|
||||||
|
state: '',
|
||||||
|
city: '',
|
||||||
|
neighborhood: '',
|
||||||
|
street: '',
|
||||||
|
number: '',
|
||||||
|
complement: '',
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
adminEmail: '',
|
||||||
|
adminPassword: '',
|
||||||
|
adminName: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/agencies/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.text();
|
||||||
|
throw new Error(errorData || 'Erro ao criar agência');
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push('/superadmin/agencies');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[e.target.name]: e.target.value
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 h-full overflow-auto">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Nova Agência</h1>
|
||||||
|
<p className="text-gray-600 mt-2">Cadastre uma nova agência no sistema Aggios</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-800">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-8">
|
||||||
|
{/* Informações da Agência */}
|
||||||
|
<section className="bg-white p-6 rounded-lg border border-gray-200">
|
||||||
|
<h2 className="text-lg font-semibold mb-4 text-gray-900">Informações da Agência</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Nome da Agência *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="agencyName"
|
||||||
|
required
|
||||||
|
value={formData.agencyName}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Subdomínio *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="subdomain"
|
||||||
|
required
|
||||||
|
value={formData.subdomain}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="exemplo"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-purple-500"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Será usado como: exemplo.aggios.app</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">CNPJ</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="cnpj"
|
||||||
|
value={formData.cnpj}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Razão Social</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="razaoSocial"
|
||||||
|
value={formData.razaoSocial}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Website</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
name="website"
|
||||||
|
value={formData.website}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="https://"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Telefone</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
name="phone"
|
||||||
|
value={formData.phone}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Setor</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="industry"
|
||||||
|
value={formData.industry}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Ex: Tecnologia, Marketing"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Tamanho do Time</label>
|
||||||
|
<select
|
||||||
|
name="teamSize"
|
||||||
|
value={formData.teamSize}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-purple-500"
|
||||||
|
>
|
||||||
|
<option value="">Selecione</option>
|
||||||
|
<option value="1-10">1-10 pessoas</option>
|
||||||
|
<option value="11-50">11-50 pessoas</option>
|
||||||
|
<option value="51-200">51-200 pessoas</option>
|
||||||
|
<option value="201+">201+ pessoas</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Descrição</label>
|
||||||
|
<textarea
|
||||||
|
name="description"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={handleChange}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Endereço */}
|
||||||
|
<section className="bg-white p-6 rounded-lg border border-gray-200">
|
||||||
|
<h2 className="text-lg font-semibold mb-4 text-gray-900">Endereço</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">CEP</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="cep"
|
||||||
|
value={formData.cep}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Estado</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="state"
|
||||||
|
value={formData.state}
|
||||||
|
onChange={handleChange}
|
||||||
|
maxLength={2}
|
||||||
|
placeholder="SP"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Cidade</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="city"
|
||||||
|
value={formData.city}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Bairro</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="neighborhood"
|
||||||
|
value={formData.neighborhood}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Número</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="number"
|
||||||
|
value={formData.number}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Rua</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="street"
|
||||||
|
value={formData.street}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Complemento</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="complement"
|
||||||
|
value={formData.complement}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Administrador */}
|
||||||
|
<section className="bg-white p-6 rounded-lg border border-gray-200">
|
||||||
|
<h2 className="text-lg font-semibold mb-4 text-gray-900">Administrador da Agência</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Nome do Admin *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="adminName"
|
||||||
|
required
|
||||||
|
value={formData.adminName}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Email do Admin *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="adminEmail"
|
||||||
|
required
|
||||||
|
value={formData.adminEmail}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Senha do Admin *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="adminPassword"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
value={formData.adminPassword}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-purple-500"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Mínimo 8 caracteres</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Botões */}
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="px-6 py-2 border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="px-6 py-2 bg-purple-600 text-white rounded-md hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Criando...' : 'Criar Agência'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
533
front-end-dash.aggios.app/app/superadmin/agencies/page.tsx
Normal file
533
front-end-dash.aggios.app/app/superadmin/agencies/page.tsx
Normal file
@@ -0,0 +1,533 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Fragment, useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Menu, Listbox, Transition } from '@headlessui/react';
|
||||||
|
import CreateAgencyModal from '@/components/agencies/CreateAgencyModal';
|
||||||
|
import {
|
||||||
|
BuildingOfficeIcon,
|
||||||
|
TrashIcon,
|
||||||
|
EyeIcon,
|
||||||
|
PencilIcon,
|
||||||
|
EllipsisVerticalIcon,
|
||||||
|
MagnifyingGlassIcon,
|
||||||
|
FunnelIcon,
|
||||||
|
CalendarIcon,
|
||||||
|
CheckIcon,
|
||||||
|
ChevronUpDownIcon,
|
||||||
|
PlusIcon,
|
||||||
|
XMarkIcon
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
interface Agency {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
subdomain: string;
|
||||||
|
domain: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
cnpj: string;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
logo_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ id: 'all', name: 'Todos os Status' },
|
||||||
|
{ id: 'active', name: 'Ativas' },
|
||||||
|
{ id: 'inactive', name: 'Inativas' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DATE_PRESETS = [
|
||||||
|
{ id: 'all', name: 'Todo o período' },
|
||||||
|
{ id: '7d', name: 'Últimos 7 dias' },
|
||||||
|
{ id: '15d', name: 'Últimos 15 dias' },
|
||||||
|
{ id: '30d', name: 'Últimos 30 dias' },
|
||||||
|
{ id: 'custom', name: 'Personalizado' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AgenciesPage() {
|
||||||
|
const [agencies, setAgencies] = useState<Agency[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// Filtros
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState(STATUS_OPTIONS[0]);
|
||||||
|
const [selectedDatePreset, setSelectedDatePreset] = useState(DATE_PRESETS[0]);
|
||||||
|
const [startDate, setStartDate] = useState('');
|
||||||
|
const [endDate, setEndDate] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAgencies();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchAgencies = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/agencies', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setAgencies(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching agencies:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('Tem certeza que deseja excluir esta agência? Esta ação não pode ser desfeita.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/admin/agencies/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setAgencies(agencies.filter(a => a.id !== id));
|
||||||
|
} else {
|
||||||
|
alert('Erro ao excluir agência');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting agency:', error);
|
||||||
|
alert('Erro ao excluir agência');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleActive = async (id: string, currentStatus: boolean) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/admin/agencies/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ is_active: !currentStatus }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setAgencies(agencies.map(a =>
|
||||||
|
a.id === id ? { ...a, is_active: !currentStatus } : a
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling agency status:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSearchTerm('');
|
||||||
|
setSelectedStatus(STATUS_OPTIONS[0]);
|
||||||
|
setSelectedDatePreset(DATE_PRESETS[0]);
|
||||||
|
setStartDate('');
|
||||||
|
setEndDate('');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lógica de Filtragem
|
||||||
|
const filteredAgencies = agencies.filter((agency) => {
|
||||||
|
// Texto
|
||||||
|
const searchLower = searchTerm.toLowerCase();
|
||||||
|
const matchesSearch =
|
||||||
|
(agency.name?.toLowerCase() || '').includes(searchLower) ||
|
||||||
|
(agency.email?.toLowerCase() || '').includes(searchLower) ||
|
||||||
|
(agency.subdomain?.toLowerCase() || '').includes(searchLower);
|
||||||
|
|
||||||
|
// Status
|
||||||
|
const matchesStatus =
|
||||||
|
selectedStatus.id === 'all' ? true :
|
||||||
|
selectedStatus.id === 'active' ? agency.is_active :
|
||||||
|
!agency.is_active;
|
||||||
|
|
||||||
|
// Data
|
||||||
|
let matchesDate = true;
|
||||||
|
const agencyDate = new Date(agency.created_at);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
if (selectedDatePreset.id === 'custom') {
|
||||||
|
if (startDate) {
|
||||||
|
const start = new Date(startDate);
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
if (agencyDate < start) matchesDate = false;
|
||||||
|
}
|
||||||
|
if (endDate) {
|
||||||
|
const end = new Date(endDate);
|
||||||
|
end.setHours(23, 59, 59, 999);
|
||||||
|
if (agencyDate > end) matchesDate = false;
|
||||||
|
}
|
||||||
|
} else if (selectedDatePreset.id !== 'all') {
|
||||||
|
const diffTime = Math.abs(now.getTime() - agencyDate.getTime());
|
||||||
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (selectedDatePreset.id === '7d') matchesDate = diffDays <= 7;
|
||||||
|
if (selectedDatePreset.id === '15d') matchesDate = diffDays <= 15;
|
||||||
|
if (selectedDatePreset.id === '30d') matchesDate = diffDays <= 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchesSearch && matchesStatus && matchesDate;
|
||||||
|
});
|
||||||
|
|
||||||
|
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">Agências</h1>
|
||||||
|
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
|
||||||
|
Gerencie seus parceiros e acompanhe o desempenho.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCreateModalOpen(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 Agência
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toolbar de Filtros */}
|
||||||
|
<div className="flex flex-col lg:flex-row gap-4 items-center justify-between">
|
||||||
|
{/* Busca */}
|
||||||
|
<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 por nome, email ou subdomínio..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 w-full lg:w-auto">
|
||||||
|
{/* Filtro de Status */}
|
||||||
|
<Listbox value={selectedStatus} onChange={setSelectedStatus}>
|
||||||
|
<div className="relative w-full sm:w-[180px]">
|
||||||
|
<Listbox.Button className="relative w-full cursor-pointer rounded-lg bg-white dark:bg-zinc-900 py-2 pl-3 pr-10 text-left text-sm border border-zinc-200 dark:border-zinc-700 focus:outline-none focus:border-[var(--brand-color)] focus:ring-1 focus:ring-[var(--brand-color)] text-zinc-700 dark:text-zinc-300">
|
||||||
|
<span className="block truncate">{selectedStatus.name}</span>
|
||||||
|
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||||
|
<ChevronUpDownIcon className="h-4 w-4 text-zinc-400" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
</Listbox.Button>
|
||||||
|
<Transition
|
||||||
|
as={Fragment}
|
||||||
|
leave="transition ease-in duration-100"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white dark:bg-zinc-800 py-1 text-base ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm border border-zinc-200 dark:border-zinc-700">
|
||||||
|
{STATUS_OPTIONS.map((status, statusIdx) => (
|
||||||
|
<Listbox.Option
|
||||||
|
key={statusIdx}
|
||||||
|
className={({ active, selected }) =>
|
||||||
|
`relative cursor-default select-none py-2 pl-10 pr-4 ${active ? 'bg-zinc-100 dark:bg-zinc-700 text-zinc-900 dark:text-white' : 'text-zinc-900 dark:text-zinc-100'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
value={status}
|
||||||
|
>
|
||||||
|
{({ selected }) => (
|
||||||
|
<>
|
||||||
|
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||||
|
{status.name}
|
||||||
|
</span>
|
||||||
|
{selected ? (
|
||||||
|
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-[var(--brand-color)]">
|
||||||
|
<CheckIcon className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Listbox.Option>
|
||||||
|
))}
|
||||||
|
</Listbox.Options>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</Listbox>
|
||||||
|
|
||||||
|
{/* Filtro de Data Unificado */}
|
||||||
|
<Menu as="div" className="relative w-full sm:w-auto">
|
||||||
|
<Menu.Button className="relative w-full sm:w-[220px] cursor-pointer rounded-lg bg-white dark:bg-zinc-900 py-2 pl-3 pr-10 text-left text-sm border border-zinc-200 dark:border-zinc-700 focus:outline-none focus:border-[var(--brand-color)] focus:ring-1 focus:ring-[var(--brand-color)] text-zinc-700 dark:text-zinc-300 flex items-center gap-2">
|
||||||
|
<CalendarIcon className="w-4 h-4 text-zinc-400" />
|
||||||
|
<span className="block truncate">
|
||||||
|
{selectedDatePreset.id === 'custom'
|
||||||
|
? (startDate && endDate ? `${new Date(startDate).toLocaleDateString()} - ${new Date(endDate).toLocaleDateString()}` : 'Selecionar período')
|
||||||
|
: selectedDatePreset.name}
|
||||||
|
</span>
|
||||||
|
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||||
|
<ChevronUpDownIcon className="h-4 w-4 text-zinc-400" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
</Menu.Button>
|
||||||
|
<Transition
|
||||||
|
as={Fragment}
|
||||||
|
enter="transition ease-out duration-100"
|
||||||
|
enterFrom="transform opacity-0 scale-95"
|
||||||
|
enterTo="transform opacity-100 scale-100"
|
||||||
|
leave="transition ease-in duration-75"
|
||||||
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
|
leaveTo="transform opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<Menu.Items className="absolute right-0 z-10 mt-2 w-72 origin-top-right rounded-xl bg-white dark:bg-zinc-900 ring-1 ring-black ring-opacity-5 focus:outline-none border border-zinc-200 dark:border-zinc-700 divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||||
|
<div className="p-1">
|
||||||
|
{DATE_PRESETS.filter(p => p.id !== 'custom').map((preset) => (
|
||||||
|
<Menu.Item key={preset.id}>
|
||||||
|
{({ active }) => (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedDatePreset(preset);
|
||||||
|
setStartDate('');
|
||||||
|
setEndDate('');
|
||||||
|
}}
|
||||||
|
className={`${active ? 'bg-zinc-100 dark:bg-zinc-800' : ''
|
||||||
|
} ${selectedDatePreset.id === preset.id ? 'text-[var(--brand-color)] font-medium' : 'text-zinc-700 dark:text-zinc-300'
|
||||||
|
} group flex w-full items-center rounded-lg px-2 py-2 text-sm`}
|
||||||
|
>
|
||||||
|
{preset.name}
|
||||||
|
{selectedDatePreset.id === preset.id && (
|
||||||
|
<CheckIcon className="ml-auto h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="p-3 space-y-3">
|
||||||
|
<div className="text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
|
||||||
|
Personalizado
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Início</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => {
|
||||||
|
setStartDate(e.target.value);
|
||||||
|
setSelectedDatePreset(DATE_PRESETS.find(p => p.id === 'custom')!);
|
||||||
|
}}
|
||||||
|
className="block w-full px-2 py-1 text-xs border border-zinc-200 dark:border-zinc-700 rounded bg-zinc-50 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 focus:outline-none focus:border-[var(--brand-color)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Fim</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEndDate(e.target.value);
|
||||||
|
setSelectedDatePreset(DATE_PRESETS.find(p => p.id === 'custom')!);
|
||||||
|
}}
|
||||||
|
className="block w-full px-2 py-1 text-xs border border-zinc-200 dark:border-zinc-700 rounded bg-zinc-50 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 focus:outline-none focus:border-[var(--brand-color)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Menu.Items>
|
||||||
|
</Transition>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
{/* Botão Limpar */}
|
||||||
|
{(searchTerm || selectedStatus.id !== 'all' || selectedDatePreset.id !== 'all') && (
|
||||||
|
<button
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="inline-flex items-center justify-center px-3 py-2 border border-zinc-200 dark:border-zinc-700 text-sm font-medium rounded-lg text-zinc-700 dark:text-zinc-200 bg-white dark:bg-zinc-900 hover:bg-zinc-50 dark:hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--brand-color)]"
|
||||||
|
title="Limpar Filtros"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabela */}
|
||||||
|
{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>
|
||||||
|
) : filteredAgencies.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">
|
||||||
|
<BuildingOfficeIcon className="w-8 h-8 text-zinc-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-zinc-900 dark:text-white mb-1">
|
||||||
|
Nenhuma agência encontrada
|
||||||
|
</h3>
|
||||||
|
<p className="text-zinc-500 dark:text-zinc-400 max-w-sm mx-auto">
|
||||||
|
Não encontramos resultados para os filtros selecionados. Tente limpar a busca ou alterar os filtros.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="mt-4 text-sm text-[var(--brand-color)] hover:underline font-medium"
|
||||||
|
>
|
||||||
|
Limpar todos os filtros
|
||||||
|
</button>
|
||||||
|
</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">Agência</th>
|
||||||
|
<th className="px-6 py-4 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Contato</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-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Data Cadastro</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">
|
||||||
|
{filteredAgencies.map((agency) => (
|
||||||
|
<tr key={agency.id} className="group 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-4">
|
||||||
|
{agency.logo_url ? (
|
||||||
|
<img
|
||||||
|
src={agency.logo_url}
|
||||||
|
alt={agency.name}
|
||||||
|
className="w-10 h-10 rounded-lg object-cover bg-white dark:bg-zinc-800"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded-lg flex items-center justify-center text-white font-bold text-sm"
|
||||||
|
style={{ background: 'var(--gradient)' }}
|
||||||
|
>
|
||||||
|
{agency.name?.substring(0, 2).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-zinc-900 dark:text-white">
|
||||||
|
{agency.name}
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={`http://${agency.subdomain}.localhost`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-zinc-500 hover:text-[var(--brand-color)] transition-colors flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{agency.subdomain}.aggios.app
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-sm text-zinc-700 dark:text-zinc-300">{agency.email}</span>
|
||||||
|
<span className="text-xs text-zinc-400">{agency.phone || 'Sem telefone'}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleActive(agency.id, agency.is_active)}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border transition-all ${agency.is_active
|
||||||
|
? 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-900/20 dark:text-emerald-400 dark:border-emerald-900/30'
|
||||||
|
: 'bg-zinc-100 text-zinc-600 border-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:border-zinc-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${agency.is_active ? 'bg-emerald-500' : 'bg-zinc-400'}`} />
|
||||||
|
{agency.is_active ? 'Ativo' : 'Inativo'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
{new Date(agency.created_at).toLocaleDateString('pt-BR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric'
|
||||||
|
})}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||||
|
<Menu as="div" className="relative inline-block text-left">
|
||||||
|
<Menu.Button className="p-2 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">
|
||||||
|
<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 }) => (
|
||||||
|
<Link
|
||||||
|
href={`/superadmin/agencies/${agency.id}`}
|
||||||
|
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`}
|
||||||
|
>
|
||||||
|
<EyeIcon className="mr-2 h-4 w-4 text-zinc-400" />
|
||||||
|
Detalhes
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item>
|
||||||
|
{({ active }) => (
|
||||||
|
<Link
|
||||||
|
href={`/superadmin/agencies/${agency.id}/edit`}
|
||||||
|
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
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</Menu.Item>
|
||||||
|
</div>
|
||||||
|
<div className="px-1 py-1">
|
||||||
|
<Menu.Item>
|
||||||
|
{({ active }) => (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(agency.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>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer da Tabela (Paginação Mockada) */}
|
||||||
|
<div className="px-6 py-4 border-t border-zinc-200 dark:border-zinc-800 bg-zinc-50/50 dark:bg-zinc-800/50 flex items-center justify-between">
|
||||||
|
<p className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
Mostrando <span className="font-medium">{filteredAgencies.length}</span> resultados
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button disabled className="px-3 py-1 text-xs font-medium text-zinc-400 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-md cursor-not-allowed opacity-50">
|
||||||
|
Anterior
|
||||||
|
</button>
|
||||||
|
<button disabled className="px-3 py-1 text-xs font-medium text-zinc-400 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-md cursor-not-allowed opacity-50">
|
||||||
|
Próxima
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CreateAgencyModal
|
||||||
|
isOpen={isCreateModalOpen}
|
||||||
|
onClose={() => setIsCreateModalOpen(false)}
|
||||||
|
onSuccess={fetchAgencies}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { LinkIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
export default function AgencyTemplatesPage() {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<LinkIcon className="w-6 h-6 text-gray-600 dark:text-gray-400" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Templates de Agência</h1>
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Gerencie templates para cadastro de novas agências
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-8 text-center">
|
||||||
|
<LinkIcon className="w-12 h-12 text-gray-400 mx-auto mb-3" />
|
||||||
|
<h3 className="text-base font-medium text-gray-900 dark:text-white mb-1">
|
||||||
|
Página em desenvolvimento
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
A gestão de templates de agência estará disponível em breve
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
front-end-dash.aggios.app/app/superadmin/layout.tsx
Normal file
15
front-end-dash.aggios.app/app/superadmin/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { DashboardLayout } from '@/components/layout/DashboardLayout';
|
||||||
|
|
||||||
|
export default function SuperAdminLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
{children}
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,484 +2,288 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { isAuthenticated, getUser, clearAuth } from '@/lib/auth';
|
import Link from 'next/link';
|
||||||
|
import {
|
||||||
|
BuildingOfficeIcon,
|
||||||
|
UserGroupIcon,
|
||||||
|
LinkIcon,
|
||||||
|
ChartBarIcon,
|
||||||
|
ArrowTrendingUpIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
XCircleIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
interface Agency {
|
interface Agency {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
subdomain: string;
|
subdomain: string;
|
||||||
domain: string;
|
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AgencyDetails {
|
interface Stats {
|
||||||
access_url: string;
|
totalAgencies: number;
|
||||||
tenant: {
|
activeAgencies: number;
|
||||||
id: string;
|
inactiveAgencies: number;
|
||||||
name: string;
|
totalUsers: number;
|
||||||
domain: string;
|
|
||||||
subdomain: string;
|
|
||||||
cnpj?: string;
|
|
||||||
razao_social?: string;
|
|
||||||
email?: string;
|
|
||||||
phone?: string;
|
|
||||||
website?: string;
|
|
||||||
address?: string;
|
|
||||||
city?: string;
|
|
||||||
state?: string;
|
|
||||||
zip?: string;
|
|
||||||
description?: string;
|
|
||||||
industry?: string;
|
|
||||||
is_active: boolean;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
};
|
|
||||||
admin?: {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
name: string;
|
|
||||||
role: string;
|
|
||||||
created_at: string;
|
|
||||||
tenant_id?: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PainelPage() {
|
export default function SuperAdminDashboard() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [userData, setUserData] = useState<any>(null);
|
|
||||||
const [agencies, setAgencies] = useState<Agency[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [loadingAgencies, setLoadingAgencies] = useState(true);
|
const [agencies, setAgencies] = useState<Agency[]>([]);
|
||||||
const [selectedAgencyId, setSelectedAgencyId] = useState<string | null>(null);
|
const [stats, setStats] = useState<Stats>({
|
||||||
const [selectedDetails, setSelectedDetails] = useState<AgencyDetails | null>(null);
|
totalAgencies: 0,
|
||||||
const [detailsLoadingId, setDetailsLoadingId] = useState<string | null>(null);
|
activeAgencies: 0,
|
||||||
const [detailsError, setDetailsError] = useState<string | null>(null);
|
inactiveAgencies: 0,
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
totalUsers: 0,
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Verificar se usuário está logado
|
|
||||||
if (!isAuthenticated()) {
|
|
||||||
router.push('/login');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = getUser();
|
|
||||||
if (user) {
|
|
||||||
// Verificar se é SUPERADMIN
|
|
||||||
if (user.role !== 'SUPERADMIN') {
|
|
||||||
alert('Acesso negado. Apenas SUPERADMIN pode acessar este painel.');
|
|
||||||
clearAuth();
|
|
||||||
router.push('/login');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setUserData(user);
|
|
||||||
setLoading(false);
|
|
||||||
loadAgencies();
|
|
||||||
} else {
|
|
||||||
router.push('/login');
|
|
||||||
}
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
const loadAgencies = async () => {
|
|
||||||
setLoadingAgencies(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/admin/agencies');
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
setAgencies(data);
|
|
||||||
if (selectedAgencyId && !data.some((agency: Agency) => agency.id === selectedAgencyId)) {
|
|
||||||
setSelectedAgencyId(null);
|
|
||||||
setSelectedDetails(null);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error('Erro ao carregar agências');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erro ao carregar agências:', error);
|
|
||||||
} finally {
|
|
||||||
setLoadingAgencies(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleViewDetails = async (agencyId: string) => {
|
|
||||||
setDetailsError(null);
|
|
||||||
setDetailsLoadingId(agencyId);
|
|
||||||
setSelectedAgencyId(agencyId);
|
|
||||||
setSelectedDetails(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/admin/agencies/${agencyId}`);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
setDetailsError(data?.error || 'Não foi possível carregar os detalhes da agência.');
|
|
||||||
setSelectedAgencyId(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedDetails(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erro ao carregar detalhes da agência:', error);
|
|
||||||
setDetailsError('Erro ao carregar detalhes da agência.');
|
|
||||||
setSelectedAgencyId(null);
|
|
||||||
} finally {
|
|
||||||
setDetailsLoadingId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteAgency = async (agencyId: string) => {
|
|
||||||
const confirmDelete = window.confirm('Tem certeza que deseja excluir esta agência? Esta ação não pode ser desfeita.');
|
|
||||||
if (!confirmDelete) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setDeletingId(agencyId);
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/admin/agencies/${agencyId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok && response.status !== 204) {
|
useEffect(() => {
|
||||||
const data = await response.json().catch(() => ({ error: 'Erro ao excluir agência.' }));
|
const token = localStorage.getItem('token');
|
||||||
alert(data?.error || 'Erro ao excluir agência.');
|
const userData = localStorage.getItem('user');
|
||||||
|
|
||||||
|
if (!token || !userData) {
|
||||||
|
router.push('/login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
alert('Agência excluída com sucesso!');
|
const user = JSON.parse(userData);
|
||||||
if (selectedAgencyId === agencyId) {
|
if (user.role !== 'SUPERADMIN') {
|
||||||
setSelectedAgencyId(null);
|
localStorage.removeItem('token');
|
||||||
setSelectedDetails(null);
|
localStorage.removeItem('user');
|
||||||
|
router.push('/login');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadAgencies();
|
loadData();
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/agencies', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setAgencies(data.slice(0, 5)); // Apenas as 5 primeiras
|
||||||
|
|
||||||
|
// Calcular estatísticas
|
||||||
|
setStats({
|
||||||
|
totalAgencies: data.length,
|
||||||
|
activeAgencies: data.filter((a: Agency) => a.is_active).length,
|
||||||
|
inactiveAgencies: data.filter((a: Agency) => !a.is_active).length,
|
||||||
|
totalUsers: data.length * 2, // Mock - implementar depois
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erro ao excluir agência:', error);
|
console.error('Erro ao carregar dados:', error);
|
||||||
alert('Erro ao excluir agência.');
|
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingId(null);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const statCards = [
|
||||||
|
{
|
||||||
|
name: 'Total de Agências',
|
||||||
|
value: stats.totalAgencies,
|
||||||
|
icon: BuildingOfficeIcon,
|
||||||
|
color: 'orange',
|
||||||
|
href: '/superadmin/agencies',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Agências Ativas',
|
||||||
|
value: stats.activeAgencies,
|
||||||
|
icon: CheckCircleIcon,
|
||||||
|
color: 'green',
|
||||||
|
href: '/superadmin/agencies',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Links de Cadastro',
|
||||||
|
value: '5', // Mock
|
||||||
|
icon: LinkIcon,
|
||||||
|
color: 'pink',
|
||||||
|
href: '/superadmin/signup-templates',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Total de Usuários',
|
||||||
|
value: stats.totalUsers,
|
||||||
|
icon: UserGroupIcon,
|
||||||
|
color: 'rose',
|
||||||
|
href: '/superadmin/users',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
<div className="flex items-center justify-center h-full p-8">
|
||||||
<div className="text-center">
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-600"></div>
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-500 mx-auto mb-4"></div>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400">Carregando...</p>
|
|
||||||
</div>
|
|
||||||
{detailsLoadingId && (
|
|
||||||
<div className="mt-8 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-dashed border-brand-500 p-6 text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
Carregando detalhes da agência selecionada...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{detailsError && !detailsLoadingId && (
|
|
||||||
<div className="mt-8 bg-red-50 dark:bg-red-900/40 border border-red-200 dark:border-red-800 rounded-lg p-6 text-red-700 dark:text-red-200">
|
|
||||||
{detailsError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedDetails && !detailsLoadingId && (
|
|
||||||
<div className="mt-8 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
|
||||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Detalhes da Agência</h3>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">Informações enviadas no cadastro e dados administrativos</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<a
|
|
||||||
href={selectedDetails.access_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-sm text-brand-600 hover:text-brand-700"
|
|
||||||
>
|
|
||||||
Abrir painel da agência
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedAgencyId(null);
|
|
||||||
setSelectedDetails(null);
|
|
||||||
setDetailsError(null);
|
|
||||||
}}
|
|
||||||
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
|
|
||||||
>
|
|
||||||
Fechar
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-6 py-6 space-y-6">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">Dados da Agência</h4>
|
|
||||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<p className="text-gray-500 dark:text-gray-400">Nome Fantasia</p>
|
|
||||||
<p className="text-gray-900 dark:text-white font-medium">{selectedDetails.tenant.name}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-gray-500 dark:text-gray-400">Razão Social</p>
|
|
||||||
<p className="text-gray-900 dark:text-white font-medium">{selectedDetails.tenant.razao_social || '—'}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-gray-500 dark:text-gray-400">CNPJ</p>
|
|
||||||
<p className="text-gray-900 dark:text-white font-medium">{selectedDetails.tenant.cnpj || '—'}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-gray-500 dark:text-gray-400">Segmento</p>
|
|
||||||
<p className="text-gray-900 dark:text-white font-medium">{selectedDetails.tenant.industry || '—'}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-gray-500 dark:text-gray-400">Descrição</p>
|
|
||||||
<p className="text-gray-900 dark:text-white">{selectedDetails.tenant.description || '—'}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-gray-500 dark:text-gray-400">Status</p>
|
|
||||||
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold ${selectedDetails.tenant.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300' : 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-200'}`}>
|
|
||||||
{selectedDetails.tenant.is_active ? 'Ativa' : 'Inativa'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">Endereço e Contato</h4>
|
|
||||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<p className="text-gray-500 dark:text-gray-400">Endereço completo</p>
|
|
||||||
<p className="text-gray-900 dark:text-white">{selectedDetails.tenant.address || '—'}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-gray-500 dark:text-gray-400">Cidade / Estado</p>
|
|
||||||
<p className="text-gray-900 dark:text-white">{selectedDetails.tenant.city || '—'} {selectedDetails.tenant.state ? `- ${selectedDetails.tenant.state}` : ''}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-gray-500 dark:text-gray-400">CEP</p>
|
|
||||||
<p className="text-gray-900 dark:text-white">{selectedDetails.tenant.zip || '—'}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-gray-500 dark:text-gray-400">Website</p>
|
|
||||||
{selectedDetails.tenant.website ? (
|
|
||||||
<a href={selectedDetails.tenant.website} target="_blank" rel="noopener noreferrer" className="text-brand-600 hover:text-brand-700">
|
|
||||||
{selectedDetails.tenant.website}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<p className="text-gray-900 dark:text-white">—</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-gray-500 dark:text-gray-400">E-mail comercial</p>
|
|
||||||
<p className="text-gray-900 dark:text-white">{selectedDetails.tenant.email || '—'}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-gray-500 dark:text-gray-400">Telefone</p>
|
|
||||||
<p className="text-gray-900 dark:text-white">{selectedDetails.tenant.phone || '—'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">Administrador da Agência</h4>
|
|
||||||
{selectedDetails.admin ? (
|
|
||||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<p className="text-gray-500 dark:text-gray-400">Nome</p>
|
|
||||||
<p className="text-gray-900 dark:text-white font-medium">{selectedDetails.admin.name}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-gray-500 dark:text-gray-400">E-mail</p>
|
|
||||||
<p className="text-gray-900 dark:text-white">{selectedDetails.admin.email}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-gray-500 dark:text-gray-400">Perfil</p>
|
|
||||||
<p className="text-gray-900 dark:text-white">{selectedDetails.admin.role}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-gray-500 dark:text-gray-400">Criado em</p>
|
|
||||||
<p className="text-gray-900 dark:text-white">{new Date(selectedDetails.admin.created_at).toLocaleString('pt-BR')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="mt-4 text-sm text-gray-500 dark:text-gray-400">Nenhum administrador associado encontrado.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-xs text-gray-400 dark:text-gray-500">
|
|
||||||
Última atualização: {new Date(selectedDetails.tenant.updated_at).toLocaleString('pt-BR')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
<div className="p-6 h-full overflow-auto">
|
||||||
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex items-center justify-center w-10 h-10 bg-gradient-to-r from-brand-500 to-brand-700 rounded-lg">
|
|
||||||
<span className="text-white font-bold text-lg">A</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Aggios</h1>
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">Painel Administrativo</p>
|
Dashboard
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Visão geral da plataforma Aggios
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="text-sm font-medium text-gray-900 dark:text-white">Admin AGGIOS</p>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">{userData?.email}</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
clearAuth();
|
|
||||||
router.push('/login');
|
|
||||||
}}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white"
|
|
||||||
>
|
|
||||||
Sair
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
||||||
{/* Stats Grid */}
|
{/* Stats Grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
{statCards.map((stat) => {
|
||||||
|
const Icon = stat.icon;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={stat.name}
|
||||||
|
href={stat.href}
|
||||||
|
className="group relative overflow-hidden rounded-xl bg-white dark:bg-gray-900 p-4 border border-gray-200 dark:border-gray-800 transition-all"
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Total de Agências</p>
|
<p className="text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||||
<p className="text-3xl font-bold text-gray-900 dark:text-white mt-2">{agencies.length}</p>
|
{stat.name}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{stat.value}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center">
|
<div
|
||||||
<svg className="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
className={`rounded-lg p-2 bg-${stat.color}-100 dark:bg-${stat.color}-900/20`}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
>
|
||||||
</svg>
|
<Icon
|
||||||
|
className={`h-5 w-5 text-${stat.color}-600 dark:text-${stat.color}-400`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
{/* Recent Agencies */}
|
||||||
|
<div className="rounded-xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800">
|
||||||
|
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-800">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
|
||||||
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Agências Ativas</p>
|
Agências Recentes
|
||||||
<p className="text-3xl font-bold text-gray-900 dark:text-white mt-2">{agencies.filter(a => a.is_active).length}</p>
|
</h2>
|
||||||
</div>
|
<Link
|
||||||
<div className="w-12 h-12 bg-green-100 dark:bg-green-900 rounded-lg flex items-center justify-center">
|
href="/superadmin/agencies"
|
||||||
<svg className="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
className="text-xs font-medium text-purple-600 hover:text-purple-500 dark:text-purple-400"
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
>
|
||||||
</svg>
|
Ver todas →
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="divide-y divide-gray-200 dark:divide-gray-800">
|
||||||
|
{agencies.length === 0 ? (
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
<div className="px-4 py-8 text-center">
|
||||||
<div className="flex items-center justify-between">
|
<BuildingOfficeIcon className="mx-auto h-10 w-10 text-gray-400" />
|
||||||
<div>
|
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Agências Inativas</p>
|
Nenhuma agência
|
||||||
<p className="text-3xl font-bold text-gray-900 dark:text-white mt-2">{agencies.filter(a => !a.is_active).length}</p>
|
</h3>
|
||||||
</div>
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
<div className="w-12 h-12 bg-red-100 dark:bg-red-900 rounded-lg flex items-center justify-center">
|
Comece criando uma nova agência.
|
||||||
<svg className="w-6 h-6 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
</p>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Agencies Table */}
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
|
||||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Agências Cadastradas</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loadingAgencies ? (
|
|
||||||
<div className="p-8 text-center">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-500 mx-auto mb-4"></div>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400">Carregando agências...</p>
|
|
||||||
</div>
|
|
||||||
) : agencies.length === 0 ? (
|
|
||||||
<div className="p-8 text-center">
|
|
||||||
<p className="text-gray-600 dark:text-gray-400">Nenhuma agência cadastrada ainda.</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
agencies.map((agency) => (
|
||||||
<table className="w-full">
|
<div
|
||||||
<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">Agência</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Subdomínio</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Domínio</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 de Criação</th>
|
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Ações</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
|
||||||
{agencies.map((agency) => (
|
|
||||||
<tr
|
|
||||||
key={agency.id}
|
key={agency.id}
|
||||||
className={`hover:bg-gray-50 dark:hover:bg-gray-700 ${selectedAgencyId === agency.id ? 'bg-brand-50/70 dark:bg-gray-700/60' : ''}`}
|
className="px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||||
>
|
>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex-shrink-0 h-10 w-10 bg-gradient-to-br from-brand-500 to-brand-700 rounded-lg flex items-center justify-center">
|
<div className="h-8 w-8 rounded-lg flex items-center justify-center" style={{ background: 'var(--gradient)' }}>
|
||||||
<span className="text-white font-bold">{agency.name.charAt(0).toUpperCase()}</span>
|
<span className="text-xs font-medium text-white">
|
||||||
</div>
|
{agency.name.charAt(0).toUpperCase()}
|
||||||
<div className="ml-4">
|
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{agency.name}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="text-sm text-gray-900 dark:text-white font-mono">{agency.subdomain}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">{agency.domain || '-'}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${agency.is_active
|
|
||||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'
|
|
||||||
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300'
|
|
||||||
}`}>
|
|
||||||
{agency.is_active ? 'Ativa' : 'Inativa'}
|
|
||||||
</span>
|
</span>
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{new Date(agency.created_at).toLocaleDateString('pt-BR')}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
|
|
||||||
<button
|
|
||||||
onClick={() => handleViewDetails(agency.id)}
|
|
||||||
className="inline-flex items-center px-3 py-1.5 rounded-md bg-gradient-to-r from-brand-500 to-brand-700 text-white hover:opacity-90 transition"
|
|
||||||
disabled={detailsLoadingId === agency.id || deletingId === agency.id}
|
|
||||||
>
|
|
||||||
{detailsLoadingId === agency.id ? 'Carregando...' : 'Visualizar'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteAgency(agency.id)}
|
|
||||||
className="inline-flex items-center px-3 py-1.5 rounded-md border border-red-500 text-red-600 hover:bg-red-500 hover:text-white transition disabled:opacity-60"
|
|
||||||
disabled={deletingId === agency.id || detailsLoadingId === agency.id}
|
|
||||||
>
|
|
||||||
{deletingId === agency.id ? 'Excluindo...' : 'Excluir'}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{agency.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{agency.subdomain}.aggios.app
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{agency.is_active ? (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-green-100 dark:bg-green-900/20 px-2 py-0.5 text-[10px] font-medium text-green-800 dark:text-green-400">
|
||||||
|
<CheckCircleIcon className="h-3 w-3" />
|
||||||
|
Ativo
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-red-100 dark:bg-red-900/20 px-2 py-0.5 text-[10px] font-medium text-red-800 dark:text-red-400">
|
||||||
|
<XCircleIcon className="h-3 w-3" />
|
||||||
|
Inativo
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{new Date(agency.created_at).toLocaleDateString('pt-BR')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<Link
|
||||||
|
href="/superadmin/agencies"
|
||||||
|
className="group relative overflow-hidden rounded-xl p-4 transition-all"
|
||||||
|
style={{ background: 'var(--gradient)' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 text-white">
|
||||||
|
<BuildingOfficeIcon className="h-6 w-6" />
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-sm">Gerenciar Agências</h3>
|
||||||
|
<p className="text-xs text-white/80">Ver e editar agências</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/superadmin/signup-templates"
|
||||||
|
className="group relative overflow-hidden rounded-xl bg-gradient-to-r from-orange-500 to-pink-600 p-4 transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 text-white">
|
||||||
|
<LinkIcon className="h-6 w-6" />
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-sm">Links de Cadastro</h3>
|
||||||
|
<p className="text-xs text-white/80">Criar links personalizados</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/superadmin/reports"
|
||||||
|
className="group relative overflow-hidden rounded-xl bg-gradient-to-r from-pink-500 to-rose-600 p-4 transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 text-white">
|
||||||
|
<ChartBarIcon className="h-6 w-6" />
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-sm">Relatórios</h3>
|
||||||
|
<p className="text-xs text-white/80">Análises e métricas</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
29
front-end-dash.aggios.app/app/superadmin/reports/page.tsx
Normal file
29
front-end-dash.aggios.app/app/superadmin/reports/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChartBarIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
export default function ReportsPage() {
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<ChartBarIcon className="w-8 h-8 text-gray-600 dark:text-gray-400" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Relatórios</h1>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Visualize métricas e relatórios do sistema
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-12 text-center">
|
||||||
|
<ChartBarIcon className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||||
|
Página em desenvolvimento
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Os relatórios e analytics estarão disponíveis em breve
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
front-end-dash.aggios.app/app/superadmin/settings/page.tsx
Normal file
29
front-end-dash.aggios.app/app/superadmin/settings/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Cog6ToothIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<Cog6ToothIcon className="w-6 h-6 text-gray-600 dark:text-gray-400" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Configurações</h1>
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Configure o sistema e preferências globais
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-8 text-center">
|
||||||
|
<Cog6ToothIcon className="w-12 h-12 text-gray-400 mx-auto mb-3" />
|
||||||
|
<h3 className="text-base font-medium text-gray-900 dark:text-white mb-1">
|
||||||
|
Página em desenvolvimento
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
As configurações do sistema estarão disponíveis em breve
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { PlusIcon, LinkIcon, PencilSquareIcon, TrashIcon, ClipboardDocumentIcon } from '@heroicons/react/24/outline';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import Input from '@/components/ui/Input';
|
||||||
|
import Dialog from '@/components/ui/Dialog';
|
||||||
|
|
||||||
|
interface FormField {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type: string;
|
||||||
|
required: boolean;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SignupTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
slug: string;
|
||||||
|
form_fields: FormField[];
|
||||||
|
enabled_modules: string[];
|
||||||
|
redirect_url?: string;
|
||||||
|
success_message?: string;
|
||||||
|
custom_logo_url?: string;
|
||||||
|
custom_primary_color?: string;
|
||||||
|
is_active: boolean;
|
||||||
|
usage_count: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AVAILABLE_FIELDS = [
|
||||||
|
{ name: 'email', label: 'E-mail', type: 'email', required: true },
|
||||||
|
{ name: 'password', label: 'Senha', type: 'password', required: true },
|
||||||
|
{ name: 'subdomain', label: 'Subdomínio', type: 'text', required: true },
|
||||||
|
{ name: 'company_name', label: 'Nome da Empresa', type: 'text', required: false },
|
||||||
|
{ name: 'cnpj', label: 'CNPJ', type: 'text', required: false },
|
||||||
|
{ name: 'phone', label: 'Telefone', type: 'tel', required: false },
|
||||||
|
{ name: 'address', label: 'Endereço', type: 'text', required: false },
|
||||||
|
{ name: 'city', label: 'Cidade', type: 'text', required: false },
|
||||||
|
{ name: 'state', label: 'Estado', type: 'text', required: false },
|
||||||
|
{ name: 'zipcode', label: 'CEP', type: 'text', required: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const AVAILABLE_MODULES = [
|
||||||
|
'CRM',
|
||||||
|
'ERP',
|
||||||
|
'PROJECTS',
|
||||||
|
'FINANCIAL',
|
||||||
|
'INVENTORY',
|
||||||
|
'HR',
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SignupTemplatesPage() {
|
||||||
|
const [templates, setTemplates] = useState<SignupTemplate[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
|
const [editingTemplate, setEditingTemplate] = useState<SignupTemplate | null>(null);
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
slug: '',
|
||||||
|
redirect_url: '',
|
||||||
|
success_message: '',
|
||||||
|
});
|
||||||
|
const [selectedFields, setSelectedFields] = useState<FormField[]>([]);
|
||||||
|
const [selectedModules, setSelectedModules] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTemplates();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadTemplates = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const response = await fetch('/api/admin/signup-templates', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setTemplates(data || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao carregar templates:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFieldToggle = (field: typeof AVAILABLE_FIELDS[0]) => {
|
||||||
|
// Campos obrigatórios não podem ser removidos
|
||||||
|
if (field.required) return;
|
||||||
|
|
||||||
|
setSelectedFields(prev => {
|
||||||
|
const exists = prev.find(f => f.name === field.name);
|
||||||
|
if (exists) {
|
||||||
|
return prev.filter(f => f.name !== field.name);
|
||||||
|
} else {
|
||||||
|
return [...prev, { ...field, order: prev.length + 1 }];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModuleToggle = (module: string) => {
|
||||||
|
setSelectedModules(prev => {
|
||||||
|
if (prev.includes(module)) {
|
||||||
|
return prev.filter(m => m !== module);
|
||||||
|
} else {
|
||||||
|
return [...prev, module];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const template = {
|
||||||
|
...formData,
|
||||||
|
form_fields: selectedFields,
|
||||||
|
enabled_modules: selectedModules,
|
||||||
|
is_active: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const url = editingTemplate
|
||||||
|
? `/api/admin/signup-templates/${editingTemplate.id}`
|
||||||
|
: '/api/admin/signup-templates';
|
||||||
|
|
||||||
|
const method = editingTemplate ? 'PUT' : 'POST';
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(template),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
loadTemplates();
|
||||||
|
handleCloseDialog();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao salvar template:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('Tem certeza que deseja deletar este template?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const response = await fetch(`/api/admin/signup-templates/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
loadTemplates();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao deletar template:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (template: SignupTemplate) => {
|
||||||
|
setEditingTemplate(template);
|
||||||
|
setFormData({
|
||||||
|
name: template.name,
|
||||||
|
description: template.description,
|
||||||
|
slug: template.slug,
|
||||||
|
redirect_url: template.redirect_url || '',
|
||||||
|
success_message: template.success_message || '',
|
||||||
|
});
|
||||||
|
setSelectedFields(template.form_fields);
|
||||||
|
setSelectedModules(template.enabled_modules);
|
||||||
|
setShowDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseDialog = () => {
|
||||||
|
setShowDialog(false);
|
||||||
|
setEditingTemplate(null);
|
||||||
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
slug: '',
|
||||||
|
redirect_url: '',
|
||||||
|
success_message: '',
|
||||||
|
});
|
||||||
|
// Sempre iniciar com os campos obrigatórios selecionados
|
||||||
|
const requiredFields = AVAILABLE_FIELDS.filter(f => f.required).map((f, idx) => ({
|
||||||
|
...f,
|
||||||
|
order: idx + 1
|
||||||
|
}));
|
||||||
|
setSelectedFields(requiredFields);
|
||||||
|
setSelectedModules([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inicializar com campos obrigatórios na primeira renderização
|
||||||
|
useEffect(() => {
|
||||||
|
const requiredFields = AVAILABLE_FIELDS.filter(f => f.required).map((f, idx) => ({
|
||||||
|
...f,
|
||||||
|
order: idx + 1
|
||||||
|
}));
|
||||||
|
if (selectedFields.length === 0) {
|
||||||
|
setSelectedFields(requiredFields);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const copyToClipboard = (slug: string) => {
|
||||||
|
const url = `${window.location.origin}/cadastro/${slug}`;
|
||||||
|
navigator.clipboard.writeText(url);
|
||||||
|
alert('Link copiado para a área de transferência!');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Links de Cadastro</h1>
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Crie links personalizados de cadastro com campos e módulos específicos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setShowDialog(true)} size="sm">
|
||||||
|
<PlusIcon className="w-4 h-4 mr-2" />
|
||||||
|
Novo Link
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-gray-900 dark:border-white mx-auto"></div>
|
||||||
|
</div>
|
||||||
|
) : templates.length === 0 ? (
|
||||||
|
<div className="text-center py-8 bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800">
|
||||||
|
<LinkIcon className="w-10 h-10 text-gray-400 mx-auto mb-3" />
|
||||||
|
<h3 className="text-base font-medium text-gray-900 dark:text-white mb-1">
|
||||||
|
Nenhum link criado
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||||
|
Crie seu primeiro link de cadastro personalizado
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => setShowDialog(true)} size="sm">
|
||||||
|
<PlusIcon className="w-4 h-4 mr-2" />
|
||||||
|
Criar Primeiro Link
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{templates.map((template) => (
|
||||||
|
<div
|
||||||
|
key={template.id}
|
||||||
|
className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-4"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-1">
|
||||||
|
{template.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
{template.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<code className="px-2 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-xs font-mono text-gray-900 dark:text-white">
|
||||||
|
/cadastro/{template.slug}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(template.slug)}
|
||||||
|
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
|
||||||
|
title="Copiar link"
|
||||||
|
>
|
||||||
|
<ClipboardDocumentIcon className="w-4 h-4 text-gray-600 dark:text-gray-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
|
<span className="text-[10px] text-gray-600 dark:text-gray-400">Campos:</span>
|
||||||
|
{template.form_fields.map((field) => (
|
||||||
|
<span
|
||||||
|
key={field.name}
|
||||||
|
className="px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded text-[10px]"
|
||||||
|
>
|
||||||
|
{field.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<span className="text-[10px] text-gray-600 dark:text-gray-400">Módulos:</span>
|
||||||
|
{template.enabled_modules.map((module) => (
|
||||||
|
<span
|
||||||
|
key={module}
|
||||||
|
className="px-1.5 py-0.5 bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 rounded text-[10px]"
|
||||||
|
>
|
||||||
|
{module}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
<div className="text-right mr-3">
|
||||||
|
<div className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{template.usage_count}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-gray-600 dark:text-gray-400">
|
||||||
|
cadastros
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(template)}
|
||||||
|
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
|
||||||
|
>
|
||||||
|
<PencilSquareIcon className="w-4 h-4 text-gray-600 dark:text-gray-400" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(template.id)}
|
||||||
|
className="p-1.5 hover:bg-red-100 dark:hover:bg-red-900 rounded"
|
||||||
|
>
|
||||||
|
<TrashIcon className="w-4 h-4 text-red-600 dark:text-red-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dialog de Criação/Edição */}
|
||||||
|
<Dialog
|
||||||
|
isOpen={showDialog}
|
||||||
|
onClose={handleCloseDialog}
|
||||||
|
title={editingTemplate ? 'Editar Link de Cadastro' : 'Novo Link de Cadastro'}
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label="Nome do Template"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Slug (URL)"
|
||||||
|
value={formData.slug}
|
||||||
|
onChange={(e) => setFormData({ ...formData, slug: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-') })}
|
||||||
|
required
|
||||||
|
placeholder="ex: crm-rapido"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Descrição"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||||
|
Campos do Formulário
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{AVAILABLE_FIELDS.map((field) => {
|
||||||
|
const isSelected = selectedFields.some(f => f.name === field.name);
|
||||||
|
const isRequired = field.required;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={field.name}
|
||||||
|
className={`flex items-center gap-2 p-2 rounded border ${isRequired
|
||||||
|
? 'border-purple-300 dark:border-purple-700 bg-purple-50 dark:bg-purple-900/20'
|
||||||
|
: 'border-gray-200 dark:border-gray-700'
|
||||||
|
} ${isRequired
|
||||||
|
? 'cursor-not-allowed'
|
||||||
|
: 'hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer'
|
||||||
|
}`}
|
||||||
|
title={isRequired ? 'Campo obrigatório - não pode ser removido' : ''}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => handleFieldToggle(field)}
|
||||||
|
disabled={isRequired}
|
||||||
|
className={`rounded ${isRequired ? 'cursor-not-allowed opacity-60' : ''}`}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-900 dark:text-white">{field.label}</span>
|
||||||
|
{isRequired && (
|
||||||
|
<span className="ml-auto text-xs px-1.5 py-0.5 bg-purple-600 dark:bg-purple-500 text-white rounded font-medium">
|
||||||
|
OBRIGATÓRIO
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Os campos Email, Senha e Subdomínio são obrigatórios e não podem ser removidos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||||
|
Módulos Habilitados
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{AVAILABLE_MODULES.map((module) => (
|
||||||
|
<label
|
||||||
|
key={module}
|
||||||
|
className="flex items-center gap-2 p-2 rounded border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedModules.includes(module)}
|
||||||
|
onChange={() => handleModuleToggle(module)}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-900 dark:text-white">{module}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 justify-end pt-4 border-t border-gray-200 dark:border-gray-800">
|
||||||
|
<Button type="button" variant="outline" onClick={handleCloseDialog}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">
|
||||||
|
{editingTemplate ? 'Salvar Alterações' : 'Criar Link'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
front-end-dash.aggios.app/app/superadmin/users/page.tsx
Normal file
29
front-end-dash.aggios.app/app/superadmin/users/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { UserGroupIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
export default function UsersPage() {
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<UserGroupIcon className="w-8 h-8 text-gray-600 dark:text-gray-400" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Usuários</h1>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Gerencie todos os usuários do sistema
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-12 text-center">
|
||||||
|
<UserGroupIcon className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||||
|
Página em desenvolvimento
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
A gestão de usuários estará disponível em breve
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
--color-gradient-brand: linear-gradient(135deg, #ff3a05, #ff0080);
|
--color-gradient-brand: linear-gradient(135deg, #ff3a05, #ff0080);
|
||||||
|
|
||||||
/* Cores sólidas de marca (usadas em textos/bordas) */
|
/* Cores sólidas de marca (usadas em textos/bordas) */
|
||||||
--brand-color: #ff3a05;
|
--brand-color: #ff0080;
|
||||||
--brand-color-strong: #ff0080;
|
--brand-color-strong: #ff0080;
|
||||||
|
|
||||||
/* Superfícies e tipografia */
|
/* Superfícies e tipografia */
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Fragment, useState } from 'react';
|
||||||
|
import { Dialog, Transition, Tab } from '@headlessui/react';
|
||||||
|
import {
|
||||||
|
XMarkIcon,
|
||||||
|
BuildingOfficeIcon,
|
||||||
|
MapPinIcon,
|
||||||
|
UserIcon,
|
||||||
|
CheckCircleIcon
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
interface CreateAgencyModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function classNames(...classes: string[]) {
|
||||||
|
return classes.filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreateAgencyModal({ isOpen, onClose, onSuccess }: CreateAgencyModalProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
// Agência
|
||||||
|
agencyName: '',
|
||||||
|
subdomain: '',
|
||||||
|
cnpj: '',
|
||||||
|
razaoSocial: '',
|
||||||
|
description: '',
|
||||||
|
website: '',
|
||||||
|
industry: '',
|
||||||
|
phone: '',
|
||||||
|
teamSize: '',
|
||||||
|
|
||||||
|
// Endereço
|
||||||
|
cep: '',
|
||||||
|
state: '',
|
||||||
|
city: '',
|
||||||
|
neighborhood: '',
|
||||||
|
street: '',
|
||||||
|
number: '',
|
||||||
|
complement: '',
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
adminEmail: '',
|
||||||
|
adminPassword: '',
|
||||||
|
adminName: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[e.target.name]: e.target.value
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/agencies/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.text();
|
||||||
|
throw new Error(errorData || 'Erro ao criar agência');
|
||||||
|
}
|
||||||
|
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
// Reset form?
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ name: 'Dados Gerais', icon: BuildingOfficeIcon },
|
||||||
|
{ name: 'Endereço', icon: MapPinIcon },
|
||||||
|
{ name: 'Administrador', icon: UserIcon },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Transition.Root show={isOpen} as={Fragment}>
|
||||||
|
<Dialog as="div" className="relative z-50" onClose={onClose}>
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 bg-zinc-900/40 backdrop-blur-sm transition-opacity" />
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
|
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||||
|
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
|
>
|
||||||
|
<Dialog.Panel className="relative transform overflow-hidden rounded-2xl bg-white dark:bg-zinc-900 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-3xl border border-zinc-200 dark:border-zinc-800">
|
||||||
|
<div className="absolute right-0 top-0 hidden pr-4 pt-4 sm:block">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-md bg-white dark:bg-zinc-900 text-zinc-400 hover:text-zinc-500 focus:outline-none"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Fechar</span>
|
||||||
|
<XMarkIcon className="h-6 w-6" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 sm:p-8">
|
||||||
|
<div className="sm:flex sm:items-start mb-6">
|
||||||
|
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-zinc-100 dark:bg-zinc-800 sm:mx-0 sm:h-10 sm:w-10">
|
||||||
|
<BuildingOfficeIcon className="h-6 w-6 text-[var(--brand-color)]" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
|
||||||
|
<Dialog.Title as="h3" className="text-xl font-semibold leading-6 text-zinc-900 dark:text-white">
|
||||||
|
Nova Agência
|
||||||
|
</Dialog.Title>
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
Preencha os dados abaixo para cadastrar uma nova agência parceira.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-sm text-red-800 dark:text-red-300">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Tab.Group>
|
||||||
|
<Tab.List className="flex space-x-1 rounded-xl bg-zinc-100 dark:bg-zinc-800/50 p-1 mb-6">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<Tab
|
||||||
|
key={tab.name}
|
||||||
|
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-[var(--brand-color)] focus:outline-none focus:ring-2',
|
||||||
|
selected
|
||||||
|
? 'bg-white dark:bg-zinc-800 text-[var(--brand-color)] shadow'
|
||||||
|
: 'text-zinc-500 hover:bg-white/[0.12] hover:text-zinc-700 dark:hover:text-zinc-300'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<tab.icon className="w-4 h-4" />
|
||||||
|
{tab.name}
|
||||||
|
</div>
|
||||||
|
</Tab>
|
||||||
|
))}
|
||||||
|
</Tab.List>
|
||||||
|
<Tab.Panels>
|
||||||
|
{/* Dados Gerais */}
|
||||||
|
<Tab.Panel className="space-y-4 focus:outline-none">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Input label="Nome da Agência *" name="agencyName" value={formData.agencyName} onChange={handleChange} required />
|
||||||
|
<Input label="Subdomínio *" name="subdomain" value={formData.subdomain} onChange={handleChange} required prefix="http://" suffix=".aggios.app" />
|
||||||
|
<Input label="CNPJ" name="cnpj" value={formData.cnpj} onChange={handleChange} />
|
||||||
|
<Input label="Razão Social" name="razaoSocial" value={formData.razaoSocial} onChange={handleChange} />
|
||||||
|
<Input label="Telefone" name="phone" value={formData.phone} onChange={handleChange} />
|
||||||
|
<Input label="Website" name="website" value={formData.website} onChange={handleChange} />
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1">Descrição</label>
|
||||||
|
<textarea
|
||||||
|
name="description"
|
||||||
|
rows={3}
|
||||||
|
className="w-full rounded-lg border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800/50 px-3 py-2 text-sm text-zinc-900 dark:text-white focus:border-[var(--brand-color)] focus:ring-1 focus:ring-[var(--brand-color)] outline-none transition-all"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tab.Panel>
|
||||||
|
|
||||||
|
{/* Endereço */}
|
||||||
|
<Tab.Panel className="space-y-4 focus:outline-none">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Input label="CEP" name="cep" value={formData.cep} onChange={handleChange} />
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Input label="Estado" name="state" value={formData.state} onChange={handleChange} />
|
||||||
|
<Input label="Cidade" name="city" value={formData.city} onChange={handleChange} />
|
||||||
|
</div>
|
||||||
|
<Input label="Bairro" name="neighborhood" value={formData.neighborhood} onChange={handleChange} />
|
||||||
|
<Input label="Rua" name="street" value={formData.street} onChange={handleChange} />
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Input label="Número" name="number" value={formData.number} onChange={handleChange} />
|
||||||
|
<Input label="Complemento" name="complement" value={formData.complement} onChange={handleChange} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tab.Panel>
|
||||||
|
|
||||||
|
{/* Administrador */}
|
||||||
|
<Tab.Panel className="space-y-4 focus:outline-none">
|
||||||
|
<div className="bg-zinc-50 dark:bg-zinc-800/50 p-4 rounded-lg mb-4 border border-zinc-100 dark:border-zinc-800">
|
||||||
|
<p className="text-sm text-zinc-600 dark:text-zinc-400 flex items-center gap-2">
|
||||||
|
<UserIcon className="w-4 h-4 text-[var(--brand-color)]" />
|
||||||
|
Este usuário será o administrador principal da agência.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
<Input label="Nome Completo *" name="adminName" value={formData.adminName} onChange={handleChange} required />
|
||||||
|
<Input label="E-mail *" name="adminEmail" type="email" value={formData.adminEmail} onChange={handleChange} required />
|
||||||
|
<Input label="Senha *" name="adminPassword" type="password" value={formData.adminPassword} onChange={handleChange} required />
|
||||||
|
</div>
|
||||||
|
</Tab.Panel>
|
||||||
|
</Tab.Panels>
|
||||||
|
</Tab.Group>
|
||||||
|
|
||||||
|
<div className="mt-8 flex items-center justify-end gap-3 border-t border-zinc-100 dark:border-zinc-800 pt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-lg px-4 py-2 text-sm font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="inline-flex justify-center rounded-lg px-4 py-2 text-sm font-medium text-white shadow-sm hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:ring-offset-2 disabled:opacity-50 transition-all"
|
||||||
|
style={{ background: 'var(--gradient)' }}
|
||||||
|
>
|
||||||
|
{loading ? 'Criando...' : 'Criar Agência'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Transition.Child>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label: string;
|
||||||
|
prefix?: string;
|
||||||
|
suffix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Input({ label, prefix, suffix, className, ...props }: InputProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<div className="relative flex rounded-lg shadow-sm">
|
||||||
|
{prefix && (
|
||||||
|
<span className="inline-flex items-center rounded-l-lg border border-r-0 border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800 px-3 text-zinc-500 sm:text-sm">
|
||||||
|
{prefix}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
className={classNames(
|
||||||
|
"block w-full border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800/50 text-zinc-900 dark:text-white focus:border-[var(--brand-color)] focus:ring-1 focus:ring-[var(--brand-color)] sm:text-sm outline-none transition-all py-2 px-3",
|
||||||
|
prefix ? "rounded-none" : "rounded-l-lg",
|
||||||
|
suffix ? "rounded-none" : "rounded-r-lg",
|
||||||
|
!prefix && !suffix ? "rounded-lg" : "",
|
||||||
|
className || ""
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{suffix && (
|
||||||
|
<span className="inline-flex items-center rounded-r-lg border border-l-0 border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800 px-3 text-zinc-500 sm:text-sm">
|
||||||
|
{suffix}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user