chore(release): snapshot 1.4.2

This commit is contained in:
Erik Silva
2025-12-17 13:36:23 -03:00
parent 2a112f169d
commit 99d828869a
95 changed files with 9933 additions and 1601 deletions

View File

@@ -19,7 +19,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server ./cmd/serv
# Runtime image
FROM alpine:latest
RUN apk --no-cache add ca-certificates tzdata
RUN apk --no-cache add ca-certificates tzdata postgresql-client
WORKDIR /root/

View File

@@ -18,7 +18,7 @@ import (
func initDB(cfg *config.Config) (*sql.DB, error) {
connStr := fmt.Sprintf(
"host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
"host=%s port=%s user=%s password=%s dbname=%s sslmode=disable client_encoding=UTF8",
cfg.Database.Host,
cfg.Database.Port,
cfg.Database.User,
@@ -58,11 +58,13 @@ func main() {
agencyTemplateRepo := repository.NewAgencyTemplateRepository(db)
planRepo := repository.NewPlanRepository(db)
subscriptionRepo := repository.NewSubscriptionRepository(db)
crmRepo := repository.NewCRMRepository(db)
solutionRepo := repository.NewSolutionRepository(db)
// Initialize services
authService := service.NewAuthService(userRepo, tenantRepo, cfg)
agencyService := service.NewAgencyService(userRepo, tenantRepo, cfg)
tenantService := service.NewTenantService(tenantRepo)
agencyService := service.NewAgencyService(userRepo, tenantRepo, cfg, db)
tenantService := service.NewTenantService(tenantRepo, db)
companyService := service.NewCompanyService(companyRepo)
planService := service.NewPlanService(planRepo, subscriptionRepo)
@@ -74,6 +76,8 @@ func main() {
tenantHandler := handlers.NewTenantHandler(tenantService)
companyHandler := handlers.NewCompanyHandler(companyService)
planHandler := handlers.NewPlanHandler(planService)
crmHandler := handlers.NewCRMHandler(crmRepo)
solutionHandler := handlers.NewSolutionHandler(solutionRepo)
signupTemplateHandler := handlers.NewSignupTemplateHandler(signupTemplateRepo, userRepo, tenantRepo, agencyService)
agencyTemplateHandler := handlers.NewAgencyTemplateHandler(agencyTemplateRepo, agencyService, userRepo, tenantRepo)
filesHandler := handlers.NewFilesHandler(cfg)
@@ -84,6 +88,9 @@ func main() {
log.Fatalf("❌ Erro ao inicializar upload handler: %v", err)
}
// Initialize backup handler
backupHandler := handlers.NewBackupHandler()
// Create middleware chain
tenantDetector := middleware.TenantDetector(tenantRepo)
corsMiddleware := middleware.CORS(cfg)
@@ -140,6 +147,12 @@ func main() {
router.HandleFunc("/api/admin/agencies", tenantHandler.ListAll).Methods("GET")
router.HandleFunc("/api/admin/agencies/{id}", agencyHandler.HandleAgency).Methods("GET", "PATCH", "DELETE")
// SUPERADMIN: Backup & Restore
router.Handle("/api/superadmin/backups", authMiddleware(http.HandlerFunc(backupHandler.ListBackups))).Methods("GET")
router.Handle("/api/superadmin/backup/create", authMiddleware(http.HandlerFunc(backupHandler.CreateBackup))).Methods("POST")
router.Handle("/api/superadmin/backup/restore", authMiddleware(http.HandlerFunc(backupHandler.RestoreBackup))).Methods("POST")
router.Handle("/api/superadmin/backup/download/{filename}", authMiddleware(http.HandlerFunc(backupHandler.DownloadBackup))).Methods("GET")
// SUPERADMIN: Agency template management
router.Handle("/api/admin/agency-templates", authMiddleware(http.HandlerFunc(agencyTemplateHandler.ListTemplates))).Methods("GET")
router.Handle("/api/admin/agency-templates", authMiddleware(http.HandlerFunc(agencyTemplateHandler.CreateTemplate))).Methods("POST")
@@ -167,6 +180,37 @@ func main() {
// SUPERADMIN: Plans management
planHandler.RegisterRoutes(router)
// SUPERADMIN: Solutions management
router.Handle("/api/admin/solutions", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
solutionHandler.GetAllSolutions(w, r)
case http.MethodPost:
solutionHandler.CreateSolution(w, r)
}
}))).Methods("GET", "POST")
router.Handle("/api/admin/solutions/{id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
solutionHandler.GetSolution(w, r)
case http.MethodPut, http.MethodPatch:
solutionHandler.UpdateSolution(w, r)
case http.MethodDelete:
solutionHandler.DeleteSolution(w, r)
}
}))).Methods("GET", "PUT", "PATCH", "DELETE")
// SUPERADMIN: Plan <-> Solutions
router.Handle("/api/admin/plans/{plan_id}/solutions", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
solutionHandler.GetPlanSolutions(w, r)
case http.MethodPut:
solutionHandler.SetPlanSolutions(w, r)
}
}))).Methods("GET", "PUT")
// ADMIN_AGENCIA: Client registration
router.Handle("/api/agencies/clients/register", authMiddleware(http.HandlerFunc(agencyHandler.RegisterClient))).Methods("POST")
@@ -190,6 +234,63 @@ func main() {
router.Handle("/api/companies", authMiddleware(http.HandlerFunc(companyHandler.List))).Methods("GET")
router.Handle("/api/companies/create", authMiddleware(http.HandlerFunc(companyHandler.Create))).Methods("POST")
// ==================== CRM ROUTES (TENANT) ====================
// Tenant solutions (which solutions the tenant has access to)
router.Handle("/api/tenant/solutions", authMiddleware(http.HandlerFunc(solutionHandler.GetTenantSolutions))).Methods("GET")
// Customers
router.Handle("/api/crm/customers", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
crmHandler.GetCustomers(w, r)
case http.MethodPost:
crmHandler.CreateCustomer(w, r)
}
}))).Methods("GET", "POST")
router.Handle("/api/crm/customers/{id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
crmHandler.GetCustomer(w, r)
case http.MethodPut, http.MethodPatch:
crmHandler.UpdateCustomer(w, r)
case http.MethodDelete:
crmHandler.DeleteCustomer(w, r)
}
}))).Methods("GET", "PUT", "PATCH", "DELETE")
// Lists
router.Handle("/api/crm/lists", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
crmHandler.GetLists(w, r)
case http.MethodPost:
crmHandler.CreateList(w, r)
}
}))).Methods("GET", "POST")
router.Handle("/api/crm/lists/{id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
crmHandler.GetList(w, r)
case http.MethodPut, http.MethodPatch:
crmHandler.UpdateList(w, r)
case http.MethodDelete:
crmHandler.DeleteList(w, r)
}
}))).Methods("GET", "PUT", "PATCH", "DELETE")
// Customer <-> List relationship
router.Handle("/api/crm/customers/{customer_id}/lists/{list_id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
crmHandler.AddCustomerToList(w, r)
case http.MethodDelete:
crmHandler.RemoveCustomerFromList(w, r)
}
}))).Methods("POST", "DELETE")
// Apply global middlewares: tenant -> cors -> security -> rateLimit -> router
handler := tenantDetector(corsMiddleware(securityMiddleware(rateLimitMiddleware(router))))

15
backend/generate_hash.go Normal file
View File

@@ -0,0 +1,15 @@
package main
import (
"fmt"
"golang.org/x/crypto/bcrypt"
)
func main() {
password := "Android@2020"
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
panic(err)
}
fmt.Println(string(hash))
}

View File

@@ -0,0 +1,264 @@
package handlers
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
)
type BackupHandler struct {
backupDir string
}
type BackupInfo struct {
Filename string `json:"filename"`
Size string `json:"size"`
Date string `json:"date"`
Timestamp string `json:"timestamp"`
}
func NewBackupHandler() *BackupHandler {
// Usa o caminho montado no container
backupDir := "/backups"
// Garante que o diretório existe
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
os.MkdirAll(backupDir, 0755)
}
return &BackupHandler{
backupDir: backupDir,
}
}
// ListBackups lista todos os backups disponíveis
func (h *BackupHandler) ListBackups(w http.ResponseWriter, r *http.Request) {
files, err := ioutil.ReadDir(h.backupDir)
if err != nil {
http.Error(w, "Error reading backups directory", http.StatusInternalServerError)
return
}
var backups []BackupInfo
for _, file := range files {
if strings.HasPrefix(file.Name(), "aggios_backup_") && strings.HasSuffix(file.Name(), ".sql") {
// Extrai timestamp do nome do arquivo
timestamp := strings.TrimPrefix(file.Name(), "aggios_backup_")
timestamp = strings.TrimSuffix(timestamp, ".sql")
// Formata a data
t, _ := time.Parse("2006-01-02_15-04-05", timestamp)
dateStr := t.Format("02/01/2006 15:04:05")
// Formata o tamanho
sizeMB := float64(file.Size()) / 1024
sizeStr := fmt.Sprintf("%.2f KB", sizeMB)
backups = append(backups, BackupInfo{
Filename: file.Name(),
Size: sizeStr,
Date: dateStr,
Timestamp: timestamp,
})
}
}
// Ordena por data (mais recente primeiro)
sort.Slice(backups, func(i, j int) bool {
return backups[i].Timestamp > backups[j].Timestamp
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"backups": backups,
})
}
// CreateBackup cria um novo backup do banco de dados
func (h *BackupHandler) CreateBackup(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
timestamp := time.Now().Format("2006-01-02_15-04-05")
filename := fmt.Sprintf("aggios_backup_%s.sql", timestamp)
filepath := filepath.Join(h.backupDir, filename)
// Usa pg_dump diretamente (backend e postgres estão na mesma rede docker)
dbPassword := os.Getenv("DB_PASSWORD")
if dbPassword == "" {
dbPassword = "A9g10s_S3cur3_P@ssw0rd_2025!"
}
cmd := exec.Command("pg_dump",
"-h", "postgres",
"-U", "aggios",
"-d", "aggios_db",
"--no-password")
// Define a variável de ambiente para a senha
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbPassword))
output, err := cmd.Output()
if err != nil {
http.Error(w, fmt.Sprintf("Error creating backup: %v", err), http.StatusInternalServerError)
return
}
// Salva o backup no arquivo
err = ioutil.WriteFile(filepath, output, 0644)
if err != nil {
http.Error(w, fmt.Sprintf("Error saving backup: %v", err), http.StatusInternalServerError)
return
}
// Limpa backups antigos (mantém apenas os últimos 10)
h.cleanOldBackups()
fileInfo, _ := os.Stat(filepath)
sizeMB := float64(fileInfo.Size()) / 1024
sizeStr := fmt.Sprintf("%.2f KB", sizeMB)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Backup created successfully",
"filename": filename,
"size": sizeStr,
})
}
// RestoreBackup restaura um backup específico
func (h *BackupHandler) RestoreBackup(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Filename string `json:"filename"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if req.Filename == "" {
http.Error(w, "Filename is required", http.StatusBadRequest)
return
}
// Valida que o arquivo existe e está no diretório correto
backupPath := filepath.Join(h.backupDir, req.Filename)
if !strings.HasPrefix(backupPath, h.backupDir) {
http.Error(w, "Invalid filename", http.StatusBadRequest)
return
}
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
http.Error(w, "Backup file not found", http.StatusNotFound)
return
}
// Lê o conteúdo do backup
backupContent, err := ioutil.ReadFile(backupPath)
if err != nil {
http.Error(w, fmt.Sprintf("Error reading backup: %v", err), http.StatusInternalServerError)
return
}
// Restaura o backup usando psql diretamente
dbPassword := os.Getenv("DB_PASSWORD")
if dbPassword == "" {
dbPassword = "A9g10s_S3cur3_P@ssw0rd_2025!"
}
cmd := exec.Command("psql",
"-h", "postgres",
"-U", "aggios",
"-d", "aggios_db",
"--no-password")
cmd.Stdin = strings.NewReader(string(backupContent))
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbPassword))
if err := cmd.Run(); err != nil {
http.Error(w, fmt.Sprintf("Error restoring backup: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Backup restored successfully",
})
}
// DownloadBackup permite fazer download de um backup
func (h *BackupHandler) DownloadBackup(w http.ResponseWriter, r *http.Request) {
// Extrai o filename da URL
parts := strings.Split(r.URL.Path, "/")
filename := parts[len(parts)-1]
if filename == "" {
http.Error(w, "Filename is required", http.StatusBadRequest)
return
}
// Valida que o arquivo existe e está no diretório correto
backupPath := filepath.Join(h.backupDir, filename)
if !strings.HasPrefix(backupPath, h.backupDir) {
http.Error(w, "Invalid filename", http.StatusBadRequest)
return
}
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
http.Error(w, "Backup file not found", http.StatusNotFound)
return
}
// Lê o arquivo
data, err := ioutil.ReadFile(backupPath)
if err != nil {
http.Error(w, "Error reading file", http.StatusInternalServerError)
return
}
// Define headers para download
w.Header().Set("Content-Type", "application/sql")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
w.Write(data)
}
// cleanOldBackups mantém apenas os últimos 10 backups
func (h *BackupHandler) cleanOldBackups() {
files, err := ioutil.ReadDir(h.backupDir)
if err != nil {
return
}
var backupFiles []os.FileInfo
for _, file := range files {
if strings.HasPrefix(file.Name(), "aggios_backup_") && strings.HasSuffix(file.Name(), ".sql") {
backupFiles = append(backupFiles, file)
}
}
// Ordena por data de modificação (mais recente primeiro)
sort.Slice(backupFiles, func(i, j int) bool {
return backupFiles[i].ModTime().After(backupFiles[j].ModTime())
})
// Remove backups antigos (mantém os 10 mais recentes)
if len(backupFiles) > 10 {
for _, file := range backupFiles[10:] {
os.Remove(filepath.Join(h.backupDir, file.Name()))
}
}
}

View File

@@ -0,0 +1,470 @@
package handlers
import (
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/repository"
"aggios-app/backend/internal/api/middleware"
"encoding/json"
"log"
"net/http"
"github.com/google/uuid"
"github.com/gorilla/mux"
)
type CRMHandler struct {
repo *repository.CRMRepository
}
func NewCRMHandler(repo *repository.CRMRepository) *CRMHandler {
return &CRMHandler{repo: repo}
}
// ==================== CUSTOMERS ====================
func (h *CRMHandler) CreateCustomer(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
userID, _ := r.Context().Value(middleware.UserIDKey).(string)
if tenantID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Missing tenant_id",
})
return
}
var customer domain.CRMCustomer
if err := json.NewDecoder(r.Body).Decode(&customer); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Invalid request body",
"message": err.Error(),
})
return
}
customer.ID = uuid.New().String()
customer.TenantID = tenantID
customer.CreatedBy = userID
customer.IsActive = true
if err := h.repo.CreateCustomer(&customer); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Failed to create customer",
"message": err.Error(),
})
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]interface{}{
"customer": customer,
})
}
func (h *CRMHandler) GetCustomers(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
if tenantID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Missing tenant_id",
})
return
}
customers, err := h.repo.GetCustomersByTenant(tenantID)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Failed to fetch customers",
"message": err.Error(),
})
return
}
if customers == nil {
customers = []domain.CRMCustomer{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"customers": customers,
})
}
func (h *CRMHandler) GetCustomer(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
if tenantID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Missing tenant_id",
})
return
}
vars := mux.Vars(r)
customerID := vars["id"]
customer, err := h.repo.GetCustomerByID(customerID, tenantID)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{
"error": "Customer not found",
"message": err.Error(),
})
return
}
// Buscar listas do cliente
lists, _ := h.repo.GetCustomerLists(customerID)
if lists == nil {
lists = []domain.CRMList{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"customer": customer,
"lists": lists,
})
}
func (h *CRMHandler) UpdateCustomer(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
if tenantID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Missing tenant_id",
})
return
}
vars := mux.Vars(r)
customerID := vars["id"]
var customer domain.CRMCustomer
if err := json.NewDecoder(r.Body).Decode(&customer); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Invalid request body",
"message": err.Error(),
})
return
}
customer.ID = customerID
customer.TenantID = tenantID
if err := h.repo.UpdateCustomer(&customer); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Failed to update customer",
"message": err.Error(),
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "Customer updated successfully",
})
}
func (h *CRMHandler) DeleteCustomer(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
if tenantID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Missing tenant_id",
})
return
}
vars := mux.Vars(r)
customerID := vars["id"]
if err := h.repo.DeleteCustomer(customerID, tenantID); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Failed to delete customer",
"message": err.Error(),
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "Customer deleted successfully",
})
}
// ==================== LISTS ====================
func (h *CRMHandler) CreateList(w http.ResponseWriter, r *http.Request) {
tenantIDVal := r.Context().Value(middleware.TenantIDKey)
userIDVal := r.Context().Value(middleware.UserIDKey)
log.Printf("🔍 CreateList DEBUG: tenantID type=%T value=%v | userID type=%T value=%v",
tenantIDVal, tenantIDVal, userIDVal, userIDVal)
tenantID, ok := tenantIDVal.(string)
if !ok || tenantID == "" {
log.Printf("❌ CreateList: Missing or invalid tenant_id")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Missing tenant_id",
})
return
}
userID, _ := userIDVal.(string)
var list domain.CRMList
if err := json.NewDecoder(r.Body).Decode(&list); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Invalid request body",
"message": err.Error(),
})
return
}
list.ID = uuid.New().String()
list.TenantID = tenantID
list.CreatedBy = userID
if list.Color == "" {
list.Color = "#3b82f6"
}
if err := h.repo.CreateList(&list); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Failed to create list",
"message": err.Error(),
})
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]interface{}{
"list": list,
})
}
func (h *CRMHandler) GetLists(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
if tenantID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Missing tenant_id",
})
return
}
lists, err := h.repo.GetListsByTenant(tenantID)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Failed to fetch lists",
"message": err.Error(),
})
return
}
if lists == nil {
lists = []domain.CRMListWithCustomers{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"lists": lists,
})
}
func (h *CRMHandler) GetList(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
if tenantID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Missing tenant_id",
})
return
}
vars := mux.Vars(r)
listID := vars["id"]
list, err := h.repo.GetListByID(listID, tenantID)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{
"error": "List not found",
"message": err.Error(),
})
return
}
// Buscar clientes da lista
customers, _ := h.repo.GetListCustomers(listID, tenantID)
if customers == nil {
customers = []domain.CRMCustomer{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"list": list,
"customers": customers,
})
}
func (h *CRMHandler) UpdateList(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
if tenantID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Missing tenant_id",
})
return
}
vars := mux.Vars(r)
listID := vars["id"]
var list domain.CRMList
if err := json.NewDecoder(r.Body).Decode(&list); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Invalid request body",
"message": err.Error(),
})
return
}
list.ID = listID
list.TenantID = tenantID
if err := h.repo.UpdateList(&list); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Failed to update list",
"message": err.Error(),
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "List updated successfully",
})
}
func (h *CRMHandler) DeleteList(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
if tenantID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Missing tenant_id",
})
return
}
vars := mux.Vars(r)
listID := vars["id"]
if err := h.repo.DeleteList(listID, tenantID); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Failed to delete list",
"message": err.Error(),
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "List deleted successfully",
})
}
// ==================== CUSTOMER <-> LIST ====================
func (h *CRMHandler) AddCustomerToList(w http.ResponseWriter, r *http.Request) {
userID, _ := r.Context().Value(middleware.UserIDKey).(string)
vars := mux.Vars(r)
customerID := vars["customer_id"]
listID := vars["list_id"]
if err := h.repo.AddCustomerToList(customerID, listID, userID); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Failed to add customer to list",
"message": err.Error(),
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "Customer added to list successfully",
})
}
func (h *CRMHandler) RemoveCustomerFromList(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
customerID := vars["customer_id"]
listID := vars["list_id"]
if err := h.repo.RemoveCustomerFromList(customerID, listID); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Failed to remove customer from list",
"message": err.Error(),
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "Customer removed from list successfully",
})
}

View File

@@ -46,20 +46,26 @@ func (h *PlanHandler) CreatePlan(w http.ResponseWriter, r *http.Request) {
var req domain.CreatePlanRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Printf("❌ Invalid request body: %v", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "Invalid request body", "message": err.Error()})
return
}
plan, err := h.planService.CreatePlan(&req)
if err != nil {
log.Printf("❌ Error creating plan: %v", err)
w.Header().Set("Content-Type", "application/json")
switch err {
case service.ErrPlanSlugTaken:
http.Error(w, err.Error(), http.StatusConflict)
w.WriteHeader(http.StatusConflict)
json.NewEncoder(w).Encode(map[string]string{"error": "Slug already taken", "message": err.Error()})
case service.ErrInvalidUserRange:
http.Error(w, err.Error(), http.StatusBadRequest)
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "Invalid user range", "message": err.Error()})
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": "Internal server error", "message": err.Error()})
}
return
}

View File

@@ -0,0 +1,252 @@
package handlers
import (
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/repository"
"aggios-app/backend/internal/api/middleware"
"encoding/json"
"log"
"net/http"
"github.com/google/uuid"
"github.com/gorilla/mux"
)
type SolutionHandler struct {
repo *repository.SolutionRepository
}
func NewSolutionHandler(repo *repository.SolutionRepository) *SolutionHandler {
return &SolutionHandler{repo: repo}
}
// ==================== CRUD SOLUTIONS (SUPERADMIN) ====================
func (h *SolutionHandler) CreateSolution(w http.ResponseWriter, r *http.Request) {
var solution domain.Solution
if err := json.NewDecoder(r.Body).Decode(&solution); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Invalid request body",
"message": err.Error(),
})
return
}
solution.ID = uuid.New().String()
if err := h.repo.CreateSolution(&solution); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Failed to create solution",
"message": err.Error(),
})
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]interface{}{
"solution": solution,
})
}
func (h *SolutionHandler) GetAllSolutions(w http.ResponseWriter, r *http.Request) {
solutions, err := h.repo.GetAllSolutions()
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Failed to fetch solutions",
"message": err.Error(),
})
return
}
if solutions == nil {
solutions = []domain.Solution{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"solutions": solutions,
})
}
func (h *SolutionHandler) GetSolution(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
solutionID := vars["id"]
solution, err := h.repo.GetSolutionByID(solutionID)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{
"error": "Solution not found",
"message": err.Error(),
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"solution": solution,
})
}
func (h *SolutionHandler) UpdateSolution(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
solutionID := vars["id"]
var solution domain.Solution
if err := json.NewDecoder(r.Body).Decode(&solution); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Invalid request body",
"message": err.Error(),
})
return
}
solution.ID = solutionID
if err := h.repo.UpdateSolution(&solution); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Failed to update solution",
"message": err.Error(),
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "Solution updated successfully",
})
}
func (h *SolutionHandler) DeleteSolution(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
solutionID := vars["id"]
if err := h.repo.DeleteSolution(solutionID); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Failed to delete solution",
"message": err.Error(),
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "Solution deleted successfully",
})
}
// ==================== TENANT SOLUTIONS (AGENCY) ====================
func (h *SolutionHandler) GetTenantSolutions(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
log.Printf("🔍 GetTenantSolutions: tenantID=%s", tenantID)
if tenantID == "" {
log.Printf("❌ GetTenantSolutions: Missing tenant_id")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Missing tenant_id",
})
return
}
solutions, err := h.repo.GetTenantSolutions(tenantID)
if err != nil {
log.Printf("❌ GetTenantSolutions: Error fetching solutions: %v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Failed to fetch solutions",
"message": err.Error(),
})
return
}
log.Printf("✅ GetTenantSolutions: Found %d solutions for tenant %s", len(solutions), tenantID)
if solutions == nil {
solutions = []domain.Solution{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"solutions": solutions,
})
}
// ==================== PLAN SOLUTIONS ====================
func (h *SolutionHandler) GetPlanSolutions(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
planID := vars["plan_id"]
solutions, err := h.repo.GetPlanSolutions(planID)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Failed to fetch plan solutions",
"message": err.Error(),
})
return
}
if solutions == nil {
solutions = []domain.Solution{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"solutions": solutions,
})
}
func (h *SolutionHandler) SetPlanSolutions(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
planID := vars["plan_id"]
var req struct {
SolutionIDs []string `json:"solution_ids"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Invalid request body",
"message": err.Error(),
})
return
}
if err := h.repo.SetPlanSolutions(planID, req.SolutionIDs); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Failed to update plan solutions",
"message": err.Error(),
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "Plan solutions updated successfully",
})
}

View File

@@ -5,7 +5,6 @@ import (
"log"
"net/http"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/service"
)
@@ -28,14 +27,15 @@ func (h *TenantHandler) ListAll(w http.ResponseWriter, r *http.Request) {
return
}
tenants, err := h.tenantService.ListAll()
tenants, err := h.tenantService.ListAllWithDetails()
if err != nil {
log.Printf("Error listing tenants with details: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if tenants == nil {
tenants = []*domain.Tenant{}
tenants = []map[string]interface{}{}
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")

View File

@@ -0,0 +1,53 @@
package domain
import "time"
type CRMCustomer struct {
ID string `json:"id" db:"id"`
TenantID string `json:"tenant_id" db:"tenant_id"`
Name string `json:"name" db:"name"`
Email string `json:"email" db:"email"`
Phone string `json:"phone" db:"phone"`
Company string `json:"company" db:"company"`
Position string `json:"position" db:"position"`
Address string `json:"address" db:"address"`
City string `json:"city" db:"city"`
State string `json:"state" db:"state"`
ZipCode string `json:"zip_code" db:"zip_code"`
Country string `json:"country" db:"country"`
Notes string `json:"notes" db:"notes"`
Tags []string `json:"tags" db:"tags"`
IsActive bool `json:"is_active" db:"is_active"`
CreatedBy string `json:"created_by" db:"created_by"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type CRMList struct {
ID string `json:"id" db:"id"`
TenantID string `json:"tenant_id" db:"tenant_id"`
Name string `json:"name" db:"name"`
Description string `json:"description" db:"description"`
Color string `json:"color" db:"color"`
CreatedBy string `json:"created_by" db:"created_by"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type CRMCustomerList struct {
CustomerID string `json:"customer_id" db:"customer_id"`
ListID string `json:"list_id" db:"list_id"`
AddedAt time.Time `json:"added_at" db:"added_at"`
AddedBy string `json:"added_by" db:"added_by"`
}
// DTO com informações extras
type CRMCustomerWithLists struct {
CRMCustomer
Lists []CRMList `json:"lists"`
}
type CRMListWithCustomers struct {
CRMList
CustomerCount int `json:"customer_count"`
}

View File

@@ -0,0 +1,20 @@
package domain
import "time"
type Solution struct {
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Slug string `json:"slug" db:"slug"`
Icon string `json:"icon" db:"icon"`
Description string `json:"description" db:"description"`
IsActive bool `json:"is_active" db:"is_active"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type PlanSolution struct {
PlanID string `json:"plan_id" db:"plan_id"`
SolutionID string `json:"solution_id" db:"solution_id"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}

View File

@@ -47,5 +47,13 @@ type CreateTenantRequest struct {
type AgencyDetails struct {
Tenant *Tenant `json:"tenant"`
Admin *User `json:"admin,omitempty"`
Subscription *AgencySubscriptionInfo `json:"subscription,omitempty"`
AccessURL string `json:"access_url"`
}
type AgencySubscriptionInfo struct {
PlanID string `json:"plan_id"`
PlanName string `json:"plan_name"`
Status string `json:"status"`
Solutions []Solution `json:"solutions"`
}

View File

@@ -0,0 +1,346 @@
package repository
import (
"aggios-app/backend/internal/domain"
"database/sql"
"fmt"
"github.com/lib/pq"
)
type CRMRepository struct {
db *sql.DB
}
func NewCRMRepository(db *sql.DB) *CRMRepository {
return &CRMRepository{db: db}
}
// ==================== CUSTOMERS ====================
func (r *CRMRepository) CreateCustomer(customer *domain.CRMCustomer) error {
query := `
INSERT INTO crm_customers (
id, tenant_id, name, email, phone, company, position,
address, city, state, zip_code, country, notes, tags,
is_active, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
RETURNING created_at, updated_at
`
return r.db.QueryRow(
query,
customer.ID, customer.TenantID, customer.Name, customer.Email, customer.Phone,
customer.Company, customer.Position, customer.Address, customer.City, customer.State,
customer.ZipCode, customer.Country, customer.Notes, pq.Array(customer.Tags),
customer.IsActive, customer.CreatedBy,
).Scan(&customer.CreatedAt, &customer.UpdatedAt)
}
func (r *CRMRepository) GetCustomersByTenant(tenantID string) ([]domain.CRMCustomer, error) {
query := `
SELECT id, tenant_id, name, email, phone, company, position,
address, city, state, zip_code, country, notes, tags,
is_active, created_by, created_at, updated_at
FROM crm_customers
WHERE tenant_id = $1 AND is_active = true
ORDER BY created_at DESC
`
rows, err := r.db.Query(query, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var customers []domain.CRMCustomer
for rows.Next() {
var c domain.CRMCustomer
err := rows.Scan(
&c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position,
&c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags),
&c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt,
)
if err != nil {
return nil, err
}
customers = append(customers, c)
}
return customers, nil
}
func (r *CRMRepository) GetCustomerByID(id string, tenantID string) (*domain.CRMCustomer, error) {
query := `
SELECT id, tenant_id, name, email, phone, company, position,
address, city, state, zip_code, country, notes, tags,
is_active, created_by, created_at, updated_at
FROM crm_customers
WHERE id = $1 AND tenant_id = $2
`
var c domain.CRMCustomer
err := r.db.QueryRow(query, id, tenantID).Scan(
&c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position,
&c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags),
&c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt,
)
if err != nil {
return nil, err
}
return &c, nil
}
func (r *CRMRepository) UpdateCustomer(customer *domain.CRMCustomer) error {
query := `
UPDATE crm_customers SET
name = $1, email = $2, phone = $3, company = $4, position = $5,
address = $6, city = $7, state = $8, zip_code = $9, country = $10,
notes = $11, tags = $12, is_active = $13
WHERE id = $14 AND tenant_id = $15
`
result, err := r.db.Exec(
query,
customer.Name, customer.Email, customer.Phone, customer.Company, customer.Position,
customer.Address, customer.City, customer.State, customer.ZipCode, customer.Country,
customer.Notes, pq.Array(customer.Tags), customer.IsActive,
customer.ID, customer.TenantID,
)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return fmt.Errorf("customer not found")
}
return nil
}
func (r *CRMRepository) DeleteCustomer(id string, tenantID string) error {
query := `DELETE FROM crm_customers WHERE id = $1 AND tenant_id = $2`
result, err := r.db.Exec(query, id, tenantID)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return fmt.Errorf("customer not found")
}
return nil
}
// ==================== LISTS ====================
func (r *CRMRepository) CreateList(list *domain.CRMList) error {
query := `
INSERT INTO crm_lists (id, tenant_id, name, description, color, created_by)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING created_at, updated_at
`
return r.db.QueryRow(
query,
list.ID, list.TenantID, list.Name, list.Description, list.Color, list.CreatedBy,
).Scan(&list.CreatedAt, &list.UpdatedAt)
}
func (r *CRMRepository) GetListsByTenant(tenantID string) ([]domain.CRMListWithCustomers, error) {
query := `
SELECT l.id, l.tenant_id, l.name, l.description, l.color, l.created_by,
l.created_at, l.updated_at,
COUNT(cl.customer_id) as customer_count
FROM crm_lists l
LEFT JOIN crm_customer_lists cl ON l.id = cl.list_id
WHERE l.tenant_id = $1
GROUP BY l.id
ORDER BY l.created_at DESC
`
rows, err := r.db.Query(query, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var lists []domain.CRMListWithCustomers
for rows.Next() {
var l domain.CRMListWithCustomers
err := rows.Scan(
&l.ID, &l.TenantID, &l.Name, &l.Description, &l.Color, &l.CreatedBy,
&l.CreatedAt, &l.UpdatedAt, &l.CustomerCount,
)
if err != nil {
return nil, err
}
lists = append(lists, l)
}
return lists, nil
}
func (r *CRMRepository) GetListByID(id string, tenantID string) (*domain.CRMList, error) {
query := `
SELECT id, tenant_id, name, description, color, created_by, created_at, updated_at
FROM crm_lists
WHERE id = $1 AND tenant_id = $2
`
var l domain.CRMList
err := r.db.QueryRow(query, id, tenantID).Scan(
&l.ID, &l.TenantID, &l.Name, &l.Description, &l.Color, &l.CreatedBy,
&l.CreatedAt, &l.UpdatedAt,
)
if err != nil {
return nil, err
}
return &l, nil
}
func (r *CRMRepository) UpdateList(list *domain.CRMList) error {
query := `
UPDATE crm_lists SET
name = $1, description = $2, color = $3
WHERE id = $4 AND tenant_id = $5
`
result, err := r.db.Exec(query, list.Name, list.Description, list.Color, list.ID, list.TenantID)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return fmt.Errorf("list not found")
}
return nil
}
func (r *CRMRepository) DeleteList(id string, tenantID string) error {
query := `DELETE FROM crm_lists WHERE id = $1 AND tenant_id = $2`
result, err := r.db.Exec(query, id, tenantID)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return fmt.Errorf("list not found")
}
return nil
}
// ==================== CUSTOMER <-> LIST ====================
func (r *CRMRepository) AddCustomerToList(customerID, listID, addedBy string) error {
query := `
INSERT INTO crm_customer_lists (customer_id, list_id, added_by)
VALUES ($1, $2, $3)
ON CONFLICT (customer_id, list_id) DO NOTHING
`
_, err := r.db.Exec(query, customerID, listID, addedBy)
return err
}
func (r *CRMRepository) RemoveCustomerFromList(customerID, listID string) error {
query := `DELETE FROM crm_customer_lists WHERE customer_id = $1 AND list_id = $2`
_, err := r.db.Exec(query, customerID, listID)
return err
}
func (r *CRMRepository) GetCustomerLists(customerID string) ([]domain.CRMList, error) {
query := `
SELECT l.id, l.tenant_id, l.name, l.description, l.color, l.created_by,
l.created_at, l.updated_at
FROM crm_lists l
INNER JOIN crm_customer_lists cl ON l.id = cl.list_id
WHERE cl.customer_id = $1
ORDER BY l.name
`
rows, err := r.db.Query(query, customerID)
if err != nil {
return nil, err
}
defer rows.Close()
var lists []domain.CRMList
for rows.Next() {
var l domain.CRMList
err := rows.Scan(
&l.ID, &l.TenantID, &l.Name, &l.Description, &l.Color, &l.CreatedBy,
&l.CreatedAt, &l.UpdatedAt,
)
if err != nil {
return nil, err
}
lists = append(lists, l)
}
return lists, nil
}
func (r *CRMRepository) GetListCustomers(listID string, tenantID string) ([]domain.CRMCustomer, error) {
query := `
SELECT c.id, c.tenant_id, c.name, c.email, c.phone, c.company, c.position,
c.address, c.city, c.state, c.zip_code, c.country, c.notes, c.tags,
c.is_active, c.created_by, c.created_at, c.updated_at
FROM crm_customers c
INNER JOIN crm_customer_lists cl ON c.id = cl.customer_id
WHERE cl.list_id = $1 AND c.tenant_id = $2 AND c.is_active = true
ORDER BY c.name
`
rows, err := r.db.Query(query, listID, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var customers []domain.CRMCustomer
for rows.Next() {
var c domain.CRMCustomer
err := rows.Scan(
&c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position,
&c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags),
&c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt,
)
if err != nil {
return nil, err
}
customers = append(customers, c)
}
return customers, nil
}

View File

@@ -0,0 +1,300 @@
package repository
import (
"aggios-app/backend/internal/domain"
"database/sql"
"fmt"
)
type SolutionRepository struct {
db *sql.DB
}
func NewSolutionRepository(db *sql.DB) *SolutionRepository {
return &SolutionRepository{db: db}
}
// ==================== SOLUTIONS ====================
func (r *SolutionRepository) CreateSolution(solution *domain.Solution) error {
query := `
INSERT INTO solutions (id, name, slug, icon, description, is_active)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING created_at, updated_at
`
return r.db.QueryRow(
query,
solution.ID, solution.Name, solution.Slug, solution.Icon,
solution.Description, solution.IsActive,
).Scan(&solution.CreatedAt, &solution.UpdatedAt)
}
func (r *SolutionRepository) GetAllSolutions() ([]domain.Solution, error) {
query := `
SELECT id, name, slug, icon, description, is_active, created_at, updated_at
FROM solutions
ORDER BY created_at DESC
`
rows, err := r.db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var solutions []domain.Solution
for rows.Next() {
var s domain.Solution
err := rows.Scan(
&s.ID, &s.Name, &s.Slug, &s.Icon, &s.Description,
&s.IsActive, &s.CreatedAt, &s.UpdatedAt,
)
if err != nil {
return nil, err
}
solutions = append(solutions, s)
}
return solutions, nil
}
func (r *SolutionRepository) GetActiveSolutions() ([]domain.Solution, error) {
query := `
SELECT id, name, slug, icon, description, is_active, created_at, updated_at
FROM solutions
WHERE is_active = true
ORDER BY name
`
rows, err := r.db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var solutions []domain.Solution
for rows.Next() {
var s domain.Solution
err := rows.Scan(
&s.ID, &s.Name, &s.Slug, &s.Icon, &s.Description,
&s.IsActive, &s.CreatedAt, &s.UpdatedAt,
)
if err != nil {
return nil, err
}
solutions = append(solutions, s)
}
return solutions, nil
}
func (r *SolutionRepository) GetSolutionByID(id string) (*domain.Solution, error) {
query := `
SELECT id, name, slug, icon, description, is_active, created_at, updated_at
FROM solutions
WHERE id = $1
`
var s domain.Solution
err := r.db.QueryRow(query, id).Scan(
&s.ID, &s.Name, &s.Slug, &s.Icon, &s.Description,
&s.IsActive, &s.CreatedAt, &s.UpdatedAt,
)
if err != nil {
return nil, err
}
return &s, nil
}
func (r *SolutionRepository) GetSolutionBySlug(slug string) (*domain.Solution, error) {
query := `
SELECT id, name, slug, icon, description, is_active, created_at, updated_at
FROM solutions
WHERE slug = $1
`
var s domain.Solution
err := r.db.QueryRow(query, slug).Scan(
&s.ID, &s.Name, &s.Slug, &s.Icon, &s.Description,
&s.IsActive, &s.CreatedAt, &s.UpdatedAt,
)
if err != nil {
return nil, err
}
return &s, nil
}
func (r *SolutionRepository) UpdateSolution(solution *domain.Solution) error {
query := `
UPDATE solutions SET
name = $1, slug = $2, icon = $3, description = $4, is_active = $5, updated_at = CURRENT_TIMESTAMP
WHERE id = $6
`
result, err := r.db.Exec(
query,
solution.Name, solution.Slug, solution.Icon, solution.Description,
solution.IsActive, solution.ID,
)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return fmt.Errorf("solution not found")
}
return nil
}
func (r *SolutionRepository) DeleteSolution(id string) error {
query := `DELETE FROM solutions WHERE id = $1`
result, err := r.db.Exec(query, id)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return fmt.Errorf("solution not found")
}
return nil
}
// ==================== PLAN <-> SOLUTION ====================
func (r *SolutionRepository) AddSolutionToPlan(planID, solutionID string) error {
query := `
INSERT INTO plan_solutions (plan_id, solution_id)
VALUES ($1, $2)
ON CONFLICT (plan_id, solution_id) DO NOTHING
`
_, err := r.db.Exec(query, planID, solutionID)
return err
}
func (r *SolutionRepository) RemoveSolutionFromPlan(planID, solutionID string) error {
query := `DELETE FROM plan_solutions WHERE plan_id = $1 AND solution_id = $2`
_, err := r.db.Exec(query, planID, solutionID)
return err
}
func (r *SolutionRepository) GetPlanSolutions(planID string) ([]domain.Solution, error) {
query := `
SELECT s.id, s.name, s.slug, s.icon, s.description, s.is_active, s.created_at, s.updated_at
FROM solutions s
INNER JOIN plan_solutions ps ON s.id = ps.solution_id
WHERE ps.plan_id = $1
ORDER BY s.name
`
rows, err := r.db.Query(query, planID)
if err != nil {
return nil, err
}
defer rows.Close()
var solutions []domain.Solution
for rows.Next() {
var s domain.Solution
err := rows.Scan(
&s.ID, &s.Name, &s.Slug, &s.Icon, &s.Description,
&s.IsActive, &s.CreatedAt, &s.UpdatedAt,
)
if err != nil {
return nil, err
}
solutions = append(solutions, s)
}
return solutions, nil
}
func (r *SolutionRepository) SetPlanSolutions(planID string, solutionIDs []string) error {
// Inicia transação
tx, err := r.db.Begin()
if err != nil {
return err
}
// Remove todas as soluções antigas do plano
_, err = tx.Exec(`DELETE FROM plan_solutions WHERE plan_id = $1`, planID)
if err != nil {
tx.Rollback()
return err
}
// Adiciona as novas soluções
stmt, err := tx.Prepare(`INSERT INTO plan_solutions (plan_id, solution_id) VALUES ($1, $2)`)
if err != nil {
tx.Rollback()
return err
}
defer stmt.Close()
for _, solutionID := range solutionIDs {
_, err = stmt.Exec(planID, solutionID)
if err != nil {
tx.Rollback()
return err
}
}
return tx.Commit()
}
func (r *SolutionRepository) GetTenantSolutions(tenantID string) ([]domain.Solution, error) {
query := `
SELECT DISTINCT s.id, s.name, s.slug, s.icon, s.description, s.is_active, s.created_at, s.updated_at
FROM solutions s
INNER JOIN plan_solutions ps ON s.id = ps.solution_id
INNER JOIN agency_subscriptions asub ON ps.plan_id = asub.plan_id
WHERE asub.agency_id = $1 AND s.is_active = true AND asub.status = 'active'
ORDER BY s.name
`
rows, err := r.db.Query(query, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var solutions []domain.Solution
for rows.Next() {
var s domain.Solution
err := rows.Scan(
&s.ID, &s.Name, &s.Slug, &s.Icon, &s.Description,
&s.IsActive, &s.CreatedAt, &s.UpdatedAt,
)
if err != nil {
return nil, err
}
solutions = append(solutions, s)
}
// Se não encontrou via subscription, retorna array vazio
if solutions == nil {
solutions = []domain.Solution{}
}
return solutions, nil
}

View File

@@ -4,6 +4,7 @@ import (
"aggios-app/backend/internal/config"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/repository"
"database/sql"
"fmt"
"github.com/google/uuid"
@@ -15,14 +16,16 @@ type AgencyService struct {
userRepo *repository.UserRepository
tenantRepo *repository.TenantRepository
cfg *config.Config
db *sql.DB
}
// NewAgencyService creates a new agency service
func NewAgencyService(userRepo *repository.UserRepository, tenantRepo *repository.TenantRepository, cfg *config.Config) *AgencyService {
func NewAgencyService(userRepo *repository.UserRepository, tenantRepo *repository.TenantRepository, cfg *config.Config, db *sql.DB) *AgencyService {
return &AgencyService{
userRepo: userRepo,
tenantRepo: tenantRepo,
cfg: cfg,
db: db,
}
}
@@ -180,6 +183,43 @@ func (s *AgencyService) GetAgencyDetails(id uuid.UUID) (*domain.AgencyDetails, e
details.Admin = admin
}
// Buscar subscription e soluções
var subscription domain.AgencySubscriptionInfo
query := `
SELECT
s.plan_id,
p.name as plan_name,
s.status
FROM agency_subscriptions s
JOIN plans p ON s.plan_id = p.id
WHERE s.agency_id = $1
LIMIT 1
`
err = s.db.QueryRow(query, id).Scan(&subscription.PlanID, &subscription.PlanName, &subscription.Status)
if err == nil {
// Buscar soluções do plano
solutionsQuery := `
SELECT sol.id, sol.name, sol.slug, sol.icon
FROM solutions sol
JOIN plan_solutions ps ON sol.id = ps.solution_id
WHERE ps.plan_id = $1
ORDER BY sol.name
`
rows, err := s.db.Query(solutionsQuery, subscription.PlanID)
if err == nil {
defer rows.Close()
var solutions []domain.Solution
for rows.Next() {
var solution domain.Solution
if err := rows.Scan(&solution.ID, &solution.Name, &solution.Slug, &solution.Icon); err == nil {
solutions = append(solutions, solution)
}
}
subscription.Solutions = solutions
details.Subscription = &subscription
}
}
return details, nil
}

View File

@@ -17,12 +17,14 @@ var (
// TenantService handles tenant business logic
type TenantService struct {
tenantRepo *repository.TenantRepository
db *sql.DB
}
// NewTenantService creates a new tenant service
func NewTenantService(tenantRepo *repository.TenantRepository) *TenantService {
func NewTenantService(tenantRepo *repository.TenantRepository, db *sql.DB) *TenantService {
return &TenantService{
tenantRepo: tenantRepo,
db: db,
}
}
@@ -79,6 +81,84 @@ func (s *TenantService) ListAll() ([]*domain.Tenant, error) {
return s.tenantRepo.FindAll()
}
// ListAllWithDetails retrieves all tenants with their plan and solutions information
func (s *TenantService) ListAllWithDetails() ([]map[string]interface{}, error) {
tenants, err := s.tenantRepo.FindAll()
if err != nil {
return nil, err
}
var result []map[string]interface{}
for _, tenant := range tenants {
tenantData := map[string]interface{}{
"id": tenant.ID,
"name": tenant.Name,
"subdomain": tenant.Subdomain,
"domain": tenant.Domain,
"email": tenant.Email,
"phone": tenant.Phone,
"cnpj": tenant.CNPJ,
"is_active": tenant.IsActive,
"created_at": tenant.CreatedAt,
"logo_url": tenant.LogoURL,
"logo_horizontal_url": tenant.LogoHorizontalURL,
"primary_color": tenant.PrimaryColor,
"secondary_color": tenant.SecondaryColor,
}
// Buscar subscription e soluções
var planName sql.NullString
var planID string
query := `
SELECT
s.plan_id,
p.name as plan_name
FROM agency_subscriptions s
JOIN plans p ON s.plan_id = p.id
WHERE s.agency_id = $1 AND s.status = 'active'
LIMIT 1
`
err = s.db.QueryRow(query, tenant.ID).Scan(&planID, &planName)
if err == nil && planName.Valid {
tenantData["plan_name"] = planName.String
// Buscar soluções do plano
solutionsQuery := `
SELECT sol.id, sol.name, sol.slug, sol.icon
FROM solutions sol
JOIN plan_solutions ps ON sol.id = ps.solution_id
WHERE ps.plan_id = $1
ORDER BY sol.name
`
rows, err := s.db.Query(solutionsQuery, planID)
if err == nil {
defer rows.Close()
var solutions []map[string]interface{}
for rows.Next() {
var id, name, slug string
var icon sql.NullString
if err := rows.Scan(&id, &name, &slug, &icon); err == nil {
solution := map[string]interface{}{
"id": id,
"name": name,
"slug": slug,
}
if icon.Valid {
solution["icon"] = icon.String
}
solutions = append(solutions, solution)
}
}
tenantData["solutions"] = solutions
}
}
result = append(result, tenantData)
}
return result, nil
}
// Delete removes a tenant by ID
func (s *TenantService) Delete(id uuid.UUID) error {
if err := s.tenantRepo.Delete(id); err != nil {

View File

@@ -104,12 +104,15 @@ services:
dockerfile: Dockerfile
container_name: aggios-backend
restart: unless-stopped
ports:
- "8085:8080"
labels:
- "traefik.enable=true"
- "traefik.http.routers.backend.rule=Host(`api.aggios.local`) || Host(`api.localhost`)"
- "traefik.http.routers.backend.entrypoints=web"
- "traefik.http.services.backend.loadbalancer.server.port=8080"
environment:
TZ: America/Sao_Paulo
SERVER_HOST: 0.0.0.0
SERVER_PORT: 8080
JWT_SECRET: ${JWT_SECRET:-Th1s_1s_A_V3ry_S3cur3_JWT_S3cr3t_K3y_2025_Ch@ng3_In_Pr0d!}
@@ -125,6 +128,8 @@ services:
MINIO_PUBLIC_URL: http://files.localhost
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD:-M1n10_S3cur3_P@ss_2025!}
volumes:
- ./backups:/backups
depends_on:
postgres:
condition: service_healthy

186
docs/backup-system.md Normal file
View File

@@ -0,0 +1,186 @@
# 📦 Sistema de Backup & Restore - Aggios
## 🎯 Funcionalidades Implementadas
### Interface Web (Superadmin)
**URL:** `http://dash.localhost/superadmin/backup`
Disponível apenas para usuários com role `superadmin`.
#### Recursos:
1. **Criar Backup**
- Botão para criar novo backup instantâneo
- Mostra nome do arquivo e tamanho
- Mantém automaticamente apenas os últimos 10 backups
2. **Listar Backups**
- Exibe todos os backups disponíveis
- Informações: nome, data, tamanho
- Seleção visual do backup ativo
3. **Restaurar Backup**
- Seleção de backup na lista
- Confirmação de segurança (alerta de sobrescrita)
- Recarrega a página após restauração
4. **Download de Backup**
- Botão de download em cada backup
- Download direto do arquivo .sql
### API Endpoints
#### 1. Listar Backups
```
GET /api/superadmin/backups
Authorization: Bearer {token}
```
**Resposta:**
```json
{
"backups": [
{
"filename": "aggios_backup_2025-12-13_20-23-08.sql",
"size": "20.49 KB",
"date": "13/12/2025 20:23:08",
"timestamp": "2025-12-13_20-23-08"
}
]
}
```
#### 2. Criar Backup
```
POST /api/superadmin/backup/create
Authorization: Bearer {token}
```
**Resposta:**
```json
{
"message": "Backup created successfully",
"filename": "aggios_backup_2025-12-13_20-30-15.sql",
"size": "20.52 KB"
}
```
#### 3. Restaurar Backup
```
POST /api/superadmin/backup/restore
Authorization: Bearer {token}
Content-Type: application/json
{
"filename": "aggios_backup_2025-12-13_20-23-08.sql"
}
```
**Resposta:**
```json
{
"message": "Backup restored successfully"
}
```
#### 4. Download de Backup
```
GET /api/superadmin/backup/download/{filename}
Authorization: Bearer {token}
```
**Resposta:** Arquivo .sql para download
## 📂 Estrutura de Arquivos
```
backups/
├── aggios_backup_2025-12-13_19-56-18.sql
├── aggios_backup_2025-12-13_20-12-49.sql
├── aggios_backup_2025-12-13_20-17-59.sql
└── aggios_backup_2025-12-13_20-23-08.sql (mais recente)
```
## ⚙️ Scripts PowerShell (ainda funcionam!)
### Backup Manual
```powershell
cd g:\Projetos\aggios-app\scripts
.\backup-db.ps1
```
### Restaurar Último Backup
```powershell
cd g:\Projetos\aggios-app\scripts
.\restore-db.ps1
```
## 🔒 Segurança
1. ✅ Apenas superadmins podem acessar
2. ✅ Validação de arquivos (apenas .sql na pasta backups/)
3. ✅ Proteção contra path traversal
4. ✅ Autenticação JWT obrigatória
5. ✅ Confirmação dupla antes de restaurar
## ⚠️ Avisos Importantes
1. **Backup Automático:**
- Ainda não configurado
- Por enquanto, fazer backups manuais antes de `docker-compose down -v`
2. **Limite de Backups:**
- Sistema mantém apenas os **últimos 10 backups**
- Backups antigos são deletados automaticamente
3. **Restauração:**
- ⚠️ **SOBRESCREVE TODOS OS DADOS ATUAIS**
- Sempre peça confirmação dupla
- Cria um backup automático antes? (implementar depois)
## 🚀 Como Usar
1. **Acesse o Superadmin:**
- Login: admin@aggios.app
- Senha: Ag@}O%}Z;if)97o*JOgNMbP2025!
2. **No Menu Lateral:**
- Clique em "Backup & Restore" (ícone de servidor)
3. **Criar Backup:**
- Clique em "Criar Novo Backup"
- Aguarde confirmação
4. **Restaurar:**
- Selecione o backup desejado na lista
- Clique em "Restaurar Backup"
- Confirme o alerta
- Aguarde reload da página
## 🐛 Troubleshooting
### Erro ao criar backup
```bash
# Verificar se o container está rodando
docker ps | grep aggios-postgres
# Verificar logs
docker logs aggios-backend --tail 50
```
### Erro ao restaurar
```bash
# Verificar permissões
ls -la g:\Projetos\aggios-app\backups\
# Testar manualmente
docker exec -i aggios-postgres psql -U aggios aggios_db < backup.sql
```
## 📝 TODO Futuro
- [ ] Backup automático agendado (diário)
- [ ] Backup antes de restaurar (safety)
- [ ] Upload de backup externo
- [ ] Exportar/importar apenas tabelas específicas
- [ ] Histórico de restaurações
- [ ] Notificações por email

View File

@@ -3,6 +3,7 @@
import { DashboardLayout } from '@/components/layout/DashboardLayout';
import { AgencyBranding } from '@/components/layout/AgencyBranding';
import AuthGuard from '@/components/auth/AuthGuard';
import { useState, useEffect } from 'react';
import {
HomeIcon,
RocketLaunchIcon,
@@ -119,10 +120,60 @@ interface AgencyLayoutClientProps {
}
export function AgencyLayoutClient({ children, colors }: AgencyLayoutClientProps) {
const [filteredMenuItems, setFilteredMenuItems] = useState(AGENCY_MENU_ITEMS);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchTenantSolutions = async () => {
try {
console.log('🔍 Buscando soluções do tenant...');
const response = await fetch('/api/tenant/solutions', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
console.log('📡 Response status:', response.status);
if (response.ok) {
const data = await response.json();
console.log('📦 Dados recebidos:', data);
const solutions = data.solutions || [];
console.log('✅ Soluções:', solutions);
// Mapear slugs de solutions para IDs de menu
const solutionSlugs = solutions.map((s: any) => s.slug.toLowerCase());
console.log('🏷️ Slugs das soluções:', solutionSlugs);
// Sempre mostrar dashboard + soluções disponíveis
const filtered = AGENCY_MENU_ITEMS.filter(item => {
if (item.id === 'dashboard') return true;
return solutionSlugs.includes(item.id);
});
console.log('📋 Menu filtrado:', filtered.map(i => i.id));
setFilteredMenuItems(filtered);
} else {
console.error('❌ Erro na resposta:', response.status);
// Em caso de erro, mostrar todos (fallback)
setFilteredMenuItems(AGENCY_MENU_ITEMS);
}
} catch (error) {
console.error('❌ Error fetching solutions:', error);
// Em caso de erro, mostrar todos (fallback)
setFilteredMenuItems(AGENCY_MENU_ITEMS);
} finally {
setLoading(false);
}
};
fetchTenantSolutions();
}, []);
return (
<AuthGuard>
<AgencyBranding colors={colors} />
<DashboardLayout menuItems={AGENCY_MENU_ITEMS}>
<DashboardLayout menuItems={loading ? [AGENCY_MENU_ITEMS[0]] : filteredMenuItems}>
{children}
</DashboardLayout>
</AuthGuard>

View File

@@ -1,10 +1,16 @@
'use client';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
export default function ContratosPage() {
return (
<SolutionGuard requiredSolution="contratos">
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">Contratos</h1>
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-8 text-center">
<p className="text-gray-500">Gestão de Contratos e Assinaturas em breve</p>
</div>
</div>
</SolutionGuard>
);
}

View File

@@ -0,0 +1,548 @@
"use client";
import { Fragment, useEffect, useState } from 'react';
import { Menu, Transition } from '@headlessui/react';
import ConfirmDialog from '@/components/layout/ConfirmDialog';
import { useToast } from '@/components/layout/ToastContext';
import {
UserIcon,
TrashIcon,
PencilIcon,
EllipsisVerticalIcon,
MagnifyingGlassIcon,
PlusIcon,
XMarkIcon,
PhoneIcon,
EnvelopeIcon,
MapPinIcon,
TagIcon,
} from '@heroicons/react/24/outline';
interface Customer {
id: string;
tenant_id: string;
name: string;
email: string;
phone: string;
company: string;
position: string;
address: string;
city: string;
state: string;
zip_code: string;
country: string;
tags: string[];
notes: string;
created_at: string;
updated_at: string;
}
export default function CustomersPage() {
const toast = useToast();
const [customers, setCustomers] = useState<Customer[]>([]);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [customerToDelete, setCustomerToDelete] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [formData, setFormData] = useState({
name: '',
email: '',
phone: '',
company: '',
position: '',
address: '',
city: '',
state: '',
zip_code: '',
country: 'Brasil',
tags: '',
notes: '',
});
useEffect(() => {
fetchCustomers();
}, []);
const fetchCustomers = async () => {
try {
const response = await fetch('/api/crm/customers', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
if (response.ok) {
const data = await response.json();
setCustomers(data.customers || []);
}
} catch (error) {
console.error('Error fetching customers:', error);
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const url = editingCustomer
? `/api/crm/customers/${editingCustomer.id}`
: '/api/crm/customers';
const method = editingCustomer ? 'PUT' : 'POST';
const payload = {
...formData,
tags: formData.tags.split(',').map(t => t.trim()).filter(t => t.length > 0),
};
try {
const response = await fetch(url, {
method,
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (response.ok) {
toast.success(
editingCustomer ? 'Cliente atualizado' : 'Cliente criado',
editingCustomer ? 'O cliente foi atualizado com sucesso.' : 'O novo cliente foi criado com sucesso.'
);
fetchCustomers();
handleCloseModal();
} else {
const error = await response.json();
toast.error('Erro', error.message || 'Não foi possível salvar o cliente.');
}
} catch (error) {
console.error('Error saving customer:', error);
toast.error('Erro', 'Ocorreu um erro ao salvar o cliente.');
}
};
const handleEdit = (customer: Customer) => {
setEditingCustomer(customer);
setFormData({
name: customer.name,
email: customer.email,
phone: customer.phone,
company: customer.company,
position: customer.position,
address: customer.address,
city: customer.city,
state: customer.state,
zip_code: customer.zip_code,
country: customer.country,
tags: customer.tags?.join(', ') || '',
notes: customer.notes,
});
setIsModalOpen(true);
};
const handleDeleteClick = (id: string) => {
setCustomerToDelete(id);
setConfirmOpen(true);
};
const handleConfirmDelete = async () => {
if (!customerToDelete) return;
try {
const response = await fetch(`/api/crm/customers/${customerToDelete}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
if (response.ok) {
setCustomers(customers.filter(c => c.id !== customerToDelete));
toast.success('Cliente excluído', 'O cliente foi excluído com sucesso.');
} else {
toast.error('Erro ao excluir', 'Não foi possível excluir o cliente.');
}
} catch (error) {
console.error('Error deleting customer:', error);
toast.error('Erro ao excluir', 'Ocorreu um erro ao excluir o cliente.');
} finally {
setConfirmOpen(false);
setCustomerToDelete(null);
}
};
const handleCloseModal = () => {
setIsModalOpen(false);
setEditingCustomer(null);
setFormData({
name: '',
email: '',
phone: '',
company: '',
position: '',
address: '',
city: '',
state: '',
zip_code: '',
country: 'Brasil',
tags: '',
notes: '',
});
};
const filteredCustomers = customers.filter((customer) => {
const searchLower = searchTerm.toLowerCase();
return (
(customer.name?.toLowerCase() || '').includes(searchLower) ||
(customer.email?.toLowerCase() || '').includes(searchLower) ||
(customer.company?.toLowerCase() || '').includes(searchLower) ||
(customer.phone?.toLowerCase() || '').includes(searchLower)
);
});
return (
<div className="p-6 max-w-[1600px] mx-auto space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white tracking-tight">Clientes</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
Gerencie seus clientes e contatos
</p>
</div>
<button
onClick={() => setIsModalOpen(true)}
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium text-white rounded-lg hover:opacity-90 transition-opacity"
style={{ background: 'var(--gradient)' }}
>
<PlusIcon className="w-4 h-4" />
Novo Cliente
</button>
</div>
{/* Search */}
<div className="relative w-full lg:w-96">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<MagnifyingGlassIcon className="h-5 w-5 text-zinc-400" aria-hidden="true" />
</div>
<input
type="text"
className="block w-full pl-10 pr-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg leading-5 bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:outline-none focus:ring-1 focus:ring-[var(--brand-color)] focus:border-[var(--brand-color)] sm:text-sm transition duration-150 ease-in-out"
placeholder="Buscar por nome, email, empresa..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
{/* Table */}
{loading ? (
<div className="flex items-center justify-center h-64 bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--brand-color)]"></div>
</div>
) : filteredCustomers.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">
<UserIcon className="w-8 h-8 text-zinc-400" />
</div>
<h3 className="text-lg font-medium text-zinc-900 dark:text-white mb-1">
Nenhum cliente encontrado
</h3>
<p className="text-zinc-500 dark:text-zinc-400 max-w-sm mx-auto">
{searchTerm ? 'Nenhum cliente corresponde à sua busca.' : 'Comece adicionando seu primeiro cliente.'}
</p>
</div>
) : (
<div className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-zinc-50/50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-800">
<th className="px-6 py-4 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Cliente</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Empresa</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">Tags</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">
{filteredCustomers.map((customer) => (
<tr key={customer.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-3">
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold text-sm"
style={{ background: 'var(--gradient)' }}
>
{customer.name.substring(0, 2).toUpperCase()}
</div>
<div>
<div className="text-sm font-semibold text-zinc-900 dark:text-white">
{customer.name}
</div>
{customer.position && (
<div className="text-xs text-zinc-500 dark:text-zinc-400">
{customer.position}
</div>
)}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-zinc-700 dark:text-zinc-300">
{customer.company || '-'}
</td>
<td className="px-6 py-4 text-sm text-zinc-600 dark:text-zinc-400">
<div className="space-y-1">
{customer.email && (
<div className="flex items-center gap-2">
<EnvelopeIcon className="w-4 h-4 text-zinc-400" />
<span>{customer.email}</span>
</div>
)}
{customer.phone && (
<div className="flex items-center gap-2">
<PhoneIcon className="w-4 h-4 text-zinc-400" />
<span>{customer.phone}</span>
</div>
)}
</div>
</td>
<td className="px-6 py-4">
<div className="flex flex-wrap gap-1">
{customer.tags && customer.tags.length > 0 ? (
customer.tags.slice(0, 3).map((tag, idx) => (
<span
key={idx}
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
>
{tag}
</span>
))
) : (
<span className="text-xs text-zinc-400">-</span>
)}
{customer.tags && customer.tags.length > 3 && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400">
+{customer.tags.length - 3}
</span>
)}
</div>
</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 }) => (
<button
onClick={() => handleEdit(customer)}
className={`${active ? 'bg-zinc-50 dark:bg-zinc-800' : ''
} group flex w-full items-center rounded-lg px-2 py-2 text-sm text-zinc-700 dark:text-zinc-300`}
>
<PencilIcon className="mr-2 h-4 w-4 text-zinc-400" />
Editar
</button>
)}
</Menu.Item>
</div>
<div className="px-1 py-1">
<Menu.Item>
{({ active }) => (
<button
onClick={() => handleDeleteClick(customer.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>
</div>
)}
{/* Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<div className="fixed inset-0 bg-zinc-900/40 backdrop-blur-sm transition-opacity" onClick={handleCloseModal}></div>
<div className="relative transform overflow-hidden rounded-2xl bg-white dark:bg-zinc-900 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl border border-zinc-200 dark:border-zinc-800">
<div className="absolute right-0 top-0 pr-6 pt-6">
<button
type="button"
className="rounded-lg p-1.5 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
onClick={handleCloseModal}
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 sm:p-8">
<div className="flex items-start gap-4 mb-6">
<div
className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl shadow-lg"
style={{ background: 'var(--gradient)' }}
>
<UserIcon className="h-6 w-6 text-white" />
</div>
<div>
<h3 className="text-xl font-bold text-zinc-900 dark:text-white">
{editingCustomer ? 'Editar Cliente' : 'Novo Cliente'}
</h3>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
{editingCustomer ? 'Atualize as informações do cliente.' : 'Adicione um novo cliente ao seu CRM.'}
</p>
</div>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Nome Completo *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Email
</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Telefone
</label>
<input
type="tel"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Empresa
</label>
<input
type="text"
value={formData.company}
onChange={(e) => setFormData({ ...formData, company: e.target.value })}
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Cargo
</label>
<input
type="text"
value={formData.position}
onChange={(e) => setFormData({ ...formData, position: e.target.value })}
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Tags <span className="text-xs font-normal text-zinc-500">(separadas por vírgula)</span>
</label>
<input
type="text"
value={formData.tags}
onChange={(e) => setFormData({ ...formData, tags: e.target.value })}
placeholder="vip, premium, lead-quente"
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Observações
</label>
<textarea
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
rows={3}
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent resize-none transition-all"
/>
</div>
</div>
</div>
<div className="mt-8 pt-6 border-t border-zinc-200 dark:border-zinc-700 flex gap-3">
<button
type="button"
onClick={handleCloseModal}
className="flex-1 px-4 py-2.5 border border-zinc-200 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 font-medium rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
Cancelar
</button>
<button
type="submit"
className="flex-1 px-4 py-2.5 text-white font-medium rounded-lg transition-all shadow-lg hover:shadow-xl"
style={{ background: 'var(--gradient)' }}
>
{editingCustomer ? 'Atualizar' : 'Criar Cliente'}
</button>
</div>
</form>
</div>
</div>
</div>
)}
<ConfirmDialog
isOpen={confirmOpen}
onClose={() => {
setConfirmOpen(false);
setCustomerToDelete(null);
}}
onConfirm={handleConfirmDelete}
title="Excluir Cliente"
message="Tem certeza que deseja excluir este cliente? Esta ação não pode ser desfeita."
confirmText="Excluir"
cancelText="Cancelar"
variant="danger"
/>
</div>
);
}

View File

@@ -0,0 +1,31 @@
"use client";
import { FunnelIcon } from '@heroicons/react/24/outline';
export default function FunisPage() {
return (
<div className="p-6 h-full flex items-center justify-center">
<div className="text-center max-w-md">
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-blue-500 to-purple-600">
<FunnelIcon className="h-10 w-10 text-white" />
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Funis de Vendas
</h1>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Esta funcionalidade está em desenvolvimento
</p>
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
<div className="flex gap-1">
<span className="animate-bounce inline-block h-2 w-2 rounded-full bg-blue-600" style={{ animationDelay: '0ms' }}></span>
<span className="animate-bounce inline-block h-2 w-2 rounded-full bg-blue-600" style={{ animationDelay: '150ms' }}></span>
<span className="animate-bounce inline-block h-2 w-2 rounded-full bg-blue-600" style={{ animationDelay: '300ms' }}></span>
</div>
<span className="text-sm font-medium text-blue-600 dark:text-blue-400">
Em breve
</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,432 @@
"use client";
import { Fragment, useEffect, useState } from 'react';
import { Menu, Transition } from '@headlessui/react';
import ConfirmDialog from '@/components/layout/ConfirmDialog';
import { useToast } from '@/components/layout/ToastContext';
import {
ListBulletIcon,
TrashIcon,
PencilIcon,
EllipsisVerticalIcon,
MagnifyingGlassIcon,
PlusIcon,
XMarkIcon,
UserGroupIcon,
} from '@heroicons/react/24/outline';
interface List {
id: string;
tenant_id: string;
name: string;
description: string;
color: string;
customer_count: number;
created_at: string;
updated_at: string;
}
const COLORS = [
{ name: 'Azul', value: '#3B82F6' },
{ name: 'Verde', value: '#10B981' },
{ name: 'Roxo', value: '#8B5CF6' },
{ name: 'Rosa', value: '#EC4899' },
{ name: 'Laranja', value: '#F97316' },
{ name: 'Amarelo', value: '#EAB308' },
{ name: 'Vermelho', value: '#EF4444' },
{ name: 'Cinza', value: '#6B7280' },
];
export default function ListsPage() {
const toast = useToast();
const [lists, setLists] = useState<List[]>([]);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingList, setEditingList] = useState<List | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [listToDelete, setListToDelete] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [formData, setFormData] = useState({
name: '',
description: '',
color: COLORS[0].value,
});
useEffect(() => {
fetchLists();
}, []);
const fetchLists = async () => {
try {
const response = await fetch('/api/crm/lists', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
if (response.ok) {
const data = await response.json();
setLists(data.lists || []);
}
} catch (error) {
console.error('Error fetching lists:', error);
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const url = editingList
? `/api/crm/lists/${editingList.id}`
: '/api/crm/lists';
const method = editingList ? 'PUT' : 'POST';
try {
const response = await fetch(url, {
method,
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(formData),
});
if (response.ok) {
toast.success(
editingList ? 'Lista atualizada' : 'Lista criada',
editingList ? 'A lista foi atualizada com sucesso.' : 'A nova lista foi criada com sucesso.'
);
fetchLists();
handleCloseModal();
} else {
const error = await response.json();
toast.error('Erro', error.message || 'Não foi possível salvar a lista.');
}
} catch (error) {
console.error('Error saving list:', error);
toast.error('Erro', 'Ocorreu um erro ao salvar a lista.');
}
};
const handleEdit = (list: List) => {
setEditingList(list);
setFormData({
name: list.name,
description: list.description,
color: list.color,
});
setIsModalOpen(true);
};
const handleDeleteClick = (id: string) => {
setListToDelete(id);
setConfirmOpen(true);
};
const handleConfirmDelete = async () => {
if (!listToDelete) return;
try {
const response = await fetch(`/api/crm/lists/${listToDelete}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
if (response.ok) {
setLists(lists.filter(l => l.id !== listToDelete));
toast.success('Lista excluída', 'A lista foi excluída com sucesso.');
} else {
toast.error('Erro ao excluir', 'Não foi possível excluir a lista.');
}
} catch (error) {
console.error('Error deleting list:', error);
toast.error('Erro ao excluir', 'Ocorreu um erro ao excluir a lista.');
} finally {
setConfirmOpen(false);
setListToDelete(null);
}
};
const handleCloseModal = () => {
setIsModalOpen(false);
setEditingList(null);
setFormData({
name: '',
description: '',
color: COLORS[0].value,
});
};
const filteredLists = lists.filter((list) => {
const searchLower = searchTerm.toLowerCase();
return (
(list.name?.toLowerCase() || '').includes(searchLower) ||
(list.description?.toLowerCase() || '').includes(searchLower)
);
});
return (
<div className="p-6 max-w-[1600px] mx-auto space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white tracking-tight">Listas</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
Organize seus clientes em listas personalizadas
</p>
</div>
<button
onClick={() => setIsModalOpen(true)}
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium text-white rounded-lg hover:opacity-90 transition-opacity"
style={{ background: 'var(--gradient)' }}
>
<PlusIcon className="w-4 h-4" />
Nova Lista
</button>
</div>
{/* Search */}
<div className="relative w-full lg:w-96">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<MagnifyingGlassIcon className="h-5 w-5 text-zinc-400" aria-hidden="true" />
</div>
<input
type="text"
className="block w-full pl-10 pr-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg leading-5 bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:outline-none focus:ring-1 focus:ring-[var(--brand-color)] focus:border-[var(--brand-color)] sm:text-sm transition duration-150 ease-in-out"
placeholder="Buscar listas..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
{/* Grid */}
{loading ? (
<div className="flex items-center justify-center h-64 bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--brand-color)]"></div>
</div>
) : filteredLists.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 text-center p-8">
<div className="w-16 h-16 bg-zinc-50 dark:bg-zinc-800 rounded-full flex items-center justify-center mb-4">
<ListBulletIcon className="w-8 h-8 text-zinc-400" />
</div>
<h3 className="text-lg font-medium text-zinc-900 dark:text-white mb-1">
Nenhuma lista encontrada
</h3>
<p className="text-zinc-500 dark:text-zinc-400 max-w-sm mx-auto">
{searchTerm ? 'Nenhuma lista corresponde à sua busca.' : 'Comece criando sua primeira lista.'}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredLists.map((list) => (
<div
key={list.id}
className="group relative bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 p-6 hover:shadow-lg transition-all"
>
{/* Color indicator */}
<div
className="absolute top-0 left-0 w-1 h-full rounded-l-xl"
style={{ backgroundColor: list.color }}
/>
<div className="flex items-start justify-between mb-4 pl-3">
<div className="flex items-center gap-3">
<div
className="w-12 h-12 rounded-lg flex items-center justify-center text-white"
style={{ backgroundColor: list.color }}
>
<ListBulletIcon className="w-6 h-6" />
</div>
<div>
<h3 className="text-lg font-semibold text-zinc-900 dark:text-white">
{list.name}
</h3>
<div className="flex items-center gap-1 mt-1 text-sm text-zinc-500 dark:text-zinc-400">
<UserGroupIcon className="w-4 h-4" />
<span>{list.customer_count || 0} clientes</span>
</div>
</div>
</div>
<Menu as="div" className="relative">
<Menu.Button className="p-1.5 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 transition-colors outline-none opacity-0 group-hover:opacity-100">
<EllipsisVerticalIcon className="w-5 h-5" />
</Menu.Button>
<Menu.Items
transition
portal
anchor="bottom end"
className="w-48 origin-top-right divide-y divide-zinc-100 dark:divide-zinc-800 rounded-xl bg-white dark:bg-zinc-900 focus:outline-none z-50 border border-zinc-200 dark:border-zinc-800 [--anchor-gap:8px] transition duration-100 ease-out data-[closed]:scale-95 data-[closed]:opacity-0"
>
<div className="px-1 py-1">
<Menu.Item>
{({ active }) => (
<button
onClick={() => handleEdit(list)}
className={`${active ? 'bg-zinc-50 dark:bg-zinc-800' : ''
} group flex w-full items-center rounded-lg px-2 py-2 text-sm text-zinc-700 dark:text-zinc-300`}
>
<PencilIcon className="mr-2 h-4 w-4 text-zinc-400" />
Editar
</button>
)}
</Menu.Item>
</div>
<div className="px-1 py-1">
<Menu.Item>
{({ active }) => (
<button
onClick={() => handleDeleteClick(list.id)}
className={`${active ? 'bg-red-50 dark:bg-red-900/20' : ''
} group flex w-full items-center rounded-lg px-2 py-2 text-sm text-red-600 dark:text-red-400`}
>
<TrashIcon className="mr-2 h-4 w-4" />
Excluir
</button>
)}
</Menu.Item>
</div>
</Menu.Items>
</Menu>
</div>
{list.description && (
<p className="text-sm text-zinc-600 dark:text-zinc-400 pl-3 line-clamp-2">
{list.description}
</p>
)}
</div>
))}
</div>
)}
{/* Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<div className="fixed inset-0 bg-zinc-900/40 backdrop-blur-sm transition-opacity" onClick={handleCloseModal}></div>
<div className="relative transform overflow-hidden rounded-2xl bg-white dark:bg-zinc-900 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg border border-zinc-200 dark:border-zinc-800">
<div className="absolute right-0 top-0 pr-6 pt-6">
<button
type="button"
className="rounded-lg p-1.5 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
onClick={handleCloseModal}
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 sm:p-8">
<div className="flex items-start gap-4 mb-6">
<div
className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl shadow-lg"
style={{ backgroundColor: formData.color }}
>
<ListBulletIcon className="h-6 w-6 text-white" />
</div>
<div>
<h3 className="text-xl font-bold text-zinc-900 dark:text-white">
{editingList ? 'Editar Lista' : 'Nova Lista'}
</h3>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
{editingList ? 'Atualize as informações da lista.' : 'Crie uma nova lista para organizar seus clientes.'}
</p>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Nome da Lista *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Ex: Clientes VIP"
required
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Descrição
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Descreva o propósito desta lista"
rows={3}
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent resize-none transition-all"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-3">
Cor
</label>
<div className="grid grid-cols-8 gap-2">
{COLORS.map((color) => (
<button
key={color.value}
type="button"
onClick={() => setFormData({ ...formData, color: color.value })}
className={`w-10 h-10 rounded-lg transition-all ${formData.color === color.value
? 'ring-2 ring-offset-2 ring-zinc-400 dark:ring-zinc-600 scale-110'
: 'hover:scale-105'
}`}
style={{ backgroundColor: color.value }}
title={color.name}
/>
))}
</div>
</div>
</div>
<div className="mt-8 pt-6 border-t border-zinc-200 dark:border-zinc-700 flex gap-3">
<button
type="button"
onClick={handleCloseModal}
className="flex-1 px-4 py-2.5 border border-zinc-200 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 font-medium rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
Cancelar
</button>
<button
type="submit"
className="flex-1 px-4 py-2.5 text-white font-medium rounded-lg transition-all shadow-lg hover:shadow-xl"
style={{ background: 'var(--gradient)' }}
>
{editingList ? 'Atualizar' : 'Criar Lista'}
</button>
</div>
</form>
</div>
</div>
</div>
)}
<ConfirmDialog
isOpen={confirmOpen}
onClose={() => {
setConfirmOpen(false);
setListToDelete(null);
}}
onConfirm={handleConfirmDelete}
title="Excluir Lista"
message="Tem certeza que deseja excluir esta lista? Os clientes não serão excluídos, apenas removidos da lista."
confirmText="Excluir"
cancelText="Cancelar"
variant="danger"
/>
</div>
);
}

View File

@@ -0,0 +1,31 @@
"use client";
import { CurrencyDollarIcon } from '@heroicons/react/24/outline';
export default function NegociacoesPage() {
return (
<div className="p-6 h-full flex items-center justify-center">
<div className="text-center max-w-md">
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-green-500 to-emerald-600">
<CurrencyDollarIcon className="h-10 w-10 text-white" />
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Negociações
</h1>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Esta funcionalidade está em desenvolvimento
</p>
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
<div className="flex gap-1">
<span className="animate-bounce inline-block h-2 w-2 rounded-full bg-green-600" style={{ animationDelay: '0ms' }}></span>
<span className="animate-bounce inline-block h-2 w-2 rounded-full bg-green-600" style={{ animationDelay: '150ms' }}></span>
<span className="animate-bounce inline-block h-2 w-2 rounded-full bg-green-600" style={{ animationDelay: '300ms' }}></span>
</div>
<span className="text-sm font-medium text-green-600 dark:text-green-400">
Em breve
</span>
</div>
</div>
</div>
);
}

View File

@@ -1,10 +1,14 @@
"use client";
import Link from 'next/link';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
import {
UsersIcon,
CurrencyDollarIcon,
ChartPieIcon,
ArrowTrendingUpIcon,
ListBulletIcon,
ArrowRightIcon,
} from '@heroicons/react/24/outline';
export default function CRMPage() {
@@ -15,12 +19,30 @@ export default function CRMPage() {
{ name: 'Crescimento', value: '+12%', icon: ArrowTrendingUpIcon, color: 'orange' },
];
const quickLinks = [
{
name: 'Clientes',
description: 'Gerencie seus contatos e clientes',
icon: UsersIcon,
href: '/crm/clientes',
color: 'blue',
},
{
name: 'Listas',
description: 'Organize clientes em listas',
icon: ListBulletIcon,
href: '/crm/listas',
color: 'purple',
},
];
return (
<SolutionGuard requiredSolution="crm">
<div className="p-6 h-full overflow-auto">
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Mission Control (CRM)
CRM
</h1>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
Visão geral do relacionamento com clientes
@@ -57,6 +79,46 @@ export default function CRMPage() {
})}
</div>
{/* Quick Links */}
<div className="mb-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Acesso Rápido
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{quickLinks.map((link) => {
const Icon = link.icon;
return (
<Link
key={link.name}
href={link.href}
className="group relative overflow-hidden rounded-xl bg-white dark:bg-gray-900 p-6 border border-gray-200 dark:border-gray-800 hover:border-gray-300 dark:hover:border-gray-700 transition-all hover:shadow-lg"
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<div
className={`rounded-lg p-3 bg-${link.color}-100 dark:bg-${link.color}-900/20`}
>
<Icon
className={`h-6 w-6 text-${link.color}-600 dark:text-${link.color}-400`}
/>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">
{link.name}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{link.description}
</p>
</div>
</div>
<ArrowRightIcon className="w-5 h-5 text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300 group-hover:translate-x-1 transition-all" />
</div>
</Link>
);
})}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="rounded-xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-6 h-64 flex items-center justify-center">
<p className="text-gray-500">Funil de Vendas (Em breve)</p>
@@ -67,5 +129,6 @@ export default function CRMPage() {
</div>
</div>
</div>
</SolutionGuard>
);
}

View File

@@ -128,7 +128,43 @@ export default function DashboardPage() {
</div>
{/* Top Stats */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div>
{/* Mobile: Scroll Horizontal */}
<div className="md:hidden overflow-x-auto scrollbar-hide">
<div className="flex gap-4 min-w-max">
{overviewStats.map((stat) => {
const Icon = stat.icon;
return (
<div
key={stat.name}
className="relative overflow-hidden rounded-xl bg-white dark:bg-zinc-900 p-4 border border-gray-200 dark:border-zinc-800 shadow-sm w-[280px] flex-shrink-0"
>
<div className="flex items-center justify-between">
<div className={`rounded-lg p-2 bg-${stat.color}-50 dark:bg-${stat.color}-900/20`}>
<Icon className={`h-6 w-6 text-${stat.color}-600 dark:text-${stat.color}-400`} />
</div>
<div className={`flex items-baseline text-sm font-semibold ${stat.changeType === 'increase' ? 'text-green-600' : 'text-red-600'
}`}>
{stat.changeType === 'increase' ? (
<ArrowTrendingUpIcon className="h-4 w-4 mr-1" />
) : (
<ArrowTrendingDownIcon className="h-4 w-4 mr-1" />
)}
{stat.change}
</div>
</div>
<div className="mt-4">
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{stat.name}</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">{stat.value}</p>
</div>
</div>
);
})}
</div>
</div>
{/* Desktop: Grid */}
<div className="hidden md:grid md:grid-cols-2 lg:grid-cols-4 gap-4">
{overviewStats.map((stat) => {
const Icon = stat.icon;
return (
@@ -158,6 +194,7 @@ export default function DashboardPage() {
);
})}
</div>
</div>
{/* Modules Grid */}
<div>

View File

@@ -1,10 +1,16 @@
'use client';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
export default function DocumentosPage() {
return (
<SolutionGuard requiredSolution="documentos">
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">Documentos</h1>
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-8 text-center">
<p className="text-gray-500">Gestão Eletrônica de Documentos (GED) em breve</p>
</div>
</div>
</SolutionGuard>
);
}

View File

@@ -1,10 +1,16 @@
'use client';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
export default function ERPPage() {
return (
<SolutionGuard requiredSolution="erp">
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">ERP</h1>
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-8 text-center">
<p className="text-gray-500">Sistema Integrado de Gestão Empresarial em breve</p>
</div>
</div>
</SolutionGuard>
);
}

View File

@@ -1,10 +1,16 @@
'use client';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
export default function HelpdeskPage() {
return (
<SolutionGuard requiredSolution="helpdesk">
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">Helpdesk</h1>
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-8 text-center">
<p className="text-gray-500">Central de Suporte e Chamados em breve</p>
</div>
</div>
</SolutionGuard>
);
}

View File

@@ -1,10 +1,16 @@
'use client';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
export default function PagamentosPage() {
return (
<SolutionGuard requiredSolution="pagamentos">
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">Pagamentos</h1>
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-8 text-center">
<p className="text-gray-500">Gestão de Pagamentos e Cobranças em breve</p>
</div>
</div>
</SolutionGuard>
);
}

View File

@@ -1,10 +1,16 @@
'use client';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
export default function ProjetosPage() {
return (
<SolutionGuard requiredSolution="projetos">
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">Projetos</h1>
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-8 text-center">
<p className="text-gray-500">Gestão de Projetos em breve</p>
</div>
</div>
</SolutionGuard>
);
}

View File

@@ -1,10 +1,16 @@
'use client';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
export default function SocialPage() {
return (
<SolutionGuard requiredSolution="social">
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">Gestão de Redes Sociais</h1>
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-8 text-center">
<p className="text-gray-500">Planejamento e Publicação de Posts em breve</p>
</div>
</div>
</SolutionGuard>
);
}

View File

@@ -0,0 +1,7 @@
'use client';
import { ToastProvider } from '@/components/layout/ToastContext';
export function ClientProviders({ children }: { children: React.ReactNode }) {
return <ToastProvider>{children}</ToastProvider>;
}

View File

@@ -67,16 +67,16 @@ html.dark {
}
::selection {
background-color: var(--color-brand-500);
color: var(--color-text-inverse);
background-color: var(--color-brand-100);
color: var(--color-text-primary);
}
/* Seleção em campos de formulário usa o gradiente padrão da marca */
/* Seleção em campos de formulário usa cor mais visível */
input::selection,
textarea::selection,
select::selection {
background: var(--color-gradient-brand);
color: var(--color-text-inverse);
background-color: var(--color-brand-200);
color: var(--color-text-primary);
}
.surface-card {
@@ -181,3 +181,14 @@ html.dark {
@apply bg-background text-foreground;
}
}
@layer utilities {
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
}

View File

@@ -4,6 +4,7 @@ import "./globals.css";
import LayoutWrapper from "./LayoutWrapper";
import { ThemeProvider } from "next-themes";
import { getAgencyLogo } from "@/lib/server-api";
import { ClientProviders } from "./ClientProviders";
const arimo = Arimo({
variable: "--font-arimo",
@@ -54,9 +55,11 @@ export default function RootLayout({
</head>
<body className={`${arimo.variable} ${openSans.variable} ${firaCode.variable} antialiased`} suppressHydrationWarning>
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
<ClientProviders>
<LayoutWrapper>
{children}
</LayoutWrapper>
</ClientProviders>
</ThemeProvider>
</body>
</html>

View File

@@ -40,6 +40,18 @@ export default function LoginPage() {
setSubdomain(sub);
setIsSuperAdmin(superAdmin);
// Verificar se tem parâmetro de erro de tenant não encontrado
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('error') === 'tenant_not_found') {
console.log('⚠️ Tenant não encontrado, limpando autenticação...');
clearAuth();
localStorage.removeItem('agency-logo-url');
localStorage.removeItem('agency-primary-color');
localStorage.removeItem('agency-secondary-color');
setErrorMessage('Esta agência não existe mais ou foi desativada.');
return;
}
if (isAuthenticated()) {
// Validar token antes de redirecionar para evitar loops
const token = getToken();

View File

@@ -12,6 +12,18 @@
--brand-rgb: 255 58 5;
--brand-strong-rgb: 255 0 128;
/* Escala de cores da marca */
--color-brand-50: #fff1f0;
--color-brand-100: #ffe0dd;
--color-brand-200: #ffc7c0;
--color-brand-300: #ffa094;
--color-brand-400: #ff6b57;
--color-brand-500: #ff3a05;
--color-brand-600: #ff0080;
--color-brand-700: #d6006a;
--color-brand-800: #ad0058;
--color-brand-900: #8a004a;
/* Superfícies e tipografia */
--color-surface-light: #ffffff;
--color-surface-dark: #0a0a0a;
@@ -52,5 +64,17 @@
--color-text-primary: #f8fafc;
--color-text-secondary: #cbd5f5;
--color-text-inverse: #0f172a;
/* Cores da marca com maior contraste para dark mode */
--color-brand-50: #4a0029;
--color-brand-100: #660037;
--color-brand-200: #8a004a;
--color-brand-300: #ad0058;
--color-brand-400: #d6006a;
--color-brand-500: #ff0080;
--color-brand-600: #ff3a05;
--color-brand-700: #ff6b57;
--color-brand-800: #ffa094;
--color-brand-900: #ffc7c0;
}
}

View File

@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { isAuthenticated } from '@/lib/auth';
import { isAuthenticated, clearAuth } from '@/lib/auth';
export default function AuthGuard({ children }: { children: React.ReactNode }) {
const router = useRouter();
@@ -22,9 +22,9 @@ export default function AuthGuard({ children }: { children: React.ReactNode }) {
if (!isAuth) {
setAuthorized(false);
// Evitar redirect loop se já estiver no login (embora o AuthGuard deva ser usado apenas em rotas protegidas)
// Evitar redirect loop se já estiver no login
if (pathname !== '/login') {
router.push('/login');
router.push('/login?error=unauthorized');
}
} else {
setAuthorized(true);
@@ -33,7 +33,7 @@ export default function AuthGuard({ children }: { children: React.ReactNode }) {
checkAuth();
// Opcional: Adicionar listener para storage events para logout em outras abas
// Listener para logout em outras abas
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'token' || e.key === 'user') {
checkAuth();
@@ -44,8 +44,7 @@ export default function AuthGuard({ children }: { children: React.ReactNode }) {
return () => window.removeEventListener('storage', handleStorageChange);
}, [router, pathname, mounted]);
// Enquanto verifica (ou não está montado), mostra um loading simples
// Isso evita problemas de hidratação mantendo a estrutura DOM consistente
// Enquanto verifica, mostra loading
if (!mounted || authorized === null) {
return (
<div className="flex h-screen w-full items-center justify-center bg-gray-100 dark:bg-zinc-950">

View File

@@ -0,0 +1,74 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { useToast } from '@/components/layout/ToastContext';
interface SolutionGuardProps {
children: React.ReactNode;
requiredSolution: string; // slug da solução (ex: 'crm', 'erp')
}
export function SolutionGuard({ children, requiredSolution }: SolutionGuardProps) {
const router = useRouter();
const pathname = usePathname();
const { error } = useToast();
const [hasAccess, setHasAccess] = useState<boolean | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const checkAccess = async () => {
try {
const response = await fetch('/api/tenant/solutions', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
if (response.ok) {
const data = await response.json();
const solutions = data.solutions || [];
const solutionSlugs = solutions.map((s: any) => s.slug.toLowerCase());
// Dashboard é sempre permitido
if (requiredSolution === 'dashboard') {
setHasAccess(true);
} else {
const hasPermission = solutionSlugs.includes(requiredSolution.toLowerCase());
if (!hasPermission) {
// Mostra toast de aviso
error('Acesso Negado', 'Você não tem acesso a este módulo. Contate o suporte para mais informações.');
// Redireciona imediatamente
router.replace('/dashboard');
return;
}
setHasAccess(hasPermission);
}
} else {
// Em caso de erro, redireciona para segurança
error('Erro de Acesso', 'Não foi possível verificar suas permissões. Contate o suporte.');
router.replace('/dashboard');
return;
}
} catch (err) {
// Em caso de erro, redireciona para segurança
error('Erro de Acesso', 'Não foi possível verificar suas permissões. Contate o suporte.');
router.replace('/dashboard');
return;
} finally {
setLoading(false);
}
};
checkAccess();
}, [requiredSolution, router, pathname, error]);
if (loading || hasAccess === false) {
return null;
}
return <>{children}</>;
}

View File

@@ -97,6 +97,32 @@ export function AgencyBranding({ colors }: AgencyBrandingProps) {
const cachedLogo = localStorage.getItem('agency-logo-url');
if (cachedLogo) {
updateFavicon(cachedLogo);
} else {
// Se não tiver no cache, buscar do backend
const fetchAndUpdateFavicon = async () => {
const token = localStorage.getItem('token');
if (!token) return;
try {
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080/api';
const res = await fetch(`${API_BASE}/agency/profile`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
if (data.logo_url) {
localStorage.setItem('agency-logo-url', data.logo_url);
updateFavicon(data.logo_url);
console.log('✅ Favicon carregado do backend:', data.logo_url);
}
}
} catch (error) {
console.error('❌ Erro ao buscar logo para favicon:', error);
}
};
fetchAndUpdateFavicon();
}
// Listener para atualizações em tempo real

View File

@@ -0,0 +1,123 @@
import { Fragment } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import { ExclamationTriangleIcon, XMarkIcon } from '@heroicons/react/24/outline';
interface ConfirmDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: 'danger' | 'warning' | 'info';
}
export default function ConfirmDialog({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = 'Confirmar',
cancelText = 'Cancelar',
variant = 'danger'
}: ConfirmDialogProps) {
const handleConfirm = () => {
onConfirm();
onClose();
};
const variantStyles = {
danger: {
icon: 'bg-red-100 dark:bg-red-900/20',
iconColor: 'text-red-600 dark:text-red-400',
button: 'bg-red-600 hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-800'
},
warning: {
icon: 'bg-yellow-100 dark:bg-yellow-900/20',
iconColor: 'text-yellow-600 dark:text-yellow-400',
button: 'bg-yellow-600 hover:bg-yellow-700 dark:bg-yellow-700 dark:hover:bg-yellow-800'
},
info: {
icon: 'bg-blue-100 dark:bg-blue-900/20',
iconColor: 'text-blue-600 dark:text-blue-400',
button: 'bg-blue-600 hover:bg-blue-700 dark:bg-blue-700 dark:hover:bg-blue-800'
}
};
const style = variantStyles[variant];
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-lg border border-zinc-200 dark:border-zinc-800">
<div className="p-6">
<div className="flex items-start gap-4">
<div className={`flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl ${style.icon}`}>
<ExclamationTriangleIcon className={`h-6 w-6 ${style.iconColor}`} />
</div>
<div className="flex-1">
<Dialog.Title className="text-lg font-semibold text-zinc-900 dark:text-white">
{title}
</Dialog.Title>
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
{message}
</p>
</div>
<button
onClick={onClose}
className="rounded-lg p-1.5 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
<div className="mt-6 flex gap-3">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2.5 border border-zinc-200 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 font-medium rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
{cancelText}
</button>
<button
type="button"
onClick={handleConfirm}
className={`flex-1 px-4 py-2.5 text-white font-medium rounded-lg transition-colors ${style.button}`}
>
{confirmText}
</button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
}

View File

@@ -4,6 +4,7 @@ import React, { useState } from 'react';
import { usePathname } from 'next/navigation';
import { SidebarRail, MenuItem } from './SidebarRail';
import { TopBar } from './TopBar';
import { MobileBottomBar } from './MobileBottomBar';
interface DashboardLayoutProps {
children: React.ReactNode;
@@ -16,26 +17,36 @@ export const DashboardLayout: React.FC<DashboardLayoutProps> = ({ children, menu
const pathname = usePathname();
return (
<div className="flex h-screen w-full bg-gray-100 dark:bg-zinc-950 text-slate-900 dark:text-slate-100 overflow-hidden p-3 gap-3 transition-colors duration-300">
{/* Sidebar controla seu próprio estado visual via props */}
<div className="flex h-screen w-full bg-gray-100 dark:bg-zinc-950 text-slate-900 dark:text-slate-100 overflow-hidden md:p-3 md:gap-3 transition-colors duration-300">
{/* Sidebar controla seu próprio estado visual via props - Desktop Only */}
<div className="hidden md:flex">
<SidebarRail
isExpanded={isExpanded}
onToggle={() => setIsExpanded(!isExpanded)}
menuItems={menuItems}
/>
</div>
{/* Área de Conteúdo (Children) */}
<main className="flex-1 h-full min-w-0 overflow-hidden flex flex-col bg-white dark:bg-zinc-900 rounded-2xl shadow-lg relative transition-colors duration-300 border border-transparent dark:border-zinc-800">
<main className="flex-1 h-full min-w-0 overflow-hidden flex flex-col bg-gray-50 dark:bg-zinc-900 md:rounded-2xl shadow-lg relative transition-colors duration-300 border border-transparent dark:border-zinc-800"
style={{
backgroundImage: `radial-gradient(circle, rgb(200 200 200 / 0.15) 1px, transparent 1px)`,
backgroundSize: '24px 24px'
}}
>
{/* TopBar com Breadcrumbs e Search */}
<TopBar />
{/* Conteúdo das páginas */}
<div className="flex-1 overflow-auto">
<div className="flex-1 overflow-auto pb-20 md:pb-0">
<div className="max-w-7xl mx-auto w-full h-full">
{children}
</div>
</div>
</main>
{/* Mobile Bottom Bar */}
<MobileBottomBar />
</div>
);
};

View File

@@ -0,0 +1,129 @@
'use client';
import React, { useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
HomeIcon,
RocketLaunchIcon,
Squares2X2Icon
} from '@heroicons/react/24/outline';
import {
HomeIcon as HomeIconSolid,
RocketLaunchIcon as RocketIconSolid,
Squares2X2Icon as GridIconSolid
} from '@heroicons/react/24/solid';
export const MobileBottomBar: React.FC = () => {
const pathname = usePathname();
const [showMoreMenu, setShowMoreMenu] = useState(false);
const isActive = (path: string) => {
if (path === '/dashboard') {
return pathname === '/dashboard';
}
return pathname.startsWith(path);
};
const navItems = [
{
label: 'Início',
path: '/dashboard',
icon: HomeIcon,
iconSolid: HomeIconSolid
},
{
label: 'CRM',
path: '/crm',
icon: RocketLaunchIcon,
iconSolid: RocketIconSolid
},
{
label: 'Mais',
path: '#',
icon: Squares2X2Icon,
iconSolid: GridIconSolid,
onClick: () => setShowMoreMenu(true)
}
];
return (
<>
{/* Bottom Navigation - Mobile Only */}
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-white dark:bg-zinc-900 border-t border-gray-200 dark:border-zinc-800 shadow-lg">
<div className="flex items-center justify-around h-16 px-4">
{navItems.map((item) => {
const active = isActive(item.path);
const Icon = active ? item.iconSolid : item.icon;
if (item.onClick) {
return (
<button
key={item.label}
onClick={item.onClick}
className="flex flex-col items-center justify-center min-w-[70px] h-full gap-1"
>
<Icon className={`w-6 h-6 ${active ? 'text-[var(--brand-color)]' : 'text-gray-500 dark:text-gray-400'}`} />
<span className={`text-xs font-medium ${active ? 'text-[var(--brand-color)]' : 'text-gray-500 dark:text-gray-400'}`}>
{item.label}
</span>
</button>
);
}
return (
<Link
key={item.label}
href={item.path}
className="flex flex-col items-center justify-center min-w-[70px] h-full gap-1"
>
<Icon className={`w-6 h-6 ${active ? 'text-[var(--brand-color)]' : 'text-gray-500 dark:text-gray-400'}`} />
<span className={`text-xs font-medium ${active ? 'text-[var(--brand-color)]' : 'text-gray-500 dark:text-gray-400'}`}>
{item.label}
</span>
</Link>
);
})}
</div>
</nav>
{/* More Menu Modal */}
{showMoreMenu && (
<div className="md:hidden fixed inset-0 z-[100] bg-black/50 backdrop-blur-sm" onClick={() => setShowMoreMenu(false)}>
<div
className="absolute bottom-0 left-0 right-0 bg-white dark:bg-zinc-900 rounded-t-3xl shadow-2xl max-h-[70vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="p-6">
{/* Handle bar */}
<div className="w-12 h-1.5 bg-gray-300 dark:bg-zinc-700 rounded-full mx-auto mb-6" />
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-6">
Todos os Módulos
</h2>
<div className="grid grid-cols-3 gap-4">
<Link
href="/erp"
onClick={() => setShowMoreMenu(false)}
className="flex flex-col items-center gap-3 p-4 rounded-2xl hover:bg-gray-50 dark:hover:bg-zinc-800 transition-colors"
>
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white shadow-lg">
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
</svg>
</div>
<span className="text-sm font-medium text-gray-900 dark:text-white text-center">
ERP
</span>
</Link>
{/* Add more modules here */}
</div>
</div>
</div>
</div>
)}
</>
);
};

View File

@@ -39,7 +39,7 @@ interface SidebarRailProps {
export const SidebarRail: React.FC<SidebarRailProps> = ({
isExpanded,
onToggle,
menuItems,
menuItems
}) => {
const pathname = usePathname();
const router = useRouter();
@@ -167,7 +167,11 @@ export const SidebarRail: React.FC<SidebarRailProps> = ({
const showLabels = isExpanded && !openSubmenu;
return (
<div className={`flex h-full relative z-20 transition-all duration-300 ${openSubmenu ? 'shadow-xl' : 'shadow-lg'} rounded-2xl`} ref={sidebarRef}>
<div className={`
flex h-full relative z-20 transition-all duration-300
${openSubmenu ? 'shadow-xl' : 'shadow-lg'}
rounded-2xl
`} ref={sidebarRef}>
{/* Rail Principal (Ícones + Labels Opcionais) */}
<div
className={`
@@ -182,7 +186,7 @@ export const SidebarRail: React.FC<SidebarRailProps> = ({
{!openSubmenu && (
<button
onClick={onToggle}
className="absolute -right-3 top-8 z-50 flex h-6 w-6 items-center justify-center rounded-full border border-gray-200 bg-white text-gray-500 shadow-sm hover:bg-gray-50 hover:text-gray-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-200 transition-colors"
className="absolute -right-3 top-8 z-50 h-6 w-6 flex items-center justify-center rounded-full border border-gray-200 bg-white text-gray-500 shadow-sm hover:bg-gray-50 hover:text-gray-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-200 transition-colors"
aria-label={isExpanded ? 'Recolher menu' : 'Expandir menu'}
>
{isExpanded ? (
@@ -223,22 +227,13 @@ export const SidebarRail: React.FC<SidebarRailProps> = ({
active={pathname === item.href || (item.href !== '/dashboard' && pathname?.startsWith(item.href))}
onClick={(e: any) => {
if (item.subItems) {
// Se já estiver aberto, fecha e previne navegação (opcional)
// Se já estiver aberto, fecha e previne navegação
if (openSubmenu === item.id) {
// Se quisermos permitir fechar sem navegar:
// e.preventDefault();
// setOpenSubmenu(null);
// Mas se o usuário quer ir para a home do módulo, deixamos navegar.
// O useEffect vai reabrir se a rota for do módulo.
// Para forçar o fechamento, teríamos que ter lógica mais complexa.
// Vamos assumir que clicar no pai sempre leva pra home do pai.
// E o useEffect cuida de abrir o menu.
// Então NÃO fazemos nada aqui se for abrir.
e.preventDefault();
setOpenSubmenu(null);
} else {
// Se for abrir, deixamos o Link navegar.
// O useEffect vai abrir o menu quando a rota mudar.
// NÃO setamos o estado aqui para evitar conflito com a navegação.
// Se estiver fechado, abre o submenu
setOpenSubmenu(item.id);
}
} else {
setOpenSubmenu(null);

View File

@@ -0,0 +1,59 @@
'use client';
import { createContext, useContext, useState, useCallback } from 'react';
import ToastNotification, { Toast } from './ToastNotification';
interface ToastContextType {
showToast: (type: Toast['type'], title: string, message?: string) => void;
success: (title: string, message?: string) => void;
error: (title: string, message?: string) => void;
info: (title: string, message?: string) => void;
}
const ToastContext = createContext<ToastContextType | undefined>(undefined);
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = useCallback((type: Toast['type'], title: string, message?: string) => {
const id = Date.now().toString();
setToasts(prev => [...prev, { id, type, title, message }]);
}, []);
const success = useCallback((title: string, message?: string) => {
showToast('success', title, message);
}, [showToast]);
const error = useCallback((title: string, message?: string) => {
showToast('error', title, message);
}, [showToast]);
const info = useCallback((title: string, message?: string) => {
showToast('info', title, message);
}, [showToast]);
const removeToast = useCallback((id: string) => {
setToasts(prev => prev.filter(toast => toast.id !== id));
}, []);
return (
<ToastContext.Provider value={{ showToast, success, error, info }}>
{children}
<div className="fixed inset-0 z-50 flex items-end justify-end p-4 sm:p-6 pointer-events-none">
<div className="flex w-full flex-col items-end space-y-4 sm:items-end">
{toasts.map(toast => (
<ToastNotification key={toast.id} toast={toast} onClose={removeToast} />
))}
</div>
</div>
</ToastContext.Provider>
);
}
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within ToastProvider');
}
return context;
}

View File

@@ -0,0 +1,100 @@
import { Fragment, useEffect } from 'react';
import { Transition } from '@headlessui/react';
import {
CheckCircleIcon,
XCircleIcon,
InformationCircleIcon,
XMarkIcon
} from '@heroicons/react/24/outline';
export interface Toast {
id: string;
type: 'success' | 'error' | 'info';
title: string;
message?: string;
}
interface ToastNotificationProps {
toast: Toast;
onClose: (id: string) => void;
}
export default function ToastNotification({ toast, onClose }: ToastNotificationProps) {
useEffect(() => {
const timer = setTimeout(() => {
onClose(toast.id);
}, 5000);
return () => clearTimeout(timer);
}, [toast.id, onClose]);
const styles = {
success: {
bg: 'bg-emerald-50 dark:bg-emerald-900/20',
border: 'border-emerald-200 dark:border-emerald-900/30',
icon: 'text-emerald-600 dark:text-emerald-400',
title: 'text-emerald-900 dark:text-emerald-300',
IconComponent: CheckCircleIcon
},
error: {
bg: 'bg-red-50 dark:bg-red-900/20',
border: 'border-red-200 dark:border-red-900/30',
icon: 'text-red-600 dark:text-red-400',
title: 'text-red-900 dark:text-red-300',
IconComponent: XCircleIcon
},
info: {
bg: 'bg-blue-50 dark:bg-blue-900/20',
border: 'border-blue-200 dark:border-blue-900/30',
icon: 'text-blue-600 dark:text-blue-400',
title: 'text-blue-900 dark:text-blue-300',
IconComponent: InformationCircleIcon
}
};
const style = styles[toast.type];
const Icon = style.IconComponent;
return (
<Transition
show={true}
as={Fragment}
enter="transform ease-out duration-300 transition"
enterFrom="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
enterTo="translate-y-0 opacity-100 sm:translate-x-0"
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className={`pointer-events-auto w-full max-w-md rounded-lg border shadow-lg ${style.bg} ${style.border}`}>
<div className="p-4">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 pt-0.5">
<Icon className={`h-5 w-5 ${style.icon}`} />
</div>
<div className="flex-1 min-w-0">
<p className={`text-sm font-semibold ${style.title}`}>
{toast.title}
</p>
{toast.message && (
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
{toast.message}
</p>
)}
</div>
<div className="flex-shrink-0">
<button
type="button"
onClick={() => onClose(toast.id)}
className="inline-flex rounded-md text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--brand-color)] transition-colors"
>
<span className="sr-only">Fechar</span>
<XMarkIcon className="h-4 w-4" />
</button>
</div>
</div>
</div>
</div>
</Transition>
);
}

View File

@@ -1,14 +1,21 @@
'use client';
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { usePathname } from 'next/navigation';
import Link from 'next/link';
import { MagnifyingGlassIcon, ChevronRightIcon, HomeIcon, BellIcon, Cog6ToothIcon } from '@heroicons/react/24/outline';
import CommandPalette from '@/components/ui/CommandPalette';
import { getUser } from '@/lib/auth';
export const TopBar: React.FC = () => {
const pathname = usePathname();
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
const [user, setUser] = useState<any>(null);
useEffect(() => {
const userData = getUser();
setUser(userData);
}, []);
const generateBreadcrumbs = () => {
const paths = pathname?.split('/').filter(Boolean) || [];
@@ -44,10 +51,20 @@ export const TopBar: React.FC = () => {
return (
<>
<div className="bg-white dark:bg-zinc-900 border-b border-gray-200 dark:border-zinc-800 px-6 py-3 flex items-center justify-between transition-colors">
{/* Breadcrumbs */}
<nav className="flex items-center gap-2 text-xs">
{breadcrumbs.map((crumb, index) => {
<div className="bg-white dark:bg-zinc-900 border-b border-gray-200 dark:border-zinc-800 px-4 md:px-6 py-3 flex items-center justify-between transition-colors">
{/* Logo Mobile */}
<Link href="/dashboard" className="md:hidden flex items-center gap-2">
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-white font-bold shrink-0 shadow-md overflow-hidden bg-brand-500">
{user?.logoUrl ? (
<img src={user.logoUrl} alt={user?.company || 'Logo'} className="w-full h-full object-cover" />
) : (
(user?.company?.charAt(0)?.toUpperCase() || 'A')
)}
</div>
</Link>
{/* Breadcrumbs Desktop */}
<nav className="hidden md:flex items-center gap-2 text-xs">{breadcrumbs.map((crumb, index) => {
const Icon = crumb.icon;
const isLast = index === breadcrumbs.length - 1;
@@ -77,14 +94,14 @@ export const TopBar: React.FC = () => {
</nav>
{/* Search Bar Trigger */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 md:gap-4">
<button
onClick={() => setIsCommandPaletteOpen(true)}
className="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-500 dark:text-zinc-400 bg-gray-100 dark:bg-zinc-800 rounded-lg hover:bg-gray-200 dark:hover:bg-zinc-700 transition-colors"
className="flex items-center gap-2 px-2 md:px-3 py-1.5 text-sm text-gray-500 dark:text-zinc-400 bg-gray-100 dark:bg-zinc-800 rounded-lg hover:bg-gray-200 dark:hover:bg-zinc-700 transition-colors"
>
<MagnifyingGlassIcon className="w-4 h-4" />
<span className="hidden sm:inline">Buscar...</span>
<kbd className="hidden sm:inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium text-gray-400 bg-white dark:bg-zinc-900 rounded border border-gray-200 dark:border-zinc-700">
<span className="hidden md:inline">Buscar...</span>
<kbd className="hidden md:inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium text-gray-400 bg-white dark:bg-zinc-900 rounded border border-gray-200 dark:border-zinc-700">
Ctrl K
</kbd>
</button>
@@ -95,7 +112,7 @@ export const TopBar: React.FC = () => {
</button>
<Link
href="/configuracoes"
className="p-2 text-gray-500 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition-colors"
className="flex p-2 text-gray-500 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition-colors"
>
<Cog6ToothIcon className="w-5 h-5" />
</Link>

View File

@@ -26,9 +26,41 @@ interface CommandPaletteProps {
export default function CommandPalette({ isOpen, setIsOpen }: CommandPaletteProps) {
const [query, setQuery] = useState('');
const [availableSolutions, setAvailableSolutions] = useState<string[]>([]);
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
// Buscar soluções disponíveis
useEffect(() => {
const fetchSolutions = async () => {
try {
const response = await fetch('/api/tenant/solutions', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
if (response.ok) {
const data = await response.json();
const solutions = data.solutions || [];
const slugs = solutions.map((s: any) => s.slug.toLowerCase());
setAvailableSolutions(['dashboard', ...slugs]); // Dashboard sempre disponível
} else {
// Fallback: mostrar tudo
setAvailableSolutions(['dashboard', 'crm', 'erp', 'projetos', 'helpdesk', 'pagamentos', 'contratos', 'documentos', 'social']);
}
} catch (error) {
console.error('Erro ao buscar soluções:', error);
// Fallback: mostrar tudo
setAvailableSolutions(['dashboard', 'crm', 'erp', 'projetos', 'helpdesk', 'pagamentos', 'contratos', 'documentos', 'social']);
}
};
if (isOpen) {
fetchSolutions();
}
}, [isOpen]);
// Atalho de teclado (Ctrl+K ou Cmd+K)
useEffect(() => {
const onKeydown = (event: KeyboardEvent) => {
@@ -44,26 +76,31 @@ export default function CommandPalette({ isOpen, setIsOpen }: CommandPaletteProp
}, [setIsOpen]);
const navigation = [
{ name: 'Visão Geral', href: '/dashboard', icon: HomeIcon, category: 'Navegação' },
{ name: 'CRM (Mission Control)', href: '/crm', icon: RocketLaunchIcon, category: 'Navegação' },
{ name: 'ERP', href: '/erp', icon: ChartBarIcon, category: 'Navegação' },
{ name: 'Projetos', href: '/projetos', icon: BriefcaseIcon, category: 'Navegação' },
{ name: 'Helpdesk', href: '/helpdesk', icon: LifebuoyIcon, category: 'Navegação' },
{ name: 'Pagamentos', href: '/pagamentos', icon: CreditCardIcon, category: 'Navegação' },
{ name: 'Contratos', href: '/contratos', icon: DocumentTextIcon, category: 'Navegação' },
{ name: 'Documentos', href: '/documentos', icon: FolderIcon, category: 'Navegação' },
{ name: 'Redes Sociais', href: '/social', icon: ShareIcon, category: 'Navegação' },
{ name: 'Configurações', href: '/configuracoes', icon: Cog6ToothIcon, category: 'Navegação' },
{ name: 'Visão Geral', href: '/dashboard', icon: HomeIcon, category: 'Navegação', solution: 'dashboard' },
{ name: 'CRM', href: '/crm', icon: RocketLaunchIcon, category: 'Navegação', solution: 'crm' },
{ name: 'ERP', href: '/erp', icon: ChartBarIcon, category: 'Navegação', solution: 'erp' },
{ name: 'Projetos', href: '/projetos', icon: BriefcaseIcon, category: 'Navegação', solution: 'projetos' },
{ name: 'Helpdesk', href: '/helpdesk', icon: LifebuoyIcon, category: 'Navegação', solution: 'helpdesk' },
{ name: 'Pagamentos', href: '/pagamentos', icon: CreditCardIcon, category: 'Navegação', solution: 'pagamentos' },
{ name: 'Contratos', href: '/contratos', icon: DocumentTextIcon, category: 'Navegação', solution: 'contratos' },
{ name: 'Documentos', href: '/documentos', icon: FolderIcon, category: 'Navegação', solution: 'documentos' },
{ name: 'Redes Sociais', href: '/social', icon: ShareIcon, category: 'Navegação', solution: 'social' },
{ name: 'Configurações', href: '/configuracoes', icon: Cog6ToothIcon, category: 'Navegação', solution: 'dashboard' },
// Ações
{ name: 'Novo Projeto', href: '/projetos/novo', icon: PlusIcon, category: 'Ações' },
{ name: 'Novo Chamado', href: '/helpdesk/novo', icon: PlusIcon, category: 'Ações' },
{ name: 'Novo Contrato', href: '/contratos/novo', icon: PlusIcon, category: 'Ações' },
{ name: 'Novo Projeto', href: '/projetos/novo', icon: PlusIcon, category: 'Ações', solution: 'projetos' },
{ name: 'Novo Chamado', href: '/helpdesk/novo', icon: PlusIcon, category: 'Ações', solution: 'helpdesk' },
{ name: 'Novo Contrato', href: '/contratos/novo', icon: PlusIcon, category: 'Ações', solution: 'contratos' },
];
// Filtrar por soluções disponíveis
const allowedNavigation = navigation.filter(item =>
availableSolutions.includes(item.solution)
);
const filteredItems =
query === ''
? navigation
: navigation.filter((item) => {
? allowedNavigation
: allowedNavigation.filter((item) => {
return item.name.toLowerCase().includes(query.toLowerCase());
});

View File

@@ -11,12 +11,8 @@ export async function middleware(request: NextRequest) {
const hostnameWithoutPort = hostname.split(':')[0];
const subdomain = hostnameWithoutPort.split('.')[0];
// Rotas públicas que não precisam de validação de tenant
const publicPaths = ['/login', '/cadastro', '/'];
const isPublicPath = publicPaths.some(path => url.pathname === path || url.pathname.startsWith(path + '/'));
// Validar subdomínio de agência ({subdomain}.localhost) apenas se não for rota pública
if (hostname.includes('.') && !isPublicPath) {
// Se tem subdomínio (ex: vivo.localhost), SEMPRE validar se existe
if (hostname.includes('.')) {
try {
const res = await fetch(`${apiBase}/api/tenant/check?subdomain=${subdomain}`, {
cache: 'no-store',
@@ -26,26 +22,25 @@ export async function middleware(request: NextRequest) {
});
if (!res.ok) {
console.error(`Tenant check failed for ${subdomain}: ${res.status}`);
// Se for 404, realmente não existe. Se for 500, pode ser erro temporário.
// Por segurança, vamos redirecionar apenas se tivermos certeza que falhou a validação (ex: 404)
// ou se o backend estiver inalcançável de forma persistente.
// Para evitar loops durante desenvolvimento, vamos permitir passar se for erro de servidor (5xx)
// mas redirecionar se for 404.
console.error(`Tenant check failed for ${subdomain}: ${res.status}`);
if (res.status === 404) {
const baseHost = hostname.split('.').slice(1).join('.') || hostname;
const redirectUrl = new URL(url.toString());
redirectUrl.hostname = baseHost;
redirectUrl.pathname = '/';
console.error(`❌ Tenant ${subdomain} não encontrado - BLOQUEANDO ACESSO`);
// Tenant não existe, redirecionar para página principal (sem subdomínio)
const baseHost = hostname.split('.').slice(1).join('.') || 'localhost';
const redirectUrl = new URL(`http://${baseHost}/`);
return NextResponse.redirect(redirectUrl);
}
}
// Se passou pela validação, tenant existe - continuar
console.log(`✅ Tenant ${subdomain} validado com sucesso`);
} catch (err) {
console.error('Middleware error:', err);
// Em caso de erro de rede (backend fora do ar), permitir carregar a página
// para não travar o frontend completamente (pode mostrar erro na tela depois)
// return NextResponse.next();
console.error('Middleware error:', err);
// Em caso de erro de rede, bloquear por segurança
const baseHost = hostname.split('.').slice(1).join('.') || 'localhost';
const redirectUrl = new URL(`http://${baseHost}/`);
return NextResponse.redirect(redirectUrl);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -67,16 +67,16 @@ html.dark {
}
::selection {
background-color: var(--color-brand-500);
color: var(--color-text-inverse);
background-color: var(--color-brand-100);
color: var(--color-text-primary);
}
/* Seleção em campos de formulário usa o gradiente padrão da marca */
/* Seleção em campos de formulário usa cor mais visível */
input::selection,
textarea::selection,
select::selection {
background: var(--color-gradient-brand);
color: var(--color-text-inverse);
background-color: var(--color-brand-200);
color: var(--color-text-primary);
}
.surface-card {
@@ -181,3 +181,34 @@ html.dark {
@apply bg-background text-foreground;
}
}
/* Animações customizadas para o modal de boas-vindas */
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes progress {
0% {
width: 0%;
}
100% {
width: 100%;
}
}
.animate-fade-in {
animation: fade-in 0.8s ease-out;
}
.animate-progress {
animation: progress 3.5s ease-in-out;
}

View File

@@ -3,6 +3,7 @@ import { Open_Sans, Fira_Code, Arimo } from "next/font/google";
import "./globals.css";
import LayoutWrapper from "./LayoutWrapper";
import { ThemeProvider } from "next-themes";
import { ToastProvider } from "@/components/layout/ToastContext";
const arimo = Arimo({
variable: "--font-arimo",
@@ -39,9 +40,11 @@ export default function RootLayout({
</head>
<body className={`${arimo.variable} ${openSans.variable} ${firaCode.variable} antialiased`}>
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
<ToastProvider>
<LayoutWrapper>
{children}
</LayoutWrapper>
</ToastProvider>
</ThemeProvider>
</body>
</html>

View File

@@ -3,7 +3,6 @@
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';
@@ -52,17 +51,17 @@ export default function LoginPage() {
e.preventDefault();
if (!formData.email) {
toast.error('Por favor, insira seu email');
console.error('Por favor, insira seu email');
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
toast.error('Por favor, insira um email válido');
console.error('Por favor, insira um email válido');
return;
}
if (!formData.password) {
toast.error('Por favor, insira sua senha');
console.error('Por favor, insira sua senha');
return;
}
@@ -90,51 +89,22 @@ export default function LoginPage() {
saveAuth(data.token, data.user);
console.log('Login successful:', data.user);
toast.success('Login realizado com sucesso! Redirecionando...');
console.log('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.');
const errorMsg = error.message || 'Erro ao fazer login. Verifique suas credenciais.';
console.error('Erro no login:', errorMsg);
alert(errorMsg);
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">

View File

@@ -1,9 +1,35 @@
'use client';
import { BuildingOfficeIcon, ArrowLeftIcon, PaintBrushIcon, MapPinIcon } from '@heroicons/react/24/outline';
import {
BuildingOfficeIcon,
ArrowLeftIcon,
PaintBrushIcon,
MapPinIcon,
UserGroupIcon,
ChartBarIcon,
FolderIcon,
LifebuoyIcon,
CreditCardIcon,
DocumentTextIcon,
ArchiveBoxIcon,
ShareIcon
} from '@heroicons/react/24/outline';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import Tabs, { TabItem } from '@/components/ui/Tabs';
// Mapeamento de ícones para cada solução
const SOLUTION_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
'crm': UserGroupIcon,
'erp': ChartBarIcon,
'projetos': FolderIcon,
'helpdesk': LifebuoyIcon,
'pagamentos': CreditCardIcon,
'contratos': DocumentTextIcon,
'documentos': ArchiveBoxIcon,
'social': ShareIcon,
};
interface AgencyTenant {
id: string;
@@ -41,6 +67,17 @@ interface AgencyDetails {
email: string;
name: string;
};
subscription?: {
plan_id: string;
plan_name: string;
status: string;
solutions: Array<{
id: string;
name: string;
slug: string;
icon: string;
}>;
};
access_url: string;
}
@@ -110,76 +147,16 @@ export default function AgencyDetailPage() {
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-200 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">
const tabsConfig: TabItem[] = [
{
name: 'Visão Geral',
icon: BuildingOfficeIcon,
content: (
<div className="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>
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-4">Dados da Empresa</h3>
<div className="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>
@@ -203,15 +180,15 @@ export default function AgencyDetailPage() {
</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">
<div className="border-t border-gray-200 dark:border-gray-800 pt-6"></div>
{/* Localização */}
<div>
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-4 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">
</h3>
<div className="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">
@@ -240,58 +217,13 @@ export default function AgencyDetailPage() {
</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>
<div className="border-t border-gray-200 dark:border-gray-800 pt-6"></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>
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-4">Contato</h3>
<div className="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">Email</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-white break-all">{tenant.email || '-'}</dd>
@@ -300,7 +232,7 @@ export default function AgencyDetailPage() {
<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>
<div className="md:col-span-2">
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Website</dt>
<dd className="mt-1">
{tenant.website ? (
@@ -308,7 +240,7 @@ export default function AgencyDetailPage() {
href={tenant.website}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 dark:text-blue-400 hover:underline break-all"
className="text-sm text-[var(--brand-color)] hover:underline break-all"
>
{tenant.website}
</a>
@@ -318,25 +250,257 @@ export default function AgencyDetailPage() {
</div>
</div>
<div className="border-t border-gray-200 dark:border-gray-800 pt-6"></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">
<div>
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-4">Metadados</h3>
<dl className="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">Criada em</dt>
<dd className="mt-1 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">
<div>
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Última atualização</dt>
<dd className="mt-1 text-sm font-medium text-gray-900 dark:text-white">
{new Date(tenant.updated_at).toLocaleDateString('pt-BR')}
</dd>
</div>
</dl>
</div>
</div>
)
},
{
name: 'Identidade Visual',
icon: PaintBrushIcon,
content: (
<div className="space-y-8">
{/* Cores da Marca */}
<div>
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-4">Cores da Marca</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<dt className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Cor Primária</dt>
<div className="flex items-center gap-4">
<div
className="w-20 h-20 rounded-lg border-2 border-gray-200 dark:border-gray-700 shadow-sm"
style={{ backgroundColor: tenant.primary_color || '#000000' }}
/>
<div>
<span className="text-xs font-mono text-gray-900 dark:text-white block mb-1">
{tenant.primary_color || '-'}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
Cor principal da marca
</span>
</div>
</div>
</div>
<div>
<dt className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Cor Secundária</dt>
<div className="flex items-center gap-4">
<div
className="w-20 h-20 rounded-lg border-2 border-gray-200 dark:border-gray-700 shadow-sm"
style={{ backgroundColor: tenant.secondary_color || '#ffffff' }}
/>
<div>
<span className="text-xs font-mono text-gray-900 dark:text-white block mb-1">
{tenant.secondary_color || '-'}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
Cor de apoio da marca
</span>
</div>
</div>
</div>
</div>
</div>
{/* Logos */}
<div className="border-t border-gray-200 dark:border-gray-800 pt-8">
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-4">Logotipos</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Logo Principal */}
<div>
<dt className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Logo Principal</dt>
<div className="bg-gray-50 dark:bg-gray-800 p-6 rounded-lg border border-gray-200 dark:border-gray-700 flex items-center justify-center min-h-[120px]">
{tenant.logo_url ? (
<img src={tenant.logo_url} alt="Logo" className="max-h-20 max-w-full object-contain" />
) : (
<span className="text-sm text-gray-400">Sem logo</span>
)}
</div>
</div>
{/* Logo Horizontal */}
<div>
<dt className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Logo Horizontal</dt>
<div className="bg-gray-50 dark:bg-gray-800 p-6 rounded-lg border border-gray-200 dark:border-gray-700 flex items-center justify-center min-h-[120px]">
{tenant.logo_horizontal_url ? (
<img src={tenant.logo_horizontal_url} alt="Logo Horizontal" className="max-h-16 max-w-full object-contain" />
) : (
<span className="text-sm text-gray-400">Sem logo horizontal</span>
)}
</div>
</div>
</div>
</div>
</div>
)
},
{
name: 'Plano e Soluções',
content: (
<div className="space-y-6">
{details.subscription ? (
<>
{/* Informações do Plano */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Plano</dt>
<dd className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">{details.subscription.plan_name}</dd>
</div>
<div>
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Status</dt>
<span className={`px-3 py-1 inline-flex text-sm font-medium rounded-full ${details.subscription.status === 'active'
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400'
}`}>
{details.subscription.status === 'active' ? 'Ativa' : details.subscription.status}
</span>
</div>
</div>
<div className="border-t border-gray-200 dark:border-gray-800 pt-6"></div>
{/* Soluções Disponíveis */}
{details.subscription.solutions && details.subscription.solutions.length > 0 && (
<div>
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-4">
Soluções Disponíveis ({details.subscription.solutions.length})
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{details.subscription.solutions.map((solution) => {
const Icon = SOLUTION_ICONS[solution.slug] || FolderIcon;
return (
<div
key={solution.id}
className="flex items-center gap-3 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
>
<div className="p-2.5 rounded-xl bg-white dark:bg-zinc-800 border border-gray-200 dark:border-gray-700">
<Icon className="w-5 h-5 text-[var(--brand-color)]" />
</div>
<div>
<span className="text-sm font-medium text-gray-900 dark:text-white block">
{solution.name}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
{solution.slug}
</span>
</div>
</div>
);
})}
</div>
</div>
)}
<div className="border-t border-gray-200 dark:border-gray-800 pt-6"></div>
{/* Ações */}
<div className="flex gap-3">
<Link
href={`/superadmin/plans/${details.subscription.plan_id}`}
className="inline-flex items-center px-4 py-2 bg-[var(--brand-color)] text-white rounded-lg hover:opacity-90 transition-all font-medium text-sm"
>
Ver Detalhes do Plano
</Link>
<Link
href={`/superadmin/plans/${details.subscription.plan_id}`}
className="inline-flex items-center px-4 py-2 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-700 transition-colors font-medium text-sm"
>
Gerenciar Soluções
</Link>
</div>
</>
) : (
<div className="text-center py-12">
<svg className="w-16 h-16 text-gray-300 dark:text-gray-700 mx-auto mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">Nenhuma Assinatura</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Esta agência ainda não possui um plano ativo.
</p>
</div>
)}
</div>
)
}
];
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-[var(--brand-color)] 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-200 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-[var(--brand-color)] text-white rounded-lg hover:opacity-90 transition-all font-medium text-sm"
>
Acessar Painel
</button>
</div>
</div>
</div>
<Tabs tabs={tabsConfig} />
</div>
);
}

View File

@@ -2,8 +2,12 @@
import { Fragment, useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Menu, Listbox, Transition } from '@headlessui/react';
import CreateAgencyModal from '@/components/agencies/CreateAgencyModal';
import ConfirmDialog from '@/components/layout/ConfirmDialog';
import Pagination from '@/components/layout/Pagination';
import { useToast } from '@/components/layout/ToastContext';
import {
BuildingOfficeIcon,
TrashIcon,
@@ -16,7 +20,15 @@ import {
CheckIcon,
ChevronUpDownIcon,
PlusIcon,
XMarkIcon
XMarkIcon,
UserGroupIcon,
ChartBarIcon,
FolderIcon,
LifebuoyIcon,
CreditCardIcon,
DocumentTextIcon,
ArchiveBoxIcon,
ShareIcon
} from '@heroicons/react/24/outline';
interface Agency {
@@ -30,8 +42,22 @@ interface Agency {
is_active: boolean;
created_at: string;
logo_url?: string;
plan_name?: string;
solutions?: Array<{ id: string; name: string; slug: string }>;
}
// Mapeamento de ícones para cada solução
const SOLUTION_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
'crm': UserGroupIcon,
'erp': ChartBarIcon,
'projetos': FolderIcon,
'helpdesk': LifebuoyIcon,
'pagamentos': CreditCardIcon,
'contratos': DocumentTextIcon,
'documentos': ArchiveBoxIcon,
'social': ShareIcon,
};
const STATUS_OPTIONS = [
{ id: 'all', name: 'Todos os Status' },
{ id: 'active', name: 'Ativas' },
@@ -47,10 +73,21 @@ const DATE_PRESETS = [
];
export default function AgenciesPage() {
const toast = useToast();
const router = useRouter();
const [agencies, setAgencies] = useState<Agency[]>([]);
const [loading, setLoading] = useState(true);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
// Confirmação e seleção múltipla
const [confirmOpen, setConfirmOpen] = useState(false);
const [agencyToDelete, setAgencyToDelete] = useState<string | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// Paginação
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
// Filtros
const [searchTerm, setSearchTerm] = useState('');
const [selectedStatus, setSelectedStatus] = useState(STATUS_OPTIONS[0]);
@@ -80,13 +117,16 @@ export default function AgenciesPage() {
}
};
const handleDelete = async (id: string) => {
if (!confirm('Tem certeza que deseja excluir esta agência? Esta ação não pode ser desfeita.')) {
return;
}
const handleDeleteClick = (id: string) => {
setAgencyToDelete(id);
setConfirmOpen(true);
};
const handleConfirmDelete = async () => {
if (!agencyToDelete) return;
try {
const response = await fetch(`/api/admin/agencies/${id}`, {
const response = await fetch(`/api/admin/agencies/${agencyToDelete}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
@@ -94,16 +134,48 @@ export default function AgenciesPage() {
});
if (response.ok) {
setAgencies(agencies.filter(a => a.id !== id));
setAgencies(agencies.filter(a => a.id !== agencyToDelete));
selectedIds.delete(agencyToDelete);
setSelectedIds(new Set(selectedIds));
toast.success('Agência excluída', 'A agência foi excluída com sucesso.');
} else {
alert('Erro ao excluir agência');
toast.error('Erro ao excluir', 'Não foi possível excluir a agência.');
}
} catch (error) {
console.error('Error deleting agency:', error);
alert('Erro ao excluir agência');
toast.error('Erro ao excluir', 'Ocorreu um erro ao excluir a agência.');
}
};
const handleDeleteSelected = () => {
if (selectedIds.size === 0) return;
setAgencyToDelete('multiple');
setConfirmOpen(true);
};
const handleConfirmDeleteMultiple = async () => {
const idsToDelete = Array.from(selectedIds);
let successCount = 0;
for (const id of idsToDelete) {
try {
const response = await fetch(`/api/admin/agencies/${id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
if (response.ok) successCount++;
} catch (error) {
console.error('Error deleting agency:', error);
}
}
setAgencies(agencies.filter(a => !selectedIds.has(a.id)));
setSelectedIds(new Set());
toast.success(`${successCount} agência(s) excluída(s)`, 'As agências selecionadas foram excluídas.');
};
const toggleActive = async (id: string, currentStatus: boolean) => {
try {
const response = await fetch(`/api/admin/agencies/${id}`, {
@@ -176,6 +248,33 @@ export default function AgenciesPage() {
return matchesSearch && matchesStatus && matchesDate;
});
// Paginação
const totalItems = filteredAgencies.length;
const totalPages = Math.ceil(totalItems / itemsPerPage);
const paginatedAgencies = filteredAgencies.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
);
// Seleção múltipla
const toggleSelectAll = () => {
if (selectedIds.size === paginatedAgencies.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(paginatedAgencies.map(a => a.id)));
}
};
const toggleSelect = (id: string) => {
const newSelected = new Set(selectedIds);
if (newSelected.has(id)) {
newSelected.delete(id);
} else {
newSelected.add(id);
}
setSelectedIds(newSelected);
};
return (
<div className="p-6 max-w-[1600px] mx-auto space-y-6">
{/* Header */}
@@ -186,6 +285,16 @@ export default function AgenciesPage() {
Gerencie seus parceiros e acompanhe o desempenho.
</p>
</div>
<div className="flex gap-2">
{selectedIds.size > 0 && (
<button
onClick={handleDeleteSelected}
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium text-white rounded-lg bg-red-600 hover:bg-red-700 transition-colors"
>
<TrashIcon className="w-4 h-4" />
Excluir ({selectedIds.size})
</button>
)}
<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"
@@ -195,6 +304,7 @@ export default function AgenciesPage() {
Nova Agência
</button>
</div>
</div>
{/* Toolbar de Filtros */}
<div className="flex flex-col lg:flex-row gap-4 items-center justify-between">
@@ -380,16 +490,50 @@ export default function AgenciesPage() {
<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 w-12">
<input
type="checkbox"
checked={selectedIds.size === paginatedAgencies.length && paginatedAgencies.length > 0}
onChange={toggleSelectAll}
className="w-4 h-4 rounded border-zinc-300 dark:border-zinc-600"
style={{ accentColor: 'var(--brand-color)' }}
/>
</th>
<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">Plano</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Soluções</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">
{paginatedAgencies.map((agency) => (
<tr
key={agency.id}
onClick={(e) => {
// Não navegar se clicar no checkbox, botões ou links
if (
(e.target as HTMLElement).closest('input[type="checkbox"]') ||
(e.target as HTMLElement).closest('button') ||
(e.target as HTMLElement).closest('a')
) {
return;
}
router.push(`/superadmin/agencies/${agency.id}`);
}}
className="group hover:bg-zinc-50 dark:hover:bg-zinc-800/50 transition-colors cursor-pointer"
>
<td className="px-6 py-4" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedIds.has(agency.id)}
onChange={() => toggleSelect(agency.id)}
className="w-4 h-4 rounded border-zinc-300 dark:border-zinc-600"
style={{ accentColor: 'var(--brand-color)' }}
/>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-4">
{agency.logo_url ? (
@@ -428,6 +572,40 @@ export default function AgenciesPage() {
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{agency.plan_name ? (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-purple-50 text-purple-700 border border-purple-200 dark:bg-purple-900/20 dark:text-purple-400 dark:border-purple-900/30">
{agency.plan_name}
</span>
) : (
<span className="text-xs text-zinc-400">Sem plano</span>
)}
</td>
<td className="px-6 py-4">
{agency.solutions && agency.solutions.length > 0 ? (
<div className="flex flex-wrap gap-1 max-w-xs">
{agency.solutions.slice(0, 3).map((solution) => {
const Icon = SOLUTION_ICONS[solution.slug] || FolderIcon;
return (
<span
key={solution.id}
className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300 border border-zinc-200 dark:border-zinc-700"
>
<Icon className="w-3.5 h-3.5 text-[var(--brand-color)]" />
{solution.name}
</span>
);
})}
{agency.solutions.length > 3 && (
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
+{agency.solutions.length - 3}
</span>
)}
</div>
) : (
<span className="text-xs text-zinc-400">Sem soluções</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap" onClick={(e) => e.stopPropagation()}>
<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
@@ -446,7 +624,7 @@ export default function AgenciesPage() {
year: 'numeric'
})}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<td className="px-6 py-4 whitespace-nowrap text-right" onClick={(e) => e.stopPropagation()}>
<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" />
@@ -487,7 +665,7 @@ export default function AgenciesPage() {
<Menu.Item>
{({ active }) => (
<button
onClick={() => handleDelete(agency.id)}
onClick={() => handleDeleteClick(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`}
>
@@ -506,23 +684,29 @@ export default function AgenciesPage() {
</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>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems}
itemsPerPage={itemsPerPage}
onPageChange={setCurrentPage}
/>
</div>
)}
<ConfirmDialog
isOpen={confirmOpen}
onClose={() => setConfirmOpen(false)}
onConfirm={agencyToDelete === 'multiple' ? handleConfirmDeleteMultiple : handleConfirmDelete}
title={agencyToDelete === 'multiple' ? `Excluir ${selectedIds.size} agências` : 'Excluir agência'}
message={agencyToDelete === 'multiple'
? `Tem certeza que deseja excluir ${selectedIds.size} agências? Esta ação não pode ser desfeita.`
: 'Tem certeza que deseja excluir esta agência? Esta ação não pode ser desfeita.'}
confirmText="Excluir"
cancelText="Cancelar"
variant="danger"
/>
<CreateAgencyModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}

View File

@@ -0,0 +1,385 @@
"use client";
import { useState, useEffect } from "react";
import {
ArrowDownTrayIcon,
ArrowUpTrayIcon,
ClockIcon,
ServerIcon,
CheckCircleIcon,
ExclamationTriangleIcon,
BoltIcon,
} from '@heroicons/react/24/outline';
interface Backup {
filename: string;
size: string;
date: string;
timestamp: string;
}
export default function BackupPage() {
const [loading, setLoading] = useState(false);
const [backups, setBackups] = useState<Backup[]>([]);
const [selectedBackup, setSelectedBackup] = useState<string>("");
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
const [autoBackupEnabled, setAutoBackupEnabled] = useState(false);
const [autoBackupInterval, setAutoBackupInterval] = useState<number>(6);
useEffect(() => {
loadBackups();
loadAutoBackupSettings();
}, []);
const loadAutoBackupSettings = () => {
const enabled = localStorage.getItem('autoBackupEnabled') === 'true';
const interval = parseInt(localStorage.getItem('autoBackupInterval') || '6');
setAutoBackupEnabled(enabled);
setAutoBackupInterval(interval);
};
const toggleAutoBackup = () => {
const newValue = !autoBackupEnabled;
setAutoBackupEnabled(newValue);
localStorage.setItem('autoBackupEnabled', newValue.toString());
if (newValue) {
startAutoBackup();
setMessage({ type: 'success', text: `Backup automático ativado (a cada ${autoBackupInterval}h)` });
} else {
stopAutoBackup();
setMessage({ type: 'success', text: 'Backup automático desativado' });
}
};
const startAutoBackup = () => {
const intervalMs = autoBackupInterval * 60 * 60 * 1000; // horas para ms
const intervalId = setInterval(() => {
createBackup();
}, intervalMs);
localStorage.setItem('autoBackupIntervalId', intervalId.toString());
};
const stopAutoBackup = () => {
const intervalId = localStorage.getItem('autoBackupIntervalId');
if (intervalId) {
clearInterval(parseInt(intervalId));
localStorage.removeItem('autoBackupIntervalId');
}
};
const changeInterval = (hours: number) => {
setAutoBackupInterval(hours);
localStorage.setItem('autoBackupInterval', hours.toString());
if (autoBackupEnabled) {
stopAutoBackup();
startAutoBackup();
setMessage({ type: 'success', text: `Intervalo alterado para ${hours}h` });
}
};
const loadBackups = async () => {
try {
const token = localStorage.getItem('token');
const response = await fetch('http://localhost:8085/api/superadmin/backups', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const data = await response.json();
setBackups(data.backups || []);
}
} catch (error) {
console.error('Erro ao carregar backups:', error);
}
};
const createBackup = async () => {
setLoading(true);
setMessage(null);
try {
const token = localStorage.getItem('token');
const response = await fetch('http://localhost:8085/api/superadmin/backup/create', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
}
});
const data = await response.json();
if (response.ok) {
setMessage({ type: 'success', text: `Backup criado: ${data.filename} (${data.size})` });
await loadBackups();
} else {
setMessage({ type: 'error', text: data.error || 'Erro ao criar backup' });
}
} catch (error) {
setMessage({ type: 'error', text: 'Erro ao criar backup' });
} finally {
setLoading(false);
}
};
const restoreBackup = async () => {
if (!selectedBackup) {
setMessage({ type: 'error', text: 'Selecione um backup para restaurar' });
return;
}
if (!confirm(`⚠️ ATENÇÃO: Isso irá SOBRESCREVER todos os dados atuais!\n\nDeseja restaurar o backup:\n${selectedBackup}?`)) {
return;
}
setLoading(true);
setMessage(null);
try {
const token = localStorage.getItem('token');
const response = await fetch('http://localhost:8085/api/superadmin/backup/restore', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ filename: selectedBackup })
});
const data = await response.json();
if (response.ok) {
setMessage({ type: 'success', text: 'Backup restaurado com sucesso! Recarregando...' });
setTimeout(() => {
window.location.reload();
}, 2000);
} else {
setMessage({ type: 'error', text: data.error || 'Erro ao restaurar backup' });
}
} catch (error) {
setMessage({ type: 'error', text: 'Erro ao restaurar backup' });
} finally {
setLoading(false);
}
};
const downloadBackup = async (filename: string) => {
try {
const token = localStorage.getItem('token');
const response = await fetch(`http://localhost:8085/api/superadmin/backup/download/${filename}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} else {
setMessage({ type: 'error', text: 'Erro ao baixar backup' });
}
} catch (error) {
setMessage({ type: 'error', text: 'Erro ao baixar backup' });
}
};
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Backup & Restore
</h1>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Gerencie backups do banco de dados PostgreSQL
</p>
</div>
{message && (
<div className={`mb-6 p-4 rounded-lg flex items-center gap-3 ${message.type === 'success'
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
}`}>
{message.type === 'success' ? (
<CheckCircleIcon className="h-5 w-5" />
) : (
<ExclamationTriangleIcon className="h-5 w-5" />
)}
<span>{message.text}</span>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-3 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<ArrowDownTrayIcon className="h-6 w-6 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Criar Backup
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
Exportar dados atuais
</p>
</div>
</div>
<button
onClick={createBackup}
disabled={loading}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{loading ? 'Criando...' : 'Criar Novo Backup'}
</button>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-3 bg-green-100 dark:bg-green-900/30 rounded-lg">
<BoltIcon className="h-6 w-6 text-green-600 dark:text-green-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Backup Automático
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
{autoBackupEnabled ? `Ativo (${autoBackupInterval}h)` : 'Desativado'}
</p>
</div>
</div>
<div className="space-y-3">
<select
value={autoBackupInterval}
onChange={(e) => changeInterval(parseInt(e.target.value))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value={1}>A cada 1 hora</option>
<option value={3}>A cada 3 horas</option>
<option value={6}>A cada 6 horas</option>
<option value={12}>A cada 12 horas</option>
<option value={24}>A cada 24 horas</option>
</select>
<button
onClick={toggleAutoBackup}
className={`w-full px-4 py-2 rounded-lg transition-colors ${
autoBackupEnabled
? 'bg-red-600 hover:bg-red-700 text-white'
: 'bg-green-600 hover:bg-green-700 text-white'
}`}
>
{autoBackupEnabled ? 'Desativar' : 'Ativar'}
</button>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-3 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
<ArrowUpTrayIcon className="h-6 w-6 text-amber-600 dark:text-amber-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Restaurar Backup
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
Sobrescreve dados atuais
</p>
</div>
</div>
<select
value={selectedBackup}
onChange={(e) => setSelectedBackup(e.target.value)}
className="w-full mb-3 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="">Selecione um backup...</option>
{backups.map((backup) => (
<option key={backup.filename} value={backup.filename}>
{backup.filename} - {backup.size}
</option>
))}
</select>
<button
onClick={restoreBackup}
disabled={loading || !selectedBackup}
className="w-full px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{loading ? 'Restaurando...' : 'Restaurar Backup'}
</button>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<ServerIcon className="h-6 w-6 text-gray-600 dark:text-gray-400" />
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Backups Disponíveis
</h2>
<span className="ml-auto text-sm text-gray-500 dark:text-gray-400">
{backups.length} arquivo(s)
</span>
</div>
</div>
<div className="p-6">
{backups.length === 0 ? (
<div className="text-center py-12">
<ServerIcon className="mx-auto h-12 w-12 text-gray-400" />
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Nenhum backup encontrado
</p>
</div>
) : (
<div className="space-y-3">
{backups.map((backup) => (
<div
key={backup.filename}
className={`p-4 rounded-lg border transition-colors cursor-pointer ${selectedBackup === backup.filename
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'
}`}
onClick={() => setSelectedBackup(backup.filename)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<ClockIcon className="h-5 w-5 text-gray-400" />
<div>
<p className="font-medium text-gray-900 dark:text-white">
{backup.filename}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{backup.date} {backup.size}
</p>
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
downloadBackup(backup.filename);
}}
className="px-3 py-1 text-sm text-blue-600 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-900/30 rounded"
>
Download
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -8,14 +8,18 @@ import {
DocumentTextIcon,
Cog6ToothIcon,
SparklesIcon,
ServerIcon,
RectangleGroupIcon,
} from '@heroicons/react/24/outline';
const SUPERADMIN_MENU_ITEMS = [
{ id: 'dashboard', label: 'Dashboard', href: '/superadmin', icon: HomeIcon },
{ id: 'agencies', label: 'Agências', href: '/superadmin/agencies', icon: BuildingOfficeIcon },
{ id: 'plans', label: 'Planos', href: '/superadmin/plans', icon: SparklesIcon },
{ id: 'solutions', label: 'Soluções', href: '/superadmin/solutions', icon: RectangleGroupIcon },
{ id: 'templates', label: 'Templates', href: '/superadmin/signup-templates', icon: LinkIcon },
{ id: 'agency-templates', label: 'Templates Agência', href: '/superadmin/agency-templates', icon: DocumentTextIcon },
{ id: 'backup', label: 'Backup & Restore', href: '/superadmin/backup', icon: ServerIcon },
{ id: 'settings', label: 'Configurações', href: '/superadmin/settings', icon: Cog6ToothIcon },
];

View File

@@ -2,7 +2,30 @@
import { useEffect, useState } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { ArrowLeftIcon, CheckCircleIcon } from '@heroicons/react/24/outline';
import {
ArrowLeftIcon,
CheckCircleIcon,
UserGroupIcon,
ChartBarIcon,
FolderIcon,
LifebuoyIcon,
CreditCardIcon,
DocumentTextIcon,
ArchiveBoxIcon,
ShareIcon
} from '@heroicons/react/24/outline';
// Mapeamento de ícones para cada solução
const SOLUTION_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
'crm': UserGroupIcon,
'erp': ChartBarIcon,
'projetos': FolderIcon,
'helpdesk': LifebuoyIcon,
'pagamentos': CreditCardIcon,
'contratos': DocumentTextIcon,
'documentos': ArchiveBoxIcon,
'social': ShareIcon,
};
interface Plan {
id: string;
@@ -20,6 +43,15 @@ interface Plan {
created_at: string;
}
interface Solution {
id: string;
name: string;
slug: string;
icon: string;
description: string;
is_active: boolean;
}
export default function EditPlanPage() {
const router = useRouter();
const params = useParams();
@@ -32,6 +64,10 @@ export default function EditPlanPage() {
const [plan, setPlan] = useState<Plan | null>(null);
const [formData, setFormData] = useState<Partial<Plan>>({});
const [allSolutions, setAllSolutions] = useState<Solution[]>([]);
const [selectedSolutions, setSelectedSolutions] = useState<string[]>([]);
const [loadingSolutions, setLoadingSolutions] = useState(true);
useEffect(() => {
const token = localStorage.getItem('token');
if (!token) {
@@ -40,6 +76,8 @@ export default function EditPlanPage() {
}
fetchPlan();
fetchSolutions();
fetchPlanSolutions();
}, [planId, router]);
const fetchPlan = async () => {
@@ -66,6 +104,46 @@ export default function EditPlanPage() {
}
};
const fetchSolutions = async () => {
try {
const token = localStorage.getItem('token');
const response = await fetch('/api/admin/solutions', {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
const data = await response.json();
setAllSolutions(data.solutions || []);
}
} catch (err) {
console.error('Erro ao carregar soluções:', err);
}
};
const fetchPlanSolutions = async () => {
try {
setLoadingSolutions(true);
const token = localStorage.getItem('token');
const response = await fetch(`/api/admin/plans/${planId}/solutions`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
const data = await response.json();
const solutionIds = data.solutions?.map((s: Solution) => s.id) || [];
setSelectedSolutions(solutionIds);
}
} catch (err) {
console.error('Erro ao carregar soluções do plano:', err);
} finally {
setLoadingSolutions(false);
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value, type } = e.target;
@@ -126,6 +204,20 @@ export default function EditPlanPage() {
throw new Error(error.message || 'Erro ao atualizar plano');
}
// Salvar soluções associadas
const solutionsResponse = await fetch(`/api/admin/plans/${planId}/solutions`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ solution_ids: selectedSolutions }),
});
if (!solutionsResponse.ok) {
throw new Error('Erro ao atualizar soluções do plano');
}
setSuccess(true);
setTimeout(() => setSuccess(false), 3000);
} catch (err) {
@@ -138,10 +230,7 @@ export default function EditPlanPage() {
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-zinc-600 dark:text-zinc-400">Carregando plano...</p>
</div>
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--brand-color)]"></div>
</div>
);
}
@@ -155,24 +244,28 @@ export default function EditPlanPage() {
}
return (
<div className="space-y-6">
<div className="p-6 max-w-[1600px] mx-auto space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-4 mb-6">
<button
onClick={() => router.back()}
className="p-2 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg transition-colors"
className="inline-flex items-center gap-2 text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-200 transition-colors"
>
<ArrowLeftIcon className="h-6 w-6 text-zinc-600 dark:text-zinc-400" />
<ArrowLeftIcon className="w-4 h-4" />
Voltar
</button>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-zinc-900 dark:text-white">Editar Plano</h1>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{plan.name}</p>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white tracking-tight">Editar Plano</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">{plan.name}</p>
</div>
</div>
{/* Success Message */}
{success && (
<div className="rounded-lg bg-emerald-50 dark:bg-emerald-900/20 p-4 border border-emerald-200 dark:border-emerald-800 flex items-center gap-3">
<div className="rounded-xl bg-emerald-50 dark:bg-emerald-900/20 p-4 border border-emerald-200 dark:border-emerald-800 flex items-center gap-3">
<CheckCircleIcon className="h-5 w-5 text-emerald-600 dark:text-emerald-400 flex-shrink-0" />
<p className="text-sm font-medium text-emerald-800 dark:text-emerald-400">Plano atualizado com sucesso!</p>
</div>
@@ -180,18 +273,19 @@ export default function EditPlanPage() {
{/* Error Message */}
{error && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-4 border border-red-200 dark:border-red-800">
<div className="rounded-xl bg-red-50 dark:bg-red-900/20 p-4 border border-red-200 dark:border-red-800">
<p className="text-sm font-medium text-red-800 dark:text-red-400">{error}</p>
</div>
)}
{/* Form Card */}
<div className="rounded-2xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 p-6 sm:p-8">
<form className="space-y-6" onSubmit={(e) => { e.preventDefault(); handleSave(); }}>
<div className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 overflow-hidden">
<form className="p-6" onSubmit={(e) => { e.preventDefault(); handleSave(); }}>
<div className="space-y-6">
{/* Row 1: Nome e Slug */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-zinc-900 dark:text-white mb-2">
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Nome do Plano
</label>
<input
@@ -199,11 +293,11 @@ export default function EditPlanPage() {
name="name"
value={formData.name || ''}
onChange={handleInputChange}
className="w-full px-4 py-2.5 border border-zinc-300 dark:border-zinc-600 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
</div>
<div>
<label className="block text-sm font-semibold text-zinc-900 dark:text-white mb-2">
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Slug
</label>
<input
@@ -211,14 +305,14 @@ export default function EditPlanPage() {
name="slug"
value={formData.slug || ''}
onChange={handleInputChange}
className="w-full px-4 py-2.5 border border-zinc-300 dark:border-zinc-600 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
</div>
</div>
{/* Descrição */}
<div>
<label className="block text-sm font-semibold text-zinc-900 dark:text-white mb-2">
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Descrição
</label>
<textarea
@@ -226,14 +320,14 @@ export default function EditPlanPage() {
value={formData.description || ''}
onChange={handleInputChange}
rows={3}
className="w-full px-4 py-2.5 border border-zinc-300 dark:border-zinc-600 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all resize-none"
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all resize-none"
/>
</div>
{/* Row 2: Usuários */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-zinc-900 dark:text-white mb-2">
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Mínimo de Usuários
</label>
<input
@@ -242,11 +336,11 @@ export default function EditPlanPage() {
value={formData.min_users || 1}
onChange={handleInputChange}
min="1"
className="w-full px-4 py-2.5 border border-zinc-300 dark:border-zinc-600 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
</div>
<div>
<label className="block text-sm font-semibold text-zinc-900 dark:text-white mb-2">
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Máximo de Usuários (-1 = ilimitado)
</label>
<input
@@ -254,15 +348,15 @@ export default function EditPlanPage() {
name="max_users"
value={formData.max_users || 30}
onChange={handleInputChange}
className="w-full px-4 py-2.5 border border-zinc-300 dark:border-zinc-600 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
</div>
</div>
{/* Row 3: Preços */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-zinc-900 dark:text-white mb-2">
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Preço Mensal (BRL)
</label>
<input
@@ -271,11 +365,11 @@ export default function EditPlanPage() {
value={formData.monthly_price || ''}
onChange={handleInputChange}
step="0.01"
className="w-full px-4 py-2.5 border border-zinc-300 dark:border-zinc-600 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
</div>
<div>
<label className="block text-sm font-semibold text-zinc-900 dark:text-white mb-2">
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Preço Anual (BRL)
</label>
<input
@@ -284,14 +378,14 @@ export default function EditPlanPage() {
value={formData.annual_price || ''}
onChange={handleInputChange}
step="0.01"
className="w-full px-4 py-2.5 border border-zinc-300 dark:border-zinc-600 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
</div>
</div>
{/* Armazenamento */}
<div>
<label className="block text-sm font-semibold text-zinc-900 dark:text-white mb-2">
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Armazenamento (GB)
</label>
<input
@@ -300,58 +394,156 @@ export default function EditPlanPage() {
value={formData.storage_gb || 1}
onChange={handleInputChange}
min="1"
className="w-full px-4 py-2.5 border border-zinc-300 dark:border-zinc-600 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
</div>
{/* Features */}
<div>
<label className="block text-sm font-semibold text-zinc-900 dark:text-white mb-2">
Recursos <span className="text-xs font-normal text-zinc-500">(separados por vírgula)</span>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Recursos <span className="text-xs font-normal text-zinc-500 dark:text-zinc-400">(separados por vírgula)</span>
</label>
<textarea
name="features"
value={typeof formData.features === 'string' ? formData.features : (formData.features || []).join(', ')}
onChange={handleInputChange}
rows={3}
className="w-full px-4 py-2.5 border border-zinc-300 dark:border-zinc-600 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all resize-none"
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all resize-none"
/>
</div>
{/* Differentiators */}
<div>
<label className="block text-sm font-semibold text-zinc-900 dark:text-white mb-2">
Diferenciais <span className="text-xs font-normal text-zinc-500">(separados por vírgula)</span>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Diferenciais <span className="text-xs font-normal text-zinc-500 dark:text-zinc-400">(separados por vírgula)</span>
</label>
<textarea
name="differentiators"
value={typeof formData.differentiators === 'string' ? formData.differentiators : (formData.differentiators || []).join(', ')}
onChange={handleInputChange}
rows={3}
className="w-full px-4 py-2.5 border border-zinc-300 dark:border-zinc-600 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all resize-none"
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all resize-none"
/>
</div>
{/* Soluções Incluídas */}
<div className="pt-6 border-t border-zinc-200 dark:border-zinc-700">
<div className="mb-4">
<h3 className="text-base font-medium text-zinc-900 dark:text-white mb-1">
Soluções Incluídas
</h3>
<p className="text-sm text-zinc-600 dark:text-zinc-400">
Selecione quais soluções estarão disponíveis para agências com este plano
</p>
</div>
{loadingSolutions ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--brand-color)]"></div>
</div>
) : allSolutions.length === 0 ? (
<div className="rounded-lg bg-zinc-50 dark:bg-zinc-800 p-6 text-center">
<p className="text-sm text-zinc-600 dark:text-zinc-400">
Nenhuma solução cadastrada ainda.
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{allSolutions.map((solution) => (
<label
key={solution.id}
className={`flex items-start gap-3 p-4 rounded-lg border-2 cursor-pointer transition-all ${selectedSolutions.includes(solution.id)
? 'bg-zinc-50 dark:bg-zinc-800/50'
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600'
}`}
style={{
borderColor: selectedSolutions.includes(solution.id) ? 'var(--brand-color)' : undefined
}}
>
<input
type="checkbox"
checked={selectedSolutions.includes(solution.id)}
onChange={(e) => {
if (e.target.checked) {
setSelectedSolutions([...selectedSolutions, solution.id]);
} else {
setSelectedSolutions(selectedSolutions.filter(id => id !== solution.id));
}
}}
className="mt-0.5 h-5 w-5 rounded border-zinc-300 dark:border-zinc-600 text-[var(--brand-color)] focus:ring-[var(--brand-color)] dark:bg-zinc-800 cursor-pointer"
style={{
accentColor: 'var(--brand-color)'
}}
/>
<div className="flex-1">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-white dark:bg-zinc-800 border-2 flex items-center justify-center flex-shrink-0" style={{ borderColor: 'var(--brand-color)' }}>
{(() => {
const Icon = SOLUTION_ICONS[solution.slug] || FolderIcon;
return <Icon className="w-4 h-4 text-[var(--brand-color)]" />;
})()}
</div>
<span className="font-medium text-zinc-900 dark:text-white">
{solution.name}
</span>
{!solution.is_active && (
<span className="px-2 py-0.5 text-xs font-medium bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-400 rounded">
Inativo
</span>
)}
</div>
{solution.description && (
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
{solution.description}
</p>
)}
<code className="mt-1 inline-block text-xs text-zinc-500 dark:text-zinc-500">
{solution.slug}
</code>
</div>
</label>
))}
</div>
)}
{selectedSolutions.length > 0 && (
<div className="mt-4 p-4 rounded-lg border" style={{
backgroundColor: 'var(--brand-color-light, rgba(59, 130, 246, 0.1))',
borderColor: 'var(--brand-color)'
}}>
<p className="text-sm font-medium" style={{ color: 'var(--brand-color)' }}>
{selectedSolutions.length} {selectedSolutions.length === 1 ? 'solução selecionada' : 'soluções selecionadas'}
</p>
</div>
)}
</div>
{/* Status Checkbox */}
<div className="pt-4 border-t border-zinc-200 dark:border-zinc-800">
<div className="pt-4 border-t border-zinc-200 dark:border-zinc-700">
<label className="flex items-center gap-3">
<input
type="checkbox"
name="is_active"
checked={formData.is_active || false}
onChange={handleInputChange}
className="h-5 w-5 rounded border-zinc-300 dark:border-zinc-600 text-blue-600 focus:ring-blue-500 dark:bg-zinc-800 cursor-pointer"
className="h-5 w-5 rounded border-zinc-300 dark:border-zinc-600 text-[var(--brand-color)] focus:ring-[var(--brand-color)] dark:bg-zinc-800 cursor-pointer"
style={{
accentColor: 'var(--brand-color)'
}}
/>
<span className="text-sm font-semibold text-zinc-900 dark:text-white">Plano Ativo</span>
<span className="text-sm font-medium text-zinc-900 dark:text-white">Plano Ativo</span>
</label>
</div>
{/* Buttons */}
<div className="flex gap-3 pt-6 border-t border-zinc-200 dark:border-zinc-800">
<div className="flex gap-3 pt-6 border-t border-zinc-200 dark:border-zinc-700">
<button
type="submit"
disabled={saving}
className="flex-1 px-6 py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors"
className="flex-1 px-6 py-2.5 bg-gradient-to-r text-white font-medium rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-sm hover:shadow-md"
style={{
backgroundImage: 'var(--gradient)'
}}
>
{saving ? 'Salvando...' : 'Salvar Alterações'}
</button>
@@ -359,11 +551,12 @@ export default function EditPlanPage() {
type="button"
onClick={() => router.back()}
disabled={saving}
className="flex-1 px-6 py-3 border border-zinc-300 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 font-semibold rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
className="flex-1 px-6 py-2.5 border border-zinc-300 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 font-medium rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Cancelar
</button>
</div>
</div>
</form>
</div>
</div>

View File

@@ -1,14 +1,62 @@
'use client';
"use client";
import { useEffect, useState } from 'react';
import { PencilIcon, TrashIcon, PlusIcon } from '@heroicons/react/24/outline';
import { Fragment, useEffect, useState } from 'react';
import { Menu, Listbox, Transition } from '@headlessui/react';
import CreatePlanModal from '@/components/plans/CreatePlanModal';
import EditPlanModal from '@/components/plans/EditPlanModal';
import ConfirmDialog from '@/components/layout/ConfirmDialog';
import Pagination from '@/components/layout/Pagination';
import { useToast } from '@/components/layout/ToastContext';
import {
SparklesIcon,
TrashIcon,
PencilIcon,
EllipsisVerticalIcon,
MagnifyingGlassIcon,
CheckIcon,
ChevronUpDownIcon,
PlusIcon,
XMarkIcon
} from '@heroicons/react/24/outline';
interface Plan {
id: string;
name: string;
description?: string;
monthly_price?: string;
annual_price?: string;
min_users: number;
max_users: number;
storage_gb: number;
is_active: boolean;
features?: string[];
differentiators?: string[];
solutions_count?: number;
}
const STATUS_OPTIONS = [
{ id: 'all', name: 'Todos os Status' },
{ id: 'active', name: 'Ativos' },
{ id: 'inactive', name: 'Inativos' },
];
export default function PlansPage() {
const [plans, setPlans] = useState<any[]>([]);
const toast = useToast();
const [plans, setPlans] = useState<Plan[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [editingPlanId, setEditingPlanId] = useState<string | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [planToDelete, setPlanToDelete] = useState<string | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
const [searchTerm, setSearchTerm] = useState('');
const [selectedStatus, setSelectedStatus] = useState(STATUS_OPTIONS[0]);
useEffect(() => {
fetchPlans();
@@ -16,256 +64,498 @@ export default function PlansPage() {
const fetchPlans = async () => {
try {
setLoading(true);
const token = localStorage.getItem('token');
const response = await fetch('/api/admin/plans', {
headers: {
'Authorization': `Bearer ${token}`,
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
if (!response.ok) {
throw new Error('Erro ao carregar planos');
}
if (response.ok) {
const data = await response.json();
setPlans(data.plans || []);
setError('');
} catch (err: any) {
setError(err.message);
const plansData = data.plans || [];
// Buscar contagem de soluções para cada plano
const plansWithSolutions = await Promise.all(
plansData.map(async (plan: Plan) => {
try {
const solResponse = await fetch(`/api/admin/plans/${plan.id}/solutions`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
if (solResponse.ok) {
const solData = await solResponse.json();
return { ...plan, solutions_count: solData.solutions?.length || 0 };
}
} catch (e) {
console.error('Erro ao buscar soluções do plano:', e);
}
return { ...plan, solutions_count: 0 };
})
);
setPlans(plansWithSolutions);
}
} catch (error) {
console.error('Error fetching plans:', error);
} finally {
setLoading(false);
}
};
const handleDeletePlan = async (id: string) => {
if (!confirm('Tem certeza que deseja deletar este plano? Esta ação não pode ser desfeita.')) {
return;
}
const handleDeleteClick = (id: string) => {
setPlanToDelete(id);
setConfirmOpen(true);
};
const handleConfirmDelete = async () => {
if (!planToDelete) return;
try {
const token = localStorage.getItem('token');
const response = await fetch(`/api/admin/plans/${id}`, {
const response = await fetch(`/api/admin/plans/${planToDelete}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
if (!response.ok) {
throw new Error('Erro ao deletar plano');
if (response.ok) {
setPlans(plans.filter(p => p.id !== planToDelete));
selectedIds.delete(planToDelete);
setSelectedIds(new Set(selectedIds));
toast.success('Plano excluído', 'O plano foi excluído com sucesso.');
} else {
toast.error('Erro ao excluir', 'Não foi possível excluir o plano.');
}
fetchPlans();
} catch (err: any) {
setError(err.message);
} catch (error) {
console.error('Error deleting plan:', error);
toast.error('Erro ao excluir', 'Ocorreu um erro ao excluir o plano.');
} finally {
setConfirmOpen(false);
setPlanToDelete(null);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-zinc-600 dark:text-zinc-400">Carregando planos...</p>
</div>
</div>
);
const handleDeleteSelected = () => {
if (selectedIds.size === 0) return;
setPlanToDelete('multiple');
setConfirmOpen(true);
};
const handleConfirmDeleteMultiple = async () => {
const idsToDelete = Array.from(selectedIds);
let successCount = 0;
for (const id of idsToDelete) {
try {
const response = await fetch(`/api/admin/plans/${id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
if (response.ok) successCount++;
} catch (error) {
console.error('Error deleting plan:', error);
}
}
setPlans(plans.filter(p => !selectedIds.has(p.id)));
setSelectedIds(new Set());
toast.success(`${successCount} plano(s) excluído(s)`, 'Os planos selecionados foram excluídos.');
setConfirmOpen(false);
setPlanToDelete(null);
};
const toggleActive = async (id: string, currentStatus: boolean) => {
try {
const response = await fetch(`/api/admin/plans/${id}`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ is_active: !currentStatus }),
});
if (response.ok) {
setPlans(plans.map(p =>
p.id === id ? { ...p, is_active: !currentStatus } : p
));
}
} catch (error) {
console.error('Error toggling plan status:', error);
}
};
const clearFilters = () => {
setSearchTerm('');
setSelectedStatus(STATUS_OPTIONS[0]);
};
const handleEdit = (planId: string) => {
setEditingPlanId(planId);
setIsEditModalOpen(true);
};
const handleEditSuccess = () => {
fetchPlans();
toast.success('Plano atualizado', 'O plano foi atualizado com sucesso.');
};
// Lógica de Filtragem
const filteredPlans = plans.filter((plan) => {
// Texto
const searchLower = searchTerm.toLowerCase();
const matchesSearch =
(plan.name?.toLowerCase() || '').includes(searchLower) ||
(plan.description?.toLowerCase() || '').includes(searchLower);
// Status
const matchesStatus =
selectedStatus.id === 'all' ? true :
selectedStatus.id === 'active' ? plan.is_active :
!plan.is_active;
return matchesSearch && matchesStatus;
});
// Paginação
const totalItems = filteredPlans.length;
const totalPages = Math.ceil(totalItems / itemsPerPage);
const paginatedPlans = filteredPlans.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
);
// Seleção múltipla
const toggleSelectAll = () => {
if (selectedIds.size === paginatedPlans.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(paginatedPlans.map(p => p.id)));
}
};
const toggleSelect = (id: string) => {
const newSelected = new Set(selectedIds);
if (newSelected.has(id)) {
newSelected.delete(id);
} else {
newSelected.add(id);
}
setSelectedIds(newSelected);
};
return (
<div className="space-y-6">
<div className="p-6 max-w-[1600px] mx-auto space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-zinc-900 dark:text-white">Planos</h1>
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
Gerencie os planos de assinatura disponíveis para as agências
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white tracking-tight">Planos</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
Gerencie os planos de assinatura da plataforma.
</p>
</div>
<div className="flex items-center gap-2">
{selectedIds.size > 0 && (
<button
onClick={() => setIsModalOpen(true)}
className="inline-flex items-center gap-2 px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition-colors shadow-sm"
onClick={handleDeleteSelected}
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors"
>
<PlusIcon className="h-5 w-5" />
<TrashIcon className="w-4 h-4" />
Excluir ({selectedIds.size})
</button>
)}
<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" />
Novo Plano
</button>
</div>
{/* Error Message */}
{error && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-4 border border-red-200 dark:border-red-800">
<p className="text-sm font-medium text-red-800 dark:text-red-400">{error}</p>
</div>
)}
{/* Plans Grid */}
{plans.length > 0 ? (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{plans.map((plan) => (
<div
key={plan.id}
className="group rounded-2xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 hover:shadow-lg dark:hover:shadow-2xl transition-all duration-200 overflow-hidden"
{/* 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"
>
{/* Header */}
<div className="px-6 pt-6 pb-4 border-b border-zinc-100 dark:border-zinc-800">
<div className="flex items-start justify-between mb-2">
<div>
<h3 className="text-lg font-semibold text-zinc-900 dark:text-white">
{plan.name}
</h3>
{!plan.is_active && (
<span className="inline-block mt-2 px-2 py-1 rounded-full text-xs font-semibold bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400">
Inativo
</span>
)}
</div>
{plan.is_active && (
<span className="px-2.5 py-1 rounded-full text-xs font-semibold bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400">
Ativo
</span>
)}
</div>
{plan.description && (
<p className="text-sm text-zinc-600 dark:text-zinc-400 line-clamp-2">
{plan.description}
</p>
)}
</div>
{/* Content */}
<div className="px-6 py-4 space-y-4">
{/* Pricing */}
<div className="space-y-2">
{plan.monthly_price && (
<div className="flex justify-between items-center">
<span className="text-sm text-zinc-600 dark:text-zinc-400">Mensal</span>
<span className="text-2xl font-bold text-zinc-900 dark:text-white">
R$ <span className="text-xl">{parseFloat(plan.monthly_price).toFixed(2)}</span>
</span>
</div>
)}
{plan.annual_price && (
<div className="flex justify-between items-center">
<span className="text-sm text-zinc-600 dark:text-zinc-400">Anual</span>
<span className="text-2xl font-bold text-zinc-900 dark:text-white">
R$ <span className="text-xl">{parseFloat(plan.annual_price).toFixed(2)}</span>
</span>
</div>
)}
</div>
{/* Stats */}
<div className="grid grid-cols-2 gap-3 pt-2 border-t border-zinc-100 dark:border-zinc-800">
<div className="p-3 bg-zinc-50 dark:bg-zinc-800 rounded-lg">
<p className="text-xs text-zinc-600 dark:text-zinc-400 mb-1">Usuários</p>
<p className="text-sm font-semibold text-zinc-900 dark:text-white">
{plan.min_users} - {plan.max_users === -1 ? '∞' : plan.max_users}
</p>
</div>
<div className="p-3 bg-zinc-50 dark:bg-zinc-800 rounded-lg">
<p className="text-xs text-zinc-600 dark:text-zinc-400 mb-1">Armazenamento</p>
<p className="text-sm font-semibold text-zinc-900 dark:text-white">
{plan.storage_gb} GB
</p>
</div>
</div>
{/* Features */}
{plan.features && plan.features.length > 0 && (
<div className="pt-2">
<p className="text-xs font-semibold text-zinc-700 dark:text-zinc-300 uppercase tracking-wide mb-2">
Recursos
</p>
<ul className="space-y-1">
{plan.features.slice(0, 4).map((feature: string, idx: number) => (
<li key={idx} className="text-xs text-zinc-600 dark:text-zinc-400 flex items-center gap-2">
<span className="inline-block h-1.5 w-1.5 bg-blue-600 rounded-full"></span>
{feature}
</li>
))}
{plan.features.length > 4 && (
<li className="text-xs text-zinc-600 dark:text-zinc-400 italic">
+{plan.features.length - 4} mais
</li>
)}
</ul>
</div>
)}
{/* Differentiators */}
{plan.differentiators && plan.differentiators.length > 0 && (
<div className="pt-2">
<p className="text-xs font-semibold text-zinc-700 dark:text-zinc-300 uppercase tracking-wide mb-2">
Diferenciais
</p>
<ul className="space-y-1">
{plan.differentiators.slice(0, 2).map((diff: string, idx: number) => (
<li key={idx} className="text-xs text-zinc-600 dark:text-zinc-400 flex items-center gap-2">
<span className="inline-block h-1.5 w-1.5 bg-emerald-600 rounded-full"></span>
{diff}
</li>
))}
{plan.differentiators.length > 2 && (
<li className="text-xs text-zinc-600 dark:text-zinc-400 italic">
+{plan.differentiators.length - 2} mais
</li>
)}
</ul>
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 bg-zinc-50 dark:bg-zinc-800/50 border-t border-zinc-100 dark:border-zinc-800 flex gap-2">
<a
href={`/superadmin/plans/${plan.id}`}
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 hover:bg-blue-200 dark:hover:bg-blue-900/50 font-medium rounded-lg transition-colors text-sm"
<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}
>
<PencilIcon className="h-4 w-4" />
Editar
</a>
{({ 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>
{/* Botão Limpar */}
{(searchTerm || selectedStatus.id !== 'all') && (
<button
onClick={() => handleDeletePlan(plan.id)}
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-900/50 font-medium rounded-lg transition-colors text-sm"
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"
>
<TrashIcon className="h-4 w-4" />
Deletar
<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>
) : filteredPlans.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">
<SparklesIcon className="w-8 h-8 text-zinc-400" />
</div>
<h3 className="text-lg font-medium text-zinc-900 dark:text-white mb-1">
Nenhum plano encontrado
</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>
))}
</div>
) : (
<div className="text-center py-12">
<div className="text-zinc-400 dark:text-zinc-600 mb-2">
<svg
className="h-12 w-12 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
<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 w-12">
<input
type="checkbox"
checked={selectedIds.size === paginatedPlans.length && paginatedPlans.length > 0}
onChange={toggleSelectAll}
className="w-4 h-4 text-[var(--brand-color)] bg-zinc-100 border-zinc-300 rounded focus:ring-[var(--brand-color)] dark:focus:ring-[var(--brand-color)] dark:ring-offset-zinc-900 focus:ring-2 dark:bg-zinc-700 dark:border-zinc-600"
/>
</svg>
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Plano</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Preços</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Usuários</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Armazenamento</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Soluções</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Status</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
{paginatedPlans.map((plan) => (
<tr key={plan.id} className="group hover:bg-zinc-50 dark:hover:bg-zinc-800/50 transition-colors cursor-pointer" onClick={() => handleEdit(plan.id)}>
<td className="px-6 py-4 w-12" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedIds.has(plan.id)}
onChange={() => toggleSelect(plan.id)}
className="w-4 h-4 text-[var(--brand-color)] bg-zinc-100 border-zinc-300 rounded focus:ring-[var(--brand-color)] dark:focus:ring-[var(--brand-color)] dark:ring-offset-zinc-900 focus:ring-2 dark:bg-zinc-700 dark:border-zinc-600"
/>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-4">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center text-white font-bold text-sm"
style={{ background: 'var(--gradient)' }}
>
{plan.name?.substring(0, 2).toUpperCase()}
</div>
<p className="text-zinc-600 dark:text-zinc-400 text-lg font-medium">Nenhum plano criado</p>
<p className="text-zinc-500 dark:text-zinc-500 text-sm">Clique no botão acima para criar o primeiro plano</p>
<div>
<div className="text-sm font-semibold text-zinc-900 dark:text-white">
{plan.name}
</div>
{plan.description && (
<div className="text-xs text-zinc-500">
{plan.description}
</div>
)}
</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">
{plan.monthly_price ? `R$ ${plan.monthly_price}/mês` : '-'}
</span>
<span className="text-xs text-zinc-400">
{plan.annual_price ? `R$ ${plan.annual_price}/ano` : '-'}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-zinc-700 dark:text-zinc-300">
{plan.min_users} - {plan.max_users}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-zinc-700 dark:text-zinc-300">
{plan.storage_gb} GB
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 border border-zinc-200 dark:border-zinc-700">
<span className="w-1.5 h-1.5 rounded-full bg-[var(--brand-color)]" />
{plan.solutions_count || 0} {plan.solutions_count === 1 ? 'solução' : 'soluções'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => toggleActive(plan.id, plan.is_active)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border transition-all ${plan.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 ${plan.is_active ? 'bg-emerald-500' : 'bg-zinc-400'}`} />
{plan.is_active ? 'Ativo' : 'Inativo'}
</button>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right" onClick={(e) => e.stopPropagation()}>
<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 }) => (
<button
onClick={() => handleEdit(plan.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`}
>
<PencilIcon className="mr-2 h-4 w-4 text-zinc-400" />
Editar
</button>
)}
</Menu.Item>
</div>
<div className="px-1 py-1">
<Menu.Item>
{({ active }) => (
<button
onClick={() => handleDeleteClick(plan.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>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems}
itemsPerPage={itemsPerPage}
onPageChange={setCurrentPage}
/>
</div>
)}
{/* Create Plan Modal */}
<CreatePlanModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSuccess={() => {
fetchPlans();
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSuccess={(plan) => {
setPlans([...plans, plan]);
setIsCreateModalOpen(false);
}}
/>
<EditPlanModal
isOpen={isEditModalOpen}
onClose={() => {
setIsEditModalOpen(false);
setEditingPlanId(null);
}}
planId={editingPlanId}
onSuccess={handleEditSuccess}
/>
<ConfirmDialog
isOpen={confirmOpen}
onClose={() => {
setConfirmOpen(false);
setPlanToDelete(null);
}}
onConfirm={planToDelete === 'multiple' ? handleConfirmDeleteMultiple : handleConfirmDelete}
title={planToDelete === 'multiple' ? 'Excluir Planos' : 'Excluir Plano'}
message={
planToDelete === 'multiple'
? `Tem certeza que deseja excluir ${selectedIds.size} plano(s)? Esta ação não pode ser desfeita.`
: 'Tem certeza que deseja excluir este plano? Esta ação não pode ser desfeita.'
}
confirmText="Excluir"
cancelText="Cancelar"
variant="danger"
/>
</div>
);
}

View File

@@ -0,0 +1,480 @@
"use client";
import { Fragment, useEffect, useState } from 'react';
import { Menu, Transition } from '@headlessui/react';
import ConfirmDialog from '@/components/layout/ConfirmDialog';
import { useToast } from '@/components/layout/ToastContext';
import {
SparklesIcon,
TrashIcon,
PencilIcon,
EllipsisVerticalIcon,
MagnifyingGlassIcon,
PlusIcon,
XMarkIcon,
UserGroupIcon,
ChartBarIcon,
FolderIcon,
LifebuoyIcon,
CreditCardIcon,
DocumentTextIcon,
ArchiveBoxIcon,
ShareIcon
} from '@heroicons/react/24/outline';
// Mapeamento de ícones para cada solução
const SOLUTION_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
'crm': UserGroupIcon,
'erp': ChartBarIcon,
'projetos': FolderIcon,
'helpdesk': LifebuoyIcon,
'pagamentos': CreditCardIcon,
'contratos': DocumentTextIcon,
'documentos': ArchiveBoxIcon,
'social': ShareIcon,
};
interface Solution {
id: string;
name: string;
slug: string;
icon: string;
description: string;
is_active: boolean;
created_at: string;
updated_at: string;
}
export default function SolutionsPage() {
const toast = useToast();
const [solutions, setSolutions] = useState<Solution[]>([]);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingSolution, setEditingSolution] = useState<Solution | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [solutionToDelete, setSolutionToDelete] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
// Form state
const [formData, setFormData] = useState({
name: '',
slug: '',
icon: '',
description: '',
is_active: true,
});
useEffect(() => {
fetchSolutions();
}, []);
const fetchSolutions = async () => {
try {
const response = await fetch('/api/admin/solutions', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
if (response.ok) {
const data = await response.json();
setSolutions(data.solutions || []);
}
} catch (error) {
console.error('Error fetching solutions:', error);
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const url = editingSolution
? `/api/admin/solutions/${editingSolution.id}`
: '/api/admin/solutions';
const method = editingSolution ? 'PUT' : 'POST';
try {
const response = await fetch(url, {
method,
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(formData),
});
if (response.ok) {
toast.success(
editingSolution ? 'Solução atualizada' : 'Solução criada',
editingSolution ? 'A solução foi atualizada com sucesso.' : 'A nova solução foi criada com sucesso.'
);
fetchSolutions();
handleCloseModal();
} else {
const error = await response.json();
toast.error('Erro', error.message || 'Não foi possível salvar a solução.');
}
} catch (error) {
console.error('Error saving solution:', error);
toast.error('Erro', 'Ocorreu um erro ao salvar a solução.');
}
};
const handleEdit = (solution: Solution) => {
setEditingSolution(solution);
setFormData({
name: solution.name,
slug: solution.slug,
icon: solution.icon,
description: solution.description,
is_active: solution.is_active,
});
setIsModalOpen(true);
};
const handleDeleteClick = (id: string) => {
setSolutionToDelete(id);
setConfirmOpen(true);
};
const handleConfirmDelete = async () => {
if (!solutionToDelete) return;
try {
const response = await fetch(`/api/admin/solutions/${solutionToDelete}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
if (response.ok) {
setSolutions(solutions.filter(s => s.id !== solutionToDelete));
toast.success('Solução excluída', 'A solução foi excluída com sucesso.');
} else {
toast.error('Erro ao excluir', 'Não foi possível excluir a solução.');
}
} catch (error) {
console.error('Error deleting solution:', error);
toast.error('Erro ao excluir', 'Ocorreu um erro ao excluir a solução.');
} finally {
setConfirmOpen(false);
setSolutionToDelete(null);
}
};
const handleCloseModal = () => {
setIsModalOpen(false);
setEditingSolution(null);
setFormData({
name: '',
slug: '',
icon: '',
description: '',
is_active: true,
});
};
const filteredSolutions = solutions.filter((solution) => {
const searchLower = searchTerm.toLowerCase();
return (
(solution.name?.toLowerCase() || '').includes(searchLower) ||
(solution.slug?.toLowerCase() || '').includes(searchLower) ||
(solution.description?.toLowerCase() || '').includes(searchLower)
);
});
return (
<div className="p-6 max-w-[1600px] mx-auto space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white tracking-tight">Soluções</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
Gerencie as soluções disponíveis na plataforma (CRM, ERP, etc.)
</p>
</div>
<button
onClick={() => setIsModalOpen(true)}
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium text-white rounded-lg hover:opacity-90 transition-opacity"
style={{ background: 'var(--gradient)' }}
>
<PlusIcon className="w-4 h-4" />
Nova Solução
</button>
</div>
{/* Search */}
<div className="relative w-full lg:w-96">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<MagnifyingGlassIcon className="h-5 w-5 text-zinc-400" aria-hidden="true" />
</div>
<input
type="text"
className="block w-full pl-10 pr-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg leading-5 bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:outline-none focus:ring-1 focus:ring-[var(--brand-color)] focus:border-[var(--brand-color)] sm:text-sm transition duration-150 ease-in-out"
placeholder="Buscar por nome, slug ou descrição..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
{/* Table */}
{loading ? (
<div className="flex items-center justify-center h-64 bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--brand-color)]"></div>
</div>
) : filteredSolutions.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">
<SparklesIcon className="w-8 h-8 text-zinc-400" />
</div>
<h3 className="text-lg font-medium text-zinc-900 dark:text-white mb-1">
Nenhuma solução encontrada
</h3>
<p className="text-zinc-500 dark:text-zinc-400 max-w-sm mx-auto">
{searchTerm ? 'Nenhuma solução corresponde à sua busca.' : 'Comece criando sua primeira solução.'}
</p>
</div>
) : (
<div className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-zinc-50/50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-800">
<th className="px-6 py-4 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Solução</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Slug</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Descrição</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Status</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
{filteredSolutions.map((solution) => {
const Icon = SOLUTION_ICONS[solution.slug] || FolderIcon;
return (
<tr key={solution.id} className="group hover:bg-zinc-50 dark:hover:bg-zinc-800/50 transition-colors cursor-pointer" onClick={() => handleEdit(solution)}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full flex items-center justify-center" style={{ background: 'var(--gradient)' }}>
<Icon className="w-5 h-5 text-white" />
</div>
<div className="text-sm font-semibold text-zinc-900 dark:text-white">
{solution.name}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-zinc-700 dark:text-zinc-300">
<code className="px-2 py-1 bg-zinc-100 dark:bg-zinc-800 rounded">{solution.slug}</code>
</td>
<td className="px-6 py-4 text-sm text-zinc-600 dark:text-zinc-400">
{solution.description || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${solution.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 ${solution.is_active ? 'bg-emerald-500' : 'bg-zinc-400'}`} />
{solution.is_active ? 'Ativo' : 'Inativo'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right" onClick={(e) => e.stopPropagation()}>
<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 }) => (
<button
onClick={() => handleEdit(solution)}
className={`${active ? 'bg-zinc-50 dark:bg-zinc-800' : ''
} group flex w-full items-center rounded-lg px-2 py-2 text-sm text-zinc-700 dark:text-zinc-300`}
>
<PencilIcon className="mr-2 h-4 w-4 text-zinc-400" />
Editar
</button>
)}
</Menu.Item>
</div>
<div className="px-1 py-1">
<Menu.Item>
{({ active }) => (
<button
onClick={() => handleDeleteClick(solution.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>
</div>
)}
{/* Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<div className="fixed inset-0 bg-zinc-900/40 backdrop-blur-sm transition-opacity" onClick={handleCloseModal}></div>
<div className="relative transform overflow-hidden rounded-2xl bg-white dark:bg-zinc-900 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg border border-zinc-200 dark:border-zinc-800">
<div className="absolute right-0 top-0 pr-6 pt-6">
<button
type="button"
className="rounded-lg p-1.5 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
onClick={handleCloseModal}
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 sm:p-8">
<div className="flex items-start gap-4 mb-6">
<div
className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl shadow-lg"
style={{ background: 'var(--gradient)' }}
>
<SparklesIcon className="h-6 w-6 text-white" />
</div>
<div>
<h3 className="text-xl font-bold text-zinc-900 dark:text-white">
{editingSolution ? 'Editar Solução' : 'Nova Solução'}
</h3>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
{editingSolution ? 'Atualize as informações da solução.' : 'Configure uma nova solução para a plataforma.'}
</p>
</div>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Nome *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Ex: CRM"
required
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Slug *
</label>
<input
type="text"
value={formData.slug}
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
placeholder="Ex: crm"
required
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Ícone
</label>
<input
type="text"
value={formData.icon}
onChange={(e) => setFormData({ ...formData, icon: e.target.value })}
placeholder="Ex: 📊 ou users"
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Descrição
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Descrição breve da solução"
rows={3}
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent resize-none transition-all"
/>
</div>
<div className="flex items-center pt-2">
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
className="h-4 w-4 rounded border-zinc-300 dark:border-zinc-600 focus:ring-2 focus:ring-[var(--brand-color)]"
style={{ accentColor: 'var(--brand-color)' }}
/>
<label className="ml-3 text-sm font-medium text-zinc-700 dark:text-zinc-300">
Solução Ativa
</label>
</div>
</div>
<div className="mt-8 pt-6 border-t border-zinc-200 dark:border-zinc-700 flex gap-3">
<button
type="button"
onClick={handleCloseModal}
className="flex-1 px-4 py-2.5 border border-zinc-200 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 font-medium rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
Cancelar
</button>
<button
type="submit"
className="flex-1 px-4 py-2.5 text-white font-medium rounded-lg transition-all shadow-lg hover:shadow-xl"
style={{ background: 'var(--gradient)' }}
>
{editingSolution ? 'Atualizar' : 'Criar Solução'}
</button>
</div>
</form>
</div>
</div>
</div>
)}
<ConfirmDialog
isOpen={confirmOpen}
onClose={() => {
setConfirmOpen(false);
setSolutionToDelete(null);
}}
onConfirm={handleConfirmDelete}
title="Excluir Solução"
message="Tem certeza que deseja excluir esta solução? Esta ação não pode ser desfeita e afetará os planos que possuem esta solução."
confirmText="Excluir"
cancelText="Cancelar"
variant="danger"
/>
</div>
);
}

View File

@@ -10,6 +10,18 @@
--brand-color: #ff0080;
--brand-color-strong: #ff0080;
/* Escala de cores da marca */
--color-brand-50: #fff1f8;
--color-brand-100: #ffe0f0;
--color-brand-200: #ffc7e4;
--color-brand-300: #ffa0d2;
--color-brand-400: #ff6bb7;
--color-brand-500: #ff3a9d;
--color-brand-600: #ff0080;
--color-brand-700: #d6006a;
--color-brand-800: #ad0058;
--color-brand-900: #8a004a;
/* Superfícies e tipografia */
--color-surface-light: #ffffff;
--color-surface-dark: #0a0a0a;
@@ -50,5 +62,17 @@
--color-text-primary: #f8fafc;
--color-text-secondary: #cbd5f5;
--color-text-inverse: #0f172a;
/* Cores da marca com maior contraste para dark mode */
--color-brand-50: #4a0029;
--color-brand-100: #660037;
--color-brand-200: #8a004a;
--color-brand-300: #ad0058;
--color-brand-400: #d6006a;
--color-brand-500: #ff0080;
--color-brand-600: #ff3a9d;
--color-brand-700: #ff6bb7;
--color-brand-800: #ffa0d2;
--color-brand-900: #ffc7e4;
}
}

View File

@@ -1,4 +1,4 @@
"use client";
"use client";
import { useEffect, useState } from "react";
import DashboardPreview from "./DashboardPreview";
@@ -14,109 +14,79 @@ interface DynamicBrandingProps {
export default function DynamicBranding({
currentStep,
companyName = '',
subdomain = '',
primaryColor = '#0ea5e9',
secondaryColor = '#0284c7',
logoUrl = ''
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: "A implementação do Aggios transformou completamente a gestão da nossa agência. Conseguimos reduzir em 65% o tempo gasto com tarefas administrativas e aumentar nossa capacidade de atendimento.",
author: "Carlos Eduardo Martins",
position: "CEO",
company: "Martins & Associados",
rating: 5
},
{
text: "Reduzi 60% do tempo gasto com controle financeiro. Tudo centralizado em um só lugar.",
author: "João Santos",
company: "TechHub",
avatar: "JS"
text: "Como diretora de operações, preciso de dados precisos em tempo real. O Aggios entrega exatamente isso. A plataforma é intuitiva e os relatórios são excepcionais para tomada de decisão estratégica.",
author: "Patricia Almeida Santos",
position: "Diretora de Operações",
company: "Digital Solutions Group",
rating: 5
},
{
text: "A melhor decisão para nossa agência. Dashboard intuitivo e relatórios incríveis!",
author: "Ana Costa",
company: "CreativeFlow",
avatar: "AC"
text: "Implementamos o Aggios há 6 meses e o ROI foi imediato. Melhor controle financeiro, visibilidade total dos projetos e uma equipe muito mais produtiva. Recomendo sem ressalvas.",
author: "Roberto Henrique Costa",
position: "Diretor Financeiro",
company: "Costa & Partners",
rating: 5
},
{
text: "A integração com nossas ferramentas foi perfeita e o suporte técnico é simplesmente excepcional. O Aggios se tornou parte fundamental da nossa operação diária.",
author: "Fernanda Silva Rodrigues",
position: "Head de TI",
company: "Tech Innovators",
rating: 5
}
];
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-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);
}, 6000);
return () => clearInterval(interval);
}, [testimonials.length]);
// Se for etapa 4, mostrar preview do dashboard
if (currentStep === 4) {
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">
<div className="relative z-10 flex flex-col h-full w-full overflow-hidden bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
{/* Decorative elements */}
<div className="absolute inset-0 opacity-10">
<div className="absolute top-0 left-0 w-96 h-96 bg-blue-500 rounded-full blur-3xl" />
<div className="absolute bottom-0 right-0 w-96 h-96 bg-purple-500 rounded-full blur-3xl" />
</div>
<div className="relative z-10 flex flex-col justify-center items-center h-full p-12 text-white">
<div className="mb-6">
<h1 className="text-4xl font-bold tracking-tight text-white text-center mb-2">
aggios
</h1>
</div>
<p className="text-sm text-white/70 font-medium tracking-wide uppercase text-center">
Gestão Inteligente para Agências
</p>
</div>
{/* Conteúdo */}
<div className="max-w-lg text-center">
<div className="max-w-lg text-center mb-8">
<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>
<p className="text-white/70 text-base">Veja como ficará seu dashboard personalizado</p>
</div>
{/* Preview */}
<div className="w-full max-w-3xl">
<div className="w-full max-w-3xl mb-6">
<DashboardPreview
companyName={companyName}
subdomain={subdomain}
@@ -126,89 +96,92 @@ export default function DynamicBranding({
/>
</div>
{/* Info */}
<div className="mt-6 text-center">
<p className="text-white/70 text-sm">
<div className="text-center">
<p className="text-white/60 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>
</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="relative z-10 flex flex-col h-full w-full overflow-hidden bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
{/* Decorative elements */}
<div className="absolute inset-0 opacity-10">
<div className="absolute top-20 right-20 w-96 h-96 bg-blue-500 rounded-full blur-3xl" />
<div className="absolute bottom-20 left-20 w-80 h-80 bg-indigo-500 rounded-full blur-3xl" />
</div>
<div className="relative z-10 flex flex-col justify-between h-full p-12 text-white">
<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">
<div className="inline-block">
<h1 className="text-4xl font-bold tracking-tight text-white mb-2">
aggios
</h1>
<p className="text-sm text-white/70 font-medium tracking-wide uppercase">
Gestão Inteligente para Agências
</p>
</div>
</div>
{/* Ícone e Título da Etapa */}
<div className="flex-1" />
<div className="space-y-6">
<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>
<h3 className="text-sm font-semibold text-white/60 uppercase tracking-wider mb-1">
Depoimentos
</h3>
<p className="text-2xl font-bold text-white">
O que nossos clientes dizem
</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 className="relative">
<div className="bg-white/5 backdrop-blur-md rounded-xl p-8 border border-white/10 shadow-2xl">
<div className="flex gap-1 mb-4">
{[...Array(testimonials[activeTestimonial].rating)].map((_, i) => (
<i key={i} className="ri-star-fill text-yellow-400 text-lg" />
))}
</div>
<div className="mb-4">
<i className="ri-double-quotes-l text-4xl text-white/20" />
</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]">
<p className="text-white/95 text-lg leading-relaxed mb-6 min-h-[140px]">
{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 className="flex items-center gap-4 pt-6 border-t border-white/10">
<div className="relative w-14 h-14 rounded-full overflow-hidden ring-2 ring-white/20 bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center">
<span className="text-white font-bold text-xl">
{testimonials[activeTestimonial].author.split(' ').map(n => n[0]).join('')}
</span>
</div>
<div>
<p className="font-semibold text-white">
<div className="flex-1">
<p className="font-semibold text-white text-lg">
{testimonials[activeTestimonial].author}
</p>
<p className="text-sm text-white/70">
{testimonials[activeTestimonial].position}
</p>
<p className="text-sm text-white/50 font-medium">
{testimonials[activeTestimonial].company}
</p>
</div>
</div>
</div>
{/* Indicadores */}
<div className="flex gap-2 justify-center mt-4">
<div className="flex gap-2 justify-center mt-6">
{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"
className={`h-2 rounded-full transition-all duration-300 ${index === activeTestimonial
? "w-12 bg-white shadow-lg shadow-white/20"
: "w-2 bg-white/30 hover:bg-white/50"
}`}
aria-label={`Ir para depoimento ${index + 1}`}
/>
@@ -216,9 +189,24 @@ export default function DynamicBranding({
</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 className="flex items-center justify-center gap-6 mt-8 pt-6 border-t border-white/10">
<div className="text-center">
<p className="text-2xl font-bold text-white">10.000+</p>
<p className="text-xs text-white/60 uppercase tracking-wide">Projetos</p>
</div>
<div className="w-px h-8 bg-white/20" />
<div className="text-center">
<p className="text-2xl font-bold text-white">98%</p>
<p className="text-xs text-white/60 uppercase tracking-wide">Satisfação</p>
</div>
<div className="w-px h-8 bg-white/20" />
<div className="text-center">
<p className="text-2xl font-bold text-white">5.000+</p>
<p className="text-xs text-white/60 uppercase tracking-wide">Usuários</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,123 @@
import { Fragment } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import { ExclamationTriangleIcon, XMarkIcon } from '@heroicons/react/24/outline';
interface ConfirmDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: 'danger' | 'warning' | 'info';
}
export default function ConfirmDialog({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = 'Confirmar',
cancelText = 'Cancelar',
variant = 'danger'
}: ConfirmDialogProps) {
const handleConfirm = () => {
onConfirm();
onClose();
};
const variantStyles = {
danger: {
icon: 'bg-red-100 dark:bg-red-900/20',
iconColor: 'text-red-600 dark:text-red-400',
button: 'bg-red-600 hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-800'
},
warning: {
icon: 'bg-yellow-100 dark:bg-yellow-900/20',
iconColor: 'text-yellow-600 dark:text-yellow-400',
button: 'bg-yellow-600 hover:bg-yellow-700 dark:bg-yellow-700 dark:hover:bg-yellow-800'
},
info: {
icon: 'bg-blue-100 dark:bg-blue-900/20',
iconColor: 'text-blue-600 dark:text-blue-400',
button: 'bg-blue-600 hover:bg-blue-700 dark:bg-blue-700 dark:hover:bg-blue-800'
}
};
const style = variantStyles[variant];
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-lg border border-zinc-200 dark:border-zinc-800">
<div className="p-6">
<div className="flex items-start gap-4">
<div className={`flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl ${style.icon}`}>
<ExclamationTriangleIcon className={`h-6 w-6 ${style.iconColor}`} />
</div>
<div className="flex-1">
<Dialog.Title className="text-lg font-semibold text-zinc-900 dark:text-white">
{title}
</Dialog.Title>
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
{message}
</p>
</div>
<button
onClick={onClose}
className="rounded-lg p-1.5 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
<div className="mt-6 flex gap-3">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2.5 border border-zinc-200 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 font-medium rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
{cancelText}
</button>
<button
type="button"
onClick={handleConfirm}
className={`flex-1 px-4 py-2.5 text-white font-medium rounded-lg transition-colors ${style.button}`}
>
{confirmText}
</button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
}

View File

@@ -0,0 +1,33 @@
import { ReactNode } from 'react';
interface EmptyStateProps {
icon: ReactNode;
title: string;
description: string;
actionLabel?: string;
onAction?: () => void;
}
export default function EmptyState({ icon, title, description, actionLabel, onAction }: EmptyStateProps) {
return (
<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">
{icon}
</div>
<h3 className="text-lg font-medium text-zinc-900 dark:text-white mb-1">
{title}
</h3>
<p className="text-zinc-500 dark:text-zinc-400 max-w-sm mx-auto">
{description}
</p>
{actionLabel && onAction && (
<button
onClick={onAction}
className="mt-4 text-sm text-[var(--brand-color)] hover:underline font-medium"
>
{actionLabel}
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,7 @@
export default function LoadingState() {
return (
<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>
);
}

View File

@@ -0,0 +1,31 @@
interface PageHeaderProps {
title: string;
description: string;
actionLabel: string;
onAction: () => void;
}
export default function PageHeader({ title, description, actionLabel, onAction }: PageHeaderProps) {
return (
<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">
{title}
</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
{description}
</p>
</div>
<button
onClick={onAction}
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)' }}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
{actionLabel}
</button>
</div>
);
}

View File

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

View File

@@ -0,0 +1,294 @@
# Componentes de Layout Padrão
Este diretório contém componentes reutilizáveis para manter um design system consistente em todas as páginas de listagem do dashboard.
## Componentes Disponíveis
### 1. **PageHeader**
Header padrão com título, descrição e botão de ação.
```tsx
import PageHeader from '@/components/layout/PageHeader';
<PageHeader
title="Agências"
description="Gerencie seus parceiros e acompanhe o desempenho."
actionLabel="Nova Agência"
onAction={() => setModalOpen(true)}
/>
```
### 2. **SearchBar**
Barra de busca padrão com ícone de lupa.
```tsx
import SearchBar from '@/components/layout/SearchBar';
<SearchBar
value={searchTerm}
onChange={setSearchTerm}
placeholder="Buscar por nome, email..."
/>
```
### 3. **StatusFilter**
Dropdown de filtro de status com Headless UI.
```tsx
import StatusFilter from '@/components/layout/StatusFilter';
const STATUS_OPTIONS = [
{ id: 'all', name: 'Todos os Status' },
{ id: 'active', name: 'Ativos' },
{ id: 'inactive', name: 'Inativos' },
];
<StatusFilter
options={STATUS_OPTIONS}
selected={selectedStatus}
onChange={setSelectedStatus}
/>
```
### 4. **EmptyState**
Estado vazio padrão com ícone, título e descrição.
```tsx
import EmptyState from '@/components/layout/EmptyState';
import { BuildingOfficeIcon } from '@heroicons/react/24/outline';
<EmptyState
icon={<BuildingOfficeIcon className="w-8 h-8 text-zinc-400" />}
title="Nenhuma agência encontrada"
description="Não encontramos resultados para os filtros selecionados."
actionLabel="Limpar todos os filtros"
onAction={clearFilters}
/>
```
### 5. **LoadingState**
Estado de carregamento com spinner.
```tsx
import LoadingState from '@/components/layout/LoadingState';
{loading && <LoadingState />}
```
### 6. **StatusBadge**
Badge de status ativo/inativo com toggle opcional.
```tsx
import StatusBadge from '@/components/layout/StatusBadge';
<StatusBadge
active={item.is_active}
onClick={() => toggleStatus(item.id, item.is_active)}
activeLabel="Ativo"
inactiveLabel="Inativo"
/>
```
### 7. **Pagination** ⭐ NEW
Paginação funcional com navegação e indicador de páginas.
```tsx
import Pagination from '@/components/layout/Pagination';
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
const totalItems = filteredItems.length;
const totalPages = Math.ceil(totalItems / itemsPerPage);
const paginatedItems = filteredItems.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
);
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems}
itemsPerPage={itemsPerPage}
onPageChange={setCurrentPage}
/>
```
### 8. **ConfirmDialog** ⭐ NEW
Modal de confirmação profissional (substitui `confirm()`).
```tsx
import { useState } from 'react';
import ConfirmDialog from '@/components/layout/ConfirmDialog';
const [confirmOpen, setConfirmOpen] = useState(false);
const [itemToDelete, setItemToDelete] = useState<string | null>(null);
const handleDeleteClick = (id: string) => {
setItemToDelete(id);
setConfirmOpen(true);
};
const handleConfirmDelete = () => {
if (itemToDelete) {
// Executar exclusão
deleteItem(itemToDelete);
}
};
<ConfirmDialog
isOpen={confirmOpen}
onClose={() => setConfirmOpen(false)}
onConfirm={handleConfirmDelete}
title="Excluir Item"
message="Tem certeza que deseja excluir este item? Esta ação não pode ser desfeita."
confirmText="Excluir"
cancelText="Cancelar"
variant="danger"
/>
```
### 9. **ToastContext & useToast** ⭐ NEW
Sistema de notificações toast (substitui `alert()`).
**Setup no layout:**
```tsx
import { ToastProvider } from '@/components/layout/ToastContext';
<ToastProvider>
{children}
</ToastProvider>
```
**Uso nas páginas:**
```tsx
import { useToast } from '@/components/layout/ToastContext';
const toast = useToast();
// Sucesso
toast.success('Item criado!', 'O item foi criado com sucesso.');
// Erro
toast.error('Erro ao excluir', 'Não foi possível excluir o item.');
// Info
toast.info('Informação', 'Ação concluída.');
```
## Exemplo de Uso Completo
```tsx
"use client";
import { useState, useEffect } from 'react';
import PageHeader from '@/components/layout/PageHeader';
import SearchBar from '@/components/layout/SearchBar';
import StatusFilter from '@/components/layout/StatusFilter';
import LoadingState from '@/components/layout/LoadingState';
import EmptyState from '@/components/layout/EmptyState';
import StatusBadge from '@/components/layout/StatusBadge';
import TableFooter from '@/components/layout/TableFooter';
import { BuildingOfficeIcon } from '@heroicons/react/24/outline';
const STATUS_OPTIONS = [
{ id: 'all', name: 'Todos os Status' },
{ id: 'active', name: 'Ativos' },
{ id: 'inactive', name: 'Inativos' },
];
export default function MyListPage() {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [selectedStatus, setSelectedStatus] = useState(STATUS_OPTIONS[0]);
const filteredItems = items.filter(item => {
const matchesSearch = item.name.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus =
selectedStatus.id === 'all' ? true :
selectedStatus.id === 'active' ? item.is_active :
!item.is_active;
return matchesSearch && matchesStatus;
});
return (
<div className="p-6 max-w-[1600px] mx-auto space-y-6">
<PageHeader
title="Minha Lista"
description="Descrição da página"
actionLabel="Novo Item"
onAction={() => {}}
/>
<div className="flex flex-col lg:flex-row gap-4 items-center justify-between">
<SearchBar
value={searchTerm}
onChange={setSearchTerm}
placeholder="Buscar..."
/>
<StatusFilter
options={STATUS_OPTIONS}
selected={selectedStatus}
onChange={setSelectedStatus}
/>
</div>
{loading ? (
<LoadingState />
) : filteredItems.length === 0 ? (
<EmptyState
icon={<BuildingOfficeIcon className="w-8 h-8 text-zinc-400" />}
title="Nenhum item encontrado"
description="Tente ajustar os filtros."
/>
) : (
<div className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 overflow-hidden">
<table className="w-full">
{/* Sua tabela aqui */}
</table>
<TableFooter count={filteredItems.length} />
</div>
)}
</div>
);
}
```
## Design System
### Cores
```css
--gradient: linear-gradient(135deg, #ff3a05, #ff0080)
--brand-color: #ff0080
```
### Classes Tailwind Padrão
**Container principal:**
```
p-6 max-w-[1600px] mx-auto space-y-6
```
**Tabela:**
```
bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800
```
**Header da tabela:**
```
bg-zinc-50/50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-800
```
**Linha hover:**
```
hover:bg-zinc-50 dark:hover:bg-zinc-800/50 transition-colors
```
## Benefícios
**Consistência visual** - Todas as páginas seguem o mesmo padrão
**Manutenção fácil** - Altere um componente, atualiza em todas as páginas
**Desenvolvimento rápido** - Reutilize componentes prontos
**Design system** - Cores e estilos centralizados
**Acessibilidade** - Componentes já otimizados

View File

@@ -0,0 +1,24 @@
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
interface SearchBarProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
export default function SearchBar({ value, onChange, placeholder = "Buscar..." }: SearchBarProps) {
return (
<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={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
}

View File

@@ -0,0 +1,28 @@
interface StatusBadgeProps {
active: boolean;
onClick?: () => void;
activeLabel?: string;
inactiveLabel?: string;
}
export default function StatusBadge({
active,
onClick,
activeLabel = 'Ativo',
inactiveLabel = 'Inativo'
}: StatusBadgeProps) {
const Component = onClick ? 'button' : 'span';
return (
<Component
onClick={onClick}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border transition-all ${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'
} ${onClick ? 'cursor-pointer hover:opacity-80' : ''}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${active ? 'bg-emerald-500' : 'bg-zinc-400'}`} />
{active ? activeLabel : inactiveLabel}
</Component>
);
}

View File

@@ -0,0 +1,61 @@
import { Fragment } from 'react';
import { Listbox, Transition } from '@headlessui/react';
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/24/outline';
interface StatusOption {
id: string;
name: string;
}
interface StatusFilterProps {
options: StatusOption[];
selected: StatusOption;
onChange: (option: StatusOption) => void;
}
export default function StatusFilter({ options, selected, onChange }: StatusFilterProps) {
return (
<Listbox value={selected} onChange={onChange}>
<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">{selected.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">
{options.map((option, idx) => (
<Listbox.Option
key={idx}
className={({ active }) =>
`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={option}
>
{({ selected }) => (
<>
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{option.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>
);
}

View File

@@ -0,0 +1,57 @@
'use client';
import { createContext, useContext, useState, useCallback } from 'react';
import ToastNotification, { Toast } from './ToastNotification';
interface ToastContextType {
showToast: (type: Toast['type'], title: string, message?: string) => void;
success: (title: string, message?: string) => void;
error: (title: string, message?: string) => void;
info: (title: string, message?: string) => void;
}
const ToastContext = createContext<ToastContextType | undefined>(undefined);
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = useCallback((type: Toast['type'], title: string, message?: string) => {
const id = Date.now().toString();
setToasts(prev => [...prev, { id, type, title, message }]);
}, []);
const success = useCallback((title: string, message?: string) => {
showToast('success', title, message);
}, [showToast]);
const error = useCallback((title: string, message?: string) => {
showToast('error', title, message);
}, [showToast]);
const info = useCallback((title: string, message?: string) => {
showToast('info', title, message);
}, [showToast]);
const removeToast = useCallback((id: string) => {
setToasts(prev => prev.filter(toast => toast.id !== id));
}, []);
return (
<ToastContext.Provider value={{ showToast, success, error, info }}>
{children}
<div className="fixed top-4 right-4 z-[9999] flex flex-col gap-3 w-96">
{toasts.map(toast => (
<ToastNotification key={toast.id} toast={toast} onClose={removeToast} />
))}
</div>
</ToastContext.Provider>
);
}
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within ToastProvider');
}
return context;
}

View File

@@ -0,0 +1,99 @@
import { Fragment, useEffect } from 'react';
import { Transition } from '@headlessui/react';
import {
CheckCircleIcon,
XCircleIcon,
InformationCircleIcon,
XMarkIcon
} from '@heroicons/react/24/outline';
export interface Toast {
id: string;
type: 'success' | 'error' | 'info';
title: string;
message?: string;
}
interface ToastNotificationProps {
toast: Toast;
onClose: (id: string) => void;
}
export default function ToastNotification({ toast, onClose }: ToastNotificationProps) {
useEffect(() => {
const timer = setTimeout(() => {
onClose(toast.id);
}, 5000);
return () => clearTimeout(timer);
}, [toast.id, onClose]);
const styles = {
success: {
bg: 'bg-emerald-50 dark:bg-emerald-900/20',
border: 'border-emerald-200 dark:border-emerald-900/30',
icon: 'text-emerald-600 dark:text-emerald-400',
title: 'text-emerald-900 dark:text-emerald-300',
IconComponent: CheckCircleIcon
},
error: {
bg: 'bg-red-50 dark:bg-red-900/20',
border: 'border-red-200 dark:border-red-900/30',
icon: 'text-red-600 dark:text-red-400',
title: 'text-red-900 dark:text-red-300',
IconComponent: XCircleIcon
},
info: {
bg: 'bg-blue-50 dark:bg-blue-900/20',
border: 'border-blue-200 dark:border-blue-900/30',
icon: 'text-blue-600 dark:text-blue-400',
title: 'text-blue-900 dark:text-blue-300',
IconComponent: InformationCircleIcon
}
};
const style = styles[toast.type];
const Icon = style.IconComponent;
return (
<Transition
show={true}
as={Fragment}
enter="transform ease-out duration-300 transition"
enterFrom="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
enterTo="translate-y-0 opacity-100 sm:translate-x-0"
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className={`pointer-events-auto w-full rounded-lg border shadow-lg ${style.bg} ${style.border}`}>
<div className="p-4">
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
<Icon className={`h-6 w-6 ${style.icon}`} />
</div>
<div className="flex-1 min-w-0">
<p className={`text-sm font-semibold ${style.title}`}>
{toast.title}
</p>
{toast.message && (
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
{toast.message}
</p>
)}
</div>
<div className="flex-shrink-0">
<button
type="button"
onClick={() => onClose(toast.id)}
className="inline-flex rounded-md text-zinc-400 hover:text-zinc-500 dark:hover:text-zinc-300 focus:outline-none"
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
</div>
</div>
</div>
</Transition>
);
}

View File

@@ -1,10 +1,12 @@
'use client';
import { Fragment, useState } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import { Dialog, Transition, Tab } from '@headlessui/react';
import {
XMarkIcon,
SparklesIcon,
PlusIcon,
MinusIcon,
} from '@heroicons/react/24/outline';
interface CreatePlanModalProps {
@@ -21,16 +23,13 @@ interface CreatePlanForm {
max_users: number;
monthly_price: string;
annual_price: string;
discount_months: number;
features: string;
differentiators: string;
storage_gb: number;
is_active: boolean;
}
function classNames(...classes: string[]) {
return classes.filter(Boolean).join(' ');
}
export default function CreatePlanModal({ isOpen, onClose, onSuccess }: CreatePlanModalProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
@@ -42,9 +41,10 @@ export default function CreatePlanModal({ isOpen, onClose, onSuccess }: CreatePl
max_users: 30,
monthly_price: '',
annual_price: '',
discount_months: 2,
features: '',
differentiators: '',
storage_gb: 1,
storage_gb: 10,
is_active: true,
});
@@ -57,10 +57,28 @@ export default function CreatePlanModal({ isOpen, onClose, onSuccess }: CreatePl
[name]: (e.target as HTMLInputElement).checked,
}));
} else if (type === 'number') {
setFormData(prev => ({
const numValue = parseFloat(value) || 0;
setFormData(prev => {
const newData = {
...prev,
[name]: parseFloat(value) || 0,
}));
[name]: numValue,
};
// Calcular preço anual automaticamente quando mensal ou discount_months muda
if ((name === 'monthly_price' || name === 'discount_months')) {
const monthlyPrice = name === 'monthly_price' ? numValue : parseFloat(prev.monthly_price) || 0;
const discountMonths = name === 'discount_months' ? numValue : prev.discount_months;
if (monthlyPrice > 0 && discountMonths >= 0) {
// Calcula: (12 meses - meses de desconto) * preço mensal
const monthsToPay = Math.max(0, 12 - discountMonths);
const annualWithDiscount = (monthlyPrice * monthsToPay).toFixed(2);
newData.annual_price = annualWithDiscount;
}
}
return newData;
});
} else {
setFormData(prev => ({
...prev,
@@ -69,13 +87,54 @@ export default function CreatePlanModal({ isOpen, onClose, onSuccess }: CreatePl
}
};
const incrementValue = (field: 'min_users' | 'max_users' | 'storage_gb' | 'discount_months', step: number = 1) => {
setFormData(prev => {
const newValue = prev[field] + step;
const newData = {
...prev,
[field]: newValue,
};
// Recalcular preço anual se mudou discount_months
if (field === 'discount_months') {
const monthlyPrice = parseFloat(prev.monthly_price) || 0;
if (monthlyPrice > 0 && newValue >= 0) {
const monthsToPay = Math.max(0, 12 - newValue);
newData.annual_price = (monthlyPrice * monthsToPay).toFixed(2);
}
}
return newData;
});
};
const decrementValue = (field: 'min_users' | 'max_users' | 'storage_gb' | 'discount_months', step: number = 1, min: number = 0) => {
setFormData(prev => {
const newValue = Math.max(min, prev[field] - step);
const newData = {
...prev,
[field]: newValue,
};
// Recalcular preço anual se mudou discount_months
if (field === 'discount_months') {
const monthlyPrice = parseFloat(prev.monthly_price) || 0;
if (monthlyPrice > 0 && newValue >= 0) {
const monthsToPay = Math.max(0, 12 - newValue);
newData.annual_price = (monthlyPrice * monthsToPay).toFixed(2);
}
}
return newData;
});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
// Validações básicas
if (!formData.name || !formData.slug) {
setError('Nome e Slug são obrigatórios');
setLoading(false);
@@ -84,7 +143,6 @@ export default function CreatePlanModal({ isOpen, onClose, onSuccess }: CreatePl
const token = localStorage.getItem('token');
// Parse features e differentiators
const features = formData.features
.split(',')
.map(f => f.trim())
@@ -109,6 +167,8 @@ export default function CreatePlanModal({ isOpen, onClose, onSuccess }: CreatePl
is_active: formData.is_active,
};
console.log('Enviando payload:', payload);
const response = await fetch('/api/admin/plans', {
method: 'POST',
headers: {
@@ -119,15 +179,18 @@ export default function CreatePlanModal({ isOpen, onClose, onSuccess }: CreatePl
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Erro ao criar plano');
const errorData = await response.json();
console.error('Erro da API:', errorData);
throw new Error(errorData.message || 'Erro ao criar plano');
}
const data = await response.json();
console.log('Plano criado:', data);
onSuccess(data.plan);
onClose();
handleClose();
} catch (err: any) {
setError(err.message);
console.error('Erro ao criar plano:', err);
setError(err.message || 'Erro desconhecido ao criar plano');
} finally {
setLoading(false);
}
@@ -144,9 +207,10 @@ export default function CreatePlanModal({ isOpen, onClose, onSuccess }: CreatePl
max_users: 30,
monthly_price: '',
annual_price: '',
discount_months: 2,
features: '',
differentiators: '',
storage_gb: 1,
storage_gb: 10,
is_active: true,
});
onClose();
@@ -179,49 +243,68 @@ export default function CreatePlanModal({ isOpen, onClose, onSuccess }: CreatePl
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-2xl border border-zinc-200 dark:border-zinc-800">
<div className="absolute right-0 top-0 hidden pr-4 pt-4 sm:block">
<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 pr-6 pt-6">
<button
type="button"
className="rounded-md bg-white dark:bg-zinc-900 text-zinc-400 hover:text-zinc-500 focus:outline-none"
className="rounded-lg p-1.5 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
onClick={handleClose}
disabled={loading}
>
<span className="sr-only">Fechar</span>
<XMarkIcon className="h-6 w-6" aria-hidden="true" />
<XMarkIcon className="h-5 w-5" />
</button>
</div>
<div className="p-6 sm:p-8">
{/* Header */}
<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-blue-100 dark:bg-blue-900 sm:mx-0 sm:h-10 sm:w-10">
<SparklesIcon className="h-6 w-6 text-blue-600 dark:text-blue-400" aria-hidden="true" />
<div className="flex items-start gap-4 mb-6">
<div
className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl shadow-lg"
style={{ background: 'var(--gradient)' }}
>
<SparklesIcon className="h-6 w-6 text-white" />
</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">
<div>
<Dialog.Title className="text-xl font-bold text-zinc-900 dark:text-white">
Criar Novo Plano
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-zinc-500 dark:text-zinc-400">
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
Configure um novo plano de assinatura para as agências.
</p>
</div>
</div>
</div>
{/* Error Message */}
{error && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 border border-red-200 dark:border-red-800">
<div className="mb-6 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 border border-red-200 dark:border-red-800">
<p className="text-sm font-medium text-red-800 dark:text-red-400">{error}</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Row 1: Nome e Slug */}
<form onSubmit={handleSubmit}>
<Tab.Group>
<Tab.List className="flex gap-2 p-1 bg-zinc-100 dark:bg-zinc-800 rounded-lg mb-6">
{['Dados Básicos', 'Usuários', 'Preços', 'Avançado'].map((tab) => (
<Tab
key={tab}
className={({ selected }) =>
`flex-1 px-4 py-2.5 text-sm font-medium rounded-lg transition-all focus:outline-none ${selected
? 'bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white shadow-sm'
: 'text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white'
}`
}
>
{tab}
</Tab>
))}
</Tab.List>
<Tab.Panels className="space-y-4">
{/* Tab 1: Dados Básicos */}
<Tab.Panel className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-900 dark:text-zinc-100 mb-1">
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Nome do Plano *
</label>
<input
@@ -230,12 +313,12 @@ export default function CreatePlanModal({ isOpen, onClose, onSuccess }: CreatePl
value={formData.name}
onChange={handleChange}
placeholder="Ex: Ignição"
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-600 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-900 dark:text-zinc-100 mb-1">
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Slug *
</label>
<input
@@ -244,15 +327,14 @@ export default function CreatePlanModal({ isOpen, onClose, onSuccess }: CreatePl
value={formData.slug}
onChange={handleChange}
placeholder="Ex: ignition"
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-600 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
required
/>
</div>
</div>
{/* Descrição */}
<div>
<label className="block text-sm font-medium text-zinc-900 dark:text-zinc-100 mb-1">
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Descrição
</label>
<textarea
@@ -260,46 +342,101 @@ export default function CreatePlanModal({ isOpen, onClose, onSuccess }: CreatePl
value={formData.description}
onChange={handleChange}
placeholder="Descrição breve do plano"
rows={2}
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-600 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
rows={3}
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent resize-none transition-all"
/>
</div>
{/* Row 2: Usuários */}
<div className="flex items-center pt-2">
<input
type="checkbox"
name="is_active"
checked={formData.is_active}
onChange={handleChange}
className="h-4 w-4 rounded border-zinc-300 dark:border-zinc-600 focus:ring-2 focus:ring-[var(--brand-color)]"
style={{ accentColor: 'var(--brand-color)' }}
/>
<label className="ml-3 text-sm font-medium text-zinc-700 dark:text-zinc-300">
Plano Ativo
</label>
</div>
</Tab.Panel>
{/* Tab 2: Usuários */}
<Tab.Panel className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-900 dark:text-zinc-100 mb-1">
Mín. Usuários
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Mínimo de Usuários
</label>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => decrementValue('min_users', 1, 1)}
className="p-2 rounded-lg border border-zinc-200 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
<MinusIcon className="w-4 h-4 text-zinc-600 dark:text-zinc-400" />
</button>
<input
type="number"
name="min_users"
value={formData.min_users}
onChange={handleChange}
min="1"
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-600 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
className="flex-1 px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white text-center font-medium focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
<button
type="button"
onClick={() => incrementValue('min_users', 1)}
className="p-2 rounded-lg border border-zinc-200 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
<PlusIcon className="w-4 h-4 text-zinc-600 dark:text-zinc-400" />
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-zinc-900 dark:text-zinc-100 mb-1">
Máx. Usuários (-1 = ilimitado)
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Máximo de Usuários
</label>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => decrementValue('max_users', 5, -1)}
className="p-2 rounded-lg border border-zinc-200 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
<MinusIcon className="w-4 h-4 text-zinc-600 dark:text-zinc-400" />
</button>
<input
type="number"
name="max_users"
value={formData.max_users}
onChange={handleChange}
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-600 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
className="flex-1 px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white text-center font-medium focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
<button
type="button"
onClick={() => incrementValue('max_users', 5)}
className="p-2 rounded-lg border border-zinc-200 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
<PlusIcon className="w-4 h-4 text-zinc-600 dark:text-zinc-400" />
</button>
</div>
<p className="mt-2 text-xs text-zinc-500 dark:text-zinc-400">
Use -1 para ilimitado
</p>
</div>
</div>
</Tab.Panel>
{/* Row 3: Preços */}
{/* Tab 3: Preços */}
<Tab.Panel className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-900 dark:text-zinc-100 mb-1">
Preço Mensal (BRL)
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Preço Mensal (R$) *
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500">R$</span>
<input
type="number"
name="monthly_price"
@@ -307,101 +444,165 @@ export default function CreatePlanModal({ isOpen, onClose, onSuccess }: CreatePl
onChange={handleChange}
placeholder="199.99"
step="0.01"
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-600 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
className="w-full pl-10 pr-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
</div>
<p className="mt-2 text-xs text-zinc-500 dark:text-zinc-400">
Digite o preço mensal
</p>
</div>
<div>
<label className="block text-sm font-medium text-zinc-900 dark:text-zinc-100 mb-1">
Preço Anual (BRL)
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Meses Grátis (Desconto)
</label>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => decrementValue('discount_months', 1, 0)}
className="p-2 rounded-lg border border-zinc-200 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
<MinusIcon className="w-4 h-4 text-zinc-600 dark:text-zinc-400" />
</button>
<input
type="number"
name="discount_months"
value={formData.discount_months}
onChange={handleChange}
min="0"
max="11"
className="flex-1 px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white text-center font-medium focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
<button
type="button"
onClick={() => incrementValue('discount_months', 1)}
className="p-2 rounded-lg border border-zinc-200 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
<PlusIcon className="w-4 h-4 text-zinc-600 dark:text-zinc-400" />
</button>
</div>
<p className="mt-2 text-xs text-zinc-500 dark:text-zinc-400">
Ex: 2 = cliente paga 10 meses
</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Preço Anual (R$) <span className="text-emerald-600 dark:text-emerald-400"> Auto</span>
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500">R$</span>
<input
type="number"
name="annual_price"
value={formData.annual_price}
onChange={handleChange}
placeholder="1919.90"
placeholder="Calculado automaticamente"
step="0.01"
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-600 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
className="w-full pl-10 pr-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
</div>
{formData.monthly_price && formData.annual_price && formData.discount_months > 0 && (
<p className="mt-2 text-xs font-medium text-emerald-600 dark:text-emerald-400">
💰 Cliente paga {12 - formData.discount_months} meses e ganha {formData.discount_months} mês(es) grátis!
</p>
)}
</div>
{/* Row 4: Storage */}
<div className="p-4 bg-gradient-to-r from-emerald-50 to-blue-50 dark:from-emerald-900/20 dark:to-blue-900/20 rounded-lg border border-emerald-200 dark:border-emerald-800">
<p className="text-sm font-medium text-emerald-900 dark:text-emerald-100 mb-1">
🎯 Cálculo Automático de Desconto
</p>
<p className="text-xs text-emerald-700 dark:text-emerald-300">
Configure quantos meses de desconto deseja oferecer. O preço anual será calculado automaticamente: <strong>Preço Mensal × (12 - Meses Grátis)</strong>. Ideal para promoções sazonais!
</p>
</div>
</Tab.Panel>
{/* Tab 4: Avançado */}
<Tab.Panel className="space-y-4">
<div>
<label className="block text-sm font-medium text-zinc-900 dark:text-zinc-100 mb-1">
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Armazenamento (GB)
</label>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => decrementValue('storage_gb', 1, 1)}
className="p-2 rounded-lg border border-zinc-200 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
<MinusIcon className="w-4 h-4 text-zinc-600 dark:text-zinc-400" />
</button>
<input
type="number"
name="storage_gb"
value={formData.storage_gb}
onChange={handleChange}
min="1"
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-600 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
className="flex-1 px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white text-center font-medium focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
<button
type="button"
onClick={() => incrementValue('storage_gb', 1)}
className="p-2 rounded-lg border border-zinc-200 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
<PlusIcon className="w-4 h-4 text-zinc-600 dark:text-zinc-400" />
</button>
</div>
<p className="mt-2 text-xs text-zinc-500 dark:text-zinc-400">
Incrementos de 1 GB
</p>
</div>
{/* Row 5: Features */}
<div>
<label className="block text-sm font-medium text-zinc-900 dark:text-zinc-100 mb-1">
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Recursos <span className="text-xs text-zinc-500">(separados por vírgula)</span>
</label>
<textarea
name="features"
value={formData.features}
onChange={handleChange}
placeholder="CRM, ERP, Projetos, Helpdesk, Pagamentos, Contratos, Documentos"
rows={2}
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-600 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
placeholder="CRM, ERP, Projetos, Helpdesk, Pagamentos"
rows={3}
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent resize-none transition-all"
/>
</div>
{/* Row 6: Diferenciais */}
<div>
<label className="block text-sm font-medium text-zinc-900 dark:text-zinc-100 mb-1">
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Diferenciais <span className="text-xs text-zinc-500">(separados por vírgula)</span>
</label>
<textarea
name="differentiators"
value={formData.differentiators}
onChange={handleChange}
placeholder="Suporte prioritário, Gerente de conta dedicado, API integrações"
rows={2}
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-600 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
placeholder="Suporte prioritário, Gerente dedicado, API avançada"
rows={3}
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent resize-none transition-all"
/>
</div>
{/* Status Checkbox */}
<div className="flex items-center pt-2">
<input
type="checkbox"
name="is_active"
checked={formData.is_active}
onChange={handleChange}
className="h-4 w-4 text-blue-600 rounded border-zinc-300 focus:ring-blue-500"
/>
<label className="ml-3 text-sm font-medium text-zinc-900 dark:text-zinc-100">
Plano Ativo
</label>
</div>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
{/* Buttons */}
<div className="mt-6 pt-4 border-t border-zinc-200 dark:border-zinc-700 flex gap-3">
<button
type="submit"
disabled={loading}
className="flex-1 px-4 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors"
>
{loading ? 'Criando...' : 'Criar Plano'}
</button>
<div className="mt-8 pt-6 border-t border-zinc-200 dark:border-zinc-700 flex gap-3">
<button
type="button"
onClick={handleClose}
disabled={loading}
className="flex-1 px-4 py-2.5 border border-zinc-300 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 font-medium rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
className="flex-1 px-4 py-2.5 border border-zinc-200 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 font-medium rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Cancelar
</button>
<button
type="submit"
disabled={loading}
className="flex-1 px-4 py-2.5 text-white font-medium rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg hover:shadow-xl"
style={{ background: loading ? '#999' : 'var(--gradient)' }}
>
{loading ? 'Criando...' : 'Criar Plano'}
</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,797 @@
"use client";
import { Fragment, useEffect, useState } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import Tabs, { TabItem } from '@/components/ui/Tabs';
import { useToast } from '@/components/layout/ToastContext';
import {
XMarkIcon,
SparklesIcon,
UserGroupIcon,
ChartBarIcon,
FolderIcon,
LifebuoyIcon,
CreditCardIcon,
DocumentTextIcon,
ArchiveBoxIcon,
ShareIcon,
DocumentIcon,
CurrencyDollarIcon,
SparklesIcon as SparklesIconOutline,
CubeIcon,
PlusIcon,
MinusIcon
} from '@heroicons/react/24/outline';
const SOLUTION_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
'crm': UserGroupIcon,
'erp': ChartBarIcon,
'projetos': FolderIcon,
'helpdesk': LifebuoyIcon,
'pagamentos': CreditCardIcon,
'contratos': DocumentTextIcon,
'documentos': ArchiveBoxIcon,
'social': ShareIcon,
};
interface Solution {
id: string;
name: string;
slug: string;
description?: string;
is_active: boolean;
}
interface EditPlanModalProps {
isOpen: boolean;
onClose: () => void;
planId: string | null;
onSuccess: () => void;
}
export default function EditPlanModal({ isOpen, onClose, planId, onSuccess }: EditPlanModalProps) {
const toast = useToast();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [loadingSolutions, setLoadingSolutions] = useState(true);
const [allSolutions, setAllSolutions] = useState<Solution[]>([]);
const [selectedSolutions, setSelectedSolutions] = useState<string[]>([]);
const [formData, setFormData] = useState({
name: '',
slug: '',
description: '',
monthly_price: '',
annual_price: '',
min_users: 1,
max_users: 30,
storage_gb: 1,
discount_months: 2,
features: '' as string | string[],
differentiators: '' as string | string[],
is_active: true,
});
useEffect(() => {
if (isOpen && planId) {
fetchPlanData();
fetchAllSolutions();
fetchPlanSolutions();
}
}, [isOpen, planId]);
const fetchPlanData = async () => {
if (!planId) return;
try {
setLoading(true);
const response = await fetch(`/api/admin/plans/${planId}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
if (response.ok) {
const data = await response.json();
const plan = data.plan;
console.log('Plano carregado:', plan);
console.log('Solu\u00e7\u00f5es do plano:', plan.solutions);
setFormData({
name: plan.name || '',
slug: plan.slug || '',
description: plan.description || '',
monthly_price: plan.monthly_price || '',
annual_price: plan.annual_price || '',
min_users: plan.min_users || 1,
max_users: plan.max_users || 30,
storage_gb: plan.storage_gb || 1,
discount_months: plan.discount_months || 2,
features: Array.isArray(plan.features) ? plan.features.join(', ') : (plan.features || ''),
differentiators: Array.isArray(plan.differentiators) ? plan.differentiators.join(', ') : (plan.differentiators || ''),
is_active: plan.is_active ?? true,
});
const solutionIds = plan.solutions?.map((s: Solution) => s.id) || [];
console.log('IDs das solu\u00e7\u00f5es extra\u00eddos:', solutionIds);
// Não seta aqui, vamos buscar via API separada
// setSelectedSolutions(solutionIds);
}
} catch (error) {
console.error('Error fetching plan:', error);
} finally {
setLoading(false);
}
};
const fetchPlanSolutions = async () => {
if (!planId) return;
try {
console.log('Buscando soluções do plano...');
const response = await fetch(`/api/admin/plans/${planId}/solutions`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
if (response.ok) {
const data = await response.json();
console.log('Soluções do plano (via API separada):', data);
const solutionIds = data.solutions?.map((s: Solution) => s.id) || [];
console.log('IDs extraídos:', solutionIds);
setSelectedSolutions(solutionIds);
} else {
console.error('Erro ao buscar soluções:', response.status);
}
} catch (error) {
console.error('Error fetching plan solutions:', error);
}
};
const fetchAllSolutions = async () => {
try {
setLoadingSolutions(true);
const response = await fetch('/api/admin/solutions', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
if (response.ok) {
const data = await response.json();
console.log('Todas as solu\u00e7\u00f5es dispon\u00edveis:', data.solutions);
setAllSolutions(data.solutions || []);
}
} catch (error) {
console.error('Error fetching solutions:', error);
} finally {
setLoadingSolutions(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!planId) return;
setSaving(true);
try {
const planPayload = {
name: formData.name,
slug: formData.slug,
description: formData.description,
monthly_price: parseFloat(String(formData.monthly_price)) || 0,
annual_price: parseFloat(String(formData.annual_price)) || 0,
min_users: parseInt(String(formData.min_users)) || 1,
max_users: parseInt(String(formData.max_users)) || 30,
storage_gb: parseInt(String(formData.storage_gb)) || 1,
is_active: formData.is_active,
features: typeof formData.features === 'string'
? formData.features.split(',').map(f => f.trim()).filter(f => f)
: formData.features,
differentiators: typeof formData.differentiators === 'string'
? formData.differentiators.split(',').map(d => d.trim()).filter(d => d)
: formData.differentiators,
};
console.log('Atualizando plano:', planPayload);
// Atualizar dados do plano
const response = await fetch(`/api/admin/plans/${planId}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(planPayload),
});
if (!response.ok) {
const errorData = await response.json();
console.error('Erro ao atualizar plano:', errorData);
toast.error('Erro ao atualizar plano', errorData.message || 'Verifique os dados');
setSaving(false);
return;
}
console.log('Plano atualizado, atualizando soluções:', selectedSolutions);
// Atualizar soluções associadas
const solutionsResponse = await fetch(`/api/admin/plans/${planId}/solutions`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ solution_ids: selectedSolutions }),
});
if (!solutionsResponse.ok) {
const errorData = await solutionsResponse.json();
console.error('Erro ao atualizar soluções:', errorData);
toast.error('Erro ao atualizar soluções', errorData.message || 'Verifique os dados');
setSaving(false);
return;
}
console.log('Soluções atualizadas com sucesso');
toast.success('Plano atualizado!', 'Todas as alterações foram salvas com sucesso');
onSuccess();
onClose();
} catch (error) {
console.error('Error updating plan:', error);
toast.error('Erro ao atualizar plano', 'Ocorreu um erro inesperado');
} finally {
setSaving(false);
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value, type } = e.target;
if (type === 'checkbox') {
const checked = (e.target as HTMLInputElement).checked;
setFormData(prev => ({ ...prev, [name]: checked }));
} else if (type === 'number') {
const numValue = parseFloat(value) || 0;
setFormData(prev => {
const newData = {
...prev,
[name]: numValue,
};
// Calcular preço anual automaticamente quando mensal ou discount_months muda
if ((name === 'monthly_price' || name === 'discount_months')) {
const monthlyPrice = name === 'monthly_price' ? numValue : parseFloat(String(prev.monthly_price)) || 0;
const discountMonths = name === 'discount_months' ? numValue : prev.discount_months;
if (monthlyPrice > 0 && discountMonths >= 0) {
// Calcula: (12 meses - meses de desconto) * preço mensal
const monthsToPay = Math.max(0, 12 - discountMonths);
const annualWithDiscount = (monthlyPrice * monthsToPay).toFixed(2);
newData.annual_price = annualWithDiscount;
}
}
return newData;
});
} else {
setFormData(prev => ({ ...prev, [name]: value }));
}
};
const incrementValue = (field: 'min_users' | 'max_users' | 'storage_gb' | 'discount_months', step: number = 1) => {
setFormData(prev => {
const newValue = prev[field] + step;
const newData = {
...prev,
[field]: newValue,
};
// Recalcular preço anual se mudou discount_months
if (field === 'discount_months') {
const monthlyPrice = parseFloat(String(prev.monthly_price)) || 0;
if (monthlyPrice > 0 && newValue >= 0) {
const monthsToPay = Math.max(0, 12 - newValue);
newData.annual_price = (monthlyPrice * monthsToPay).toFixed(2);
}
}
return newData;
});
};
const decrementValue = (field: 'min_users' | 'max_users' | 'storage_gb' | 'discount_months', step: number = 1, min: number = 0) => {
setFormData(prev => {
const newValue = Math.max(min, prev[field] - step);
const newData = {
...prev,
[field]: newValue,
};
// Recalcular preço anual se mudou discount_months
if (field === 'discount_months') {
const monthlyPrice = parseFloat(String(prev.monthly_price)) || 0;
if (monthlyPrice > 0 && newValue >= 0) {
const monthsToPay = Math.max(0, 12 - newValue);
newData.annual_price = (monthlyPrice * monthsToPay).toFixed(2);
}
}
return newData;
});
};
const handleClose = () => {
if (!saving) {
onClose();
}
};
// Configuração dos tabs
const tabsConfig: TabItem[] = [
{
name: 'Dados Básicos',
icon: DocumentIcon,
content: (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Nome do Plano *
</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleInputChange}
required
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Slug *
</label>
<input
type="text"
name="slug"
value={formData.slug}
onChange={handleInputChange}
required
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Descrição
</label>
<textarea
name="description"
value={formData.description}
onChange={handleInputChange}
rows={3}
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all resize-none"
/>
</div>
<div className="pt-2">
<label className="flex items-center gap-3">
<input
type="checkbox"
name="is_active"
checked={formData.is_active}
onChange={handleInputChange}
className="h-5 w-5 rounded border-zinc-300 dark:border-zinc-600 text-[var(--brand-color)] focus:ring-[var(--brand-color)] dark:bg-zinc-800 cursor-pointer"
style={{ accentColor: 'var(--brand-color)' }}
/>
<span className="text-sm font-medium text-zinc-900 dark:text-white">Plano Ativo</span>
</label>
</div>
</div>
)
},
{
name: 'Limites e Preços',
icon: CurrencyDollarIcon,
content: (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Mínimo de Usuários
</label>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => decrementValue('min_users', 1, 1)}
className="p-2 rounded-lg border border-zinc-200 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
<MinusIcon className="w-4 h-4 text-zinc-600 dark:text-zinc-400" />
</button>
<input
type="number"
name="min_users"
value={formData.min_users}
onChange={handleInputChange}
min="1"
className="flex-1 px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white text-center font-medium focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
<button
type="button"
onClick={() => incrementValue('min_users', 1)}
className="p-2 rounded-lg border border-zinc-200 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
<PlusIcon className="w-4 h-4 text-zinc-600 dark:text-zinc-400" />
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Máximo de Usuários
</label>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => decrementValue('max_users', 5, -1)}
className="p-2 rounded-lg border border-zinc-200 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
<MinusIcon className="w-4 h-4 text-zinc-600 dark:text-zinc-400" />
</button>
<input
type="number"
name="max_users"
value={formData.max_users}
onChange={handleInputChange}
className="flex-1 px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white text-center font-medium focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
<button
type="button"
onClick={() => incrementValue('max_users', 5)}
className="p-2 rounded-lg border border-zinc-200 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
<PlusIcon className="w-4 h-4 text-zinc-600 dark:text-zinc-400" />
</button>
</div>
<p className="mt-2 text-xs text-zinc-500 dark:text-zinc-400">
Use -1 para ilimitado
</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Armazenamento (GB)
</label>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => decrementValue('storage_gb', 1, 1)}
className="p-2 rounded-lg border border-zinc-200 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
<MinusIcon className="w-4 h-4 text-zinc-600 dark:text-zinc-400" />
</button>
<input
type="number"
name="storage_gb"
value={formData.storage_gb}
onChange={handleInputChange}
min="1"
className="flex-1 px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white text-center font-medium focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
<button
type="button"
onClick={() => incrementValue('storage_gb', 1)}
className="p-2 rounded-lg border border-zinc-200 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
<PlusIcon className="w-4 h-4 text-zinc-600 dark:text-zinc-400" />
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Preço Mensal (R$) *
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500">R$</span>
<input
type="number"
name="monthly_price"
value={formData.monthly_price}
onChange={handleInputChange}
placeholder="199.99"
step="0.01"
className="w-full pl-10 pr-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
</div>
<p className="mt-2 text-xs text-zinc-500 dark:text-zinc-400">
Digite o preço mensal
</p>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Meses Grátis (Desconto)
</label>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => decrementValue('discount_months', 1, 0)}
className="p-2 rounded-lg border border-zinc-200 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
<MinusIcon className="w-4 h-4 text-zinc-600 dark:text-zinc-400" />
</button>
<input
type="number"
name="discount_months"
value={formData.discount_months}
onChange={handleInputChange}
min="0"
max="11"
className="flex-1 px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white text-center font-medium focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
<button
type="button"
onClick={() => incrementValue('discount_months', 1)}
className="p-2 rounded-lg border border-zinc-200 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
<PlusIcon className="w-4 h-4 text-zinc-600 dark:text-zinc-400" />
</button>
</div>
<p className="mt-2 text-xs text-zinc-500 dark:text-zinc-400">
Ex: 2 = cliente paga 10 meses
</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Preço Anual (R$) <span className="text-emerald-600 dark:text-emerald-400"> Auto</span>
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500">R$</span>
<input
type="number"
name="annual_price"
value={formData.annual_price}
onChange={handleInputChange}
placeholder="Calculado automaticamente"
step="0.01"
className="w-full pl-10 pr-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
</div>
<p className="mt-2 text-xs text-emerald-600 dark:text-emerald-400">
Calculado automaticamente com base no preço mensal e desconto
</p>
</div>
</div>
)
},
{
name: 'Recursos',
icon: SparklesIconOutline,
content: (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Recursos <span className="text-xs font-normal text-zinc-500 dark:text-zinc-400">(separados por vírgula)</span>
</label>
<textarea
name="features"
value={typeof formData.features === 'string' ? formData.features : formData.features.join(', ')}
onChange={handleInputChange}
rows={4}
placeholder="Ex: Gestão de leads, Relatórios avançados, API ilimitada"
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all resize-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Diferenciais <span className="text-xs font-normal text-zinc-500 dark:text-zinc-400">(separados por vírgula)</span>
</label>
<textarea
name="differentiators"
value={typeof formData.differentiators === 'string' ? formData.differentiators : formData.differentiators.join(', ')}
onChange={handleInputChange}
rows={4}
placeholder="Ex: Suporte prioritário, Treinamento personalizado, Consultoria mensal"
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all resize-none"
/>
</div>
</div>
)
},
{
name: 'Soluções',
icon: CubeIcon,
content: (
<div className="space-y-4">
<div>
<h3 className="text-base font-medium text-zinc-900 dark:text-white mb-1">
Soluções Incluídas
</h3>
<p className="text-sm text-zinc-600 dark:text-zinc-400 mb-4">
Selecione quais soluções estarão disponíveis para agências com este plano
</p>
{loadingSolutions ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--brand-color)]"></div>
</div>
) : allSolutions.length === 0 ? (
<div className="rounded-lg bg-zinc-50 dark:bg-zinc-800 p-6 text-center">
<p className="text-sm text-zinc-600 dark:text-zinc-400">
Nenhuma solução cadastrada ainda.
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{allSolutions.map((solution) => (
<label
key={solution.id}
className={`flex items-start gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all ${selectedSolutions.includes(solution.id)
? 'bg-zinc-50 dark:bg-zinc-800/50'
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600'
}`}
style={{
borderColor: selectedSolutions.includes(solution.id) ? 'var(--brand-color)' : undefined
}}
>
<input
type="checkbox"
checked={selectedSolutions.includes(solution.id)}
onChange={(e) => {
if (e.target.checked) {
setSelectedSolutions([...selectedSolutions, solution.id]);
} else {
setSelectedSolutions(selectedSolutions.filter(id => id !== solution.id));
}
}}
className="mt-0.5 h-4 w-4 rounded border-zinc-300 dark:border-zinc-600 text-[var(--brand-color)] focus:ring-[var(--brand-color)] dark:bg-zinc-800 cursor-pointer"
style={{ accentColor: 'var(--brand-color)' }}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0" style={{ background: 'var(--gradient)' }}>
{(() => {
const Icon = SOLUTION_ICONS[solution.slug] || FolderIcon;
return <Icon className="w-3.5 h-3.5 text-white" />;
})()}
</div>
<span className="text-sm font-medium text-zinc-900 dark:text-white truncate">
{solution.name}
</span>
{!solution.is_active && (
<span className="px-1.5 py-0.5 text-xs font-medium bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-400 rounded">
Inativo
</span>
)}
</div>
{solution.description && (
<p className="mt-1 text-xs text-zinc-600 dark:text-zinc-400 line-clamp-1">
{solution.description}
</p>
)}
</div>
</label>
))}
</div>
)}
{selectedSolutions.length > 0 && (
<div className="mt-4 p-3 rounded-lg border" style={{
backgroundColor: 'var(--brand-color-light, rgba(59, 130, 246, 0.1))',
borderColor: 'var(--brand-color)'
}}>
<p className="text-sm font-medium" style={{ color: 'var(--brand-color)' }}>
{selectedSolutions.length} {selectedSolutions.length === 1 ? 'solução selecionada' : 'soluções selecionadas'}
</p>
</div>
)}
</div>
</div>
)
}
];
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={handleClose}>
<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.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"
>
<Dialog.Panel className="w-full max-w-4xl transform overflow-hidden rounded-2xl bg-white dark:bg-zinc-900 text-left align-middle shadow-xl transition-all border border-zinc-200 dark:border-zinc-800">
{loading ? (
<div className="flex items-center justify-center p-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--brand-color)]"></div>
</div>
) : (
<>
{/* Header */}
<div className="flex items-start justify-between p-6 border-b border-zinc-200 dark:border-zinc-700">
<div className="flex items-start gap-4">
<div
className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl shadow-lg"
style={{ background: 'var(--gradient)' }}
>
<SparklesIcon className="h-6 w-6 text-white" />
</div>
<div>
<Dialog.Title className="text-xl font-bold text-zinc-900 dark:text-white">
Editar Plano
</Dialog.Title>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
Atualize as informações e soluções do plano
</p>
</div>
</div>
<button
type="button"
className="rounded-lg p-1.5 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
onClick={handleClose}
disabled={saving}
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
{/* Form com Tabs */}
<form onSubmit={handleSubmit} className="p-6">
<Tabs tabs={tabsConfig} />
{/* Footer com botões */}
<div className="mt-6 pt-6 border-t border-zinc-200 dark:border-zinc-700 flex gap-3">
<button
type="button"
onClick={handleClose}
disabled={saving}
className="flex-1 px-4 py-2.5 border border-zinc-200 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 font-medium rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Cancelar
</button>
<button
type="submit"
disabled={saving}
className="flex-1 px-4 py-2.5 text-white font-medium rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-sm hover:shadow-md"
style={{ backgroundImage: 'var(--gradient)' }}
>
{saving ? 'Salvando...' : 'Salvar Alterações'}
</button>
</div>
</form>
</>
)}
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
);
}

View File

@@ -1,13 +1,13 @@
"use client";
import { ButtonHTMLAttributes, forwardRef } from "react";
import { ButtonHTMLAttributes, forwardRef, ReactNode } from "react";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "outline" | "ghost";
size?: "sm" | "md" | "lg";
isLoading?: boolean;
leftIcon?: string;
rightIcon?: string;
leftIcon?: string | ReactNode;
rightIcon?: string | ReactNode;
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
@@ -55,11 +55,19 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
<i className="ri-loader-4-line animate-spin mr-2 text-[20px]" />
)}
{!isLoading && leftIcon && (
typeof leftIcon === 'string' ? (
<i className={`${leftIcon} mr-2 text-[20px]`} />
) : (
<div className="w-5 h-5 mr-2">{leftIcon}</div>
)
)}
{children}
{!isLoading && rightIcon && (
typeof rightIcon === 'string' ? (
<i className={`${rightIcon} ml-2 text-[20px]`} />
) : (
<div className="w-5 h-5 ml-2">{rightIcon}</div>
)
)}
</button>
);

View File

@@ -1,13 +1,14 @@
"use client";
import { InputHTMLAttributes, forwardRef, useState, ReactNode } from "react";
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
helperText?: ReactNode;
leftIcon?: string;
rightIcon?: string;
leftIcon?: string | ReactNode;
rightIcon?: string | ReactNode;
onRightIconClick?: () => void;
}
@@ -41,9 +42,13 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
)}
<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]`}
/>
<div className="absolute left-3.5 top-1/2 -translate-y-1/2 text-zinc-400 dark:text-gray-400">
{typeof leftIcon === 'string' ? (
<i className={`${leftIcon} text-[20px]`} />
) : (
<div className="w-5 h-5">{leftIcon}</div>
)}
</div>
)}
<input
ref={ref}
@@ -69,21 +74,23 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
<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"
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-900 dark:text-gray-400 dark:hover:text-white transition-colors cursor-pointer"
>
<i
className={`${showPassword ? "ri-eye-off-line" : "ri-eye-line"} text-[20px]`}
/>
{showPassword ? (
<EyeSlashIcon className="w-5 h-5" />
) : (
<EyeIcon className="w-5 h-5" />
)}
</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"
>
<div className="absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-400 dark:text-gray-400">
{typeof rightIcon === 'string' ? (
<i className={`${rightIcon} text-[20px]`} />
</button>
) : (
<div className="w-5 h-5">{rightIcon}</div>
)}
</div>
)}
</div>
{error && (

View File

@@ -1,6 +1,6 @@
"use client";
import { SelectHTMLAttributes, forwardRef, useState, useRef, useEffect } from "react";
import { SelectHTMLAttributes, forwardRef, useState, useRef, useEffect, ReactNode } from "react";
interface SelectOption {
value: string;
@@ -11,7 +11,7 @@ interface SearchableSelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElem
label?: string;
error?: string;
helperText?: string;
leftIcon?: string;
leftIcon?: string | ReactNode;
options: SelectOption[];
placeholder?: string;
onChange?: (value: string) => void;
@@ -115,9 +115,13 @@ const SearchableSelect = forwardRef<HTMLSelectElement, SearchableSelectProps>(
<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`}
/>
<div className="absolute left-3.5 top-1/2 -translate-y-1/2 text-zinc-400 dark:text-gray-400 pointer-events-none z-10">
{typeof leftIcon === 'string' ? (
<i className={`${leftIcon} text-[20px]`} />
) : (
<div className="w-5 h-5">{leftIcon}</div>
)}
</div>
)}
{/* Custom trigger */}

View File

@@ -0,0 +1,102 @@
'use client';
import { Tab } from '@headlessui/react';
import { ReactNode } from 'react';
function classNames(...classes: string[]) {
return classes.filter(Boolean).join(' ');
}
export interface TabItem {
name: string;
icon?: React.ComponentType<{ className?: string }>;
content: ReactNode;
}
interface TabsProps {
tabs: TabItem[];
defaultIndex?: number;
onChange?: (index: number) => void;
variant?: 'card' | 'modal'; // Novo: variante para diferentes contextos
}
export default function Tabs({ tabs, defaultIndex = 0, onChange, variant = 'card' }: TabsProps) {
if (variant === 'modal') {
// Versão para modais - sem card wrapper
return (
<Tab.Group defaultIndex={defaultIndex} onChange={onChange}>
<Tab.List className="flex border-b border-zinc-200 dark:border-zinc-700 px-6">
{tabs.map((tab) => (
<Tab
key={tab.name}
className={({ selected }) =>
classNames(
'px-4 py-3 text-sm font-medium border-b-2 transition-colors focus:outline-none whitespace-nowrap',
selected
? 'border-[var(--brand-color)] text-[var(--brand-color)]'
: 'border-transparent text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
)
}
>
<div className="flex items-center justify-center gap-2">
{tab.icon && <tab.icon className="w-4 h-4 flex-shrink-0" />}
{tab.name}
</div>
</Tab>
))}
</Tab.List>
<Tab.Panels className="p-6">
{tabs.map((tab, idx) => (
<Tab.Panel
key={idx}
className="focus:outline-none"
>
{tab.content}
</Tab.Panel>
))}
</Tab.Panels>
</Tab.Group>
);
}
// Versão padrão para páginas - com card wrapper
return (
<Tab.Group defaultIndex={defaultIndex} onChange={onChange}>
<div className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 overflow-hidden">
<Tab.List className="flex space-x-1 rounded-t-xl bg-zinc-100 dark:bg-zinc-800/50 p-1 overflow-x-auto scrollbar-hide">
{tabs.map((tab) => (
<Tab
key={tab.name}
className={({ selected }) =>
classNames(
'w-full rounded-lg py-2 sm:py-2.5 px-3 sm:px-4 text-xs sm:text-sm font-medium leading-5 transition-all duration-200 whitespace-nowrap',
'focus:outline-none',
selected
? 'bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white 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-1.5 sm:gap-2">
{tab.icon && <tab.icon className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />}
{tab.name}
</div>
</Tab>
))}
</Tab.List>
<Tab.Panels className="p-4 sm:p-6">
{tabs.map((tab, idx) => (
<Tab.Panel
key={idx}
className="focus:outline-none"
>
{tab.content}
</Tab.Panel>
))}
</Tab.Panels>
</div>
</Tab.Group>
);
}

View File

@@ -4,6 +4,16 @@ const nextConfig: NextConfig = {
experimental: {
externalDir: true,
},
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'images.unsplash.com',
port: '',
pathname: '/**',
},
],
},
async rewrites() {
return {
beforeFiles: [

View File

@@ -16,7 +16,6 @@
"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": {
@@ -3409,6 +3408,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT",
"peer": true
},
@@ -4556,15 +4556,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/goober": {
"version": "2.1.18",
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz",
"integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==",
"license": "MIT",
"peerDependencies": {
"csstype": "^3.0.10"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -6232,23 +6223,6 @@
"react": "^19.2.0"
}
},
"node_modules/react-hot-toast": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
"integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
"license": "MIT",
"dependencies": {
"csstype": "^3.1.3",
"goober": "^2.1.16"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">=16",
"react-dom": ">=16"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",

View File

@@ -17,7 +17,6 @@
"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": {

View File

@@ -0,0 +1,12 @@
DELETE FROM users WHERE email = 'admin@aggios.app';
INSERT INTO users (id, email, password_hash, first_name, role, is_active, created_at, updated_at)
VALUES (
gen_random_uuid(),
'admin@aggios.app',
'$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi',
'Admin Master',
'SUPERADMIN',
true,
NOW(),
NOW()
);

View File

@@ -0,0 +1,15 @@
-- Migration para adicionar colunas de endereco completas e personalizacao
-- Criada em 2025-12-13 para corrigir problema de colunas faltantes
ALTER TABLE tenants
ADD COLUMN IF NOT EXISTS neighborhood VARCHAR(100),
ADD COLUMN IF NOT EXISTS street VARCHAR(100),
ADD COLUMN IF NOT EXISTS number VARCHAR(20),
ADD COLUMN IF NOT EXISTS complement VARCHAR(100),
ADD COLUMN IF NOT EXISTS team_size VARCHAR(20),
ADD COLUMN IF NOT EXISTS primary_color VARCHAR(7),
ADD COLUMN IF NOT EXISTS secondary_color VARCHAR(7),
ADD COLUMN IF NOT EXISTS logo_url TEXT,
ADD COLUMN IF NOT EXISTS logo_horizontal_url TEXT;
-- Comentario: Estas colunas sao necessarias para o cadastro completo de agencias e personalizacao

View File

@@ -0,0 +1,30 @@
-- Tabela de soluções disponíveis (CRM, ERP, etc.)
CREATE TABLE IF NOT EXISTS solutions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL UNIQUE,
slug VARCHAR(50) NOT NULL UNIQUE,
icon VARCHAR(50),
description TEXT,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Relacionamento N:N entre planos e soluções
CREATE TABLE IF NOT EXISTS plan_solutions (
plan_id UUID REFERENCES plans(id) ON DELETE CASCADE,
solution_id UUID REFERENCES solutions(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (plan_id, solution_id)
);
-- Índices
CREATE INDEX idx_solutions_slug ON solutions(slug);
CREATE INDEX idx_solutions_is_active ON solutions(is_active);
CREATE INDEX idx_plan_solutions_plan_id ON plan_solutions(plan_id);
CREATE INDEX idx_plan_solutions_solution_id ON plan_solutions(solution_id);
-- Seed inicial: CRM
INSERT INTO solutions (id, name, slug, icon, description, is_active) VALUES
('00000000-0000-0000-0000-000000000001', 'CRM', 'crm', 'users', 'Gestão de clientes e relacionamento', true)
ON CONFLICT (slug) DO NOTHING;

View File

@@ -0,0 +1,86 @@
-- Tabela de clientes do CRM (multi-tenant)
CREATE TABLE IF NOT EXISTS crm_customers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
-- Dados básicos
name VARCHAR(255) NOT NULL,
email VARCHAR(255),
phone VARCHAR(50),
company VARCHAR(255),
position VARCHAR(100),
-- Endereço
address VARCHAR(255),
city VARCHAR(100),
state VARCHAR(50),
zip_code VARCHAR(20),
country VARCHAR(100) DEFAULT 'Brasil',
-- Informações adicionais
notes TEXT,
tags TEXT[], -- Array de tags para filtros rápidos
-- Controle
is_active BOOLEAN DEFAULT true,
created_by UUID REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- Constraint: email deve ser único por agência (pode repetir entre agências)
CONSTRAINT unique_email_per_tenant UNIQUE (tenant_id, email)
);
-- Tabela de listas para organizar clientes
CREATE TABLE IF NOT EXISTS crm_lists (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
description TEXT,
color VARCHAR(7) DEFAULT '#3b82f6', -- Hex color
created_by UUID REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- Lista deve ter nome único por agência
CONSTRAINT unique_list_per_tenant UNIQUE (tenant_id, name)
);
-- Relacionamento N:N entre clientes e listas
CREATE TABLE IF NOT EXISTS crm_customer_lists (
customer_id UUID REFERENCES crm_customers(id) ON DELETE CASCADE,
list_id UUID REFERENCES crm_lists(id) ON DELETE CASCADE,
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
added_by UUID REFERENCES users(id),
PRIMARY KEY (customer_id, list_id)
);
-- Índices para performance
CREATE INDEX idx_crm_customers_tenant_id ON crm_customers(tenant_id);
CREATE INDEX idx_crm_customers_email ON crm_customers(email);
CREATE INDEX idx_crm_customers_name ON crm_customers(name);
CREATE INDEX idx_crm_customers_is_active ON crm_customers(is_active);
CREATE INDEX idx_crm_customers_tags ON crm_customers USING GIN(tags);
CREATE INDEX idx_crm_lists_tenant_id ON crm_lists(tenant_id);
CREATE INDEX idx_crm_lists_name ON crm_lists(name);
CREATE INDEX idx_crm_customer_lists_customer_id ON crm_customer_lists(customer_id);
CREATE INDEX idx_crm_customer_lists_list_id ON crm_customer_lists(list_id);
-- Trigger para atualizar updated_at
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER update_crm_customers_updated_at BEFORE UPDATE ON crm_customers
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_crm_lists_updated_at BEFORE UPDATE ON crm_lists
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

View File

@@ -0,0 +1,16 @@
-- Migration: Vincular Signup Templates com Solutions
-- Permite que ao criar um link de cadastro personalizado, o superadmin
-- selecione quais soluções estarão disponíveis para as agências que se cadastrarem
CREATE TABLE IF NOT EXISTS template_solutions (
template_id UUID NOT NULL REFERENCES agency_signup_templates(id) ON DELETE CASCADE,
solution_id UUID NOT NULL REFERENCES solutions(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (template_id, solution_id)
);
-- Index para queries rápidas
CREATE INDEX idx_template_solutions_template ON template_solutions(template_id);
CREATE INDEX idx_template_solutions_solution ON template_solutions(solution_id);
COMMENT ON TABLE template_solutions IS 'Relacionamento N:N entre signup templates e solutions - define quais soluções estarão disponíveis ao cadastrar via template';

View File

@@ -0,0 +1,28 @@
-- Migration: Seed todas as soluções do sistema
-- Cria todas as solutions disponíveis para que o superadmin possa gerenciar
-- quais aparecem nos planos das agências
-- Inserir todas as soluções (CRM já existe, apenas atualizar se necessário)
INSERT INTO solutions (id, name, slug, icon, description, is_active) VALUES
-- CRM já existe, mas vamos garantir que está correto
('00000000-0000-0000-0000-000000000001', 'CRM', 'crm', '🚀', 'Gestão de Relacionamento com Clientes', true)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
slug = EXCLUDED.slug,
icon = EXCLUDED.icon,
description = EXCLUDED.description;
-- Inserir novas soluções
INSERT INTO solutions (id, name, slug, icon, description, is_active) VALUES
('00000000-0000-0000-0000-000000000002', 'ERP', 'erp', '📊', 'Gestão Empresarial e Financeira', true),
('00000000-0000-0000-0000-000000000003', 'Projetos', 'projetos', '💼', 'Gestão de Projetos e Tarefas', true),
('00000000-0000-0000-0000-000000000004', 'Helpdesk', 'helpdesk', '🆘', 'Central de Atendimento e Suporte', true),
('00000000-0000-0000-0000-000000000005', 'Pagamentos', 'pagamentos', '💳', 'Gestão de Cobranças e Pagamentos', true),
('00000000-0000-0000-0000-000000000006', 'Contratos', 'contratos', '📄', 'Gestão de Contratos e Documentos Legais', true),
('00000000-0000-0000-0000-000000000007', 'Documentos', 'documentos', '📁', 'Armazenamento e Gestão de Arquivos', true),
('00000000-0000-0000-0000-000000000008', 'Redes Sociais', 'social', '🔗', 'Gestão de Redes Sociais', true)
ON CONFLICT (id) DO NOTHING;
-- Comentários explicativos
COMMENT ON COLUMN solutions.slug IS 'Slug usado para identificar a solução no menu (deve corresponder ao ID do menu no frontend)';
COMMENT ON COLUMN solutions.icon IS 'Emoji ou código do ícone para exibição visual';

View File

@@ -0,0 +1,11 @@
-- Migration: Corrigir encoding UTF-8 das descrições das soluções
-- Atualiza as descrições que estão com caracteres bugados
UPDATE solutions SET description = 'Gestão de Relacionamento com Clientes' WHERE slug = 'crm';
UPDATE solutions SET description = 'Gestão Empresarial e Financeira' WHERE slug = 'erp';
UPDATE solutions SET description = 'Gestão de Projetos e Tarefas' WHERE slug = 'projetos';
UPDATE solutions SET description = 'Central de Atendimento e Suporte' WHERE slug = 'helpdesk';
UPDATE solutions SET description = 'Gestão de Cobranças e Pagamentos' WHERE slug = 'pagamentos';
UPDATE solutions SET description = 'Gestão de Contratos e Documentos Legais' WHERE slug = 'contratos';
UPDATE solutions SET description = 'Armazenamento e Gestão de Arquivos' WHERE slug = 'documentos';
UPDATE solutions SET description = 'Gestão de Redes Sociais' WHERE slug = 'social';

137
scripts/README.md Normal file
View File

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

32
scripts/backup-db.ps1 Normal file
View File

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

37
scripts/rebuild-safe.ps1 Normal file
View File

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

View File

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

33
scripts/restore-db.ps1 Normal file
View File

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

View File

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

55
setup-hosts.ps1 Normal file
View File

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