feat: versão 1.5 - CRM Beta com leads, funis, campanhas e portal do cliente

This commit is contained in:
Erik Silva
2025-12-24 17:36:52 -03:00
parent 99d828869a
commit dfb91c8ba5
98 changed files with 18255 additions and 1465 deletions

View File

@@ -39,14 +39,14 @@ export const DashboardLayout: React.FC<DashboardLayoutProps> = ({ children, menu
{/* Conteúdo das páginas */}
<div className="flex-1 overflow-auto pb-20 md:pb-0">
<div className="max-w-7xl mx-auto w-full h-full">
<div className="w-full h-full">
{children}
</div>
</div>
</main>
{/* Mobile Bottom Bar */}
<MobileBottomBar />
<MobileBottomBar menuItems={menuItems} />
</div>
);
};

View File

@@ -1,51 +1,93 @@
'use client';
import React, { useState } from 'react';
import React from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
HomeIcon,
RocketLaunchIcon,
Squares2X2Icon
UserPlusIcon,
RectangleStackIcon,
UsersIcon,
ListBulletIcon
} from '@heroicons/react/24/outline';
import {
HomeIcon as HomeIconSolid,
RocketLaunchIcon as RocketIconSolid,
Squares2X2Icon as GridIconSolid
UserPlusIcon as UserPlusIconSolid,
RectangleStackIcon as RectangleStackIconSolid,
UsersIcon as UsersIconSolid,
ListBulletIcon as ListBulletIconSolid
} from '@heroicons/react/24/solid';
import { MenuItem } from './SidebarRail';
export const MobileBottomBar: React.FC = () => {
interface MobileBottomBarProps {
menuItems?: MenuItem[];
}
export const MobileBottomBar: React.FC<MobileBottomBarProps> = ({ menuItems }) => {
const pathname = usePathname();
const [showMoreMenu, setShowMoreMenu] = useState(false);
const isActive = (path: string) => {
if (path === '/dashboard') {
return pathname === '/dashboard';
if (path === '/dashboard' || path === '/cliente/dashboard') {
return pathname === path;
}
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)
}
];
// Mapeamento de ícones sólidos para os itens do menu
const getSolidIcon = (label: string, defaultIcon: any) => {
const map: Record<string, any> = {
'Dashboard': HomeIconSolid,
'Leads': UserPlusIconSolid,
'Listas': RectangleStackIconSolid,
'CRM': UsersIconSolid,
'Meus Leads': UserPlusIconSolid,
'Meu Perfil': UserPlusIconSolid,
};
return map[label] || defaultIcon;
};
const navItems = menuItems
? menuItems.reduce((acc: any[], item) => {
if (item.href !== '#') {
acc.push({
label: item.label,
path: item.href,
icon: item.icon,
iconSolid: getSolidIcon(item.label, item.icon)
});
} else if (item.subItems) {
// Adiciona subitens importantes se o item pai for '#'
item.subItems.forEach(sub => {
acc.push({
label: sub.label,
path: sub.href,
icon: item.icon, // Usa o ícone do pai
iconSolid: getSolidIcon(sub.label, item.icon)
});
});
}
return acc;
}, []).slice(0, 4) // Limita a 4 itens no mobile
: [
{
label: 'Dashboard',
path: '/dashboard',
icon: HomeIcon,
iconSolid: HomeIconSolid
},
{
label: 'Leads',
path: '/crm/leads',
icon: UserPlusIcon,
iconSolid: UserPlusIconSolid
},
{
label: 'Listas',
path: '/crm/listas',
icon: RectangleStackIcon,
iconSolid: RectangleStackIconSolid
}
];
return (
<>
@@ -56,21 +98,6 @@ export const MobileBottomBar: React.FC = () => {
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}
@@ -86,44 +113,6 @@ export const MobileBottomBar: React.FC = () => {
})}
</div>
</nav>
{/* More Menu Modal */}
{showMoreMenu && (
<div className="md:hidden fixed inset-0 z-[100] bg-black/50 backdrop-blur-sm" onClick={() => setShowMoreMenu(false)}>
<div
className="absolute bottom-0 left-0 right-0 bg-white dark:bg-zinc-900 rounded-t-3xl shadow-2xl max-h-[70vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="p-6">
{/* Handle bar */}
<div className="w-12 h-1.5 bg-gray-300 dark:bg-zinc-700 rounded-full mx-auto mb-6" />
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-6">
Todos os Módulos
</h2>
<div className="grid grid-cols-3 gap-4">
<Link
href="/erp"
onClick={() => setShowMoreMenu(false)}
className="flex flex-col items-center gap-3 p-4 rounded-2xl hover:bg-gray-50 dark:hover:bg-zinc-800 transition-colors"
>
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white shadow-lg">
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
</svg>
</div>
<span className="text-sm font-medium text-gray-900 dark:text-white text-center">
ERP
</span>
</Link>
{/* Add more modules here */}
</div>
</div>
</div>
</div>
)}
</>
);
};

View File

@@ -0,0 +1,79 @@
import { Fragment } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import { XMarkIcon } from '@heroicons/react/24/outline';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl';
}
export default function Modal({ isOpen, onClose, title, children, maxWidth = 'md' }: ModalProps) {
const maxWidthClass = {
sm: 'sm:max-w-sm',
md: 'sm:max-w-md',
lg: 'sm:max-w-lg',
xl: 'sm:max-w-xl',
'2xl': 'sm:max-w-2xl',
'3xl': 'sm:max-w-3xl',
'4xl': 'sm:max-w-4xl',
'5xl': 'sm:max-w-5xl',
}[maxWidth];
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/75 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 px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 w-full ${maxWidthClass} sm:p-6 border border-zinc-200 dark:border-zinc-800`}>
<div className="absolute right-0 top-0 hidden pr-4 pt-4 sm:block">
<button
type="button"
className="rounded-md bg-white dark:bg-zinc-900 text-zinc-400 hover:text-zinc-500 focus:outline-none"
onClick={onClose}
>
<span className="sr-only">Fechar</span>
<XMarkIcon className="h-6 w-6" aria-hidden="true" />
</button>
</div>
<div className="sm:flex sm:items-start w-full">
<div className="mt-3 text-center sm:mt-0 sm:text-left w-full">
<Dialog.Title as="h3" className="text-xl font-bold leading-6 text-zinc-900 dark:text-white mb-6">
{title}
</Dialog.Title>
<div className="mt-2">
{children}
</div>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
}

View File

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

View File

@@ -57,7 +57,7 @@ export const SidebarRail: React.FC<SidebarRailProps> = ({
// Buscar perfil da agência para atualizar logo e nome
const fetchProfile = async () => {
const token = getToken();
if (!token) return;
if (!token || currentUser?.user_type === 'customer') return;
try {
const res = await fetch(API_ENDPOINTS.agencyProfile, {

View File

@@ -6,12 +6,16 @@ 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';
import { CRMCustomerFilter } from '@/components/crm/CRMCustomerFilter';
export const TopBar: React.FC = () => {
const pathname = usePathname();
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
const [user, setUser] = useState<any>(null);
// Verifica se está em uma rota do CRM
const isInCRM = pathname?.startsWith('/crm') || false;
useEffect(() => {
const userData = getUser();
setUser(userData);
@@ -19,8 +23,11 @@ export const TopBar: React.FC = () => {
const generateBreadcrumbs = () => {
const paths = pathname?.split('/').filter(Boolean) || [];
const isCustomer = pathname?.startsWith('/cliente');
const homePath = isCustomer ? '/cliente/dashboard' : '/dashboard';
const breadcrumbs: Array<{ name: string; href: string; icon?: React.ComponentType<{ className?: string }> }> = [
{ name: 'Home', href: '/dashboard', icon: HomeIcon }
{ name: 'Home', href: homePath, icon: HomeIcon }
];
let currentPath = '';
paths.forEach((path, index) => {
@@ -34,9 +41,12 @@ export const TopBar: React.FC = () => {
'financeiro': 'Financeiro',
'configuracoes': 'Configurações',
'novo': 'Novo',
'cliente': 'Portal',
'leads': 'Leads',
'listas': 'Listas',
};
if (path !== 'dashboard') { // Evita duplicar Home/Dashboard se a rota for /dashboard
if (path !== 'dashboard' && !(isCustomer && path === 'cliente')) { // Evita duplicar Home/Dashboard ou Portal
breadcrumbs.push({
name: nameMap[path] || path.charAt(0).toUpperCase() + path.slice(1),
href: currentPath,
@@ -48,12 +58,14 @@ export const TopBar: React.FC = () => {
};
const breadcrumbs = generateBreadcrumbs();
const isCustomer = pathname?.startsWith('/cliente');
const homePath = isCustomer ? '/cliente/dashboard' : '/dashboard';
return (
<>
<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">
<Link href={homePath} 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" />
@@ -93,6 +105,13 @@ export const TopBar: React.FC = () => {
})}
</nav>
{/* CRM Customer Filter - aparece apenas em rotas CRM */}
{isInCRM && (
<div className="hidden lg:flex">
<CRMCustomerFilter />
</div>
)}
{/* Search Bar Trigger */}
<div className="flex items-center gap-2 md:gap-4">
<button
@@ -111,7 +130,7 @@ export const TopBar: React.FC = () => {
<span className="absolute top-2 right-2 w-2 h-2 bg-red-500 rounded-full border-2 border-white dark:border-zinc-900"></span>
</button>
<Link
href="/configuracoes"
href={isCustomer ? "/cliente/perfil" : "/configuracoes"}
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" />