Setup project structure, dependencies, and basic configuration for the ComFi application. Includes initial setup for Vite, React, TypeScript, Tailwind CSS, and essential development tools. Defines core types and provides a basic README for local development.
217 lines
16 KiB
TypeScript
217 lines
16 KiB
TypeScript
|
|
import React, { useState, useEffect, useRef } from 'react';
|
|
import {
|
|
LayoutDashboard, Users, KanbanSquare, FileText, Briefcase, UserCircle, Calendar,
|
|
Bell, Menu, X, ArrowDownCircle, ArrowUpCircle, LogOut, Settings, ChevronDown,
|
|
Search, ChevronRight, Building2, ScrollText
|
|
} from 'lucide-react';
|
|
import { ViewState } from './types';
|
|
import { ComFiProvider, useComFi } from './contexts/ComFiContext';
|
|
import { ToastProvider } from './contexts/ToastContext';
|
|
import { DashboardView } from './components/DashboardView';
|
|
import { FinancialReportsView } from './components/FinancialReportsView';
|
|
import { CRMView } from './components/CRMView';
|
|
import { CalendarView } from './components/CalendarView';
|
|
import { KanbanView } from './components/KanbanView';
|
|
import { ManagementView } from './components/ManagementView';
|
|
import { AccountsPayableView } from './components/AccountsPayableView';
|
|
import { AccountsReceivableView } from './components/AccountsReceivableView';
|
|
import { UserManagementView } from './components/UserManagementView';
|
|
import { SettingsView } from './components/SettingsView';
|
|
import { AIChatAssistant } from './components/AIChatAssistant';
|
|
import { ProposalsView } from './components/ProposalsView';
|
|
|
|
// --- COMPONENTS HELPERS ---
|
|
interface SidebarItemProps {
|
|
icon: any; label: string; active: boolean; onClick: () => void; collapsed?: boolean;
|
|
}
|
|
const SidebarItem: React.FC<SidebarItemProps> = ({ icon: Icon, label, active, onClick, collapsed = false }) => (
|
|
<button onClick={onClick} className={`w-full flex items-center gap-3 px-6 py-3.5 transition-all duration-200 relative group ${active ? 'text-white bg-slate-800 border-l-4 border-orange-500' : 'text-slate-400 hover:text-white hover:bg-slate-800/50 border-l-4 border-transparent'}`}>
|
|
<Icon size={20} strokeWidth={active ? 2.5 : 2} className={`${active ? 'text-orange-500' : 'group-hover:text-white'}`} />
|
|
{!collapsed && <span className="font-medium">{label}</span>}
|
|
</button>
|
|
);
|
|
|
|
const MainContent: React.FC = () => {
|
|
const {
|
|
currentUser, setCurrentUser, users, setUsers,
|
|
companies, setCompanies, services, setServices,
|
|
expenses, setExpenses, receivables, setReceivables,
|
|
clients, setClients, financialSummary, addReceivable
|
|
} = useComFi();
|
|
|
|
const [currentView, setCurrentView] = useState<ViewState>('dashboard');
|
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
|
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [searchResults, setSearchResults] = useState<any[]>([]);
|
|
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
|
const searchRef = useRef<HTMLDivElement>(null);
|
|
const userMenuRef = useRef<HTMLDivElement>(null);
|
|
|
|
const toggleMobileMenu = () => setIsMobileMenuOpen(!isMobileMenuOpen);
|
|
|
|
// Search Logic
|
|
useEffect(() => {
|
|
if (!searchQuery.trim()) { setSearchResults([]); return; }
|
|
const query = searchQuery.toLowerCase();
|
|
const results: any[] = [];
|
|
companies.forEach(c => {
|
|
if (c.name.toLowerCase().includes(query) || c.fantasyName?.toLowerCase().includes(query)) results.push({ id: c.id, type: 'client', title: c.fantasyName || c.name, subtitle: c.email, detail: 'Cliente', targetView: 'crm' });
|
|
});
|
|
services.forEach(s => {
|
|
if (s.name.toLowerCase().includes(query)) results.push({ id: s.id, type: 'service', title: s.name, subtitle: `R$ ${s.price}`, detail: s.category, targetView: 'services' });
|
|
});
|
|
setSearchResults(results);
|
|
}, [searchQuery, companies, services]);
|
|
|
|
// Click Outside Handlers
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (searchRef.current && !searchRef.current.contains(event.target as Node)) setIsSearchOpen(false);
|
|
if (userMenuRef.current && !userMenuRef.current.contains(event.target as Node)) setIsUserMenuOpen(false);
|
|
};
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
const menuItems: { id: ViewState, label: string, icon: any }[] = [
|
|
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
|
{ id: 'crm', label: 'Clientes (CRM)', icon: Users },
|
|
{ id: 'proposals', label: 'Propostas', icon: ScrollText },
|
|
{ id: 'services', label: 'Serviços', icon: Briefcase },
|
|
{ id: 'receivables', label: 'Contas a Receber', icon: ArrowUpCircle },
|
|
{ id: 'payables', label: 'Contas a Pagar', icon: ArrowDownCircle },
|
|
{ id: 'kanban', label: 'Kanban', icon: KanbanSquare },
|
|
{ id: 'invoicing', label: 'Faturamento', icon: FileText },
|
|
{ id: 'calendar', label: 'Agenda', icon: Calendar },
|
|
];
|
|
|
|
const allowedMenuItems = menuItems.filter(item => currentUser.role === 'super_admin' || currentUser.permissions.includes(item.id));
|
|
const getPageTitle = (view: ViewState) => {
|
|
if (view === 'user') return 'Gestão de Usuários';
|
|
if (view === 'settings') return 'Configurações';
|
|
return menuItems.find(i => i.id === view)?.label || 'Dashboard';
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-[#F8FAFC] flex font-sans text-slate-600">
|
|
<AIChatAssistant userName={currentUser.name.split(' ')[0]} contextData={{ revenue: financialSummary.totalRevenue, expenses: financialSummary.totalExpenses, profit: financialSummary.profit, pendingReceivables: financialSummary.receivablePending }} />
|
|
|
|
{/* Sidebar */}
|
|
<aside className="hidden lg:flex flex-col bg-[#111827] h-screen sticky top-0 w-72 transition-all duration-300 overflow-y-auto shadow-xl border-r border-slate-800 z-20">
|
|
<div className="p-8 mb-2 flex items-center gap-3">
|
|
<div className="w-8 h-8 bg-orange-500 rounded-lg flex items-center justify-center"><LayoutDashboard className="text-white" size={20} /></div>
|
|
<h1 className="font-bold text-2xl text-white tracking-tight">ComFi<span className="text-orange-500">.</span></h1>
|
|
</div>
|
|
<div className="px-6 mb-6"><p className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">Principal</p></div>
|
|
<nav className="flex-1 pb-8 space-y-1">
|
|
{allowedMenuItems.map((item) => (<SidebarItem key={item.id} icon={item.icon} label={item.label} active={currentView === item.id} onClick={() => setCurrentView(item.id)} />))}
|
|
<div className="px-6 mt-8 mb-2"><p className="text-xs font-bold text-slate-500 uppercase tracking-wider">Configurações</p></div>
|
|
<SidebarItem icon={Settings} label="Sistema" active={currentView === 'settings'} onClick={() => setCurrentView('settings')} />
|
|
{currentUser.role === 'super_admin' && <SidebarItem icon={UserCircle} label="Usuários" active={currentView === 'user'} onClick={() => setCurrentView('user')} />}
|
|
</nav>
|
|
<div className="p-4 border-t border-slate-800">
|
|
<div className="bg-slate-800/50 rounded-xl p-3 flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded-full bg-orange-500 flex items-center justify-center text-white font-bold text-xs">{currentUser.name.substring(0,2)}</div>
|
|
<div className="overflow-hidden"><p className="text-sm font-bold text-white truncate">{currentUser.name}</p><p className="text-xs text-slate-400 truncate">{currentUser.email}</p></div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Main Area */}
|
|
<main className="flex-1 flex flex-col min-w-0">
|
|
<header className="sticky top-0 z-30 bg-white/80 backdrop-blur-md px-8 py-4 flex justify-between items-center border-b border-slate-100">
|
|
<div className="flex items-center gap-4 flex-1">
|
|
<button onClick={toggleMobileMenu} className="lg:hidden p-2 text-slate-500 hover:bg-slate-100 rounded-lg"><Menu size={24} /></button>
|
|
<div className="hidden md:flex items-center relative w-full max-w-xl" ref={searchRef}>
|
|
<Search className="absolute left-3 text-slate-400 pointer-events-none" size={18} />
|
|
<input type="text" value={searchQuery} onChange={(e) => { setSearchQuery(e.target.value); setIsSearchOpen(true); }} onFocus={() => setIsSearchOpen(true)} placeholder="Pesquisar..." className="w-full pl-10 pr-4 py-2.5 bg-slate-50 border-none rounded-xl text-sm focus:ring-2 focus:ring-orange-200 outline-none text-slate-600 transition-all placeholder:text-slate-400" />
|
|
{isSearchOpen && searchQuery.length > 0 && (
|
|
<div className="absolute top-full left-0 right-0 mt-2 bg-white rounded-2xl shadow-2xl border border-slate-100 max-h-[500px] overflow-y-auto animate-fade-in z-50">
|
|
{searchResults.map(r => (
|
|
<div key={r.id} onClick={() => {setCurrentView(r.targetView); setIsSearchOpen(false);}} className="px-4 py-3 hover:bg-slate-50 cursor-pointer flex items-center gap-3 border-b border-slate-50 last:border-0">
|
|
<div className="w-8 h-8 rounded-lg bg-indigo-50 text-indigo-500 flex items-center justify-center shrink-0"><Building2 size={16} /></div>
|
|
<div className="flex-1 min-w-0"><div className="text-sm font-bold text-slate-800 truncate">{r.title}</div><div className="text-xs text-slate-500 truncate">{r.subtitle}</div></div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-4">
|
|
<button className="relative w-10 h-10 rounded-full flex items-center justify-center hover:bg-slate-50 text-slate-400 hover:text-slate-600 transition-colors"><Bell size={20} /><span className="absolute top-2.5 right-2.5 w-2 h-2 bg-red-500 rounded-full border-2 border-white"></span></button>
|
|
<div className="h-8 w-px bg-slate-200 mx-1"></div>
|
|
<div className="flex items-center gap-3 relative cursor-pointer" ref={userMenuRef} onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}>
|
|
<div className="text-right hidden md:block"><div className="text-sm font-bold text-slate-800 leading-tight">{currentUser.name}</div><div className="text-[10px] text-slate-400 font-medium capitalize">{currentUser.role === 'super_admin' ? 'Administrator' : 'User'}</div></div>
|
|
<div className="w-10 h-10 rounded-full bg-slate-200 overflow-hidden shadow-sm ring-2 ring-white"><img src={currentUser.avatar} alt="User" className="w-full h-full object-cover" /></div>
|
|
<ChevronDown size={16} className="text-slate-400 hidden md:block" />
|
|
{isUserMenuOpen && (
|
|
<div className="absolute right-0 top-full mt-2 w-56 bg-white rounded-2xl shadow-xl border border-slate-100 p-2 z-50 animate-scale-up origin-top-right">
|
|
<div className="px-4 py-3 border-b border-slate-50 mb-2"><p className="text-sm font-bold text-slate-800">{currentUser.name}</p><p className="text-xs text-slate-400">{currentUser.email}</p></div>
|
|
{users.map(u => (<button key={u.id} onClick={(e) => { e.stopPropagation(); setCurrentUser(u); setIsUserMenuOpen(false); }} className={`w-full text-left px-4 py-2 text-xs rounded-xl flex items-center gap-2 hover:bg-slate-50 ${u.id === currentUser.id ? 'text-orange-600 font-bold bg-orange-50' : 'text-slate-500'}`}><img src={u.avatar} className="w-5 h-5 rounded-full" /> Trocar para {u.name}</button>))}
|
|
<div className="border-t border-slate-50 my-2"></div>
|
|
<button onClick={() => { setCurrentView('settings'); setIsUserMenuOpen(false); }} className="w-full text-left px-4 py-2.5 text-sm text-slate-600 hover:bg-slate-50 rounded-xl flex items-center gap-2 font-medium"><Settings size={16} /> Configurações</button>
|
|
<button className="w-full text-left px-4 py-2.5 text-sm text-red-500 hover:bg-red-50 rounded-xl flex items-center gap-2 font-medium"><LogOut size={16} /> Sair</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="flex-1 px-8 pb-8 pt-6 overflow-y-auto">
|
|
<div className="max-w-[1600px] mx-auto">
|
|
<div className="mb-8"><h2 className="text-2xl font-bold text-slate-800 tracking-tight">{getPageTitle(currentView)}</h2><p className="text-slate-400 text-sm mt-1">Bem-vindo ao ComFi Dashboard.</p></div>
|
|
{(() => {
|
|
const hasAccess = currentUser.role === 'super_admin' || currentUser.permissions.includes(currentView) || currentView === 'settings';
|
|
if (!hasAccess && currentView !== 'dashboard' && currentView !== 'user') return <div className="flex flex-col items-center justify-center h-[60vh] text-center"><Settings size={32} className="text-slate-300 mb-4"/><h2 className="text-xl font-bold text-slate-800">Acesso Restrito</h2></div>;
|
|
|
|
switch(currentView) {
|
|
case 'dashboard': return <DashboardView financialSummary={financialSummary} companies={companies} expenses={expenses} receivables={receivables} />;
|
|
case 'crm': return <CRMView companies={companies} setCompanies={setCompanies} availableServices={services} />;
|
|
case 'proposals': return <ProposalsView />;
|
|
case 'services': return <ManagementView type="services" servicesData={services} setServicesData={setServices} clientsData={clients} setClientsData={setClients} />;
|
|
case 'receivables': return <AccountsReceivableView receivables={receivables} setReceivables={setReceivables} />;
|
|
case 'payables': return <AccountsPayableView expenses={expenses} setExpenses={setExpenses} />;
|
|
case 'calendar': return <CalendarView expenses={expenses} receivables={receivables} />;
|
|
case 'kanban': return <KanbanView companies={companies} onAddReceivable={addReceivable} />;
|
|
case 'invoicing': return <FinancialReportsView expenses={expenses} receivables={receivables} />;
|
|
case 'user': return currentUser.role === 'super_admin' ? <UserManagementView users={users} setUsers={setUsers} currentUser={currentUser} availableModules={menuItems} /> : null;
|
|
case 'settings': return <SettingsView />;
|
|
default: return <div className="p-10 text-center text-slate-400">Em desenvolvimento.</div>;
|
|
}
|
|
})()}
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
{/* Mobile Menu */}
|
|
{isMobileMenuOpen && (
|
|
<div className="fixed inset-0 z-50 lg:hidden">
|
|
<div className="absolute inset-0 bg-slate-900/50 backdrop-blur-sm" onClick={toggleMobileMenu}></div>
|
|
<div className="absolute left-0 top-0 bottom-0 w-72 bg-[#111827] h-screen shadow-2xl p-6 flex flex-col overflow-y-auto">
|
|
<div className="flex justify-between items-center mb-8 px-2"><h1 className="font-bold text-2xl text-white">ComFi<span className="text-orange-500">.</span></h1><button onClick={toggleMobileMenu}><X size={24} className="text-slate-500" /></button></div>
|
|
<div className="space-y-1">
|
|
{allowedMenuItems.map((item) => (<SidebarItem key={item.id} icon={item.icon} label={item.label} active={currentView === item.id} onClick={() => {setCurrentView(item.id); toggleMobileMenu();}} />))}
|
|
<SidebarItem icon={Settings} label="Sistema" active={currentView === 'settings'} onClick={() => {setCurrentView('settings'); toggleMobileMenu();}} />
|
|
{currentUser.role === 'super_admin' && <SidebarItem icon={UserCircle} label="Usuários" active={currentView === 'user'} onClick={() => {setCurrentView('user'); toggleMobileMenu();}} />}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const App: React.FC = () => {
|
|
return (
|
|
<ComFiProvider>
|
|
<ToastProvider>
|
|
<MainContent />
|
|
</ToastProvider>
|
|
</ComFiProvider>
|
|
);
|
|
};
|
|
|
|
export default App;
|