chore(release): snapshot 1.4.2
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
548
front-end-agency/app/(agency)/crm/clientes/page.tsx
Normal file
548
front-end-agency/app/(agency)/crm/clientes/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
front-end-agency/app/(agency)/crm/funis/page.tsx
Normal file
31
front-end-agency/app/(agency)/crm/funis/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
432
front-end-agency/app/(agency)/crm/listas/page.tsx
Normal file
432
front-end-agency/app/(agency)/crm/listas/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
front-end-agency/app/(agency)/crm/negociacoes/page.tsx
Normal file
31
front-end-agency/app/(agency)/crm/negociacoes/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
7
front-end-agency/app/ClientProviders.tsx
Normal file
7
front-end-agency/app/ClientProviders.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { ToastProvider } from '@/components/layout/ToastContext';
|
||||
|
||||
export function ClientProviders({ children }: { children: React.ReactNode }) {
|
||||
return <ToastProvider>{children}</ToastProvider>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
74
front-end-agency/components/auth/SolutionGuard.tsx
Normal file
74
front-end-agency/components/auth/SolutionGuard.tsx
Normal 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}</>;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
123
front-end-agency/components/layout/ConfirmDialog.tsx
Normal file
123
front-end-agency/components/layout/ConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
129
front-end-agency/components/layout/MobileBottomBar.tsx
Normal file
129
front-end-agency/components/layout/MobileBottomBar.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
59
front-end-agency/components/layout/ToastContext.tsx
Normal file
59
front-end-agency/components/layout/ToastContext.tsx
Normal 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;
|
||||
}
|
||||
100
front-end-agency/components/layout/ToastNotification.tsx
Normal file
100
front-end-agency/components/layout/ToastNotification.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user