chore(release): snapshot 1.4.2

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,123 @@
import { Fragment } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import { ExclamationTriangleIcon, XMarkIcon } from '@heroicons/react/24/outline';
interface ConfirmDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: 'danger' | 'warning' | 'info';
}
export default function ConfirmDialog({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = 'Confirmar',
cancelText = 'Cancelar',
variant = 'danger'
}: ConfirmDialogProps) {
const handleConfirm = () => {
onConfirm();
onClose();
};
const variantStyles = {
danger: {
icon: 'bg-red-100 dark:bg-red-900/20',
iconColor: 'text-red-600 dark:text-red-400',
button: 'bg-red-600 hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-800'
},
warning: {
icon: 'bg-yellow-100 dark:bg-yellow-900/20',
iconColor: 'text-yellow-600 dark:text-yellow-400',
button: 'bg-yellow-600 hover:bg-yellow-700 dark:bg-yellow-700 dark:hover:bg-yellow-800'
},
info: {
icon: 'bg-blue-100 dark:bg-blue-900/20',
iconColor: 'text-blue-600 dark:text-blue-400',
button: 'bg-blue-600 hover:bg-blue-700 dark:bg-blue-700 dark:hover:bg-blue-800'
}
};
const style = variantStyles[variant];
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-zinc-900/40 backdrop-blur-sm transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-2xl bg-white dark:bg-zinc-900 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg border border-zinc-200 dark:border-zinc-800">
<div className="p-6">
<div className="flex items-start gap-4">
<div className={`flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl ${style.icon}`}>
<ExclamationTriangleIcon className={`h-6 w-6 ${style.iconColor}`} />
</div>
<div className="flex-1">
<Dialog.Title className="text-lg font-semibold text-zinc-900 dark:text-white">
{title}
</Dialog.Title>
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
{message}
</p>
</div>
<button
onClick={onClose}
className="rounded-lg p-1.5 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
<div className="mt-6 flex gap-3">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2.5 border border-zinc-200 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 font-medium rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
{cancelText}
</button>
<button
type="button"
onClick={handleConfirm}
className={`flex-1 px-4 py-2.5 text-white font-medium rounded-lg transition-colors ${style.button}`}
>
{confirmText}
</button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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