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

@@ -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 (
<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>
<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>
</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,57 +19,116 @@ export default function CRMPage() {
{ name: 'Crescimento', value: '+12%', icon: ArrowTrendingUpIcon, color: 'orange' },
];
return (
<div className="p-6 h-full overflow-auto">
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Mission Control (CRM)
</h1>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
Visão geral do relacionamento com clientes
</p>
</div>
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',
},
];
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{stats.map((stat) => {
const Icon = stat.icon;
return (
<div
key={stat.name}
className="group relative overflow-hidden rounded-xl bg-white dark:bg-gray-900 p-4 border border-gray-200 dark:border-gray-800 transition-all"
>
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-gray-600 dark:text-gray-400">
{stat.name}
</p>
<p className="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
{stat.value}
</p>
</div>
<div
className={`rounded-lg p-2 bg-${stat.color}-100 dark:bg-${stat.color}-900/20`}
>
<Icon
className={`h-5 w-5 text-${stat.color}-600 dark:text-${stat.color}-400`}
/>
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">
CRM
</h1>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
Visão geral do relacionamento com clientes
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{stats.map((stat) => {
const Icon = stat.icon;
return (
<div
key={stat.name}
className="group relative overflow-hidden rounded-xl bg-white dark:bg-gray-900 p-4 border border-gray-200 dark:border-gray-800 transition-all"
>
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-gray-600 dark:text-gray-400">
{stat.name}
</p>
<p className="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
{stat.value}
</p>
</div>
<div
className={`rounded-lg p-2 bg-${stat.color}-100 dark:bg-${stat.color}-900/20`}
>
<Icon
className={`h-5 w-5 text-${stat.color}-600 dark:text-${stat.color}-400`}
/>
</div>
</div>
</div>
</div>
);
})}
</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>
);
})}
</div>
<div className="rounded-xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-6 h-64 flex items-center justify-center">
<p className="text-gray-500">Atividades Recentes (Em breve)</p>
{/* 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>
</div>
<div className="rounded-xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-6 h-64 flex items-center justify-center">
<p className="text-gray-500">Atividades Recentes (Em breve)</p>
</div>
</div>
</div>
</div>
</div>
</SolutionGuard>
);
}

View File

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

View File

@@ -1,10 +1,16 @@
'use client';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
export default function DocumentosPage() {
return (
<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>
<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>
</div>
</SolutionGuard>
);
}

View File

@@ -1,10 +1,16 @@
'use client';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
export default function ERPPage() {
return (
<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>
<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>
</div>
</SolutionGuard>
);
}

View File

@@ -1,10 +1,16 @@
'use client';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
export default function HelpdeskPage() {
return (
<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>
<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>
</div>
</SolutionGuard>
);
}

View File

@@ -1,10 +1,16 @@
'use client';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
export default function PagamentosPage() {
return (
<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>
<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>
</div>
</SolutionGuard>
);
}

View File

@@ -1,10 +1,16 @@
'use client';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
export default function ProjetosPage() {
return (
<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>
<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>
</div>
</SolutionGuard>
);
}

View File

@@ -1,10 +1,16 @@
'use client';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
export default function SocialPage() {
return (
<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>
<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>
</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 {
@@ -180,4 +180,15 @@ html.dark {
body {
@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}>
<LayoutWrapper>
{children}
</LayoutWrapper>
<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 */}
<SidebarRail
isExpanded={isExpanded}
onToggle={() => setIsExpanded(!isExpanded)}
menuItems={menuItems}
/>
<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,47 +51,57 @@ 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) => {
const Icon = crumb.icon;
const isLast = index === breadcrumbs.length - 1;
<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>
return (
<div key={crumb.href} className="flex items-center gap-2">
{Icon ? (
<Link
href={crumb.href}
className="flex items-center gap-1.5 text-gray-500 dark:text-zinc-400 hover:text-gray-900 dark:hover:text-zinc-200 transition-colors"
>
<Icon className="w-3.5 h-3.5" />
<span>{crumb.name}</span>
</Link>
) : (
<Link
href={crumb.href}
className={`${isLast ? 'text-gray-900 dark:text-white font-medium' : 'text-gray-500 dark:text-zinc-400 hover:text-gray-900 dark:hover:text-zinc-200'} transition-colors`}
>
{crumb.name}
</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;
{!isLast && <ChevronRightIcon className="w-3 h-3 text-gray-400 dark:text-zinc-600" />}
</div>
);
})}
return (
<div key={crumb.href} className="flex items-center gap-2">
{Icon ? (
<Link
href={crumb.href}
className="flex items-center gap-1.5 text-gray-500 dark:text-zinc-400 hover:text-gray-900 dark:hover:text-zinc-200 transition-colors"
>
<Icon className="w-3.5 h-3.5" />
<span>{crumb.name}</span>
</Link>
) : (
<Link
href={crumb.href}
className={`${isLast ? 'text-gray-900 dark:text-white font-medium' : 'text-gray-500 dark:text-zinc-400 hover:text-gray-900 dark:hover:text-zinc-200'} transition-colors`}
>
{crumb.name}
</Link>
)}
{!isLast && <ChevronRightIcon className="w-3 h-3 text-gray-400 dark:text-zinc-600" />}
</div>
);
})}
</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);
}
}