feat: Initialize ComFi project with Vite

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.
This commit is contained in:
MMrp89
2026-02-09 20:28:37 -03:00
parent 1e6a56d866
commit 1a57ac7754
28 changed files with 6070 additions and 8 deletions

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

216
App.tsx Normal file
View File

@@ -0,0 +1,216 @@
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;

View File

@@ -1,11 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
<h1>Built with AI Studio</h2>
<p>The fastest path from prompt to production with Gemini.</p>
<a href="https://aistudio.google.com/apps">Start building</a>
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/drive/1sqcyUC7sKuUBTX0WIBf65hvKVtS2ielx
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

View File

@@ -0,0 +1,265 @@
import React, { useState, useRef, useEffect } from 'react';
import { MessageSquare, X, Send, Bot, User, Sparkles, MinusCircle } from 'lucide-react';
import { GoogleGenAI } from "@google/genai";
interface AIChatAssistantProps {
userName: string;
contextData: {
revenue: number;
expenses: number;
profit: number;
pendingReceivables: number;
}
}
interface Message {
id: string;
role: 'user' | 'assistant';
text: string;
timestamp: Date;
}
export const AIChatAssistant: React.FC<AIChatAssistantProps> = ({ userName, contextData }) => {
const [isOpen, setIsOpen] = useState(false);
const [isTyping, setIsTyping] = useState(false);
const [inputValue, setInputValue] = useState('');
const [messages, setMessages] = useState<Message[]>([
{
id: 'welcome',
role: 'assistant',
text: `Olá ${userName}! 🤖\n\nSou seu consultor especialista no ComFi. Acompanho seus números em tempo real e posso ajudar com:\n\n- **Análise Financeira** (Lucro, Caixa, Despesas)\n- **Estratégias de Crescimento** (Marketing, Vendas)\n- **Gestão Operacional**\n\nComo posso ajudar seu negócio hoje?`,
timestamp: new Date()
}
]);
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
scrollToBottom();
}, [messages, isOpen]);
// Função para processar formatação de texto (Negrito e Listas)
const renderFormattedText = (text: string) => {
return text.split('\n').map((line, index) => {
// Processar item de lista
if (line.trim().startsWith('- ')) {
const content = line.trim().substring(2);
return (
<div key={index} className="flex items-start gap-2 mb-1 pl-1">
<span className="min-w-[6px] h-[6px] rounded-full bg-current mt-1.5 opacity-60"></span>
<span className="flex-1">{parseBold(content)}</span>
</div>
);
}
// Processar linha vazia
if (line.trim() === '') {
return <div key={index} className="h-2"></div>;
}
// Parágrafo normal
return <p key={index} className="mb-1 last:mb-0 leading-relaxed">{parseBold(line)}</p>;
});
};
const parseBold = (text: string) => {
return text.split(/(\*\*.*?\*\*)/).map((part, i) => {
if (part.startsWith('**') && part.endsWith('**')) {
return <strong key={i} className="font-bold">{part.slice(2, -2)}</strong>;
}
return part;
});
};
const handleSend = async () => {
if (!inputValue.trim()) return;
const userMsg: Message = {
id: Math.random().toString(),
role: 'user',
text: inputValue,
timestamp: new Date()
};
setMessages(prev => [...prev, userMsg]);
setInputValue('');
setIsTyping(true);
try {
// Inicializar Cliente Gemini
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
// Construir Instrução do Sistema com Contexto Financeiro Atual
const systemInstruction = `
Você é o **ComFi Assistant**, um consultor de elite em gestão empresarial e marketing, integrado ao sistema ComFi.
**CONTEXTO FINANCEIRO ATUAL DO USUÁRIO:**
- Receita: R$ ${contextData.revenue.toLocaleString('pt-BR')}
- Despesas: R$ ${contextData.expenses.toLocaleString('pt-BR')}
- Lucro Líquido: R$ ${contextData.profit.toLocaleString('pt-BR')}
- A Receber (Pendente): R$ ${contextData.pendingReceivables.toLocaleString('pt-BR')}
**SUA MISSÃO:**
Atuar como um estrategista sênior. Analise os dados e a pergunta do usuário para fornecer conselhos práticos, ideias de marketing criativas e insights financeiros.
**DIRETRIZES DE RESPOSTA (RIGOROSO):**
1. **Formatação Limpa:** JAMAIS escreva blocos de texto longos. Use parágrafos curtos.
2. **Uso de Listas:** Sempre que apresentar passos, ideias ou dados, use listas com marcadores (\`- \`).
3. **Destaques:** Use **negrito** para números importantes e termos-chave.
4. **Tom de Voz:** Profissional, especialista, motivador e direto ao ponto.
5. **Foco em Ação:** Dê sugestões que o usuário possa implementar hoje.
Exemplo de formato ideal:
"Baseado nos seus dados, aqui estão 3 ações:
- **Ação 1**: Explicação breve.
- **Ação 2**: Explicação breve."
`;
// Chamada à API
const response = await ai.models.generateContent({
model: 'gemini-3-flash-preview',
contents: [
{ role: 'user', parts: [{ text: inputValue }] }
],
config: {
systemInstruction: systemInstruction,
temperature: 0.7, // Criatividade balanceada para marketing
}
});
const text = response.text || "Desculpe, não consegui gerar uma resposta no momento.";
const botMsg: Message = {
id: Math.random().toString(),
role: 'assistant',
text: text,
timestamp: new Date()
};
setMessages(prev => [...prev, botMsg]);
} catch (error) {
console.error("Erro ao chamar Gemini API:", error);
const errorMsg: Message = {
id: Math.random().toString(),
role: 'assistant',
text: "Desculpe, estou enfrentando uma instabilidade temporária na minha conexão neural. 🧠\n\nPor favor, tente novamente em alguns instantes.",
timestamp: new Date()
};
setMessages(prev => [...prev, errorMsg]);
} finally {
setIsTyping(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') handleSend();
};
return (
<>
{/* Floating Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className={`fixed bottom-6 right-6 z-50 p-4 rounded-full shadow-2xl transition-all duration-300 hover:scale-110 flex items-center justify-center ${
isOpen ? 'bg-slate-800 rotate-90' : 'bg-gradient-to-r from-primary-500 to-orange-600'
}`}
>
{isOpen ? <X color="white" size={24} /> : <MessageSquare color="white" size={28} fill="currentColor" className="text-white/20" />}
{!isOpen && (
<span className="absolute top-0 right-0 w-3 h-3 bg-red-500 border-2 border-white rounded-full"></span>
)}
</button>
{/* Chat Window */}
<div
className={`fixed bottom-24 right-6 w-96 max-w-[calc(100vw-3rem)] bg-white rounded-2xl shadow-2xl border border-slate-100 z-50 flex flex-col transition-all duration-300 origin-bottom-right overflow-hidden ${
isOpen ? 'opacity-100 scale-100 translate-y-0' : 'opacity-0 scale-90 translate-y-10 pointer-events-none'
}`}
style={{ height: '550px' }}
>
{/* Header */}
<div className="bg-slate-900 p-4 flex items-center gap-3 shadow-md relative z-10">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-primary-400 to-orange-600 flex items-center justify-center border-2 border-slate-700 shadow-inner">
<Bot size={20} className="text-white" />
</div>
<div className="flex-1">
<h3 className="text-white font-bold text-sm flex items-center gap-2">
ComFi Especialista <span className="bg-primary-500 text-[10px] px-1.5 py-0.5 rounded text-white font-bold tracking-wide">AI</span>
</h3>
<p className="text-slate-400 text-xs flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span> Consultor Online
</p>
</div>
<button onClick={() => setIsOpen(false)} className="text-slate-400 hover:text-white transition-colors"><MinusCircle size={18}/></button>
</div>
{/* Messages Area */}
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-[#F8FAFC] scrollbar-thin">
{messages.map((msg) => (
<div key={msg.id} className={`flex gap-3 ${msg.role === 'user' ? 'flex-row-reverse' : ''} animate-fade-in`}>
<div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 shadow-sm border border-black/5 ${
msg.role === 'user' ? 'bg-white text-slate-600' : 'bg-primary-100 text-primary-600'
}`}>
{msg.role === 'user' ? <User size={14} /> : <Sparkles size={14} />}
</div>
<div className={`max-w-[85%] p-3.5 rounded-2xl text-sm shadow-sm ${
msg.role === 'user'
? 'bg-white text-slate-700 rounded-tr-none border border-slate-100'
: 'bg-white text-slate-800 rounded-tl-none border border-slate-100 shadow-md'
}`}>
{msg.role === 'assistant' ? (
<div className="text-slate-600">
{renderFormattedText(msg.text)}
</div>
) : (
msg.text
)}
<div className={`text-[10px] mt-2 text-right opacity-50 font-medium`}>
{msg.timestamp.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
</div>
</div>
</div>
))}
{isTyping && (
<div className="flex gap-3 animate-pulse">
<div className="w-8 h-8 rounded-full bg-primary-100 text-primary-600 flex items-center justify-center">
<Sparkles size={14} />
</div>
<div className="bg-white border border-slate-100 p-4 rounded-2xl rounded-tl-none flex gap-1 items-center shadow-sm">
<span className="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce"></span>
<span className="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce" style={{animationDelay: '0.1s'}}></span>
<span className="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce" style={{animationDelay: '0.2s'}}></span>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input Area */}
<div className="p-3 bg-white border-t border-slate-100 flex gap-2 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)] relative z-20">
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Pergunte sobre lucro, ideias de venda..."
className="flex-1 bg-slate-50 border border-slate-200 rounded-xl px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-primary-200 text-slate-700 placeholder-slate-400 transition-all"
/>
<button
onClick={handleSend}
disabled={!inputValue.trim()}
className="w-11 h-11 bg-primary-500 text-white rounded-xl flex items-center justify-center hover:bg-primary-600 disabled:opacity-50 disabled:hover:bg-primary-500 transition-all shadow-lg shadow-primary-200 active:scale-95"
>
<Send size={18} />
</button>
</div>
</div>
</>
);
};

View File

@@ -0,0 +1,159 @@
import React, { useState, useEffect } from 'react';
import { Sparkles, TrendingUp, AlertTriangle, Lightbulb, RefreshCw } from 'lucide-react';
import { FinancialSummary, Company, Expense } from '../types';
interface AIInsightsWidgetProps {
financialSummary: FinancialSummary;
topClients: Company[];
expenses: Expense[];
}
export const AIInsightsWidget: React.FC<AIInsightsWidgetProps> = ({ financialSummary, topClients, expenses }) => {
const [isLoading, setIsLoading] = useState(false);
const [insights, setInsights] = useState<{type: 'success' | 'warning' | 'info' | 'danger', title: string, message: string}[]>([]);
const generateInsights = () => {
setIsLoading(true);
// Simulating AI processing time
setTimeout(() => {
const newInsights: typeof insights = [];
// 1. Profitability Analysis
const margin = financialSummary.totalRevenue > 0 ? (financialSummary.profit / financialSummary.totalRevenue) * 100 : 0;
if (margin > 20) {
newInsights.push({
type: 'success',
title: 'Alta Rentabilidade',
message: `Sua margem de lucro de ${margin.toFixed(1)}% está acima da média do mercado.`
});
} else if (margin < 5 && margin > 0) {
newInsights.push({
type: 'warning',
title: 'Margem Apertada',
message: 'Lucro abaixo de 5%. Recomendamos revisão imediata de custos fixos.'
});
} else if (margin <= 0) {
newInsights.push({
type: 'danger',
title: 'Prejuízo Operacional',
message: 'As despesas superaram as receitas. Verifique inadimplência e corte gastos.'
});
} else {
newInsights.push({
type: 'info',
title: 'Estabilidade',
message: 'Sua margem está estável. Busque novas fontes de receita para crescer.'
});
}
// 2. Cash Flow Analysis
if (financialSummary.receivablePending > financialSummary.payablePending) {
newInsights.push({
type: 'info',
title: 'Caixa Saudável',
message: `Previsão de entrada líquida de R$ ${(financialSummary.receivablePending - financialSummary.payablePending).toLocaleString('pt-BR')}.`
});
} else {
newInsights.push({
type: 'danger',
title: 'Risco de Liquidez',
message: 'Contas a pagar superam os recebíveis previstos. Aumente o esforço de cobrança.'
});
}
// 3. Strategic / Growth
if (topClients.length > 0) {
newInsights.push({
type: 'success',
title: 'Oportunidade de Upsell',
message: `O cliente ${topClients[0].fantasyName || topClients[0].name} tem alto potencial. Ofereça novos serviços.`
});
} else {
newInsights.push({
type: 'warning',
title: 'Base de Clientes',
message: 'Cadastre mais clientes para receber insights de vendas personalizados.'
})
}
setInsights(newInsights);
setIsLoading(false);
}, 1500);
};
useEffect(() => {
generateInsights();
}, [financialSummary]);
return (
<div className="bg-white rounded-[1.5rem] p-6 border border-slate-100 shadow-sm relative overflow-hidden transition-all hover:shadow-md">
<div className="flex justify-between items-center mb-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-orange-400 to-orange-600 flex items-center justify-center shadow-lg shadow-orange-200">
<Sparkles size={20} className="text-white" />
</div>
<div>
<h3 className="font-bold text-lg text-slate-800 tracking-tight">AI Insights</h3>
<p className="text-slate-400 text-xs font-medium">Análise financeira inteligente</p>
</div>
</div>
<button
onClick={generateInsights}
className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-50 text-slate-400 hover:text-orange-500 transition-all"
title="Atualizar Análise"
>
<RefreshCw size={16} className={isLoading ? "animate-spin" : ""} />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{isLoading ? (
// Skeleton Loading Style
[1, 2, 3].map(i => (
<div key={i} className="bg-slate-50/50 rounded-2xl p-5 h-28 animate-pulse border border-slate-100">
<div className="h-4 w-24 bg-slate-200 rounded mb-3"></div>
<div className="h-3 w-full bg-slate-200 rounded mb-2"></div>
<div className="h-3 w-2/3 bg-slate-200 rounded"></div>
</div>
))
) : (
insights.map((insight, idx) => (
<div
key={idx}
className={`rounded-2xl p-5 border transition-all duration-300 hover:-translate-y-1 ${
insight.type === 'success' ? 'bg-emerald-50/50 border-emerald-100' :
insight.type === 'warning' ? 'bg-amber-50/50 border-amber-100' :
insight.type === 'danger' ? 'bg-red-50/50 border-red-100' :
'bg-blue-50/50 border-blue-100'
}`}
>
<div className="flex items-center gap-2 mb-2">
{insight.type === 'success' && <TrendingUp size={16} className="text-emerald-500" />}
{insight.type === 'warning' && <AlertTriangle size={16} className="text-amber-500" />}
{insight.type === 'danger' && <AlertTriangle size={16} className="text-red-500" />}
{insight.type === 'info' && <Lightbulb size={16} className="text-blue-500" />}
<h4 className={`font-bold text-sm ${
insight.type === 'success' ? 'text-emerald-800' :
insight.type === 'warning' ? 'text-amber-800' :
insight.type === 'danger' ? 'text-red-800' :
'text-blue-800'
}`}>{insight.title}</h4>
</div>
<p className={`text-xs leading-relaxed font-medium ${
insight.type === 'success' ? 'text-emerald-700/80' :
insight.type === 'warning' ? 'text-amber-700/80' :
insight.type === 'danger' ? 'text-red-700/80' :
'text-blue-700/80'
}`}>
{insight.message}
</p>
</div>
))
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,318 @@
import React, { useState } from 'react';
import { Search, Plus, DollarSign, Calendar, CheckCircle2, AlertCircle, Trash2, X, Pencil, Sparkles, ChevronDown } from 'lucide-react';
import { Expense } from '../types';
import { useToast } from '../contexts/ToastContext';
import { CustomSelect } from './CustomSelect';
interface AccountsPayableViewProps {
expenses: Expense[];
setExpenses: (expenses: Expense[]) => void;
}
export const AccountsPayableView: React.FC<AccountsPayableViewProps> = ({ expenses, setExpenses }) => {
const { addToast } = useToast();
const [isModalOpen, setIsModalOpen] = useState(false);
const [filterType, setFilterType] = useState<'all' | 'fixed' | 'variable'>('all');
const [newExpense, setNewExpense] = useState<Partial<Expense>>({
type: 'fixed',
status: 'pending',
dueDate: new Date().toISOString().split('T')[0]
});
const [editingId, setEditingId] = useState<string | null>(null);
// Totals Calculation
const totalPayable = expenses.reduce((acc, curr) => acc + curr.amount, 0);
const totalPaid = expenses.filter(e => e.status === 'paid').reduce((acc, curr) => acc + curr.amount, 0);
const totalPending = expenses.filter(e => e.status === 'pending' || e.status === 'overdue').reduce((acc, curr) => acc + curr.amount, 0);
const filteredExpenses = expenses.filter(e => filterType === 'all' ? true : e.type === filterType);
const openEditModal = (expense: Expense) => {
setNewExpense(expense);
setEditingId(expense.id);
setIsModalOpen(true);
};
const openCreateModal = () => {
setNewExpense({
type: 'fixed',
status: 'pending',
dueDate: new Date().toISOString().split('T')[0]
});
setEditingId(null);
setIsModalOpen(true);
}
const handleSaveExpense = () => {
if (!newExpense.title || !newExpense.amount) {
addToast({ type: 'warning', title: 'Campos Obrigatórios', message: 'Preencha o título e valor da despesa.' });
return;
}
if (editingId) {
// Edit existing
setExpenses(expenses.map(e => e.id === editingId ? { ...newExpense, id: editingId, amount: Number(newExpense.amount) } as Expense : e));
addToast({ type: 'success', title: 'Atualizado', message: 'Despesa atualizada com sucesso.' });
} else {
// Create new
const expense: Expense = {
...newExpense,
id: Math.random().toString(36).substr(2, 9),
amount: Number(newExpense.amount)
} as Expense;
setExpenses([...expenses, expense]);
addToast({ type: 'success', title: 'Registrado', message: 'Nova despesa adicionada.' });
}
setIsModalOpen(false);
setEditingId(null);
setNewExpense({ type: 'fixed', status: 'pending', dueDate: new Date().toISOString().split('T')[0] });
};
const handleDelete = (id: string) => {
if(window.confirm("Excluir conta?")) {
setExpenses(expenses.filter(e => e.id !== id));
addToast({ type: 'info', title: 'Excluído', message: 'Despesa removida.' });
}
}
const toggleStatus = (id: string) => {
setExpenses(expenses.map(e => {
if(e.id === id) {
const newStatus = e.status === 'paid' ? 'pending' : 'paid';
if (newStatus === 'paid') addToast({ type: 'success', title: 'Pago!', message: 'Despesa marcada como paga.' });
return { ...e, status: newStatus };
}
return e;
}));
}
const inputClass = "w-full p-3 bg-white border border-slate-200 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none text-slate-800";
return (
<div className="space-y-6 animate-fade-in">
{/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-white p-6 rounded-[2rem] border border-slate-50 shadow-sm flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-slate-100 flex items-center justify-center text-slate-500">
<DollarSign size={24} />
</div>
<div>
<p className="text-slate-400 text-xs font-bold uppercase">Total Previsto</p>
<h3 className="text-2xl font-bold text-slate-800">R$ {totalPayable.toLocaleString('pt-BR')}</h3>
</div>
</div>
<div className="bg-white p-6 rounded-[2rem] border border-slate-50 shadow-sm flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-green-50 flex items-center justify-center text-green-600">
<CheckCircle2 size={24} />
</div>
<div>
<p className="text-slate-400 text-xs font-bold uppercase">Total Pago</p>
<h3 className="text-2xl font-bold text-green-600">R$ {totalPaid.toLocaleString('pt-BR')}</h3>
</div>
</div>
<div className="bg-white p-6 rounded-[2rem] border border-slate-50 shadow-sm flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-red-50 flex items-center justify-center text-red-600">
<AlertCircle size={24} />
</div>
<div>
<p className="text-slate-400 text-xs font-bold uppercase">A Pagar / Pendente</p>
<h3 className="text-2xl font-bold text-red-600">R$ {totalPending.toLocaleString('pt-BR')}</h3>
</div>
</div>
</div>
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-800">Contas a Pagar</h1>
<p className="text-slate-500">Gerencie despesas fixas e variáveis.</p>
</div>
<button onClick={openCreateModal} className="flex items-center gap-2 px-5 py-3 bg-red-500 text-white rounded-xl shadow-lg shadow-red-200/50 hover:bg-red-600 font-bold transition-all">
<Plus size={20} /> Nova Despesa
</button>
</div>
<div className="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
{/* Toolbar */}
<div className="p-4 border-b border-slate-100 flex flex-wrap gap-4 items-center">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<input
type="text"
placeholder="Buscar despesa..."
className="w-full pl-10 pr-4 py-2 bg-slate-50 border-none rounded-xl focus:ring-2 focus:ring-primary-500 outline-none text-slate-600"
/>
</div>
<div className="flex gap-2 bg-slate-50 p-1 rounded-xl">
<button
onClick={() => setFilterType('all')}
className={`px-4 py-1.5 rounded-lg text-sm font-medium transition-colors ${filterType === 'all' ? 'bg-white shadow text-slate-800' : 'text-slate-500 hover:text-slate-700'}`}
>
Todas
</button>
<button
onClick={() => setFilterType('fixed')}
className={`px-4 py-1.5 rounded-lg text-sm font-medium transition-colors ${filterType === 'fixed' ? 'bg-white shadow text-slate-800' : 'text-slate-500 hover:text-slate-700'}`}
>
Fixas
</button>
<button
onClick={() => setFilterType('variable')}
className={`px-4 py-1.5 rounded-lg text-sm font-medium transition-colors ${filterType === 'variable' ? 'bg-white shadow text-slate-800' : 'text-slate-500 hover:text-slate-700'}`}
>
Variáveis
</button>
</div>
</div>
{/* Table */}
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-slate-50/50">
<tr>
<th className="p-4 text-xs font-bold uppercase text-slate-500">Descrição</th>
<th className="p-4 text-xs font-bold uppercase text-slate-500">Categoria</th>
<th className="p-4 text-xs font-bold uppercase text-slate-500">Vencimento</th>
<th className="p-4 text-xs font-bold uppercase text-slate-500">Valor</th>
<th className="p-4 text-xs font-bold uppercase text-slate-500 text-center">Tipo</th>
<th className="p-4 text-xs font-bold uppercase text-slate-500 text-center">Status</th>
<th className="p-4"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{filteredExpenses.map(expense => (
<tr key={expense.id} className="hover:bg-slate-50 transition-colors group">
<td className="p-4 font-bold text-slate-800">{expense.title}</td>
<td className="p-4">
<span className="px-2 py-1 bg-slate-100 rounded text-xs text-slate-600">{expense.category}</span>
</td>
<td className="p-4 text-sm text-slate-600">
<div className="flex items-center gap-2">
<Calendar size={14} className="text-slate-400"/>
{new Date(expense.dueDate).toLocaleDateString('pt-BR')}
</div>
</td>
<td className="p-4 font-bold text-slate-800">R$ {expense.amount.toLocaleString('pt-BR')}</td>
<td className="p-4 text-center">
<span className={`text-[10px] font-bold uppercase px-2 py-0.5 rounded ${expense.type === 'fixed' ? 'bg-blue-50 text-blue-600' : 'bg-amber-50 text-amber-600'}`}>
{expense.type === 'fixed' ? 'Fixa' : 'Variável'}
</span>
</td>
<td className="p-4 text-center">
<button onClick={() => toggleStatus(expense.id)} className={`px-3 py-1 rounded-full text-xs font-bold border transition-all ${
expense.status === 'paid' ? 'bg-green-50 text-green-600 border-green-200 hover:bg-green-100' :
expense.status === 'overdue' ? 'bg-red-50 text-red-600 border-red-200 hover:bg-red-100' :
'bg-slate-50 text-slate-500 border-slate-200 hover:bg-slate-100'
}`}>
{expense.status === 'paid' ? 'PAGO' : expense.status === 'overdue' ? 'ATRASADO' : 'PENDENTE'}
</button>
</td>
<td className="p-4 text-right">
<div className="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => openEditModal(expense)} className="p-2 text-slate-400 hover:text-primary-500 hover:bg-primary-50 rounded-lg transition-colors">
<Pencil size={18} />
</button>
<button onClick={() => handleDelete(expense.id)} className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors">
<Trash2 size={18} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{filteredExpenses.length === 0 && (
<div className="p-10 text-center text-slate-400">
<Sparkles size={32} className="mx-auto mb-2 opacity-20"/>
<p>Nenhuma despesa encontrada.</p>
</div>
)}
</div>
</div>
{/* Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm" onClick={() => setIsModalOpen(false)}></div>
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-md animate-scale-up flex flex-col max-h-[90vh]">
<div className="flex justify-between items-center px-6 py-4 border-b border-slate-100 flex-shrink-0">
<h3 className="font-bold text-slate-800 text-lg">{editingId ? 'Editar Despesa' : 'Nova Despesa'}</h3>
<button onClick={() => setIsModalOpen(false)}><X size={20} className="text-slate-400 hover:text-slate-600"/></button>
</div>
<div className="p-6 space-y-4 overflow-y-auto">
<div>
<label className="block text-sm font-bold text-slate-800 mb-1">Título</label>
<input
type="text"
className={inputClass}
placeholder="Ex: Aluguel Escritório"
value={newExpense.title || ''}
onChange={e => setNewExpense({...newExpense, title: e.target.value})}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-bold text-slate-800 mb-1">Valor (R$)</label>
<input
type="number"
className={inputClass}
placeholder="0,00"
value={newExpense.amount || ''}
onChange={e => setNewExpense({...newExpense, amount: Number(e.target.value)})}
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-800 mb-1">Vencimento</label>
<input
type="date"
className={inputClass}
value={newExpense.dueDate}
onChange={e => setNewExpense({...newExpense, dueDate: e.target.value})}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-bold text-slate-800 mb-1">Categoria</label>
<CustomSelect
value={newExpense.category || 'Operacional'}
onChange={(val) => setNewExpense({...newExpense, category: val})}
options={[
{ value: 'Operacional', label: 'Operacional' },
{ value: 'Administrativo', label: 'Administrativo' },
{ value: 'Impostos', label: 'Impostos' },
{ value: 'Marketing', label: 'Marketing' },
{ value: 'Pessoal', label: 'Pessoal / Folha' },
]}
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-800 mb-1">Tipo</label>
<CustomSelect
value={newExpense.type || 'fixed'}
onChange={(val) => setNewExpense({...newExpense, type: val})}
options={[
{ value: 'fixed', label: 'Fixa' },
{ value: 'variable', label: 'Variável' },
]}
/>
</div>
</div>
<button onClick={handleSaveExpense} className="w-full py-3 bg-red-500 text-white font-bold rounded-xl mt-4 hover:bg-red-600 shadow-lg shadow-red-200">
{editingId ? 'Salvar Alterações' : 'Registrar Despesa'}
</button>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,393 @@
import React, { useState } from 'react';
import { Search, Plus, DollarSign, CheckCircle2, TrendingUp, Trash2, X, Calendar, Pencil, RefreshCw, Sparkles, ChevronDown } from 'lucide-react';
import { Receivable } from '../types';
import { useToast } from '../contexts/ToastContext';
import { useComFi } from '../contexts/ComFiContext';
import { CustomSelect } from './CustomSelect';
interface AccountsReceivableViewProps {
receivables: Receivable[];
setReceivables: React.Dispatch<React.SetStateAction<Receivable[]>>;
}
export const AccountsReceivableView: React.FC<AccountsReceivableViewProps> = ({ receivables, setReceivables }) => {
const { addToast } = useToast();
const { companies } = useComFi(); // Acesso ao CRM para gerar recorrência
const [isModalOpen, setIsModalOpen] = useState(false);
const [filterStatus, setFilterStatus] = useState<'all' | 'paid' | 'pending'>('all');
const [newReceivable, setNewReceivable] = useState<Partial<Receivable>>({
type: 'one-time',
status: 'pending',
dueDate: new Date().toISOString().split('T')[0]
});
const [editingId, setEditingId] = useState<string | null>(null);
// KPI Calculations
const totalReceivable = receivables.reduce((acc, curr) => acc + curr.value, 0);
const totalReceived = receivables.filter(r => r.status === 'paid').reduce((acc, curr) => acc + curr.value, 0);
const totalPending = receivables.filter(r => r.status === 'pending' || r.status === 'overdue').reduce((acc, curr) => acc + curr.value, 0);
const filteredList = receivables.filter(r => filterStatus === 'all' ? true : r.status === (filterStatus === 'paid' ? 'paid' : 'pending'));
// --- ACTIONS ---
const handleGenerateRecurring = () => {
const currentMonth = new Date().toISOString().slice(0, 7); // YYYY-MM
const today = new Date().toISOString().split('T')[0];
let generatedCount = 0;
const newReceivables: Receivable[] = [];
companies.forEach(company => {
if (company.status !== 'active') return;
company.activeServices.forEach(service => {
if (service.billingType === 'recurring') {
// Check duplicates for this month
const exists = receivables.find(r =>
r.companyName === (company.fantasyName || company.name) &&
r.description === service.name &&
r.dueDate.startsWith(currentMonth)
);
if (!exists) {
newReceivables.push({
id: Math.random().toString(36).substr(2, 9),
description: service.name,
companyName: company.fantasyName || company.name,
category: service.category,
value: service.price,
dueDate: today, // Simplificação: gera para hoje ou data padrão de vencimento
status: 'pending',
type: 'recurring'
});
generatedCount++;
}
}
});
});
if (generatedCount > 0) {
setReceivables(prev => [...prev, ...newReceivables]);
addToast({ type: 'success', title: 'Processamento Concluído', message: `${generatedCount} faturas recorrentes foram geradas.` });
} else {
addToast({ type: 'info', title: 'Tudo em dia', message: 'Todas as cobranças recorrentes deste mês já foram geradas.' });
}
};
const handleSave = () => {
if (!newReceivable.description || !newReceivable.value) {
addToast({ type: 'warning', title: 'Dados Incompletos', message: 'Preencha a descrição e o valor.' });
return;
}
if (editingId) {
setReceivables(receivables.map(r => r.id === editingId ? {
...newReceivable,
id: editingId,
value: Number(newReceivable.value),
category: newReceivable.category || 'Outros',
companyName: newReceivable.companyName || 'Avulso'
} as Receivable : r));
addToast({ type: 'success', title: 'Atualizado', message: 'Recebimento atualizado com sucesso.' });
} else {
const item: Receivable = {
...newReceivable,
id: Math.random().toString(36).substr(2, 9),
value: Number(newReceivable.value),
category: newReceivable.category || 'Outros',
companyName: newReceivable.companyName || 'Avulso'
} as Receivable;
setReceivables([...receivables, item]);
addToast({ type: 'success', title: 'Criado', message: 'Novo recebimento registrado.' });
}
setIsModalOpen(false);
setEditingId(null);
setNewReceivable({ type: 'one-time', status: 'pending', dueDate: new Date().toISOString().split('T')[0] });
};
const handleDelete = (id: string) => {
if(window.confirm("Excluir recebimento?")) {
setReceivables(receivables.filter(r => r.id !== id));
addToast({ type: 'info', title: 'Excluído', message: 'Registro removido.' });
}
}
const toggleStatus = (id: string) => {
setReceivables(receivables.map(r => {
if(r.id === id) {
const newStatus = r.status === 'paid' ? 'pending' : 'paid';
if (newStatus === 'paid') addToast({ type: 'success', title: 'Recebido!', message: `Valor de R$ ${r.value} confirmado.` });
return { ...r, status: newStatus };
}
return r;
}));
}
const openEditModal = (item: Receivable) => {
setNewReceivable(item);
setEditingId(item.id);
setIsModalOpen(true);
};
const openCreateModal = () => {
setNewReceivable({
type: 'one-time',
status: 'pending',
dueDate: new Date().toISOString().split('T')[0]
});
setEditingId(null);
setIsModalOpen(true);
}
const inputClass = "w-full p-3 bg-white border border-slate-200 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none text-slate-800";
return (
<div className="space-y-6 animate-fade-in">
{/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-white p-6 rounded-[2rem] border border-slate-50 shadow-sm flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-slate-100 flex items-center justify-center text-slate-500">
<DollarSign size={24} />
</div>
<div>
<p className="text-slate-400 text-xs font-bold uppercase">Receita Total Prevista</p>
<h3 className="text-2xl font-bold text-slate-800">R$ {totalReceivable.toLocaleString('pt-BR')}</h3>
</div>
</div>
<div className="bg-white p-6 rounded-[2rem] border border-slate-50 shadow-sm flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-green-50 flex items-center justify-center text-green-600">
<CheckCircle2 size={24} />
</div>
<div>
<p className="text-slate-400 text-xs font-bold uppercase">Recebido</p>
<h3 className="text-2xl font-bold text-green-600">R$ {totalReceived.toLocaleString('pt-BR')}</h3>
</div>
</div>
<div className="bg-white p-6 rounded-[2rem] border border-slate-50 shadow-sm flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-amber-50 flex items-center justify-center text-amber-600">
<TrendingUp size={24} />
</div>
<div>
<p className="text-slate-400 text-xs font-bold uppercase">A Receber</p>
<h3 className="text-2xl font-bold text-amber-600">R$ {totalPending.toLocaleString('pt-BR')}</h3>
</div>
</div>
</div>
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-800">Contas a Receber</h1>
<p className="text-slate-500">Gestão de faturas, contratos e recebimentos avulsos.</p>
</div>
<div className="flex gap-3">
<button
onClick={handleGenerateRecurring}
className="flex items-center gap-2 px-5 py-3 bg-indigo-500 text-white rounded-xl shadow-lg shadow-indigo-200/50 hover:bg-indigo-600 font-bold transition-all"
title="Gera cobranças baseadas nos serviços ativos do CRM"
>
<RefreshCw size={20} /> <span className="hidden sm:inline">Gerar Mensalidades</span>
</button>
<button onClick={openCreateModal} className="flex items-center gap-2 px-5 py-3 bg-green-500 text-white rounded-xl shadow-lg shadow-green-200/50 hover:bg-green-600 font-bold transition-all">
<Plus size={20} /> <span className="hidden sm:inline">Novo Recebimento</span>
</button>
</div>
</div>
<div className="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
{/* Toolbar */}
<div className="p-4 border-b border-slate-100 flex flex-wrap gap-4 items-center">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<input
type="text"
placeholder="Buscar cliente ou descrição..."
className="w-full pl-10 pr-4 py-2 bg-slate-50 border-none rounded-xl focus:ring-2 focus:ring-primary-500 outline-none text-slate-600"
/>
</div>
<div className="flex gap-2 bg-slate-50 p-1 rounded-xl">
<button
onClick={() => setFilterStatus('all')}
className={`px-4 py-1.5 rounded-lg text-sm font-medium transition-colors ${filterStatus === 'all' ? 'bg-white shadow text-slate-800' : 'text-slate-500 hover:text-slate-700'}`}
>
Todos
</button>
<button
onClick={() => setFilterStatus('paid')}
className={`px-4 py-1.5 rounded-lg text-sm font-medium transition-colors ${filterStatus === 'paid' ? 'bg-white shadow text-slate-800' : 'text-slate-500 hover:text-slate-700'}`}
>
Recebidos
</button>
<button
onClick={() => setFilterStatus('pending')}
className={`px-4 py-1.5 rounded-lg text-sm font-medium transition-colors ${filterStatus === 'pending' ? 'bg-white shadow text-slate-800' : 'text-slate-500 hover:text-slate-700'}`}
>
Pendentes
</button>
</div>
</div>
{/* Table */}
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-slate-50/50">
<tr>
<th className="p-4 text-xs font-bold uppercase text-slate-500">Descrição / Cliente</th>
<th className="p-4 text-xs font-bold uppercase text-slate-500">Categoria</th>
<th className="p-4 text-xs font-bold uppercase text-slate-500">Vencimento</th>
<th className="p-4 text-xs font-bold uppercase text-slate-500">Valor</th>
<th className="p-4 text-xs font-bold uppercase text-slate-500 text-center">Tipo</th>
<th className="p-4 text-xs font-bold uppercase text-slate-500 text-center">Status</th>
<th className="p-4"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{filteredList.map(item => (
<tr key={item.id} className="hover:bg-slate-50 transition-colors group">
<td className="p-4">
<div className="font-bold text-slate-800">{item.description}</div>
<div className="text-xs text-slate-400">{item.companyName}</div>
</td>
<td className="p-4">
<span className="px-2 py-1 bg-slate-100 rounded text-xs text-slate-600">{item.category}</span>
</td>
<td className="p-4 text-sm text-slate-600">
<div className="flex items-center gap-2">
<Calendar size={14} className="text-slate-400"/>
{new Date(item.dueDate).toLocaleDateString('pt-BR')}
</div>
</td>
<td className="p-4 font-bold text-slate-800">R$ {item.value.toLocaleString('pt-BR')}</td>
<td className="p-4 text-center">
<span className={`text-[10px] font-bold uppercase px-2 py-0.5 rounded ${item.type === 'recurring' ? 'bg-indigo-50 text-indigo-600' : 'bg-slate-100 text-slate-600'}`}>
{item.type === 'recurring' ? 'Mensal' : 'Avulso'}
</span>
</td>
<td className="p-4 text-center">
<button onClick={() => toggleStatus(item.id)} className={`px-3 py-1 rounded-full text-xs font-bold border transition-all ${
item.status === 'paid' ? 'bg-green-50 text-green-600 border-green-200 hover:bg-green-100' :
item.status === 'overdue' ? 'bg-red-50 text-red-600 border-red-200 hover:bg-red-100' :
'bg-amber-50 text-amber-600 border-amber-200 hover:bg-amber-100'
}`}>
{item.status === 'paid' ? 'RECEBIDO' : item.status === 'overdue' ? 'ATRASADO' : 'PENDENTE'}
</button>
</td>
<td className="p-4 text-right">
<div className="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => openEditModal(item)} className="p-2 text-slate-400 hover:text-primary-500 hover:bg-primary-50 rounded-lg transition-colors">
<Pencil size={18} />
</button>
<button onClick={() => handleDelete(item.id)} className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors">
<Trash2 size={18} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{filteredList.length === 0 && (
<div className="p-10 text-center text-slate-400">
<Sparkles size={32} className="mx-auto mb-2 opacity-20"/>
<p>Nenhum lançamento encontrado.</p>
</div>
)}
</div>
</div>
{/* Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm" onClick={() => setIsModalOpen(false)}></div>
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-md animate-scale-up flex flex-col max-h-[90vh]">
<div className="flex justify-between items-center px-6 py-4 border-b border-slate-100 flex-shrink-0">
<h3 className="font-bold text-slate-800 text-lg">{editingId ? 'Editar Recebimento' : 'Novo Recebimento'}</h3>
<button onClick={() => setIsModalOpen(false)}><X size={20} className="text-slate-400 hover:text-slate-600"/></button>
</div>
<div className="p-6 space-y-4 overflow-y-auto">
<div>
<label className="block text-sm font-bold text-slate-800 mb-1">Descrição</label>
<input
type="text"
className={inputClass}
placeholder="Ex: Consultoria Extra"
value={newReceivable.description || ''}
onChange={e => setNewReceivable({...newReceivable, description: e.target.value})}
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-800 mb-1">Cliente / Empresa</label>
<input
type="text"
className={inputClass}
placeholder="Nome do cliente"
value={newReceivable.companyName || ''}
onChange={e => setNewReceivable({...newReceivable, companyName: e.target.value})}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-bold text-slate-800 mb-1">Valor (R$)</label>
<input
type="number"
className={inputClass}
placeholder="0,00"
value={newReceivable.value || ''}
onChange={e => setNewReceivable({...newReceivable, value: Number(e.target.value)})}
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-800 mb-1">Vencimento</label>
<input
type="date"
className={inputClass}
value={newReceivable.dueDate}
onChange={e => setNewReceivable({...newReceivable, dueDate: e.target.value})}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-bold text-slate-800 mb-1">Categoria</label>
<CustomSelect
value={newReceivable.category || 'Serviços'}
onChange={(val) => setNewReceivable({...newReceivable, category: val})}
options={[
{ value: 'Serviços', label: 'Serviços' },
{ value: 'Produtos', label: 'Produtos' },
{ value: 'Reembolso', label: 'Reembolso' },
{ value: 'Outros', label: 'Outros' }
]}
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-800 mb-1">Tipo</label>
<CustomSelect
value={newReceivable.type}
onChange={(val) => setNewReceivable({...newReceivable, type: val})}
options={[
{ value: 'one-time', label: 'Avulso' },
{ value: 'recurring', label: 'Recorrente' }
]}
/>
</div>
</div>
<button onClick={handleSave} className="w-full py-3 bg-green-500 text-white font-bold rounded-xl mt-4 hover:bg-green-600 shadow-lg shadow-green-200">
{editingId ? 'Salvar Alterações' : 'Salvar Recebimento'}
</button>
</div>
</div>
</div>
)}
</div>
);
};

648
components/CRMView.tsx Normal file
View File

@@ -0,0 +1,648 @@
import React, { useState, useRef } from 'react';
import {
Building2, Phone, Mail, MapPin, Globe, Calendar,
FileText, Users, Plus, ArrowLeft, MoreHorizontal,
Download, Search, Filter, CheckCircle, Briefcase, ExternalLink, X, Save, UploadCloud, DollarSign, MessageCircle, AlertTriangle, Pencil, Camera, Trash2, ChevronDown
} from 'lucide-react';
import { Company, ContactPerson, CompanyDocument, Service } from '../types';
import { useToast } from '../contexts/ToastContext';
interface CRMViewProps {
companies: Company[];
setCompanies: (companies: Company[]) => void;
availableServices: Service[];
}
// Simple Modal (Reused)
const Modal: React.FC<{isOpen: boolean; onClose: () => void; title: string; children: React.ReactNode; maxWidth?: string}> = ({ isOpen, onClose, title, children, maxWidth = 'max-w-lg' }) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-slate-900/50 backdrop-blur-sm" onClick={onClose}></div>
<div className={`relative bg-white rounded-2xl shadow-xl w-full ${maxWidth} overflow-hidden animate-fade-in flex flex-col max-h-[90vh]`}>
<div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center flex-shrink-0">
<h3 className="font-bold text-slate-800 text-lg">{title}</h3>
<button onClick={onClose} className="text-slate-400 hover:text-slate-600"><X size={20}/></button>
</div>
<div className="p-6 overflow-y-auto">{children}</div>
</div>
</div>
);
};
export const CRMView: React.FC<CRMViewProps> = ({ companies, setCompanies, availableServices }) => {
const { addToast } = useToast();
const [viewMode, setViewMode] = useState<'list' | 'detail'>('list');
const [selectedCompanyId, setSelectedCompanyId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'overview' | 'contacts' | 'documents' | 'services'>('overview');
// Modal States
const [isCompanyModalOpen, setIsCompanyModalOpen] = useState(false);
const [isContactModalOpen, setIsContactModalOpen] = useState(false);
const [isServiceModalOpen, setIsServiceModalOpen] = useState(false);
// Editing States
const [isEditingOverview, setIsEditingOverview] = useState(false);
const [editingContactId, setEditingContactId] = useState<string | null>(null);
// Forms State
const [newCompany, setNewCompany] = useState<Partial<Company>>({ status: 'active', industry: 'Outro', logo: '' });
const [overviewForm, setOverviewForm] = useState<Partial<Company>>({});
const [contactForm, setContactForm] = useState<Partial<ContactPerson>>({});
const [selectedServiceId, setSelectedServiceId] = useState('');
// Refs for File Uploads
const logoInputRef = useRef<HTMLInputElement>(null);
const avatarInputRef = useRef<HTMLInputElement>(null);
const docInputRef = useRef<HTMLInputElement>(null);
const selectedCompany = companies.find(c => c.id === selectedCompanyId);
const clientRevenue = selectedCompany ? selectedCompany.activeServices.reduce((acc, s) => acc + s.price, 0) : 0;
// --- HELPER: FILE TO BASE64 ---
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>, callback: (base64: string) => void) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
callback(reader.result as string);
};
reader.readAsDataURL(file);
}
};
// --- ACTIONS ---
const handleSaveCompany = () => {
if (!newCompany.name) {
addToast({ type: 'warning', title: 'Campos Obrigatórios', message: 'Por favor, informe ao menos a Razão Social.' });
return;
}
const company: Company = {
...newCompany,
id: Math.random().toString(36).substr(2, 9),
logo: newCompany.logo || `https://ui-avatars.com/api/?name=${newCompany.name}&background=random`,
contacts: [],
documents: [],
activeServices: []
} as Company;
setCompanies([...companies, company]);
setIsCompanyModalOpen(false);
setNewCompany({ status: 'active', industry: 'Outro', logo: '' });
addToast({ type: 'success', title: 'Empresa Cadastrada', message: `${company.name} foi adicionada com sucesso.` });
};
const handleUpdateOverview = () => {
if (!selectedCompany || !overviewForm.name) return;
const updatedCompanies = companies.map(c => {
if (c.id === selectedCompany.id) {
return { ...c, ...overviewForm };
}
return c;
});
setCompanies(updatedCompanies);
setIsEditingOverview(false);
addToast({ type: 'success', title: 'Dados Atualizados', message: 'As informações da empresa foram salvas.' });
};
const openContactModal = (contact?: ContactPerson) => {
if (contact) {
setEditingContactId(contact.id);
setContactForm(contact);
} else {
setEditingContactId(null);
setContactForm({ avatar: '' });
}
setIsContactModalOpen(true);
};
const handleSaveContact = () => {
if (!selectedCompany || !contactForm.name) {
addToast({ type: 'warning', title: 'Nome Obrigatório', message: 'Informe o nome do responsável.' });
return;
}
let updatedContacts = [...selectedCompany.contacts];
if (editingContactId) {
// Edit existing
updatedContacts = updatedContacts.map(c => c.id === editingContactId ? { ...c, ...contactForm } as ContactPerson : c);
} else {
// Create new
const newContact: ContactPerson = {
...contactForm,
id: Math.random().toString(36).substr(2, 9),
avatar: contactForm.avatar || `https://ui-avatars.com/api/?name=${contactForm.name}&background=random`
} as ContactPerson;
updatedContacts.push(newContact);
}
const updatedCompanies = companies.map(c => {
if (c.id === selectedCompany.id) {
return { ...c, contacts: updatedContacts };
}
return c;
});
setCompanies(updatedCompanies);
setIsContactModalOpen(false);
setContactForm({});
setEditingContactId(null);
addToast({ type: 'success', title: 'Responsável Salvo', message: 'Lista de contatos atualizada.' });
};
const handleDeleteContact = (contactId: string) => {
if (!selectedCompany || !window.confirm("Excluir responsável?")) return;
const updatedCompanies = companies.map(c => {
if (c.id === selectedCompany.id) {
return { ...c, contacts: c.contacts.filter(ct => ct.id !== contactId) };
}
return c;
});
setCompanies(updatedCompanies);
addToast({ type: 'info', title: 'Responsável Removido' });
};
const handleUploadDocument = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !selectedCompany) return;
const newDoc: CompanyDocument = {
id: Math.random().toString(36).substr(2, 9),
title: file.name,
type: file.name.endsWith('.pdf') ? 'briefing' : 'other',
date: new Date().toLocaleDateString('pt-BR'),
size: `${(file.size / 1024 / 1024).toFixed(2)} MB`
};
const updatedCompanies = companies.map(c => {
if (c.id === selectedCompany.id) {
return { ...c, documents: [...c.documents, newDoc] };
}
return c;
});
setCompanies(updatedCompanies);
addToast({ type: 'success', title: 'Upload Concluído', message: 'Arquivo anexado com sucesso.' });
};
const handleAddService = () => {
if (!selectedCompany || !selectedServiceId) return;
const serviceToAdd = availableServices.find(s => s.id === selectedServiceId);
if (!serviceToAdd) return;
const updatedCompanies = companies.map(c => {
if (c.id === selectedCompany.id) {
return { ...c, activeServices: [...c.activeServices, serviceToAdd] };
}
return c;
});
setCompanies(updatedCompanies);
setIsServiceModalOpen(false);
setSelectedServiceId('');
addToast({ type: 'success', title: 'Serviço Adicionado', message: 'Contrato atualizado.' });
};
const handleRemoveService = (index: number) => {
if (!selectedCompany) return;
const updatedCompanies = companies.map(c => {
if (c.id === selectedCompany.id) {
const newServices = [...c.activeServices];
newServices.splice(index, 1);
return { ...c, activeServices: newServices };
}
return c;
});
setCompanies(updatedCompanies);
addToast({ type: 'info', title: 'Serviço Removido' });
};
const openWhatsApp = (phone: string) => {
const cleanPhone = phone.replace(/\D/g, '');
window.open(`https://wa.me/55${cleanPhone}`, '_blank');
};
// Styles
const inputClass = "w-full p-3 bg-white border border-slate-200 rounded-xl focus:ring-2 focus:ring-primary-200 outline-none text-slate-800 text-sm";
const selectClass = "w-full bg-white border border-slate-200 rounded-xl px-4 py-3 pr-10 text-sm font-medium text-slate-700 outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 appearance-none transition-all cursor-pointer shadow-sm";
const labelClass = "block text-xs font-bold text-slate-700 mb-1 uppercase tracking-wide";
// --- VIEWS ---
if (viewMode === 'list') {
return (
<div className="space-y-8 animate-fade-in">
<Modal isOpen={isCompanyModalOpen} onClose={() => setIsCompanyModalOpen(false)} title="Nova Empresa" maxWidth="max-w-2xl">
<div className="grid grid-cols-2 gap-4">
{/* Logo Upload */}
<div className="col-span-2 flex flex-col items-center justify-center mb-4">
<div
className="w-24 h-24 rounded-2xl bg-slate-100 border-2 border-dashed border-slate-300 flex items-center justify-center cursor-pointer overflow-hidden hover:border-primary-400 transition-colors relative group"
onClick={() => logoInputRef.current?.click()}
>
{newCompany.logo ? (
<img src={newCompany.logo} alt="Logo" className="w-full h-full object-cover" />
) : (
<Camera className="text-slate-400 group-hover:text-primary-500" size={32} />
)}
<div className="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-white text-xs font-bold">Alterar</span>
</div>
</div>
<span className="text-xs text-slate-400 mt-2">Clique para adicionar logo</span>
<input
type="file"
ref={logoInputRef}
className="hidden"
accept="image/*"
onChange={(e) => handleFileChange(e, (base64) => setNewCompany({...newCompany, logo: base64}))}
/>
</div>
<div className="col-span-2">
<label className={labelClass}>Razão Social</label>
<input type="text" className={inputClass} value={newCompany.name || ''} onChange={e => setNewCompany({...newCompany, name: e.target.value})} placeholder="Ex: Tech Solutions LTDA" />
</div>
<div className="col-span-1">
<label className={labelClass}>Nome Fantasia</label>
<input type="text" className={inputClass} value={newCompany.fantasyName || ''} onChange={e => setNewCompany({...newCompany, fantasyName: e.target.value})} placeholder="Ex: Tech Sol" />
</div>
<div className="col-span-1">
<label className={labelClass}>CNPJ</label>
<input type="text" className={inputClass} value={newCompany.cnpj || ''} onChange={e => setNewCompany({...newCompany, cnpj: e.target.value})} placeholder="00.000.000/0001-00" />
</div>
<div className="col-span-1">
<label className={labelClass}>Inscrição Estadual</label>
<input type="text" className={inputClass} value={newCompany.ie || ''} onChange={e => setNewCompany({...newCompany, ie: e.target.value})} />
</div>
<div className="col-span-1">
<label className={labelClass}>Cidade - UF</label>
<input type="text" className={inputClass} value={newCompany.city || ''} onChange={e => setNewCompany({...newCompany, city: e.target.value})} />
</div>
<div className="col-span-1">
<label className={labelClass}>Telefone Geral</label>
<input type="text" className={inputClass} value={newCompany.phone || ''} onChange={e => setNewCompany({...newCompany, phone: e.target.value})} />
</div>
<div className="col-span-1">
<label className={labelClass}>Email Geral</label>
<input type="email" className={inputClass} value={newCompany.email || ''} onChange={e => setNewCompany({...newCompany, email: e.target.value})} />
</div>
<div className="col-span-2">
<label className={labelClass}>Endereço Completo</label>
<input type="text" className={inputClass} value={newCompany.address || ''} onChange={e => setNewCompany({...newCompany, address: e.target.value})} />
</div>
<div className="col-span-1">
<label className={labelClass}>Status</label>
<div className="relative">
<select className={selectClass} value={newCompany.status} onChange={e => setNewCompany({...newCompany, status: e.target.value as any})}>
<option value="active">Ativo</option>
<option value="pending">Pendente</option>
<option value="inactive">Inativo</option>
<option value="overdue">Em Atraso</option>
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" size={18} />
</div>
</div>
<div className="col-span-2 pt-4">
<button onClick={handleSaveCompany} className="w-full py-3 bg-primary-500 text-white font-bold rounded-xl shadow-lg hover:bg-primary-600 transition-colors">Cadastrar Empresa</button>
</div>
</div>
</Modal>
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-800">Clientes & Empresas</h1>
<p className="text-slate-500">Gerencie contratos, responsáveis e documentos.</p>
</div>
<button onClick={() => setIsCompanyModalOpen(true)} className="flex items-center gap-2 px-5 py-2.5 bg-primary-500 text-white rounded-xl hover:bg-primary-600 shadow-lg shadow-primary-200/50 transition-all font-bold">
<Plus size={18} /> Nova Empresa
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{companies.map((company) => {
const totalValue = company.activeServices.reduce((acc, s) => acc + s.price, 0);
return (
<div
key={company.id}
onClick={() => { setSelectedCompanyId(company.id); setViewMode('detail'); }}
className={`group bg-white p-6 rounded-[2rem] shadow-sm hover:shadow-lg hover:-translate-y-1 transition-all border cursor-pointer relative overflow-hidden ${company.status === 'overdue' ? 'border-red-200 ring-1 ring-red-100' : 'border-slate-50'}`}
>
{company.status === 'overdue' && (
<div className="absolute top-0 right-0 bg-red-500 text-white text-[10px] font-bold px-3 py-1 rounded-bl-xl">
EM ATRASO
</div>
)}
<div className="flex gap-4 mb-4 mt-2">
<div className="w-16 h-16 rounded-2xl bg-slate-100 flex items-center justify-center text-slate-400 font-bold text-xl uppercase overflow-hidden border border-slate-100">
{company.logo ? <img src={company.logo} className="w-full h-full object-cover"/> : company.name.substring(0,2)}
</div>
<div>
<h3 className="font-bold text-slate-800 text-lg group-hover:text-primary-500 line-clamp-1">{company.fantasyName || company.name}</h3>
<p className="text-xs text-slate-400">{company.city || 'Local não informado'}</p>
<div className="mt-1 flex gap-2">
{company.status === 'active' && <span className="inline-block px-2 py-0.5 bg-green-50 text-green-600 rounded text-[10px] font-bold uppercase">Ativo</span>}
{company.status === 'pending' && <span className="inline-block px-2 py-0.5 bg-amber-50 text-amber-600 rounded text-[10px] font-bold uppercase">Pendente</span>}
{company.status === 'overdue' && <span className="inline-block px-2 py-0.5 bg-red-50 text-red-600 rounded text-[10px] font-bold uppercase">Financeiro Pendente</span>}
</div>
</div>
</div>
<div className="border-t border-slate-50 pt-4 flex justify-between items-center">
<span className="text-sm text-slate-500 flex items-center gap-1"><Users size={14}/> {company.contacts.length} Resp.</span>
<span className="font-bold text-slate-800 text-sm">R$ {totalValue.toLocaleString('pt-BR')} /mês</span>
</div>
</div>
);
})}
</div>
</div>
);
}
// DETALHE DA EMPRESA
if (selectedCompany) {
return (
<div className="space-y-6 animate-fade-in pb-10">
{/* Modal Contato (Novo/Edição) */}
<Modal isOpen={isContactModalOpen} onClose={() => setIsContactModalOpen(false)} title={editingContactId ? "Editar Responsável" : "Novo Responsável"}>
<div className="space-y-4">
<div className="flex justify-center mb-4">
<div
className="w-20 h-20 rounded-full bg-slate-100 border-2 border-dashed border-slate-300 flex items-center justify-center cursor-pointer overflow-hidden hover:border-primary-400 relative group"
onClick={() => avatarInputRef.current?.click()}
>
{contactForm.avatar ? (
<img src={contactForm.avatar} alt="Avatar" className="w-full h-full object-cover" />
) : (
<Camera className="text-slate-400 group-hover:text-primary-500" size={24} />
)}
<div className="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity rounded-full">
<span className="text-white text-[10px] font-bold">Foto</span>
</div>
</div>
<input
type="file"
ref={avatarInputRef}
className="hidden"
accept="image/*"
onChange={(e) => handleFileChange(e, (base64) => setContactForm({...contactForm, avatar: base64}))}
/>
</div>
<div>
<label className={labelClass}>Nome Completo</label>
<input type="text" className={inputClass} value={contactForm.name || ''} onChange={e => setContactForm({...contactForm, name: e.target.value})} />
</div>
<div>
<label className={labelClass}>Cargo / Função</label>
<input type="text" className={inputClass} value={contactForm.role || ''} onChange={e => setContactForm({...contactForm, role: e.target.value})} />
</div>
<div>
<label className={labelClass}>Email</label>
<input type="email" className={inputClass} value={contactForm.email || ''} onChange={e => setContactForm({...contactForm, email: e.target.value})} />
</div>
<div>
<label className={labelClass}>Telefone / WhatsApp</label>
<input type="text" className={inputClass} value={contactForm.phone || ''} onChange={e => setContactForm({...contactForm, phone: e.target.value})} placeholder="(00) 00000-0000" />
</div>
<button onClick={handleSaveContact} className="w-full py-3 bg-primary-500 text-white font-bold rounded-xl mt-4">Salvar Responsável</button>
</div>
</Modal>
{/* Modal Adicionar Serviço */}
<Modal isOpen={isServiceModalOpen} onClose={() => setIsServiceModalOpen(false)} title="Adicionar Serviço">
<div className="space-y-4">
<label className={labelClass}>Selecione o Serviço</label>
<div className="relative">
<select className={selectClass} value={selectedServiceId} onChange={e => setSelectedServiceId(e.target.value)}>
<option value="">Selecione...</option>
{availableServices.map(s => <option key={s.id} value={s.id}>{s.name} - R$ {s.price}</option>)}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" size={18} />
</div>
<button onClick={handleAddService} disabled={!selectedServiceId} className="w-full py-3 bg-primary-500 text-white font-bold rounded-xl mt-4 disabled:opacity-50">Adicionar</button>
</div>
</Modal>
{/* Header Detalhe */}
<div className="flex items-center gap-4">
<button onClick={() => setViewMode('list')} className="p-2 bg-white border border-slate-200 rounded-xl hover:bg-slate-50 transition-colors">
<ArrowLeft size={20} />
</button>
<div className="flex-1">
<h1 className="text-2xl font-bold text-slate-800">{selectedCompany.fantasyName || selectedCompany.name}</h1>
<p className="text-sm text-slate-500">{selectedCompany.name} - {selectedCompany.cnpj}</p>
</div>
{selectedCompany.status === 'overdue' && (
<div className="px-4 py-2 bg-red-100 text-red-700 rounded-xl font-bold flex items-center gap-2 animate-pulse">
<AlertTriangle size={18} /> CLIENTE EM ATRASO
</div>
)}
</div>
{/* Tabs */}
<div className="flex border-b border-slate-200">
{['overview', 'contacts', 'documents', 'services'].map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab as any)}
className={`px-6 py-3 text-sm font-bold border-b-2 transition-colors ${
activeTab === tab
? 'border-primary-500 text-primary-600'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
{tab === 'overview' && 'Visão Geral'}
{tab === 'contacts' && 'Responsáveis'}
{tab === 'documents' && 'Arquivos'}
{tab === 'services' && 'Serviços'}
</button>
))}
</div>
{/* Tab Content */}
<div className="animate-fade-in">
{activeTab === 'overview' && (
<div className="space-y-6">
<div className="flex justify-end">
{!isEditingOverview ? (
<button onClick={() => { setOverviewForm(selectedCompany); setIsEditingOverview(true); }} className="flex items-center gap-2 px-4 py-2 text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-xl font-bold text-sm transition-colors">
<Pencil size={16} /> Editar Dados
</button>
) : (
<div className="flex gap-2">
<button onClick={() => setIsEditingOverview(false)} className="px-4 py-2 text-slate-500 hover:text-slate-700 font-medium">Cancelar</button>
<button onClick={handleUpdateOverview} className="flex items-center gap-2 px-4 py-2 bg-green-500 text-white rounded-xl font-bold hover:bg-green-600 shadow-lg shadow-green-200">
<Save size={16} /> Salvar Alterações
</button>
</div>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white p-6 rounded-[2rem] border border-slate-50 shadow-sm space-y-4">
<h3 className="font-bold text-slate-800 text-lg border-b border-slate-50 pb-2">Dados Cadastrais</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<span className="block text-xs text-slate-400">CNPJ</span>
{isEditingOverview ? <input className={inputClass} value={overviewForm.cnpj || ''} onChange={e => setOverviewForm({...overviewForm, cnpj: e.target.value})} /> : <span className="text-sm font-medium text-slate-700">{selectedCompany.cnpj || '-'}</span>}
</div>
<div>
<span className="block text-xs text-slate-400">Inscrição Estadual</span>
{isEditingOverview ? <input className={inputClass} value={overviewForm.ie || ''} onChange={e => setOverviewForm({...overviewForm, ie: e.target.value})} /> : <span className="text-sm font-medium text-slate-700">{selectedCompany.ie || 'Isento'}</span>}
</div>
<div className="col-span-2">
<span className="block text-xs text-slate-400">Razão Social</span>
{isEditingOverview ? <input className={inputClass} value={overviewForm.name || ''} onChange={e => setOverviewForm({...overviewForm, name: e.target.value})} /> : <span className="text-sm font-medium text-slate-700">{selectedCompany.name}</span>}
</div>
<div className="col-span-2">
<span className="block text-xs text-slate-400">Endereço</span>
{isEditingOverview ? (
<div className="grid grid-cols-3 gap-2">
<input className={`${inputClass} col-span-2`} value={overviewForm.address || ''} onChange={e => setOverviewForm({...overviewForm, address: e.target.value})} placeholder="Rua..." />
<input className={inputClass} value={overviewForm.city || ''} onChange={e => setOverviewForm({...overviewForm, city: e.target.value})} placeholder="Cidade..." />
</div>
) : (
<span className="text-sm font-medium text-slate-700">{selectedCompany.address}, {selectedCompany.city}</span>
)}
</div>
</div>
</div>
<div className="bg-white p-6 rounded-[2rem] border border-slate-50 shadow-sm space-y-4">
<h3 className="font-bold text-slate-800 text-lg border-b border-slate-50 pb-2">Contato Geral</h3>
<div className="space-y-3">
<div className="flex items-center gap-3">
<Phone size={18} className="text-slate-400"/>
{isEditingOverview ? <input className={inputClass} value={overviewForm.phone || ''} onChange={e => setOverviewForm({...overviewForm, phone: e.target.value})} /> : <span className="text-sm text-slate-700">{selectedCompany.phone}</span>}
</div>
<div className="flex items-center gap-3">
<Mail size={18} className="text-slate-400"/>
{isEditingOverview ? <input className={inputClass} value={overviewForm.email || ''} onChange={e => setOverviewForm({...overviewForm, email: e.target.value})} /> : <span className="text-sm text-slate-700">{selectedCompany.email}</span>}
</div>
<div className="flex items-center gap-3">
<Globe size={18} className="text-slate-400"/>
{isEditingOverview ? <input className={inputClass} value={overviewForm.website || ''} onChange={e => setOverviewForm({...overviewForm, website: e.target.value})} placeholder="Website" /> : <span className="text-sm text-slate-700">{selectedCompany.website || 'Sem site'}</span>}
</div>
</div>
</div>
</div>
</div>
)}
{activeTab === 'contacts' && (
<div className="space-y-4">
<div className="flex justify-end">
<button onClick={() => openContactModal()} className="flex items-center gap-2 px-4 py-2 bg-slate-800 text-white rounded-xl text-sm font-bold hover:bg-slate-900"><Plus size={16}/> Adicionar Responsável</button>
</div>
{selectedCompany.contacts.length === 0 ? (
<div className="text-center py-10 text-slate-400 bg-white rounded-[2rem] border border-dashed border-slate-200">
Nenhum responsável cadastrado.
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{selectedCompany.contacts.map(contact => (
<div key={contact.id} className="bg-white p-4 rounded-2xl border border-slate-100 flex items-center justify-between shadow-sm group">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center font-bold text-slate-500 overflow-hidden border border-slate-200">
{contact.avatar ? <img src={contact.avatar} className="w-full h-full object-cover"/> : contact.name.charAt(0)}
</div>
<div>
<h4 className="font-bold text-slate-800">{contact.name}</h4>
<p className="text-xs text-slate-500">{contact.role}</p>
<p className="text-xs text-slate-400 mt-1">{contact.email}</p>
</div>
</div>
<div className="flex gap-2">
<button onClick={() => openContactModal(contact)} className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-colors">
<Pencil size={16} />
</button>
<button onClick={() => handleDeleteContact(contact.id)} className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:bg-red-50 hover:text-red-500 transition-colors">
<Trash2 size={16} />
</button>
<button
onClick={() => openWhatsApp(contact.phone)}
className="w-8 h-8 rounded-full bg-green-50 text-green-600 flex items-center justify-center hover:bg-green-500 hover:text-white transition-colors"
title={`WhatsApp: ${contact.phone}`}
>
<MessageCircle size={16} />
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
{activeTab === 'documents' && (
<div className="space-y-6">
<input
type="file"
ref={docInputRef}
className="hidden"
onChange={handleUploadDocument}
/>
<div
onClick={() => docInputRef.current?.click()}
className="bg-slate-50 border-2 border-dashed border-slate-200 rounded-2xl p-8 text-center hover:bg-slate-100 transition-colors cursor-pointer group"
>
<UploadCloud size={48} className="mx-auto text-slate-300 group-hover:text-primary-400 mb-2 transition-colors" />
<h4 className="font-bold text-slate-600">Arraste arquivos aqui ou clique para anexar</h4>
<p className="text-xs text-slate-400 mt-1">PDFs, Contratos (DOCX), Briefings.</p>
</div>
<div className="space-y-2">
{selectedCompany.documents.map(doc => (
<div key={doc.id} className="flex items-center justify-between p-4 bg-white rounded-xl border border-slate-100 hover:shadow-sm transition-shadow">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${doc.type === 'contract' ? 'bg-blue-50 text-blue-500' : 'bg-orange-50 text-orange-500'}`}>
<FileText size={20} />
</div>
<div>
<h4 className="font-bold text-slate-800 text-sm">{doc.title}</h4>
<p className="text-xs text-slate-400">{doc.date} {doc.size}</p>
</div>
</div>
<button className="p-2 text-slate-400 hover:text-primary-500"><Download size={18}/></button>
</div>
))}
</div>
</div>
)}
{activeTab === 'services' && (
<div className="space-y-4">
<div className="flex justify-between items-center bg-white p-4 rounded-2xl border border-slate-100">
<div>
<p className="text-xs text-slate-400 uppercase font-bold">Faturamento Total</p>
<p className="text-xl font-bold text-slate-800">R$ {clientRevenue.toLocaleString('pt-BR')} <span className="text-xs font-normal text-slate-400">/mês</span></p>
</div>
<button onClick={() => setIsServiceModalOpen(true)} className="px-4 py-2 bg-primary-500 text-white text-sm font-bold rounded-xl hover:bg-primary-600">Adicionar Serviço</button>
</div>
<div className="space-y-2">
{selectedCompany.activeServices.map((service, idx) => (
<div key={idx} className="flex justify-between items-center p-4 bg-white rounded-xl border border-slate-100">
<div>
<h4 className="font-bold text-slate-800 text-sm">{service.name}</h4>
<span className="text-xs bg-slate-100 text-slate-500 px-2 py-0.5 rounded">{service.billingType === 'recurring' ? 'Recorrente' : 'Pontual'}</span>
</div>
<div className="flex items-center gap-4">
<span className="font-bold text-slate-700">R$ {service.price.toLocaleString('pt-BR')}</span>
<button onClick={() => handleRemoveService(idx)} className="text-slate-300 hover:text-red-500"><X size={16}/></button>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}
return null;
};

466
components/CalendarView.tsx Normal file
View File

@@ -0,0 +1,466 @@
import React, { useState, useMemo } from 'react';
import { ChevronLeft, ChevronRight, Clock, CheckCircle2, AlertCircle, Plus, X, Calendar as CalendarIcon, Trash2, List, Grid, DollarSign, ChevronDown } from 'lucide-react';
import { CalendarEvent, Expense, Receivable } from '../types';
import { useToast } from '../contexts/ToastContext';
import { CustomSelect } from './CustomSelect';
interface CalendarViewProps {
expenses?: Expense[];
receivables?: Receivable[];
}
const initialManualEvents: CalendarEvent[] = [
{ id: '1', title: 'Reunião Uda Studios', date: new Date().toISOString().split('T')[0], type: 'meeting', completed: false, description: 'Alinhamento mensal sobre o progresso do projeto de redesign.' },
];
export const CalendarView: React.FC<CalendarViewProps> = ({ expenses = [], receivables = [] }) => {
const { addToast } = useToast();
const [currentDate, setCurrentDate] = useState(new Date());
const [manualEvents, setManualEvents] = useState<CalendarEvent[]>(initialManualEvents);
// Combine Manual Events with Financial Data
const events = useMemo(() => {
const expenseEvents: CalendarEvent[] = expenses.map(e => ({
id: `exp-${e.id}`,
title: `Pagar: ${e.title}`,
date: e.dueDate,
type: 'payment',
description: `Valor: R$ ${e.amount.toLocaleString('pt-BR')} - Categoria: ${e.category} - Status: ${e.status === 'paid' ? 'Pago' : 'Pendente'}`,
completed: e.status === 'paid'
}));
const receivableEvents: CalendarEvent[] = receivables.map(r => ({
id: `rec-${r.id}`,
title: `Receber: ${r.description}`,
date: r.dueDate,
type: 'deadline', // Usaremos deadline logicamente, mas com cor especial visualmente
description: `Valor: R$ ${r.value.toLocaleString('pt-BR')} - Cliente: ${r.companyName} - Status: ${r.status === 'paid' ? 'Recebido' : 'Pendente'}`,
completed: r.status === 'paid'
}));
return [...manualEvents, ...expenseEvents, ...receivableEvents];
}, [manualEvents, expenses, receivables]);
// View State
const [viewMode, setViewMode] = useState<'month' | 'agenda'>('month');
// Create Modal State
const [isModalOpen, setIsModalOpen] = useState(false);
const [newEvent, setNewEvent] = useState<Partial<CalendarEvent>>({ type: 'meeting', date: new Date().toISOString().split('T')[0] });
// Detail Modal State
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null);
const getDaysInMonth = (year: number, month: number) => new Date(year, month + 1, 0).getDate();
const getFirstDayOfMonth = (year: number, month: number) => new Date(year, month, 1).getDay();
const daysInMonth = getDaysInMonth(currentDate.getFullYear(), currentDate.getMonth());
const firstDay = getFirstDayOfMonth(currentDate.getFullYear(), currentDate.getMonth());
// --- Actions ---
const handleCreateEvent = () => {
if (!newEvent.title || !newEvent.date) return;
const event: CalendarEvent = {
...newEvent,
id: Math.random().toString(36).substr(2, 9),
completed: false
} as CalendarEvent;
setManualEvents([...manualEvents, event]);
setIsModalOpen(false);
setNewEvent({ type: 'meeting', date: new Date().toISOString().split('T')[0] });
addToast({ type: 'success', title: 'Evento Criado', message: 'Agendamento salvo com sucesso.' });
};
const handleEventClick = (event: CalendarEvent) => {
setSelectedEvent(event);
setIsDetailModalOpen(true);
};
const handleDeleteEvent = () => {
if (!selectedEvent) return;
// Prevent deleting financial data from calendar
if (selectedEvent.id.startsWith('exp-') || selectedEvent.id.startsWith('rec-')) {
addToast({ type: 'warning', title: 'Ação Bloqueada', message: "Para excluir este registro, acesse o módulo Financeiro." });
return;
}
if (window.confirm('Deseja excluir este evento?')) {
setManualEvents(manualEvents.filter(e => e.id !== selectedEvent.id));
setIsDetailModalOpen(false);
setSelectedEvent(null);
addToast({ type: 'info', title: 'Evento Removido' });
}
};
const handleToggleComplete = () => {
if (!selectedEvent) return;
// Prevent modifying financial data status from calendar (simplification for now)
if (selectedEvent.id.startsWith('exp-') || selectedEvent.id.startsWith('rec-')) {
addToast({ type: 'warning', title: 'Ação Bloqueada', message: "Para baixar este pagamento, vá ao módulo Financeiro." });
return;
}
const updatedEvents = manualEvents.map(e =>
e.id === selectedEvent.id ? { ...e, completed: !e.completed } : e
);
setManualEvents(updatedEvents);
setSelectedEvent({ ...selectedEvent, completed: !selectedEvent.completed });
addToast({ type: 'success', title: selectedEvent.completed ? 'Reaberto' : 'Concluído', message: 'Status do evento atualizado.' });
};
const getEventStyle = (event: CalendarEvent) => {
if (event.id.startsWith('rec-')) {
return 'bg-green-50 border-green-400 text-green-700';
}
if (event.type === 'payment' || event.id.startsWith('exp-')) {
return 'bg-red-50 border-red-400 text-red-700';
}
if (event.type === 'deadline') {
return 'bg-amber-50 border-amber-400 text-amber-700';
}
return 'bg-blue-50 border-blue-400 text-blue-700';
};
const getEventTypeLabel = (event: CalendarEvent) => {
if (event.id.startsWith('rec-')) return 'Recebimento';
if (event.type === 'payment' || event.id.startsWith('exp-')) return 'Pagamento';
if (event.type === 'deadline') return 'Prazo';
return 'Reunião';
};
// --- Render Helpers ---
const handlePrevMonth = () => setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1));
const handleNextMonth = () => setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1));
const days = [];
// Empty slots
for (let i = 0; i < firstDay; i++) {
days.push(<div key={`empty-${i}`} className="h-32 bg-slate-50/50 border border-slate-100" />);
}
const todayStr = new Date().toISOString().split('T')[0];
// Days
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
const dayEvents = events.filter(e => e.date === dateStr);
const isToday = dateStr === todayStr;
days.push(
<div key={d} className={`h-32 border border-slate-100 p-2 transition-colors hover:bg-slate-50 ${isToday ? 'bg-orange-50/30' : 'bg-white'}`}>
<div className="flex justify-between items-start mb-2">
<span className={`text-sm font-bold w-7 h-7 flex items-center justify-center rounded-full ${isToday ? 'bg-primary-500 text-white' : 'text-slate-700'}`}>
{d}
</span>
{dayEvents.length > 0 && <span className="text-[10px] bg-slate-100 px-1.5 rounded text-slate-500">{dayEvents.length}</span>}
</div>
<div className="space-y-1 overflow-y-auto max-h-[80px] scrollbar-thin">
{dayEvents.map(ev => (
<div
key={ev.id}
onClick={(e) => { e.stopPropagation(); handleEventClick(ev); }}
className={`text-[10px] px-2 py-1 rounded border-l-2 truncate cursor-pointer transition-all hover:brightness-95 ${getEventStyle(ev)} ${ev.completed ? 'opacity-50 line-through' : ''}`}>
{ev.title}
</div>
))}
</div>
</div>
);
}
// Upcoming Deadlines Logic: Filter events from "today" onwards
const upcomingEvents = events
.filter(e => e.date >= todayStr && !e.completed)
.sort((a, b) => a.date.localeCompare(b.date));
const sidebarEvents = upcomingEvents.slice(0, 5);
// Styles for Inputs
const labelClass = "block text-sm font-bold text-slate-800 mb-1";
const inputClass = "w-full p-3 bg-white border border-slate-300 rounded-xl text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none transition-shadow";
return (
<div className="flex flex-col xl:flex-row gap-6 h-[calc(100vh-140px)] animate-fade-in relative">
{/* Create Event Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm" onClick={() => setIsModalOpen(false)}></div>
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-md p-6 animate-scale-up flex flex-col max-h-[90vh]">
<div className="flex justify-between items-center mb-6 flex-shrink-0">
<h3 className="font-bold text-slate-800 text-lg">Novo Evento</h3>
<button onClick={() => setIsModalOpen(false)}><X size={20} className="text-slate-400 hover:text-slate-600"/></button>
</div>
<div className="space-y-4 overflow-y-auto">
<div>
<label className={labelClass}>Título</label>
<input
type="text"
className={inputClass}
value={newEvent.title || ''}
onChange={e => setNewEvent({...newEvent, title: e.target.value})}
placeholder="Ex: Reunião com Cliente"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass}>Data</label>
<input
type="date"
className={inputClass}
value={newEvent.date}
onChange={e => setNewEvent({...newEvent, date: e.target.value})}
/>
</div>
<div>
<label className={labelClass}>Tipo</label>
<CustomSelect
value={newEvent.type || 'meeting'}
onChange={(val) => setNewEvent({...newEvent, type: val})}
options={[
{ value: 'meeting', label: 'Reunião' },
{ value: 'deadline', label: 'Prazo / Tarefa' }
]}
/>
</div>
</div>
<div>
<label className={labelClass}>Descrição</label>
<textarea
className={inputClass}
rows={3}
value={newEvent.description || ''}
onChange={e => setNewEvent({...newEvent, description: e.target.value})}
placeholder="Detalhes adicionais..."
/>
</div>
<button onClick={handleCreateEvent} className="w-full py-3 bg-primary-500 text-white font-bold rounded-xl mt-2 hover:bg-primary-600 transition-colors shadow-lg shadow-primary-200/50">
Salvar Evento
</button>
</div>
</div>
</div>
)}
{/* Detail Event Modal */}
{isDetailModalOpen && selectedEvent && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm" onClick={() => setIsDetailModalOpen(false)}></div>
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-md overflow-hidden animate-scale-up">
<div className={`h-2 w-full ${selectedEvent.id.startsWith('rec-') ? 'bg-green-500' : selectedEvent.type === 'payment' ? 'bg-red-500' : selectedEvent.type === 'deadline' ? 'bg-amber-500' : 'bg-blue-500'}`} />
<div className="p-6">
<div className="flex justify-between items-start mb-4">
<div>
<span className={`inline-block px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider mb-2 ${
selectedEvent.id.startsWith('rec-') ? 'bg-green-50 text-green-600' :
(selectedEvent.type === 'payment' || selectedEvent.id.startsWith('exp-')) ? 'bg-red-50 text-red-600' :
selectedEvent.type === 'deadline' ? 'bg-amber-50 text-amber-600' :
'bg-blue-50 text-blue-600'
}`}>
{getEventTypeLabel(selectedEvent)}
</span>
<h3 className={`font-bold text-xl text-slate-800 ${selectedEvent.completed ? 'line-through opacity-50' : ''}`}>
{selectedEvent.title}
</h3>
</div>
<button onClick={() => setIsDetailModalOpen(false)}><X size={20} className="text-slate-400 hover:text-slate-600"/></button>
</div>
<div className="space-y-4 mb-8">
<div className="flex items-center gap-3 text-slate-600">
<CalendarIcon size={18} className="text-slate-400"/>
<span className="font-medium">
{new Date(selectedEvent.date + 'T12:00:00').toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
</span>
</div>
{selectedEvent.description ? (
<div className="bg-slate-50 p-4 rounded-xl text-sm text-slate-600 border border-slate-100 whitespace-pre-line">
{selectedEvent.description}
</div>
) : (
<p className="text-sm text-slate-400 italic">Sem descrição.</p>
)}
</div>
<div className="flex gap-3">
<button
onClick={handleToggleComplete}
className={`flex-1 py-3 rounded-xl font-bold text-sm flex items-center justify-center gap-2 transition-colors ${
selectedEvent.completed
? 'bg-slate-100 text-slate-500 hover:bg-slate-200'
: 'bg-green-500 text-white hover:bg-green-600 shadow-lg shadow-green-200'
}`}
>
<CheckCircle2 size={18} />
{selectedEvent.completed ? 'Reabrir' : 'Concluir'}
</button>
<button
onClick={handleDeleteEvent}
className="p-3 bg-red-50 text-red-500 rounded-xl hover:bg-red-100 transition-colors"
title="Excluir"
>
<Trash2 size={18} />
</button>
</div>
</div>
</div>
</div>
)}
{/* Main Area */}
<div className="flex-1 flex flex-col bg-white rounded-[2rem] shadow-sm border border-slate-50 overflow-hidden">
{/* Calendar Header */}
<div className="p-6 border-b border-slate-100 flex flex-col sm:flex-row justify-between items-center gap-4">
<div className="flex gap-4 items-center">
{viewMode === 'month' ? (
<>
<h2 className="text-xl font-bold text-slate-800 capitalize">
{currentDate.toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' })}
</h2>
<div className="flex gap-1">
<button onClick={handlePrevMonth} className="p-1 hover:bg-slate-100 rounded-lg text-slate-500"><ChevronLeft size={20}/></button>
<button onClick={handleNextMonth} className="p-1 hover:bg-slate-100 rounded-lg text-slate-500"><ChevronRight size={20}/></button>
</div>
</>
) : (
<h2 className="text-xl font-bold text-slate-800">Agenda Completa</h2>
)}
</div>
<div className="flex gap-2">
<div className="flex bg-slate-100 p-1 rounded-xl">
<button
onClick={() => setViewMode('month')}
className={`p-2 rounded-lg transition-all ${viewMode === 'month' ? 'bg-white shadow text-primary-500' : 'text-slate-400 hover:text-slate-600'}`}
title="Visão Mensal"
>
<Grid size={18} />
</button>
<button
onClick={() => setViewMode('agenda')}
className={`p-2 rounded-lg transition-all ${viewMode === 'agenda' ? 'bg-white shadow text-primary-500' : 'text-slate-400 hover:text-slate-600'}`}
title="Lista (Agenda)"
>
<List size={18} />
</button>
</div>
<button onClick={() => setIsModalOpen(true)} className="flex items-center gap-2 px-4 py-2 bg-primary-500 text-white rounded-xl text-sm font-bold shadow-lg shadow-primary-200/50 hover:bg-primary-600">
<Plus size={16} /> <span className="hidden sm:inline">Novo Evento</span>
</button>
</div>
</div>
{viewMode === 'month' ? (
<>
<div className="grid grid-cols-7 border-b border-slate-100 bg-slate-50/50">
{['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb'].map(d => (
<div key={d} className="py-3 text-center text-xs font-bold text-slate-400 uppercase tracking-wider">{d}</div>
))}
</div>
<div className="grid grid-cols-7 flex-1 overflow-y-auto">
{days}
</div>
</>
) : (
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{upcomingEvents.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-slate-400">
<CheckCircle2 size={48} className="mb-4 opacity-20" />
<p>Nenhum evento futuro encontrado.</p>
</div>
) : (
upcomingEvents.map(event => (
<div
key={event.id}
onClick={() => handleEventClick(event)}
className="flex items-center gap-6 p-4 bg-slate-50 border border-slate-100 rounded-2xl hover:bg-white hover:shadow-md transition-all cursor-pointer group"
>
<div className="flex flex-col items-center justify-center w-16 h-16 bg-white rounded-xl border border-slate-200 shrink-0">
<span className="text-xs font-bold text-slate-400 uppercase">
{new Date(event.date + 'T12:00:00').toLocaleDateString('pt-BR', { month: 'short' }).replace('.', '')}
</span>
<span className="text-xl font-bold text-slate-800">
{new Date(event.date + 'T12:00:00').getDate()}
</span>
</div>
<div className="flex-1">
<h4 className={`font-bold text-lg text-slate-800 ${event.completed ? 'line-through opacity-50' : ''}`}>{event.title}</h4>
<p className="text-sm text-slate-500 line-clamp-1">{event.description || 'Sem descrição'}</p>
</div>
<div className="flex flex-col items-end gap-2">
<span className={`px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider ${
event.id.startsWith('rec-') ? 'bg-green-50 text-green-600' :
(event.type === 'payment' || event.id.startsWith('exp-')) ? 'bg-red-50 text-red-600' :
event.type === 'deadline' ? 'bg-amber-50 text-amber-600' :
'bg-blue-50 text-blue-600'
}`}>
{getEventTypeLabel(event)}
</span>
<span className="text-xs text-slate-400">
{new Date(event.date + 'T12:00:00').toLocaleDateString('pt-BR', { weekday: 'long' })}
</span>
</div>
</div>
))
)}
</div>
)}
</div>
{/* Sidebar: Upcoming & Deadlines */}
<div className="w-full xl:w-80 flex flex-col gap-6">
<div className="bg-white p-6 rounded-[2rem] shadow-sm border border-slate-50 flex-1 overflow-y-auto min-h-[300px]">
<h3 className="font-bold text-slate-800 text-lg mb-4 flex items-center gap-2">
<Clock size={20} className="text-primary-500"/>
Próximos Vencimentos
</h3>
<p className="text-xs text-slate-400 mb-6">Próximos eventos e pagamentos.</p>
<div className="space-y-4">
{sidebarEvents.map(event => (
<div key={event.id} onClick={() => handleEventClick(event)} className="flex gap-4 items-start p-3 hover:bg-slate-50 rounded-2xl transition-colors border border-transparent hover:border-slate-100 group cursor-pointer">
<div className={`mt-1 w-3 h-3 rounded-full shrink-0 ${
event.id.startsWith('rec-') ? 'bg-green-500 shadow-sm shadow-green-200' :
(event.type === 'payment' || event.id.startsWith('exp-')) ? 'bg-red-500 shadow-sm shadow-red-200' :
event.type === 'deadline' ? 'bg-amber-500 shadow-sm shadow-amber-200' :
'bg-blue-500 shadow-sm shadow-blue-200'
}`} />
<div>
<h4 className="font-bold text-slate-800 text-sm group-hover:text-primary-500 transition-colors">{event.title}</h4>
<p className="text-xs text-slate-500 font-medium mt-0.5">
{new Date(event.date + 'T12:00:00').toLocaleDateString('pt-BR', { day: '2-digit', month: 'long' })}
</p>
<div className="mt-2 flex gap-2">
<span className={`text-[10px] px-2 py-0.5 rounded border uppercase font-bold tracking-wider ${
event.id.startsWith('rec-') ? 'bg-green-50 border-green-100 text-green-600' :
(event.type === 'payment' || event.id.startsWith('exp-')) ? 'bg-red-50 border-red-100 text-red-600' :
event.type === 'deadline' ? 'bg-amber-50 border-amber-100 text-amber-600' :
'bg-blue-50 border-blue-100 text-blue-600'
}`}>
{getEventTypeLabel(event)}
</span>
</div>
</div>
</div>
))}
{sidebarEvents.length === 0 && (
<div className="text-center py-10 text-slate-400 text-sm">
<CheckCircle2 size={32} className="mx-auto mb-2 opacity-20"/>
Tudo tranquilo por aqui.
</div>
)}
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,90 @@
import React from 'react';
import { Phone, Mail, MoreHorizontal, Plus, Grid, List } from 'lucide-react';
import { Client } from '../types';
const contactsData: Client[] = [
{ id: '1', name: 'Jordan Nico', role: 'Designer na', company: 'Uda Studios', phone: '(11) 93456-7890', email: 'jordan@email.com', avatar: 'https://i.pravatar.cc/150?u=1' },
{ id: '2', name: 'Tony Soap', role: 'Desenvolvedor na', company: 'Uda Studios', phone: '(11) 93456-7890', email: 'tony@email.com', avatar: 'https://i.pravatar.cc/150?u=2' },
{ id: '3', name: 'Karen Hope', role: 'Gerente na', company: 'Uda Studios', phone: '(11) 93456-7890', email: 'karen@email.com', avatar: 'https://i.pravatar.cc/150?u=3' },
{ id: '4', name: 'Gabriel', role: 'Designer na', company: 'Angels Studios', phone: '(11) 93456-7890', email: 'gabriel@email.com', avatar: 'https://i.pravatar.cc/150?u=4' },
{ id: '5', name: 'Tarmiel', role: 'Designer na', company: 'Heaven Studios', phone: '(11) 93456-7890', email: 'tarmiel@email.com', avatar: 'https://i.pravatar.cc/150?u=5' },
{ id: '6', name: 'Sariel', role: 'Designer na', company: 'Sanctuary Studios', phone: '(11) 93456-7890', email: 'sariel@email.com', avatar: 'https://i.pravatar.cc/150?u=6' },
{ id: '7', name: 'Cahaya Hikari', role: 'Designer na', company: 'Jekate Studios', phone: '(11) 93456-7890', email: 'cahaya@email.com', avatar: 'https://i.pravatar.cc/150?u=7' },
{ id: '8', name: 'Nadila Adja', role: 'Designer na', company: 'FX Studios', phone: '(11) 93456-7890', email: 'nadila@email.com', avatar: 'https://i.pravatar.cc/150?u=8' },
{ id: '9', name: 'Angelina Crispy', role: 'Designer na', company: 'Patlapan Studios', phone: '(11) 93456-7890', email: 'angelina@email.com', avatar: 'https://i.pravatar.cc/150?u=9' },
];
export const ContactsView: React.FC = () => {
return (
<div className="space-y-8 animate-fade-in">
{/* Header Controls */}
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
<div className="relative w-full md:w-96">
<input
type="text"
placeholder="Pesquisar..."
className="w-full pl-12 pr-4 py-3 bg-white border-none rounded-2xl shadow-sm text-slate-600 focus:ring-2 focus:ring-primary-200 outline-none"
/>
<svg className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
</div>
<div className="flex gap-3">
<button className="w-10 h-10 bg-[#2D3648] text-white rounded-xl flex items-center justify-center hover:bg-slate-700 transition-colors shadow-lg shadow-slate-200">
<Plus size={20} />
</button>
<button className="w-10 h-10 bg-white text-slate-400 rounded-xl flex items-center justify-center hover:text-primary-500 transition-colors shadow-sm">
<List size={20} />
</button>
<button className="w-10 h-10 bg-white text-primary-500 rounded-xl flex items-center justify-center shadow-sm">
<Grid size={20} />
</button>
</div>
</div>
{/* Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{contactsData.map((contact) => (
<div key={contact.id} className="bg-white p-6 rounded-[2rem] shadow-sm hover:shadow-md transition-shadow border border-slate-50">
<div className="flex justify-between items-start mb-6">
<div className="flex gap-4">
<div className="w-16 h-16 rounded-2xl overflow-hidden bg-slate-100">
<img src={contact.avatar} alt={contact.name} className="w-full h-full object-cover" />
</div>
<div>
<h3 className="font-bold text-slate-800 text-lg">{contact.name}</h3>
<p className="text-slate-400 text-xs mt-1">{contact.role}</p>
<p className="font-semibold text-slate-700 text-sm">{contact.company}</p>
</div>
</div>
<button className="text-slate-300 hover:text-slate-600">
<MoreHorizontal size={24} />
</button>
</div>
<div className="space-y-4">
<div>
<p className="text-xs text-slate-400 mb-1 ml-1">Telefone</p>
<button className="w-full py-2 px-4 rounded-2xl border border-slate-200 flex items-center gap-3 text-slate-700 font-medium hover:bg-primary-50 hover:border-primary-200 hover:text-primary-600 transition-colors group">
<div className="w-8 h-8 rounded-full bg-primary-100/50 flex items-center justify-center text-primary-500 group-hover:bg-primary-500 group-hover:text-white transition-colors">
<Phone size={14} />
</div>
{contact.phone}
</button>
</div>
<div>
<p className="text-xs text-slate-400 mb-1 ml-1">E-mail</p>
<button className="w-full py-2 px-4 rounded-2xl border border-slate-200 flex items-center gap-3 text-slate-700 font-medium hover:bg-primary-50 hover:border-primary-200 hover:text-primary-600 transition-colors group">
<div className="w-8 h-8 rounded-full bg-primary-100/50 flex items-center justify-center text-primary-500 group-hover:bg-primary-500 group-hover:text-white transition-colors">
<Mail size={14} />
</div>
{contact.email}
</button>
</div>
</div>
</div>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,85 @@
import React, { useState, useRef, useEffect } from 'react';
import { ChevronDown, Check } from 'lucide-react';
export interface Option {
value: string | number;
label: string;
}
interface CustomSelectProps {
value: string | number;
onChange: (value: any) => void;
options: Option[];
placeholder?: string;
icon?: React.ReactNode;
className?: string;
disabled?: boolean;
}
export const CustomSelect: React.FC<CustomSelectProps> = ({
value,
onChange,
options,
placeholder = 'Selecione...',
icon,
className = '',
disabled = false
}) => {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const selectedOption = options.find(o => o.value === value);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<div className={`relative ${className}`} ref={containerRef}>
<div
onClick={() => !disabled && setIsOpen(!isOpen)}
className={`w-full p-3 bg-white border rounded-xl flex justify-between items-center cursor-pointer text-sm transition-all shadow-sm
${isOpen ? 'border-primary-500 ring-2 ring-primary-100' : 'border-slate-200 hover:border-primary-300'}
${disabled ? 'opacity-50 cursor-not-allowed bg-slate-50' : ''}
`}
>
<div className="flex items-center gap-2 truncate pr-2">
{icon && <span className="text-slate-400 shrink-0">{icon}</span>}
<span className={`truncate ${!selectedOption ? 'text-slate-400' : 'font-medium text-slate-700'}`}>
{selectedOption ? selectedOption.label : placeholder}
</span>
</div>
<ChevronDown size={18} className={`text-slate-400 shrink-0 transition-transform duration-200 ${isOpen ? 'rotate-180 text-primary-500' : ''}`} />
</div>
{isOpen && !disabled && (
<div className="absolute top-full left-0 w-full mt-2 bg-white border border-slate-100 rounded-xl shadow-2xl z-50 max-h-60 overflow-y-auto animate-fade-in">
{options.map((option) => (
<div
key={option.value}
onClick={() => { onChange(option.value); setIsOpen(false); }}
className={`p-3 text-sm cursor-pointer flex justify-between items-center transition-colors border-b border-slate-50 last:border-0
${option.value === value
? 'bg-primary-50 text-primary-700 font-bold'
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'}
`}
>
<span className="truncate">{option.label}</span>
{option.value === value && <Check size={16} className="text-primary-500 shrink-0 ml-2" />}
</div>
))}
{options.length === 0 && (
<div className="p-3 text-sm text-slate-400 text-center italic">Sem opções</div>
)}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,389 @@
import React, { useState, useMemo } from 'react';
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer
} from 'recharts';
import {
TrendingUp, TrendingDown, AlertCircle, ArrowUpRight,
Calendar as CalendarIcon, Trophy, Award, ShoppingBag, ChevronLeft, ChevronRight, CheckCircle2, PieChart, Wallet
} from 'lucide-react';
import { Company, Expense, Receivable, FinancialSummary } from '../types';
import { AIInsightsWidget } from './AIInsightsWidget';
interface DashboardViewProps {
financialSummary: FinancialSummary;
companies: Company[];
expenses: Expense[];
receivables: Receivable[];
}
const StatCard = ({ icon: Icon, label, value, subtext, type = 'neutral' }: any) => {
// Cores adaptadas para o estilo "Larkon": fundos suaves e cores de destaque
const iconStyle =
type === 'success' ? 'bg-orange-500 text-white shadow-lg shadow-orange-500/30' :
type === 'danger' ? 'bg-red-500 text-white shadow-lg shadow-red-500/30' :
type === 'warning' ? 'bg-blue-500 text-white shadow-lg shadow-blue-500/30' :
'bg-slate-800 text-white shadow-lg shadow-slate-800/30';
const trendColor =
type === 'success' ? 'text-green-500' :
type === 'danger' ? 'text-red-500' : 'text-slate-400';
return (
<div className="bg-white p-6 rounded-3xl border border-slate-100 shadow-sm hover:shadow-md transition-all flex flex-col justify-between h-full relative overflow-hidden group">
<div className="flex justify-between items-start mb-4">
<div className={`w-12 h-12 rounded-2xl flex items-center justify-center ${iconStyle} transition-transform group-hover:scale-110`}>
<Icon size={22} strokeWidth={2.5} />
</div>
{/* Mock Sparkline or Trend Indicator */}
<div className={`text-xs font-bold px-2 py-1 rounded-full bg-slate-50 ${trendColor} flex items-center gap-1`}>
{type === 'success' ? <TrendingUp size={12}/> : <TrendingDown size={12}/>}
{type === 'success' ? '+12%' : type === 'danger' ? '-5%' : '0%'}
</div>
</div>
<div>
<h3 className="text-3xl font-bold text-slate-800 mb-1 tracking-tight">{value}</h3>
<p className="text-slate-400 text-sm font-medium">{label}</p>
{subtext && <p className="text-xs mt-2 text-slate-400 opacity-80">{subtext}</p>}
</div>
</div>
);
};
export const DashboardView: React.FC<DashboardViewProps> = ({ financialSummary, companies, expenses, receivables }) => {
const [currentDate, setCurrentDate] = useState(new Date()); // Inicia com a data atual real
// --- LÓGICA DO GRÁFICO DINÂMICO ---
const chartData = useMemo(() => {
const data = [];
const today = new Date();
// Gerar os últimos 3 meses e próximos 2 meses
for (let i = -3; i <= 2; i++) {
const d = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthKey = d.toLocaleString('pt-BR', { month: 'short' });
const yearIso = String(d.getFullYear());
const monthIso = String(d.getMonth() + 1).padStart(2, '0');
const ym = `${yearIso}-${monthIso}`;
// Somar Receitas do Mês (Estritamente baseada em Receivables)
const monthlyRevenue = receivables
.filter(r => r.dueDate.startsWith(ym))
.reduce((sum, r) => sum + r.value, 0);
// Somar Despesas do Mês
const monthlyExpense = expenses
.filter(e => e.dueDate.startsWith(ym))
.reduce((sum, e) => sum + e.amount, 0);
data.push({
name: monthKey.charAt(0).toUpperCase() + monthKey.slice(1),
fullDate: ym,
receita: monthlyRevenue,
despesa: monthlyExpense,
isCurrent: i === 0
});
}
return data;
}, [expenses, receivables]);
// --- LOGICA DE RANKING (Estritamente Financeira) ---
// 1. Top Clientes (Baseado APENAS em Contas a Receber existentes)
const topClients = useMemo(() => {
// Agrupar recebíveis por empresa
const clientMap: Record<string, { name: string, total: number, count: number }> = {};
receivables.forEach(r => {
if (!clientMap[r.companyName]) {
clientMap[r.companyName] = { name: r.companyName, total: 0, count: 0 };
}
clientMap[r.companyName].total += r.value;
clientMap[r.companyName].count += 1;
});
return Object.values(clientMap)
.sort((a, b) => b.total - a.total)
.slice(0, 5);
}, [receivables]); // Depende apenas de receivables
// 2. Serviços Mais Vendidos (Baseado APENAS em Contas a Receber existentes)
const topServices = useMemo(() => {
const serviceCounts: Record<string, {name: string, count: number, revenue: number, category: string}> = {};
receivables.forEach(r => {
// Usamos a descrição como chave, pois o ID do serviço original não é salvo no receivable (simplificação)
const key = r.description;
if (!serviceCounts[key]) {
serviceCounts[key] = { name: r.description, count: 0, revenue: 0, category: r.category };
}
serviceCounts[key].count += 1;
serviceCounts[key].revenue += r.value;
});
return Object.values(serviceCounts).sort((a, b) => b.count - a.count).slice(0, 5);
}, [receivables]); // Depende apenas de receivables
// 3. Menor Inadimplência / Fidelidade (Mantido lógica de empresa pois é atributo de cadastro)
const bestPayers = useMemo(() => {
return [...companies]
.filter(c => c.status === 'active')
.sort((a, b) => parseInt(a.since) - parseInt(b.since))
.slice(0, 5);
}, [companies]);
// --- LOGICA DO CALENDÁRIO ---
const handlePrevMonth = () => setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1));
const handleNextMonth = () => setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1));
const getDaysInMonth = (year: number, month: number) => new Date(year, month + 1, 0).getDate();
const getFirstDayOfMonth = (year: number, month: number) => new Date(year, month, 1).getDay();
const daysInMonth = getDaysInMonth(currentDate.getFullYear(), currentDate.getMonth());
const firstDay = getFirstDayOfMonth(currentDate.getFullYear(), currentDate.getMonth());
const days = [];
for (let i = 0; i < firstDay; i++) {
days.push(<div key={`empty-${i}`} className="h-24 bg-slate-50/30" />);
}
const todayStr = new Date().toISOString().split('T')[0];
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
const isToday = dateStr === todayStr;
// Mapeando eventos (Despesas Reais e Recebimentos Reais)
const dayExpenses = expenses.filter(e => e.dueDate === dateStr);
const dayIncomes = receivables.filter(r => r.dueDate === dateStr);
days.push(
<div key={d} className={`h-24 border-t border-r border-slate-100 p-2 transition-colors hover:bg-slate-50 flex flex-col justify-between group ${isToday ? 'bg-orange-50/50' : 'bg-white'}`}>
<span className={`text-xs font-bold w-6 h-6 flex items-center justify-center rounded-full ${isToday ? 'bg-orange-500 text-white' : 'text-slate-400'}`}>
{d}
</span>
<div className="space-y-1 overflow-y-auto scrollbar-hide">
{dayExpenses.map(exp => (
<div key={exp.id} className="text-[9px] px-1 py-0.5 rounded bg-red-50 text-red-600 truncate font-medium cursor-help" title={`Pagar: ${exp.title} (${exp.amount.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })})`}>
-{exp.amount.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 })}
</div>
))}
{dayIncomes.map(inc => (
<div key={inc.id} className={`text-[9px] px-1 py-0.5 rounded truncate font-medium cursor-help ${inc.status === 'paid' ? 'bg-green-50 text-green-600' : 'bg-blue-50 text-blue-600'}`} title={`Receber: ${inc.description} (${inc.value.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })})`}>
+{inc.value.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 })}
</div>
))}
</div>
</div>
);
}
// Verifica se há dados para exibir no gráfico
const hasChartData = chartData.some(d => d.receita > 0 || d.despesa > 0);
return (
<div className="space-y-6 animate-fade-in">
{/* AI Insights Widget */}
<AIInsightsWidget
financialSummary={financialSummary}
topClients={companies} // Passando empresas (usado apenas para nomes no insight)
expenses={expenses}
/>
{/* KPI Cards Row */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6">
<StatCard
icon={Wallet}
label="Receita Prevista"
value={`R$ ${financialSummary.totalRevenue.toLocaleString('pt-BR')}`}
subtext="Contas a Receber (Mês Atual)"
type="success"
/>
<StatCard
icon={ShoppingBag}
label="Contas a Pagar"
value={`R$ ${financialSummary.totalExpenses.toLocaleString('pt-BR')}`}
subtext="Total de despesas (Mês Atual)"
type="danger"
/>
<StatCard
icon={TrendingUp}
label="Lucro Previsto"
value={`R$ ${financialSummary.profit.toLocaleString('pt-BR')}`}
subtext="Receita - Despesas"
type={financialSummary.profit >= 0 ? 'success' : 'danger'}
/>
<StatCard
icon={AlertCircle}
label="A Receber"
value={`R$ ${financialSummary.receivablePending.toLocaleString('pt-BR')}`}
subtext="Pendências (Total Global)"
type="warning"
/>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{/* Left Column (Main Chart) */}
<div className="xl:col-span-2 space-y-6">
{/* Financial Trend Chart */}
<div className="bg-white p-6 rounded-3xl shadow-sm border border-slate-100 relative">
<div className="flex justify-between items-center mb-6">
<div>
<h2 className="text-lg font-bold text-slate-800">Fluxo de Caixa</h2>
<p className="text-xs text-slate-400">Desempenho financeiro em tempo real</p>
</div>
<div className="flex gap-4 text-xs font-medium">
<span className="flex items-center gap-1 text-slate-600"><span className="w-2.5 h-2.5 rounded-full bg-orange-500"></span> Receitas</span>
<span className="flex items-center gap-1 text-slate-400"><span className="w-2.5 h-2.5 rounded-full bg-slate-300"></span> Despesas</span>
</div>
</div>
<div className="h-80 relative">
{hasChartData ? (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="colorReceita" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#f97316" stopOpacity={0.1}/>
<stop offset="95%" stopColor="#f97316" stopOpacity={0}/>
</linearGradient>
<linearGradient id="colorDespesa" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#cbd5e1" stopOpacity={0.2}/>
<stop offset="95%" stopColor="#cbd5e1" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} stroke="#f1f5f9" />
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 12}} />
<YAxis axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 12}} />
<Tooltip
contentStyle={{borderRadius: '16px', border: 'none', boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1)'}}
formatter={(value: number) => [`R$ ${value.toLocaleString('pt-BR')}`, '']}
/>
{/* Orange for Revenue (Primary), Grey for Expenses (Secondary) */}
<Area type="monotone" dataKey="receita" name="Receitas" stroke="#f97316" strokeWidth={3} fillOpacity={1} fill="url(#colorReceita)" />
<Area type="monotone" dataKey="despesa" name="Despesas" stroke="#94a3b8" strokeWidth={3} strokeDasharray="5 5" fillOpacity={1} fill="url(#colorDespesa)" />
</AreaChart>
</ResponsiveContainer>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center text-slate-300">
<PieChart size={48} className="mb-2 opacity-20" />
<p className="text-sm">Sem movimentações no período</p>
</div>
)}
</div>
</div>
{/* CALENDÁRIO FINANCEIRO */}
<div className="bg-white rounded-3xl shadow-sm border border-slate-100 overflow-hidden">
<div className="p-6 border-b border-slate-100 flex justify-between items-center">
<h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
<CalendarIcon size={20} className="text-orange-500" />
Agenda Financeira
</h2>
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-slate-600 capitalize w-32 text-center">{currentDate.toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' })}</span>
<div className="flex gap-1">
<button onClick={handlePrevMonth} className="p-1.5 hover:bg-slate-100 rounded-lg text-slate-400"><ChevronLeft size={16}/></button>
<button onClick={handleNextMonth} className="p-1.5 hover:bg-slate-100 rounded-lg text-slate-400"><ChevronRight size={16}/></button>
</div>
</div>
</div>
<div className="grid grid-cols-7 border-b border-slate-100 bg-slate-50">
{['D', 'S', 'T', 'Q', 'Q', 'S', 'S'].map(d => (
<div key={d} className="py-3 text-center text-[10px] font-bold text-slate-400 uppercase">{d}</div>
))}
</div>
<div className="grid grid-cols-7 bg-slate-50 border-l border-slate-100">
{days}
</div>
</div>
</div>
{/* Right Column: RANKINGS & Profit */}
<div className="space-y-6">
{/* Top Products/Services Widget */}
<div className="bg-white p-6 rounded-3xl shadow-sm border border-slate-100">
<div className="flex justify-between items-center mb-6">
<h3 className="font-bold text-slate-800 text-lg">Top Serviços</h3>
<button className="text-slate-400 hover:text-orange-500 transition-colors">
<ArrowUpRight size={18}/>
</button>
</div>
<div className="space-y-5">
{topServices.length > 0 ? topServices.map((srv, idx) => (
<div key={idx} className="flex items-center justify-between group cursor-pointer">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-orange-50 flex items-center justify-center text-orange-500 group-hover:bg-orange-500 group-hover:text-white transition-colors">
<Award size={18} />
</div>
<div>
<div className="text-sm font-bold text-slate-800 line-clamp-1">{srv.name}</div>
<div className="text-xs text-slate-400">{srv.count} vendas</div>
</div>
</div>
<div className="text-sm font-bold text-slate-700">R$ {srv.revenue.toLocaleString('pt-BR')}</div>
</div>
)) : (
<p className="text-sm text-slate-400 text-center py-4">Nenhum serviço faturado</p>
)}
</div>
<button className="w-full py-3 mt-6 text-sm font-bold text-orange-500 bg-orange-50 rounded-xl hover:bg-orange-100 transition-colors">
Ver Relatório Completo
</button>
</div>
{/* Ranking: Top Clientes */}
<div className="bg-white p-6 rounded-3xl shadow-sm border border-slate-100">
<h3 className="font-bold text-slate-800 mb-4 flex items-center gap-2">
<Trophy size={18} className="text-yellow-500" />
Melhores Clientes
</h3>
<div className="space-y-4">
{topClients.length > 0 ? topClients.map((client, idx) => (
<div key={idx} className="flex items-center gap-3 p-3 rounded-xl hover:bg-slate-50 transition-colors">
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-bold text-xs ${idx === 0 ? 'bg-yellow-100 text-yellow-700' : 'bg-slate-100 text-slate-500'}`}>
{idx + 1}
</div>
<div className="flex-1">
<div className="text-sm font-bold text-slate-800 line-clamp-1">{client.name}</div>
<div className="w-full bg-slate-100 rounded-full h-1.5 mt-1.5 overflow-hidden">
<div className="h-full bg-orange-500 rounded-full" style={{width: `${Math.min(100, (client.total / (topClients[0].total || 1)) * 100)}%`}}></div>
</div>
</div>
<div className="text-sm font-bold text-slate-700">R$ {client.total.toLocaleString('pt-BR')}</div>
</div>
)) : (
<p className="text-sm text-slate-400 text-center py-4">Sem dados</p>
)}
</div>
</div>
{/* Ranking: Menor Inadimplência (Fidelidade) */}
<div className="bg-slate-900 p-6 rounded-3xl shadow-lg text-white relative overflow-hidden">
<div className="absolute top-0 right-0 w-32 h-32 bg-white/5 rounded-full blur-2xl -translate-y-1/2 translate-x-1/2"></div>
<h3 className="font-bold text-white mb-4 flex items-center gap-2 relative z-10">
<CheckCircle2 size={18} className="text-green-400" />
Clientes Confiáveis
</h3>
<p className="text-xs text-slate-400 mb-4 relative z-10">Baseado no histórico de pagamentos.</p>
<div className="flex flex-wrap gap-2 relative z-10">
{bestPayers.map((client) => (
<span key={client.id} className="px-3 py-1 bg-white/10 hover:bg-white/20 text-white rounded-lg text-xs font-medium border border-white/5 transition-colors cursor-default">
{client.fantasyName || client.name}
</span>
))}
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,467 @@
import React, { useState, useMemo } from 'react';
import { FinancialReportType, Expense, Receivable } from '../types';
import { Download, Printer, Filter, ChevronRight, X, Calendar, Building2, CheckCircle2, FileText } from 'lucide-react';
import { useToast } from '../contexts/ToastContext';
import { CustomSelect } from './CustomSelect';
interface FinancialReportsViewProps {
expenses: Expense[];
receivables: Receivable[];
}
const ReportTab = ({ active, label, onClick }: { active: boolean, label: string, onClick: () => void }) => (
<button
onClick={onClick}
className={`px-5 py-2.5 rounded-xl text-sm font-medium transition-all duration-300 ${
active
? 'bg-primary-500 text-white shadow-lg shadow-primary-200'
: 'text-slate-500 hover:bg-slate-100'
}`}
>
{label}
</button>
);
const TableRow = ({ label, value, indent = 0, isTotal = false, isHeader = false }: { label: string, value: string | React.ReactNode, indent?: number, isTotal?: boolean, isHeader?: boolean }) => (
<div className={`flex justify-between items-center py-3 border-b border-slate-50 hover:bg-slate-50 transition-colors px-4
${isHeader ? 'bg-slate-50 font-bold text-slate-800 border-t mt-2' : ''}
${isTotal ? 'bg-slate-50/50 font-bold' : ''}`}>
<span className={`text-slate-700 ${indent === 1 ? 'pl-4' : indent === 2 ? 'pl-8' : ''} ${isTotal ? 'text-slate-900' : ''}`}>
{label}
</span>
<span className={`${isTotal ? 'text-primary-600' : 'text-slate-600'} ${isHeader ? 'text-slate-800' : ''}`}>{value}</span>
</div>
);
export const FinancialReportsView: React.FC<FinancialReportsViewProps> = ({ expenses, receivables }) => {
const { addToast } = useToast();
const [activeTab, setActiveTab] = useState<FinancialReportType>('DRE');
const [showFilters, setShowFilters] = useState(false);
const [filters, setFilters] = useState({
period: 'mensal',
costCenter: 'Todos'
});
const handleExport = () => {
addToast({ type: 'info', title: 'Gerando PDF...', message: 'O download iniciará em instantes.', duration: 2000 });
setTimeout(() => {
addToast({ type: 'success', title: 'Exportação Concluída', message: `Relatório ${activeTab} salvo com sucesso.` });
}, 2000);
};
// --- ACCOUNTING ENGINE (Cálculos Reais) ---
const reportData = useMemo(() => {
// 1. Data Preparation
const paidRevenue = receivables.filter(r => r.status === 'paid').reduce((sum, r) => sum + r.value, 0);
const pendingRevenue = receivables.filter(r => r.status === 'pending').reduce((sum, r) => sum + r.value, 0);
const totalRevenue = paidRevenue + pendingRevenue; // Competência
const paidExpenses = expenses.filter(e => e.status === 'paid');
const pendingExpenses = expenses.filter(e => e.status === 'pending');
// Categorização de Despesas Pagas
const taxExpenses = paidExpenses.filter(e => e.category === 'Impostos').reduce((sum, e) => sum + e.amount, 0);
const costExpenses = paidExpenses.filter(e => e.category === 'Operacional').reduce((sum, e) => sum + e.amount, 0); // Mocking Op as CMV/CPV
const adminExpenses = paidExpenses.filter(e => e.category === 'Administrativo' || e.category === 'TI').reduce((sum, e) => sum + e.amount, 0);
const salesExpenses = paidExpenses.filter(e => e.category === 'Marketing').reduce((sum, e) => sum + e.amount, 0);
const personnelExpenses = paidExpenses.filter(e => e.category === 'Pessoal').reduce((sum, e) => sum + e.amount, 0);
// Totais
const totalDeductions = taxExpenses; // Simplificação
const netRevenue = paidRevenue - totalDeductions;
const grossProfit = netRevenue - costExpenses;
const totalOpExpenses = adminExpenses + salesExpenses + personnelExpenses;
const netIncome = grossProfit - totalOpExpenses;
// 2. Balance Sheet (BP) Calculations (Estimated)
// Assets
const cashAndEquivalents = 50000 + (netIncome > 0 ? netIncome : 0); // Mock Start Cash + Profit
const accountsReceivable = pendingRevenue;
const fixedAssets = 150000; // Mocked Fixed Assets (Computers, Furniture)
const totalCurrentAssets = cashAndEquivalents + accountsReceivable;
const totalNonCurrentAssets = fixedAssets;
const totalAssets = totalCurrentAssets + totalNonCurrentAssets;
// Liabilities
const accountsPayable = pendingExpenses.reduce((sum, e) => sum + e.amount, 0);
const loansShortTerm = 20000; // Mock
const loansLongTerm = 100000; // Mock
const totalCurrentLiabilities = accountsPayable + loansShortTerm;
const totalNonCurrentLiabilities = loansLongTerm;
// Equity
const shareCapital = 50000; // Mock
const retainedEarnings = totalAssets - (totalCurrentLiabilities + totalNonCurrentLiabilities + shareCapital); // Balancing figure
const totalEquity = shareCapital + retainedEarnings;
// 3. Cash Flow (DFC) - Direct Method Simplified
const cashInflow = paidRevenue;
const cashOutflowOp = paidExpenses.reduce((sum, e) => sum + e.amount, 0);
const netCashOperating = cashInflow - cashOutflowOp;
// 4. Value Added (DVA)
const inputs = costExpenses + adminExpenses + salesExpenses; // Consumo de terceiros
const grossValueAdded = paidRevenue - inputs;
const netValueAdded = grossValueAdded; // Assuming no depreciation for simplicity
return {
dre: {
grossRevenue: paidRevenue,
taxes: taxExpenses,
netRevenue,
costs: costExpenses,
grossProfit,
adminExpenses,
salesExpenses,
personnelExpenses,
netIncome
},
bp: {
cashAndEquivalents,
accountsReceivable,
totalCurrentAssets,
fixedAssets,
totalAssets,
accountsPayable,
loansShortTerm,
totalCurrentLiabilities,
loansLongTerm,
shareCapital,
retainedEarnings,
totalEquityAndLiabilities: totalCurrentLiabilities + totalNonCurrentLiabilities + totalEquity
},
dfc: {
netCashOperating,
cashInflow,
cashOutflowOp
},
dva: {
grossRevenue: paidRevenue,
inputs,
grossValueAdded,
personnelExpenses,
taxExpenses,
rentals: 0,
equityRemuneration: netIncome
}
};
}, [expenses, receivables]);
const formatCurrency = (val: number) => val.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
const formatNegative = (val: number) => `(${formatCurrency(val)})`;
const periodLabel = filters.period === 'anual' ? '2024' : filters.period === 'trimestral' ? '1º Trimestre 2024' : 'Maio/2024';
const renderContent = () => {
switch (activeTab) {
case 'DRE':
return (
<div className="bg-white rounded-2xl border border-slate-100 p-0 overflow-hidden animate-fade-in max-w-4xl mx-auto">
<div className="p-6 border-b border-slate-100 flex justify-between items-center">
<div>
<h3 className="text-lg font-bold text-slate-800">Demonstração do Resultado do Exercício (DRE)</h3>
<p className="text-slate-400 text-sm">Período: {periodLabel} | Centro de Custo: {filters.costCenter}</p>
</div>
<div className="w-10 h-10 bg-primary-50 rounded-lg flex items-center justify-center text-primary-500"><FileText size={20}/></div>
</div>
<TableRow label="RECEITA OPERACIONAL BRUTA" value="" isHeader />
<TableRow label="Receita de Serviços / Vendas" value={formatCurrency(reportData.dre.grossRevenue)} indent={1} />
<TableRow label="DEDUÇÕES DA RECEITA BRUTA" value="" isHeader />
<TableRow label="(-) Impostos sobre Vendas" value={formatNegative(reportData.dre.taxes)} indent={1} />
<TableRow label="= RECEITA OPERACIONAL LÍQUIDA" value={formatCurrency(reportData.dre.netRevenue)} isTotal />
<TableRow label="CUSTOS OPERACIONAIS" value="" isHeader />
<TableRow label="(-) Custos dos Serviços Prestados (CSP)" value={formatNegative(reportData.dre.costs)} indent={1} />
<TableRow label="= LUCRO BRUTO" value={formatCurrency(reportData.dre.grossProfit)} isTotal />
<TableRow label="DESPESAS OPERACIONAIS" value="" isHeader />
<TableRow label="(-) Despesas com Pessoal" value={formatNegative(reportData.dre.personnelExpenses)} indent={1} />
<TableRow label="(-) Despesas Administrativas & TI" value={formatNegative(reportData.dre.adminExpenses)} indent={1} />
<TableRow label="(-) Despesas Comerciais / Mkt" value={formatNegative(reportData.dre.salesExpenses)} indent={1} />
<div className="p-4 bg-primary-50 mt-4 flex justify-between font-bold text-primary-700 rounded-b-2xl">
<span>= LUCRO / PREJUÍZO LÍQUIDO</span>
<span>{formatCurrency(reportData.dre.netIncome)}</span>
</div>
</div>
);
case 'BP':
return (
<div className="space-y-6 animate-fade-in">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* ATIVO */}
<div className="bg-white rounded-2xl border border-slate-100 p-0 overflow-hidden h-fit">
<div className="bg-slate-50 p-4 border-b border-slate-100 font-bold text-slate-700">ATIVO</div>
<TableRow label="ATIVO CIRCULANTE" value={formatCurrency(reportData.bp.totalCurrentAssets)} isHeader />
<TableRow label="Caixa e Equivalentes" value={formatCurrency(reportData.bp.cashAndEquivalents)} indent={1} />
<TableRow label="Contas a Receber (Clientes)" value={formatCurrency(reportData.bp.accountsReceivable)} indent={1} />
<TableRow label="ATIVO NÃO CIRCULANTE" value={formatCurrency(reportData.bp.fixedAssets)} isHeader />
<TableRow label="Imobilizado (Móveis/Equip.)" value={formatCurrency(reportData.bp.fixedAssets)} indent={1} />
<div className="p-4 bg-primary-50 mt-4 flex justify-between font-bold text-primary-700">
<span>TOTAL DO ATIVO</span>
<span>{formatCurrency(reportData.bp.totalAssets)}</span>
</div>
</div>
{/* PASSIVO */}
<div className="bg-white rounded-2xl border border-slate-100 p-0 overflow-hidden h-fit">
<div className="bg-slate-50 p-4 border-b border-slate-100 font-bold text-slate-700">PASSIVO E PATRIMÔNIO LÍQUIDO</div>
<TableRow label="PASSIVO CIRCULANTE" value={formatCurrency(reportData.bp.totalCurrentLiabilities)} isHeader />
<TableRow label="Fornecedores a Pagar" value={formatCurrency(reportData.bp.accountsPayable)} indent={1} />
<TableRow label="Empréstimos Curto Prazo" value={formatCurrency(reportData.bp.loansShortTerm)} indent={1} />
<TableRow label="PASSIVO NÃO CIRCULANTE" value={formatCurrency(reportData.bp.loansLongTerm)} isHeader />
<TableRow label="Empréstimos Longo Prazo" value={formatCurrency(reportData.bp.loansLongTerm)} indent={1} />
<TableRow label="PATRIMÔNIO LÍQUIDO" value={formatCurrency(reportData.bp.shareCapital + reportData.bp.retainedEarnings)} isHeader />
<TableRow label="Capital Social" value={formatCurrency(reportData.bp.shareCapital)} indent={1} />
<TableRow label="Lucros/Prejuízos Acumulados" value={formatCurrency(reportData.bp.retainedEarnings)} indent={1} />
<div className="p-4 bg-primary-50 mt-4 flex justify-between font-bold text-primary-700">
<span>TOTAL PASSIVO + PL</span>
<span>{formatCurrency(reportData.bp.totalEquityAndLiabilities)}</span>
</div>
</div>
</div>
</div>
);
case 'DFC':
return (
<div className="bg-white rounded-2xl border border-slate-100 p-0 overflow-hidden animate-fade-in max-w-4xl mx-auto">
<div className="p-6 border-b border-slate-100">
<h3 className="text-lg font-bold text-slate-800">Demonstração do Fluxo de Caixa (Método Direto)</h3>
<p className="text-slate-400 text-sm">Período: {periodLabel}</p>
</div>
<TableRow label="ATIVIDADES OPERACIONAIS" value="" isHeader />
<TableRow label="(+) Recebimento de Clientes" value={formatCurrency(reportData.dfc.cashInflow)} indent={1} />
<TableRow label="(-) Pagamento a Fornecedores/Despesas" value={formatNegative(reportData.dfc.cashOutflowOp)} indent={1} />
<TableRow label="(=) Caixa Líquido das Atividades Operacionais" value={formatCurrency(reportData.dfc.netCashOperating)} isTotal indent={1} />
<TableRow label="ATIVIDADES DE INVESTIMENTO" value="" isHeader />
<TableRow label="(-) Aquisição de Imobilizado" value={formatCurrency(0)} indent={1} />
<TableRow label="(=) Caixa Líquido das Atividades de Investimento" value={formatCurrency(0)} isTotal indent={1} />
<TableRow label="ATIVIDADES DE FINANCIAMENTO" value="" isHeader />
<TableRow label="(+) Novos Empréstimos" value={formatCurrency(0)} indent={1} />
<TableRow label="(=) Caixa Líquido das Atividades de Financiamento" value={formatCurrency(0)} isTotal indent={1} />
<div className={`p-4 mt-4 flex justify-between font-bold rounded-b-2xl ${reportData.dfc.netCashOperating >= 0 ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'}`}>
<span>AUMENTO/REDUÇÃO LÍQUIDA DE CAIXA</span>
<span>{formatCurrency(reportData.dfc.netCashOperating)}</span>
</div>
</div>
);
case 'DLPA':
return (
<div className="bg-white rounded-2xl border border-slate-100 p-0 overflow-hidden animate-fade-in max-w-4xl mx-auto">
<div className="p-6 border-b border-slate-100">
<h3 className="text-lg font-bold text-slate-800">Demonstração de Lucros ou Prejuízos Acumulados (DLPA)</h3>
</div>
<TableRow label="Saldo Inicial" value={formatCurrency(reportData.bp.retainedEarnings - reportData.dre.netIncome)} />
<TableRow label="(+) Ajustes de Exercícios Anteriores" value={formatCurrency(0)} />
<TableRow label="(+) Lucro Líquido do Exercício" value={formatCurrency(reportData.dre.netIncome)} isTotal />
<TableRow label="(-) Transferências para Reservas" value={formatCurrency(0)} />
<TableRow label="(-) Dividendos Distribuídos" value={formatCurrency(0)} />
<div className="p-4 bg-primary-50 mt-4 flex justify-between font-bold text-primary-700">
<span>SALDO FINAL</span>
<span>{formatCurrency(reportData.bp.retainedEarnings)}</span>
</div>
</div>
);
case 'DMPL':
return (
<div className="bg-white rounded-2xl border border-slate-100 p-0 overflow-hidden animate-fade-in max-w-5xl mx-auto overflow-x-auto">
<div className="p-6 border-b border-slate-100">
<h3 className="text-lg font-bold text-slate-800">Demonstração das Mutações do Patrimônio Líquido (DMPL)</h3>
</div>
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 text-slate-700 font-bold">
<tr>
<th className="p-4">Histórico</th>
<th className="p-4 text-right">Capital Social</th>
<th className="p-4 text-right">Reservas de Lucro</th>
<th className="p-4 text-right">Lucros Acumulados</th>
<th className="p-4 text-right">Total</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
<tr>
<td className="p-4 font-medium text-slate-700">Saldo Inicial</td>
<td className="p-4 text-right">{formatCurrency(reportData.bp.shareCapital)}</td>
<td className="p-4 text-right">R$ 0,00</td>
<td className="p-4 text-right">{formatCurrency(reportData.bp.retainedEarnings - reportData.dre.netIncome)}</td>
<td className="p-4 text-right font-bold">{formatCurrency(reportData.bp.shareCapital + (reportData.bp.retainedEarnings - reportData.dre.netIncome))}</td>
</tr>
<tr>
<td className="p-4 font-medium text-slate-700">Lucro Líquido do Período</td>
<td className="p-4 text-right">-</td>
<td className="p-4 text-right">-</td>
<td className="p-4 text-right text-green-600">{formatCurrency(reportData.dre.netIncome)}</td>
<td className="p-4 text-right font-bold text-green-600">{formatCurrency(reportData.dre.netIncome)}</td>
</tr>
</tbody>
<tfoot className="bg-slate-50 font-bold text-slate-800">
<tr>
<td className="p-4">Saldo Final</td>
<td className="p-4 text-right">{formatCurrency(reportData.bp.shareCapital)}</td>
<td className="p-4 text-right">R$ 0,00</td>
<td className="p-4 text-right">{formatCurrency(reportData.bp.retainedEarnings)}</td>
<td className="p-4 text-right text-primary-600">{formatCurrency(reportData.bp.shareCapital + reportData.bp.retainedEarnings)}</td>
</tr>
</tfoot>
</table>
</div>
);
case 'DRA':
return (
<div className="bg-white rounded-2xl border border-slate-100 p-0 overflow-hidden animate-fade-in max-w-4xl mx-auto">
<div className="p-6 border-b border-slate-100">
<h3 className="text-lg font-bold text-slate-800">Demonstração do Resultado Abrangente (DRA)</h3>
</div>
<TableRow label="Lucro Líquido do Exercício" value={formatCurrency(reportData.dre.netIncome)} isTotal />
<TableRow label="Outros Resultados Abrangentes" value="" isHeader />
<TableRow label="(+/-) Ajustes de Avaliação Patrimonial" value={formatCurrency(0)} indent={1} />
<TableRow label="(+/-) Variação Cambial de Investimentos" value={formatCurrency(0)} indent={1} />
<div className="p-4 bg-primary-50 mt-4 flex justify-between font-bold text-primary-700">
<span>RESULTADO ABRANGENTE TOTAL</span>
<span>{formatCurrency(reportData.dre.netIncome)}</span>
</div>
</div>
);
case 'DVA':
return (
<div className="bg-white rounded-2xl border border-slate-100 p-0 overflow-hidden animate-fade-in max-w-4xl mx-auto">
<div className="p-6 border-b border-slate-100">
<h3 className="text-lg font-bold text-slate-800">Demonstração do Valor Adicionado (DVA)</h3>
</div>
<TableRow label="1. RECEITAS" value={formatCurrency(reportData.dva.grossRevenue)} isHeader />
<TableRow label="Vendas de Mercadorias, Produtos e Serviços" value={formatCurrency(reportData.dva.grossRevenue)} indent={1} />
<TableRow label="2. INSUMOS ADQUIRIDOS DE TERCEIROS" value={formatNegative(reportData.dva.inputs)} isHeader />
<TableRow label="Custos Operacionais e Materiais" value={formatNegative(reportData.dva.inputs)} indent={1} />
<TableRow label="3. VALOR ADICIONADO BRUTO (1-2)" value={formatCurrency(reportData.dva.grossValueAdded)} isTotal />
<TableRow label="4. RETENÇÕES" value={formatCurrency(0)} isHeader />
<TableRow label="Depreciação, Amortização e Exaustão" value={formatCurrency(0)} indent={1} />
<TableRow label="5. VALOR ADICIONADO LÍQUIDO (3-4)" value={formatCurrency(reportData.dva.grossValueAdded)} isTotal />
<div className="bg-slate-50 p-4 border-b border-slate-100 border-t font-bold text-slate-800 mt-4">6. DISTRIBUIÇÃO DO VALOR ADICIONADO</div>
<TableRow label="Pessoal (Salários e Benefícios)" value={formatCurrency(reportData.dva.personnelExpenses)} indent={1} />
<TableRow label="Impostos, Taxas e Contribuições" value={formatCurrency(reportData.dva.taxExpenses)} indent={1} />
<TableRow label="Remuneração de Capitais de Terceiros (Aluguéis/Juros)" value={formatCurrency(reportData.dva.rentals)} indent={1} />
<TableRow label="Remuneração de Capitais Próprios (Lucros)" value={formatCurrency(reportData.dva.equityRemuneration)} indent={1} />
<div className="p-4 bg-primary-50 mt-4 flex justify-between font-bold text-primary-700">
<span>TOTAL DISTRIBUÍDO</span>
<span>{formatCurrency(reportData.dva.personnelExpenses + reportData.dva.taxExpenses + reportData.dva.rentals + reportData.dva.equityRemuneration)}</span>
</div>
</div>
);
default:
return null;
}
};
return (
<div className="space-y-6">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-800">Demonstrações Contábeis</h1>
<p className="text-slate-500">Relatórios gerados em tempo real com base nos lançamentos.</p>
</div>
<div className="flex gap-2">
<button
onClick={() => setShowFilters(!showFilters)}
className={`flex items-center gap-2 px-4 py-2 border rounded-xl transition-colors ${showFilters ? 'bg-slate-100 border-slate-300 text-slate-800' : 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'}`}
>
<Filter size={16} />
{showFilters ? 'Ocultar Filtros' : 'Filtros'}
</button>
<button onClick={handleExport} className="flex items-center gap-2 px-4 py-2 bg-primary-500 text-white rounded-xl hover:bg-primary-600 shadow-lg shadow-primary-200/50">
<Download size={16} /> Exportar
</button>
</div>
</div>
{/* Filter Panel */}
{showFilters && (
<div className="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm grid grid-cols-1 md:grid-cols-3 gap-6 animate-slide-down">
<div>
<label className="block text-xs font-semibold text-slate-500 mb-2 uppercase flex items-center gap-2">
<Calendar size={14} /> Período
</label>
<div className="relative">
<CustomSelect
value={filters.period}
onChange={(val) => setFilters({...filters, period: val})}
options={[
{ value: 'mensal', label: 'Mensal (Mês Atual)' },
{ value: 'trimestral', label: 'Trimestral' },
{ value: 'semestral', label: 'Semestral' },
{ value: 'anual', label: 'Anual (Acumulado)' }
]}
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-slate-500 mb-2 uppercase flex items-center gap-2">
<Building2 size={14} /> Centro de Custo
</label>
<div className="relative">
<CustomSelect
value={filters.costCenter}
onChange={(val) => setFilters({...filters, costCenter: val})}
options={[
{ value: 'Todos', label: 'Todos' },
{ value: 'Administrativo', label: 'Administrativo' },
{ value: 'Comercial', label: 'Comercial / Vendas' },
{ value: 'Operacional', label: 'Operacional' },
{ value: 'TI', label: 'Tecnologia (TI)' }
]}
/>
</div>
</div>
<div className="flex items-end">
<button className="w-full py-2.5 bg-slate-800 text-white rounded-xl hover:bg-slate-900 transition-colors font-medium flex items-center justify-center gap-2">
<CheckCircle2 size={16}/> Aplicar Filtros
</button>
</div>
</div>
)}
{/* Tabs */}
<div className="flex flex-wrap gap-2 pb-2">
<ReportTab active={activeTab === 'DRE'} label="DRE" onClick={() => setActiveTab('DRE')} />
<ReportTab active={activeTab === 'BP'} label="Balanço (BP)" onClick={() => setActiveTab('BP')} />
<ReportTab active={activeTab === 'DFC'} label="Fluxo de Caixa (DFC)" onClick={() => setActiveTab('DFC')} />
<ReportTab active={activeTab === 'DLPA'} label="DLPA" onClick={() => setActiveTab('DLPA')} />
<ReportTab active={activeTab === 'DMPL'} label="DMPL" onClick={() => setActiveTab('DMPL')} />
<ReportTab active={activeTab === 'DRA'} label="DRA" onClick={() => setActiveTab('DRA')} />
<ReportTab active={activeTab === 'DVA'} label="DVA" onClick={() => setActiveTab('DVA')} />
</div>
{renderContent()}
</div>
);
};

498
components/KanbanView.tsx Normal file
View File

@@ -0,0 +1,498 @@
import React, { useState } from 'react';
import { Plus, MoreHorizontal, Clock, UserCircle, X, DollarSign, GripVertical, Trash2, Building2 } from 'lucide-react';
import { KanbanColumn, KanbanTask, Company, Receivable } from '../types';
import { useToast } from '../contexts/ToastContext';
import { CustomSelect } from './CustomSelect';
const initialColumns: KanbanColumn[] = [
{
id: 'todo',
title: 'A Fazer',
tasks: [
{ id: 't1', title: 'Criar contrato Uda Studios', priority: 'high', dueDate: '2024-05-15', value: 12000, description: 'Negociação referente ao projeto de redesign completo.' },
{ id: 't2', title: 'Revisar balanço trimestral', priority: 'medium', dueDate: '2024-05-20', value: 0, description: 'Verificar lançamentos de março e abril.' },
]
},
{
id: 'progress',
title: 'Em Progresso',
tasks: [
{ id: 't3', title: 'Design do Dashboard', priority: 'high', dueDate: '2024-05-18', value: 5000 },
]
},
{
id: 'review',
title: 'Revisão',
tasks: [
{ id: 't4', title: 'Aprovação de Orçamento', priority: 'low', dueDate: '2024-05-12', value: 3500 },
]
},
{
id: 'done',
title: 'Concluído',
tasks: [
{ id: 't5', title: 'Onboarding Angels Healthcare', priority: 'medium', dueDate: '2024-05-10', value: 15000 },
]
}
];
interface KanbanViewProps {
companies: Company[];
onAddReceivable: (receivable: Receivable) => void;
}
export const KanbanView: React.FC<KanbanViewProps> = ({ companies, onAddReceivable }) => {
const { addToast } = useToast();
const [columns, setColumns] = useState<KanbanColumn[]>(initialColumns);
// States para Modais
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
const [isColumnModalOpen, setIsColumnModalOpen] = useState(false);
// State de Edição/Criação
const [currentTask, setCurrentTask] = useState<Partial<KanbanTask>>({});
const [currentColumnId, setCurrentColumnId] = useState<string>('todo');
const [newColumnTitle, setNewColumnTitle] = useState('');
const [draggedTaskId, setDraggedTaskId] = useState<string | null>(null);
const [draggedSourceColumnId, setDraggedSourceColumnId] = useState<string | null>(null);
// --- Drag and Drop Logic ---
const handleDragStart = (e: React.DragEvent, taskId: string, columnId: string) => {
setDraggedTaskId(taskId);
setDraggedSourceColumnId(columnId);
e.dataTransfer.effectAllowed = 'move';
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault(); // Necessary to allow dropping
e.dataTransfer.dropEffect = 'move';
};
const handleDrop = (e: React.DragEvent, targetColumnId: string) => {
e.preventDefault();
if (!draggedTaskId || !draggedSourceColumnId) return;
if (draggedSourceColumnId === targetColumnId) {
setDraggedTaskId(null);
setDraggedSourceColumnId(null);
return;
}
// Move logic
const sourceCol = columns.find(c => c.id === draggedSourceColumnId);
const targetCol = columns.find(c => c.id === targetColumnId);
const taskToMove = sourceCol?.tasks.find(t => t.id === draggedTaskId);
if (sourceCol && targetCol && taskToMove) {
const newColumns = columns.map(col => {
if (col.id === draggedSourceColumnId) {
return { ...col, tasks: col.tasks.filter(t => t.id !== draggedTaskId) };
}
if (col.id === targetColumnId) {
return { ...col, tasks: [...col.tasks, taskToMove] };
}
return col;
});
setColumns(newColumns);
// INTEGRAÇÃO FINANCEIRA: Se moveu para "Concluído" (done), tem valor e cliente, sugere faturar
if (targetColumnId === 'done' && taskToMove.value && taskToMove.value > 0 && taskToMove.clientId) {
const client = companies.find(c => c.id === taskToMove.clientId);
// Use toast with action instead of window.confirm for better UI?
// For now, simpler confirmation but using toast for success
if (window.confirm(`A tarefa "${taskToMove.title}" foi concluída.\n\nDeseja gerar automaticamente uma Conta a Receber no valor de R$ ${taskToMove.value.toLocaleString('pt-BR')}?`)) {
const newReceivable: Receivable = {
id: Math.random().toString(36).substr(2, 9),
description: taskToMove.title,
companyName: client?.fantasyName || client?.name || 'Cliente Kanban',
category: 'Serviços', // Default category
value: taskToMove.value,
dueDate: taskToMove.dueDate || new Date().toISOString().split('T')[0],
status: 'pending',
type: 'one-time'
};
onAddReceivable(newReceivable);
addToast({ type: 'success', title: 'Faturamento Gerado', message: `Conta a receber criada para ${client?.fantasyName}.` });
}
}
}
setDraggedTaskId(null);
setDraggedSourceColumnId(null);
};
// --- CRUD Logic ---
const openNewTaskModal = () => {
setCurrentTask({ priority: 'medium', dueDate: new Date().toISOString().split('T')[0], value: 0, description: '' });
setCurrentColumnId(columns[0].id); // Default to first column
setIsTaskModalOpen(true);
};
const openEditTaskModal = (task: KanbanTask, colId: string) => {
setCurrentTask(task);
setCurrentColumnId(colId);
setIsTaskModalOpen(true);
};
const handleSaveTask = () => {
if (!currentTask.title) {
addToast({ type: 'warning', title: 'Título Obrigatório', message: 'Dê um nome para a tarefa.' });
return;
}
// Check if updating existing or creating new
if (currentTask.id) {
// Logic to update existing task (potentially moving columns)
const newColumns = columns.map(col => {
// Remove from all columns first (in case it moved)
const filteredTasks = col.tasks.filter(t => t.id !== currentTask.id);
// If this is the target column, add the updated task
if (col.id === currentColumnId) {
return { ...col, tasks: [...filteredTasks, currentTask as KanbanTask] };
}
return { ...col, tasks: filteredTasks };
});
setColumns(newColumns);
addToast({ type: 'success', title: 'Tarefa Atualizada' });
} else {
// Create new
const newTask: KanbanTask = {
...currentTask,
id: Math.random().toString(36).substr(2, 9),
} as KanbanTask;
const newColumns = columns.map(col => {
if (col.id === currentColumnId) {
return { ...col, tasks: [newTask, ...col.tasks] }; // Add to top
}
return col;
});
setColumns(newColumns);
addToast({ type: 'success', title: 'Tarefa Criada' });
}
setIsTaskModalOpen(false);
};
const handleDeleteTask = () => {
if (!currentTask.id) return;
if (window.confirm('Tem certeza que deseja excluir esta tarefa?')) {
const newColumns = columns.map(col => ({
...col,
tasks: col.tasks.filter(t => t.id !== currentTask.id)
}));
setColumns(newColumns);
setIsTaskModalOpen(false);
addToast({ type: 'info', title: 'Tarefa Excluída' });
}
};
const handleCreateColumn = () => {
if (!newColumnTitle) return;
const newCol: KanbanColumn = {
id: Math.random().toString(36).substr(2, 9),
title: newColumnTitle,
tasks: []
};
setColumns([...columns, newCol]);
setNewColumnTitle('');
setIsColumnModalOpen(false);
addToast({ type: 'success', title: 'Coluna Adicionada' });
};
// Styles for Inputs (High Contrast)
const labelClass = "block text-sm font-bold text-slate-800 mb-1";
const inputClass = "w-full p-2.5 bg-white border border-slate-300 rounded-xl text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none transition-shadow";
return (
<div className="h-full flex flex-col animate-fade-in relative">
{/* Task Modal (Create & Edit) */}
{isTaskModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm" onClick={() => setIsTaskModalOpen(false)}></div>
<div className="relative bg-white rounded-2xl shadow-2xl w-full max-w-lg p-0 animate-scale-up flex flex-col max-h-[90vh]">
<div className="flex justify-between items-center px-6 py-4 border-b border-slate-100 bg-slate-50 flex-shrink-0">
<h3 className="font-bold text-slate-800 text-lg">
{currentTask.id ? 'Detalhes da Tarefa' : 'Nova Tarefa'}
</h3>
<div className="flex items-center gap-2">
{currentTask.id && (
<button onClick={handleDeleteTask} className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors">
<Trash2 size={18} />
</button>
)}
<button onClick={() => setIsTaskModalOpen(false)} className="p-2 text-slate-400 hover:text-slate-600 rounded-lg transition-colors">
<X size={20} />
</button>
</div>
</div>
<div className="p-6 space-y-5 overflow-y-auto">
{/* Title */}
<div>
<label className={labelClass}>Título da Tarefa</label>
<input
type="text"
className={inputClass}
value={currentTask.title || ''}
onChange={e => setCurrentTask({...currentTask, title: e.target.value})}
placeholder="Ex: Reunião com Cliente X"
/>
</div>
{/* Status & Priority Row */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass}>Coluna (Status)</label>
<CustomSelect
value={currentColumnId}
onChange={(val) => setCurrentColumnId(val)}
options={columns.map(col => ({ value: col.id, label: col.title }))}
/>
</div>
<div>
<label className={labelClass}>Prioridade</label>
<CustomSelect
value={currentTask.priority || 'medium'}
onChange={(val) => setCurrentTask({...currentTask, priority: val})}
options={[
{ value: 'low', label: 'Baixa' },
{ value: 'medium', label: 'Média' },
{ value: 'high', label: 'Alta' }
]}
/>
</div>
</div>
{/* CRM Linkage */}
<div>
<label className={labelClass}>Vincular Cliente (CRM)</label>
<CustomSelect
value={currentTask.clientId || ''}
onChange={(val) => setCurrentTask({...currentTask, clientId: val})}
placeholder="Sem vínculo"
icon={<Building2 size={16}/>}
options={[
{ value: '', label: 'Sem vínculo' },
...companies.map(c => ({ value: c.id, label: c.fantasyName || c.name }))
]}
/>
<p className="text-[10px] text-slate-400 mt-1">Ao concluir a tarefa, o sistema oferecerá gerar cobrança para este cliente.</p>
</div>
{/* Commercial Data Section */}
<div className="bg-slate-50 p-4 rounded-xl border border-slate-100 space-y-4">
<h4 className="font-bold text-slate-700 text-sm border-b border-slate-200 pb-2 mb-2 flex items-center gap-2">
Dados Comerciais & Agenda
</h4>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass}>Valor da Negociação</label>
<div className="relative">
<DollarSign size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
<input
type="number"
className={`${inputClass} pl-9`}
value={currentTask.value || ''}
onChange={e => setCurrentTask({...currentTask, value: Number(e.target.value)})}
placeholder="0,00"
/>
</div>
</div>
<div>
<label className={labelClass}>Prazo / Data</label>
<div className="relative">
<Clock size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
<input
type="date"
className={`${inputClass} pl-10`}
value={currentTask.dueDate || ''}
onChange={e => setCurrentTask({...currentTask, dueDate: e.target.value})}
/>
</div>
</div>
</div>
<div>
<label className={labelClass}>Descrição / Notas</label>
<textarea
rows={4}
className={inputClass}
value={currentTask.description || ''}
onChange={e => setCurrentTask({...currentTask, description: e.target.value})}
placeholder="Detalhes sobre a negociação, pauta da reunião, etc..."
/>
</div>
</div>
</div>
<div className="px-6 py-4 bg-slate-50 border-t border-slate-100 flex justify-end gap-3 flex-shrink-0 rounded-b-2xl">
<button onClick={() => setIsTaskModalOpen(false)} className="px-4 py-2 text-slate-600 font-medium hover:bg-slate-200 rounded-xl transition-colors">
Cancelar
</button>
<button
onClick={handleSaveTask}
className="px-6 py-2 bg-primary-500 text-white font-bold rounded-xl hover:bg-primary-600 shadow-lg shadow-primary-200/50 transition-all"
>
{currentTask.id ? 'Salvar Alterações' : 'Criar Tarefa'}
</button>
</div>
</div>
</div>
)}
{/* New Column Modal */}
{isColumnModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm" onClick={() => setIsColumnModalOpen(false)}></div>
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 animate-scale-up">
<h3 className="font-bold text-slate-800 text-lg mb-4">Nova Coluna</h3>
<div className="space-y-4">
<div>
<label className={labelClass}>Nome da Coluna</label>
<input
type="text"
className={inputClass}
value={newColumnTitle}
onChange={e => setNewColumnTitle(e.target.value)}
placeholder="Ex: Em Aprovação"
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<button onClick={() => setIsColumnModalOpen(false)} className="px-4 py-2 text-slate-500 font-medium hover:bg-slate-100 rounded-xl">Cancelar</button>
<button onClick={handleCreateColumn} className="px-4 py-2 bg-primary-500 text-white font-bold rounded-xl hover:bg-primary-600">Criar</button>
</div>
</div>
</div>
</div>
)}
{/* Kanban Header */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-6">
<div>
<h1 className="text-2xl font-bold text-slate-800">Projetos & Tarefas</h1>
<p className="text-slate-500">Gestão visual do fluxo de trabalho.</p>
</div>
<button onClick={openNewTaskModal} className="flex items-center gap-2 px-5 py-3 bg-primary-500 text-white rounded-xl shadow-lg shadow-primary-200/50 hover:bg-primary-600 font-bold transition-all">
<Plus size={20} /> Nova Tarefa
</button>
</div>
{/* Kanban Board Area */}
<div className="flex-1 overflow-x-auto">
<div className="flex gap-6 h-full pb-4">
{columns.map(col => {
// Calculate total value for the column
const totalValue = col.tasks.reduce((sum, task) => sum + (task.value || 0), 0);
return (
<div
key={col.id}
className="flex-shrink-0 flex flex-col w-[300px]"
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, col.id)}
>
{/* Column Header */}
<div className="flex justify-between items-start mb-4 p-1">
<div>
<h3 className="font-bold text-slate-700 flex items-center gap-2 truncate">
{col.title}
<span className="bg-slate-200 text-slate-600 text-[10px] px-2 py-0.5 rounded-full">{col.tasks.length}</span>
</h3>
{totalValue > 0 && (
<div className="text-xs font-bold text-slate-400 mt-1 pl-1">
Total: R$ {totalValue.toLocaleString('pt-BR')}
</div>
)}
</div>
<button className="text-slate-400 hover:text-slate-600"><MoreHorizontal size={18} /></button>
</div>
{/* Tasks Container */}
<div className="bg-slate-100/50 rounded-2xl p-2 flex-1 border border-slate-100/50 overflow-y-auto">
<div className="space-y-3 min-h-[50px]">
{col.tasks.map(task => (
<div
key={task.id}
draggable
onDragStart={(e) => handleDragStart(e, task.id, col.id)}
onClick={() => openEditTaskModal(task, col.id)}
className={`bg-white p-4 rounded-xl shadow-sm border border-slate-100 hover:shadow-md hover:border-primary-200 cursor-pointer transition-all group relative ${draggedTaskId === task.id ? 'opacity-50 border-dashed border-slate-400' : ''}`}
>
<div className="flex justify-between items-start mb-2">
<span className={`text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded ${
task.priority === 'high' ? 'bg-red-50 text-red-600' :
task.priority === 'medium' ? 'bg-amber-50 text-amber-600' :
'bg-blue-50 text-blue-600'
}`}>
{task.priority === 'high' ? 'Alta' : task.priority === 'medium' ? 'Média' : 'Baixa'}
</span>
<div className="opacity-0 group-hover:opacity-100 text-slate-300 cursor-grab active:cursor-grabbing">
<GripVertical size={16}/>
</div>
</div>
<h4 className="font-bold text-slate-800 text-sm mb-3 line-clamp-2">{task.title}</h4>
{/* Optional: Show Value if present */}
{task.value && task.value > 0 && (
<div className="mb-3 text-xs font-semibold text-slate-600 bg-slate-50 px-2 py-1 rounded inline-block">
R$ {task.value.toLocaleString('pt-BR')}
</div>
)}
{/* Show Linked Client Badge */}
{task.clientId && (
<div className="mb-3 flex items-center gap-1 text-[10px] text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded w-fit">
<Building2 size={10} /> {companies.find(c => c.id === task.clientId)?.fantasyName || 'Cliente'}
</div>
)}
<div className="flex justify-between items-center pt-3 border-t border-slate-50">
<div className="flex -space-x-2">
<div className="w-6 h-6 rounded-full bg-slate-200 border-2 border-white flex items-center justify-center text-[10px] font-bold text-slate-500">
<UserCircle size={16} />
</div>
</div>
<div className="flex items-center gap-1 text-xs text-slate-400">
<Clock size={12} /> {task.dueDate ? new Date(task.dueDate).toLocaleDateString('pt-BR', {day:'2-digit', month:'short'}) : 'Sem data'}
</div>
</div>
</div>
))}
<button onClick={openNewTaskModal} className="w-full py-2 text-slate-400 text-sm font-medium hover:bg-slate-200/50 rounded-xl transition-colors flex items-center justify-center gap-2 border border-transparent hover:border-slate-200/50">
<Plus size={16} /> Adicionar
</button>
</div>
</div>
</div>
);
})}
{/* Add Column Button */}
<div className="flex-shrink-0 w-[50px] pt-10">
<button
onClick={() => setIsColumnModalOpen(true)}
className="w-full h-[50px] bg-white border border-dashed border-slate-300 rounded-2xl flex items-center justify-center text-slate-400 hover:text-primary-500 hover:border-primary-300 hover:bg-primary-50 transition-all group tooltip-container"
title="Nova Coluna"
>
<Plus size={24} />
</button>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,353 @@
import React, { useState } from 'react';
import { Search, Plus, User, Briefcase, Pencil, X, Sparkles, Trash2 } from 'lucide-react';
import { Client, Service } from '../types';
import { useToast } from '../contexts/ToastContext';
import { CustomSelect } from './CustomSelect';
interface ManagementViewProps {
type: 'clients' | 'services';
clientsData: Client[];
setClientsData: (data: Client[]) => void;
servicesData: Service[];
setServicesData: (data: Service[]) => void;
}
// --- MODAIS ---
const ClientModal: React.FC<{ isOpen: boolean; onClose: () => void; client: Client | null; onSave: (client: Client) => void }> = ({ isOpen, onClose, client, onSave }) => {
const [formData, setFormData] = useState<Partial<Client>>(
client || { name: '', company: '', email: '', phone: '', address: '', status: 'active', value: 0 }
);
React.useEffect(() => {
if (client) setFormData(client);
else setFormData({ name: '', company: '', email: '', phone: '', address: '', status: 'active', value: 0 });
}, [client]);
if (!isOpen) return null;
const inputClass = "w-full p-3 bg-white border border-slate-200 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none text-slate-700 text-sm transition-all shadow-sm placeholder:text-slate-400";
const labelClass = "block text-xs font-bold text-slate-600 mb-1.5 uppercase tracking-wide";
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm" onClick={onClose}></div>
<div className="relative bg-white rounded-2xl shadow-2xl w-full max-w-lg animate-scale-up flex flex-col max-h-[90vh]">
<div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center bg-white flex-shrink-0">
<h3 className="text-lg font-bold text-slate-800">{client ? 'Editar Cliente' : 'Novo Cliente'}</h3>
<button onClick={onClose}><X size={20} className="text-slate-400 hover:text-slate-600" /></button>
</div>
<div className="p-6 space-y-5 overflow-y-auto">
<div>
<label className={labelClass}>Nome do Contato</label>
<input type="text" value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} className={inputClass} placeholder="Ex: João Silva"/>
</div>
<div>
<label className={labelClass}>Empresa</label>
<input type="text" value={formData.company} onChange={e => setFormData({...formData, company: e.target.value})} className={inputClass} placeholder="Ex: Empresa X"/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass}>Email</label>
<input type="email" value={formData.email} onChange={e => setFormData({...formData, email: e.target.value})} className={inputClass} />
</div>
<div>
<label className={labelClass}>Telefone</label>
<input type="text" value={formData.phone} onChange={e => setFormData({...formData, phone: e.target.value})} className={inputClass} />
</div>
</div>
</div>
<div className="px-6 py-4 bg-slate-50 border-t border-slate-100 flex justify-end gap-3 flex-shrink-0 rounded-b-2xl">
<button onClick={onClose} className="px-4 py-2 text-slate-600 font-medium hover:bg-slate-200 rounded-xl transition-colors">Cancelar</button>
<button onClick={() => onSave(formData as Client)} className="px-6 py-2 bg-primary-500 text-white font-bold rounded-xl hover:bg-primary-600 shadow-lg shadow-primary-200">Salvar</button>
</div>
</div>
</div>
);
};
const ServiceModal: React.FC<{ isOpen: boolean; onClose: () => void; service: Service | null; onSave: (service: Service) => void }> = ({ isOpen, onClose, service, onSave }) => {
const [formData, setFormData] = useState<Partial<Service>>(
service || { name: '', category: 'Consultoria', price: 0, active: true, description: '', billingType: 'one-time' }
);
React.useEffect(() => {
if (service) setFormData(service);
else setFormData({ name: '', category: 'Consultoria', price: 0, active: true, description: '', billingType: 'one-time' });
}, [service]);
if (!isOpen) return null;
const inputClass = "w-full p-3 bg-white border border-slate-200 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none text-slate-700 text-sm transition-all shadow-sm placeholder:text-slate-400";
const labelClass = "block text-xs font-bold text-slate-600 mb-1.5 uppercase tracking-wide";
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm" onClick={onClose}></div>
<div className="relative bg-white rounded-2xl shadow-2xl w-full max-w-lg animate-scale-up flex flex-col max-h-[90vh]">
<div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center bg-white flex-shrink-0">
<h3 className="text-lg font-bold text-slate-800">{service ? 'Editar Serviço' : 'Novo Serviço'}</h3>
<button onClick={onClose}><X size={20} className="text-slate-400 hover:text-slate-600" /></button>
</div>
<div className="p-6 space-y-5 overflow-y-auto">
<div>
<label className={labelClass}>Nome do Serviço</label>
<input type="text" value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} className={inputClass} placeholder="Ex: Consultoria SEO"/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass}>Preço (R$)</label>
<input type="number" value={formData.price} onChange={e => setFormData({...formData, price: Number(e.target.value)})} className={inputClass} />
</div>
<div>
<label className={labelClass}>Cobrança</label>
<CustomSelect
value={formData.billingType || 'one-time'}
onChange={(val) => setFormData({...formData, billingType: val})}
options={[
{ value: 'one-time', label: 'Pontual (Única)' },
{ value: 'recurring', label: 'Assinatura (Mensal)' }
]}
/>
</div>
</div>
<div>
<label className={labelClass}>Categoria</label>
<CustomSelect
value={formData.category || 'Consultoria'}
onChange={(val) => setFormData({...formData, category: val})}
options={[
{ value: 'Consultoria', label: 'Consultoria' },
{ value: 'TI', label: 'TI / Desenvolvimento' },
{ value: 'Marketing', label: 'Marketing / Design' },
{ value: 'Outro', label: 'Outro' }
]}
/>
</div>
<div>
<label className={labelClass}>Descrição</label>
<textarea rows={3} value={formData.description} onChange={e => setFormData({...formData, description: e.target.value})} className={inputClass} placeholder="O que está incluso..."></textarea>
</div>
</div>
<div className="px-6 py-4 bg-slate-50 border-t border-slate-100 flex justify-end gap-3 flex-shrink-0 rounded-b-2xl">
<button onClick={onClose} className="px-4 py-2 text-slate-600 font-medium hover:bg-slate-200 rounded-xl transition-colors">Cancelar</button>
<button onClick={() => onSave(formData as Service)} className="px-6 py-2 bg-primary-500 text-white font-bold rounded-xl hover:bg-primary-600 shadow-lg shadow-primary-200">Salvar</button>
</div>
</div>
</div>
);
};
export const ManagementView: React.FC<ManagementViewProps> = ({ type, clientsData, setClientsData, servicesData, setServicesData }) => {
const { addToast } = useToast();
const [isClientModalOpen, setIsClientModalOpen] = useState(false);
const [editingClient, setEditingClient] = useState<Client | null>(null);
const [isServiceModalOpen, setIsServiceModalOpen] = useState(false);
const [editingService, setEditingService] = useState<Service | null>(null);
const [searchTerm, setSearchTerm] = useState('');
// --- SERVICE ACTIONS ---
const handleSaveService = (serviceData: Service) => {
if (!serviceData.name || serviceData.price < 0) {
addToast({ type: 'warning', title: 'Dados Inválidos', message: 'Verifique o nome e o preço.' });
return;
}
if (editingService) {
setServicesData(servicesData.map(s => s.id === serviceData.id ? serviceData : s));
addToast({ type: 'success', title: 'Serviço Atualizado' });
} else {
const newService = { ...serviceData, id: Math.random().toString(36).substr(2, 9) };
setServicesData([...servicesData, newService]);
addToast({ type: 'success', title: 'Serviço Criado' });
}
setIsServiceModalOpen(false);
setEditingService(null);
};
const handleDeleteService = (id: string) => {
if (window.confirm('Excluir este serviço?')) {
setServicesData(servicesData.filter(s => s.id !== id));
addToast({ type: 'info', title: 'Serviço Removido' });
}
};
// --- CLIENT ACTIONS ---
const handleSaveClient = (clientData: Client) => {
if (!clientData.name) {
addToast({ type: 'warning', title: 'Nome Obrigatório' });
return;
}
if(editingClient) {
setClientsData(clientsData.map(c => c.id === clientData.id ? clientData : c));
addToast({ type: 'success', title: 'Cliente Atualizado' });
} else {
setClientsData([...clientsData, {...clientData, id: Math.random().toString().substr(2, 9)}]);
addToast({ type: 'success', title: 'Cliente Cadastrado' });
}
setIsClientModalOpen(false);
setEditingClient(null);
};
const handleDeleteClient = (id: string) => {
if (window.confirm('Excluir este cliente?')) {
setClientsData(clientsData.filter(c => c.id !== id));
addToast({ type: 'info', title: 'Cliente Removido' });
}
};
// --- FILTERING ---
const filteredServices = servicesData.filter(s => s.name.toLowerCase().includes(searchTerm.toLowerCase()) || s.category.toLowerCase().includes(searchTerm.toLowerCase()));
const filteredClients = clientsData.filter(c => c.name.toLowerCase().includes(searchTerm.toLowerCase()) || c.company.toLowerCase().includes(searchTerm.toLowerCase()));
return (
<div className="space-y-6 animate-fade-in relative pb-10">
<ClientModal
isOpen={isClientModalOpen}
onClose={() => setIsClientModalOpen(false)}
client={editingClient}
onSave={handleSaveClient}
/>
<ServiceModal
isOpen={isServiceModalOpen}
onClose={() => setIsServiceModalOpen(false)}
service={editingService}
onSave={handleSaveService}
/>
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-800">
{type === 'clients' ? 'Base de Clientes (Legado)' : 'Catálogo de Serviços'}
</h1>
<p className="text-slate-500">
{type === 'clients' ? 'Gestão simples de contatos.' : 'Gerencie preços e portfólio.'}
</p>
</div>
<button
onClick={() => {
if (type === 'clients') { setEditingClient(null); setIsClientModalOpen(true); }
else { setEditingService(null); setIsServiceModalOpen(true); }
}}
className="flex items-center gap-2 px-5 py-2.5 bg-primary-500 text-white rounded-xl hover:bg-primary-600 shadow-lg shadow-primary-200/50 transition-all font-bold"
>
<Plus size={18} />
{type === 'clients' ? 'Novo Cliente' : 'Novo Serviço'}
</button>
</div>
<div className="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
{/* Toolbar */}
<div className="p-4 border-b border-slate-100">
<div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<input
type="text"
placeholder={type === 'clients' ? "Buscar cliente..." : "Buscar serviço..."}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 bg-slate-50 border-none rounded-xl focus:ring-2 focus:ring-primary-500 outline-none text-slate-600"
/>
</div>
</div>
{/* Table Content */}
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead className="bg-slate-50/50">
<tr>
<th className="p-4 text-xs font-bold uppercase text-slate-500 tracking-wide">{type === 'clients' ? 'Nome / Empresa' : 'Serviço'}</th>
<th className="p-4 text-xs font-bold uppercase text-slate-500 tracking-wide">{type === 'clients' ? 'Contato' : 'Preço'}</th>
<th className="p-4 text-xs font-bold uppercase text-slate-500 tracking-wide">{type === 'clients' ? 'Status' : 'Tipo / Categoria'}</th>
<th className="p-4 text-right"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{type === 'services' && filteredServices.map(service => (
<tr key={service.id} className="hover:bg-slate-50 transition-colors group">
<td className="p-4">
<div className="font-bold text-slate-800">{service.name}</div>
{service.description && <div className="text-xs text-slate-400 line-clamp-1">{service.description}</div>}
</td>
<td className="p-4 font-bold text-slate-700">R$ {service.price.toLocaleString('pt-BR')}</td>
<td className="p-4">
<div className="flex gap-2">
<span className={`px-2 py-1 rounded text-[10px] font-bold uppercase tracking-wide ${service.billingType === 'recurring' ? 'bg-indigo-50 text-indigo-600' : 'bg-slate-100 text-slate-600'}`}>
{service.billingType === 'recurring' ? 'Assinatura' : 'Pontual'}
</span>
<span className="px-2 py-1 rounded text-[10px] font-bold uppercase tracking-wide bg-slate-100 text-slate-500">
{service.category}
</span>
</div>
</td>
<td className="p-4 text-right">
<div className="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => { setEditingService(service); setIsServiceModalOpen(true); }} className="p-2 text-slate-400 hover:text-primary-500 hover:bg-primary-50 rounded-lg transition-colors">
<Pencil size={18} />
</button>
<button onClick={() => handleDeleteService(service.id)} className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors">
<Trash2 size={18} />
</button>
</div>
</td>
</tr>
))}
{type === 'clients' && filteredClients.map(client => (
<tr key={client.id} className="hover:bg-slate-50 transition-colors group">
<td className="p-4">
<div className="font-bold text-slate-800">{client.name}</div>
<div className="text-xs text-slate-400">{client.company}</div>
</td>
<td className="p-4 text-sm text-slate-600">
<div>{client.email}</div>
<div className="text-xs text-slate-400">{client.phone}</div>
</td>
<td className="p-4">
<span className="px-2 py-1 rounded text-[10px] font-bold uppercase bg-green-50 text-green-600">
Ativo
</span>
</td>
<td className="p-4 text-right">
<div className="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => { setEditingClient(client); setIsClientModalOpen(true); }} className="p-2 text-slate-400 hover:text-primary-500 hover:bg-primary-50 rounded-lg transition-colors">
<Pencil size={18} />
</button>
<button onClick={() => handleDeleteClient(client.id)} className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors">
<Trash2 size={18} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{/* Empty State */}
{((type === 'services' && filteredServices.length === 0) || (type === 'clients' && filteredClients.length === 0)) && (
<div className="p-10 text-center text-slate-400 flex flex-col items-center">
<Sparkles size={32} className="mb-2 opacity-20"/>
<p>Nenhum registro encontrado.</p>
<button
onClick={() => {
if (type === 'clients') { setEditingClient(null); setIsClientModalOpen(true); }
else { setEditingService(null); setIsServiceModalOpen(true); }
}}
className="mt-4 text-sm font-bold text-primary-500 hover:underline"
>
Criar primeiro registro
</button>
</div>
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,452 @@
import React, { useState, useRef } from 'react';
import { Proposal, ProposalItem } from '../types';
import { useComFi } from '../contexts/ComFiContext';
import { useToast } from '../contexts/ToastContext';
import { CustomSelect } from './CustomSelect';
import {
FileText, Plus, Trash2, Printer, Edit2, CheckCircle2,
Send, X, Search, ChevronLeft, Building2, Calendar, DollarSign, Save
} from 'lucide-react';
export const ProposalsView: React.FC = () => {
const { proposals, setProposals, companies, services, tenant } = useComFi();
const { addToast } = useToast();
const [viewMode, setViewMode] = useState<'list' | 'create' | 'edit'>('list');
const [searchTerm, setSearchTerm] = useState('');
// Form State
const [currentProposal, setCurrentProposal] = useState<Partial<Proposal>>({
items: [],
issueDate: new Date().toISOString().split('T')[0],
validUntil: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
status: 'draft',
notes: 'Validade da proposta: 15 dias.\nPagamento: 50% entrada, 50% na entrega.'
});
// Items Management
const [newItemServiceId, setNewItemServiceId] = useState('');
const calculateTotal = (items: ProposalItem[]) => items.reduce((acc, item) => acc + item.total, 0);
const handleAddItem = () => {
if (!newItemServiceId) return;
const service = services.find(s => s.id === newItemServiceId);
if (!service) return;
const newItem: ProposalItem = {
id: Math.random().toString(36).substr(2, 9),
serviceId: service.id,
description: service.name,
quantity: 1,
unitPrice: service.price,
total: service.price
};
const updatedItems = [...(currentProposal.items || []), newItem];
setCurrentProposal({
...currentProposal,
items: updatedItems,
totalValue: calculateTotal(updatedItems)
});
setNewItemServiceId('');
};
const handleRemoveItem = (itemId: string) => {
const updatedItems = (currentProposal.items || []).filter(i => i.id !== itemId);
setCurrentProposal({
...currentProposal,
items: updatedItems,
totalValue: calculateTotal(updatedItems)
});
};
const handleUpdateItem = (itemId: string, field: 'quantity' | 'unitPrice', value: number) => {
const updatedItems = (currentProposal.items || []).map(item => {
if (item.id === itemId) {
const updatedItem = { ...item, [field]: value };
updatedItem.total = updatedItem.quantity * updatedItem.unitPrice;
return updatedItem;
}
return item;
});
setCurrentProposal({
...currentProposal,
items: updatedItems,
totalValue: calculateTotal(updatedItems)
});
};
const handleSave = () => {
if (!currentProposal.clientId || !currentProposal.items?.length) {
addToast({ type: 'warning', title: 'Dados Incompletos', message: 'Selecione um cliente e adicione itens.' });
return;
}
const client = companies.find(c => c.id === currentProposal.clientId);
const proposalToSave: Proposal = {
...currentProposal,
id: currentProposal.id || Math.random().toString(36).substr(2, 9),
number: currentProposal.number || `PROP-${new Date().getFullYear()}-${Math.floor(Math.random() * 1000)}`,
clientName: client?.fantasyName || client?.name || 'Cliente',
clientEmail: client?.email,
totalValue: calculateTotal(currentProposal.items || [])
} as Proposal;
if (viewMode === 'edit') {
setProposals(proposals.map(p => p.id === proposalToSave.id ? proposalToSave : p));
addToast({ type: 'success', title: 'Proposta Atualizada' });
} else {
setProposals([...proposals, proposalToSave]);
addToast({ type: 'success', title: 'Proposta Criada' });
}
setViewMode('list');
};
const handleDelete = (id: string) => {
if (window.confirm('Excluir esta proposta?')) {
setProposals(proposals.filter(p => p.id !== id));
addToast({ type: 'info', title: 'Proposta Removida' });
}
};
const openCreate = () => {
setCurrentProposal({
items: [],
issueDate: new Date().toISOString().split('T')[0],
validUntil: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
status: 'draft',
notes: 'Validade da proposta: 15 dias.\nPagamento: 50% entrada, 50% na entrega.'
});
setViewMode('create');
};
const openEdit = (proposal: Proposal) => {
setCurrentProposal(proposal);
setViewMode('edit');
};
const handlePrint = () => {
window.print();
};
// --- RENDER ---
if (viewMode === 'create' || viewMode === 'edit') {
const client = companies.find(c => c.id === currentProposal.clientId);
return (
<div className="space-y-6 animate-fade-in relative">
{/* Print Styles */}
<style>{`
@media print {
body * { visibility: hidden; }
#proposal-document, #proposal-document * { visibility: visible; }
#proposal-document { position: absolute; left: 0; top: 0; width: 100%; margin: 0; padding: 0; background: white; box-shadow: none; border: none; }
.no-print { display: none !important; }
}
`}</style>
{/* Header / Actions */}
<div className="flex justify-between items-center no-print">
<button onClick={() => setViewMode('list')} className="flex items-center gap-2 px-4 py-2 bg-white border border-slate-200 rounded-xl hover:bg-slate-50 transition-colors text-slate-600 font-medium">
<ChevronLeft size={18} /> Voltar
</button>
<div className="flex gap-2">
<button onClick={handlePrint} className="flex items-center gap-2 px-4 py-2 bg-slate-800 text-white rounded-xl hover:bg-slate-900 font-bold transition-all shadow-lg">
<Printer size={18} /> Imprimir / PDF
</button>
<button onClick={handleSave} className="flex items-center gap-2 px-6 py-2 bg-primary-500 text-white rounded-xl hover:bg-primary-600 font-bold transition-all shadow-lg shadow-primary-200">
<Save size={18} /> Salvar
</button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Editor Panel (Left) */}
<div className="lg:col-span-1 space-y-6 no-print">
<div className="bg-white p-6 rounded-[2rem] shadow-sm border border-slate-100">
<h3 className="font-bold text-slate-800 text-lg mb-4 flex items-center gap-2"><Building2 size={18}/> Cliente & Detalhes</h3>
<div className="space-y-4">
<div>
<label className="block text-xs font-bold text-slate-500 mb-1 uppercase">Cliente</label>
<CustomSelect
value={currentProposal.clientId || ''}
onChange={(val) => setCurrentProposal({...currentProposal, clientId: val})}
placeholder="Selecione o Cliente"
options={companies.map(c => ({ value: c.id, label: c.fantasyName || c.name }))}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-slate-500 mb-1 uppercase">Emissão</label>
<input type="date" className="w-full p-3 bg-white border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-200" value={currentProposal.issueDate} onChange={e => setCurrentProposal({...currentProposal, issueDate: e.target.value})} />
</div>
<div>
<label className="block text-xs font-bold text-slate-500 mb-1 uppercase">Validade</label>
<input type="date" className="w-full p-3 bg-white border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-200" value={currentProposal.validUntil} onChange={e => setCurrentProposal({...currentProposal, validUntil: e.target.value})} />
</div>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 mb-1 uppercase">Status</label>
<CustomSelect
value={currentProposal.status || 'draft'}
onChange={(val) => setCurrentProposal({...currentProposal, status: val})}
options={[
{ value: 'draft', label: 'Rascunho' },
{ value: 'sent', label: 'Enviado' },
{ value: 'accepted', label: 'Aceito' },
{ value: 'rejected', label: 'Rejeitado' }
]}
/>
</div>
</div>
</div>
<div className="bg-white p-6 rounded-[2rem] shadow-sm border border-slate-100">
<h3 className="font-bold text-slate-800 text-lg mb-4 flex items-center gap-2"><Plus size={18}/> Adicionar Item</h3>
<div className="flex gap-2 mb-2">
<div className="flex-1">
<CustomSelect
value={newItemServiceId}
onChange={setNewItemServiceId}
placeholder="Selecione um serviço..."
options={services.map(s => ({ value: s.id, label: `${s.name} (R$ ${s.price})` }))}
/>
</div>
<button onClick={handleAddItem} className="bg-slate-800 text-white p-3 rounded-xl hover:bg-slate-900 transition-colors"><Plus size={20}/></button>
</div>
</div>
<div className="bg-white p-6 rounded-[2rem] shadow-sm border border-slate-100">
<h3 className="font-bold text-slate-800 text-lg mb-4">Notas & Observações</h3>
<textarea
className="w-full p-3 bg-slate-50 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-200 h-32"
value={currentProposal.notes}
onChange={e => setCurrentProposal({...currentProposal, notes: e.target.value})}
placeholder="Termos de pagamento, prazos, etc."
></textarea>
</div>
</div>
{/* Preview Panel (Right) - The "Paper" */}
<div className="lg:col-span-2">
<div id="proposal-document" className="bg-white w-full max-w-[210mm] mx-auto min-h-[297mm] p-[15mm] shadow-2xl rounded-sm text-slate-800 relative">
{/* Header Documento */}
<div className="flex justify-between items-start border-b-2 border-primary-500 pb-8 mb-8">
<div>
{tenant.logo ? (
<img src={tenant.logo} className="h-16 object-contain mb-2" alt="Logo" />
) : (
<h1 className="text-3xl font-bold text-slate-900">{tenant.name}</h1>
)}
<p className="text-sm text-slate-500 max-w-xs">{tenant.address}</p>
<p className="text-sm text-slate-500">{tenant.email} | {tenant.phone}</p>
</div>
<div className="text-right">
<h2 className="text-4xl font-bold text-slate-200 uppercase tracking-widest">Proposta</h2>
<p className="text-lg font-bold text-primary-600 mt-2">{currentProposal.number || 'RASCUNHO'}</p>
<p className="text-sm text-slate-500 mt-1">Data: {new Date(currentProposal.issueDate || '').toLocaleDateString('pt-BR')}</p>
<p className="text-sm text-slate-500">Válido até: {new Date(currentProposal.validUntil || '').toLocaleDateString('pt-BR')}</p>
</div>
</div>
{/* Info Cliente */}
<div className="mb-10">
<h3 className="text-xs font-bold text-slate-400 uppercase mb-2">Preparado para:</h3>
<div className="bg-slate-50 p-4 rounded-xl border border-slate-100">
{client ? (
<>
<h4 className="text-xl font-bold text-slate-800">{client.fantasyName || client.name}</h4>
<p className="text-sm text-slate-600">{client.address} - {client.city}</p>
<p className="text-sm text-slate-600">CNPJ: {client.cnpj}</p>
<p className="text-sm text-slate-600">{client.email}</p>
</>
) : (
<p className="text-slate-400 italic">Selecione um cliente...</p>
)}
</div>
</div>
{/* Items Table */}
<div className="mb-8">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b-2 border-slate-100">
<th className="py-3 text-xs font-bold text-slate-500 uppercase w-1/2">Descrição / Serviço</th>
<th className="py-3 text-xs font-bold text-slate-500 uppercase text-center">Qtd</th>
<th className="py-3 text-xs font-bold text-slate-500 uppercase text-right">Valor Unit.</th>
<th className="py-3 text-xs font-bold text-slate-500 uppercase text-right">Total</th>
<th className="py-3 w-8 no-print"></th>
</tr>
</thead>
<tbody>
{(currentProposal.items || []).map((item) => (
<tr key={item.id} className="border-b border-slate-50 group">
<td className="py-4">
<span className="font-bold text-slate-700 block">{item.description}</span>
</td>
<td className="py-4 text-center">
<input
type="number"
min="1"
className="w-16 text-center bg-transparent border border-transparent hover:border-slate-200 rounded p-1 outline-none focus:border-primary-300 transition-colors"
value={item.quantity}
onChange={(e) => handleUpdateItem(item.id, 'quantity', Number(e.target.value))}
/>
</td>
<td className="py-4 text-right">
<div className="flex justify-end items-center gap-1">
<span className="text-xs text-slate-400">R$</span>
<input
type="number"
className="w-24 text-right bg-transparent border border-transparent hover:border-slate-200 rounded p-1 outline-none focus:border-primary-300 transition-colors"
value={item.unitPrice}
onChange={(e) => handleUpdateItem(item.id, 'unitPrice', Number(e.target.value))}
/>
</div>
</td>
<td className="py-4 text-right font-bold text-slate-800">
R$ {item.total.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}
</td>
<td className="py-4 text-right no-print">
<button onClick={() => handleRemoveItem(item.id)} className="text-slate-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all"><Trash2 size={16}/></button>
</td>
</tr>
))}
{(currentProposal.items || []).length === 0 && (
<tr><td colSpan={5} className="py-8 text-center text-slate-300 italic">Nenhum item adicionado</td></tr>
)}
</tbody>
</table>
</div>
{/* Totals */}
<div className="flex justify-end mb-12">
<div className="w-1/2">
<div className="flex justify-between items-center py-2 border-b border-slate-100">
<span className="text-slate-500 font-medium">Subtotal</span>
<span className="text-slate-800 font-bold">R$ {calculateTotal(currentProposal.items || []).toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</span>
</div>
<div className="flex justify-between items-center py-4">
<span className="text-lg text-slate-800 font-bold">Total Geral</span>
<span className="text-2xl text-primary-600 font-bold">R$ {calculateTotal(currentProposal.items || []).toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</span>
</div>
</div>
</div>
{/* Footer Notes */}
<div className="border-t-2 border-slate-100 pt-6">
<h4 className="text-sm font-bold text-slate-800 mb-2">Termos e Condições</h4>
<p className="text-sm text-slate-500 whitespace-pre-line">{currentProposal.notes}</p>
</div>
{/* Signature Area */}
<div className="absolute bottom-[15mm] left-[15mm] right-[15mm] flex justify-between mt-20">
<div className="w-1/3 border-t border-slate-300 pt-2 text-center">
<p className="text-xs font-bold text-slate-600">{tenant.name}</p>
<p className="text-[10px] text-slate-400">Assinatura do Emissor</p>
</div>
<div className="w-1/3 border-t border-slate-300 pt-2 text-center">
<p className="text-xs font-bold text-slate-600">{client?.name || 'Cliente'}</p>
<p className="text-[10px] text-slate-400">De acordo</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
// LIST VIEW
return (
<div className="space-y-6 animate-fade-in">
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-800">Propostas Comerciais</h1>
<p className="text-slate-500">Crie orçamentos e gerencie negociações.</p>
</div>
<button onClick={openCreate} className="flex items-center gap-2 px-5 py-2.5 bg-primary-500 text-white rounded-xl hover:bg-primary-600 shadow-lg shadow-primary-200/50 transition-all font-bold">
<Plus size={18} /> Nova Proposta
</button>
</div>
<div className="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
{/* Toolbar */}
<div className="p-4 border-b border-slate-100 flex gap-4">
<div className="relative max-w-md flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<input
type="text"
placeholder="Buscar proposta..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 bg-slate-50 border-none rounded-xl focus:ring-2 focus:ring-primary-500 outline-none text-slate-600"
/>
</div>
</div>
{/* Table */}
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-slate-50/50">
<tr>
<th className="p-4 text-xs font-bold uppercase text-slate-500">Número</th>
<th className="p-4 text-xs font-bold uppercase text-slate-500">Cliente</th>
<th className="p-4 text-xs font-bold uppercase text-slate-500">Emissão</th>
<th className="p-4 text-xs font-bold uppercase text-slate-500">Valor</th>
<th className="p-4 text-xs font-bold uppercase text-slate-500 text-center">Status</th>
<th className="p-4"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{proposals.filter(p => p.clientName.toLowerCase().includes(searchTerm.toLowerCase()) || p.number.toLowerCase().includes(searchTerm.toLowerCase())).map(prop => (
<tr key={prop.id} className="hover:bg-slate-50 transition-colors group">
<td className="p-4 font-bold text-slate-800">{prop.number}</td>
<td className="p-4 text-sm text-slate-700">{prop.clientName}</td>
<td className="p-4 text-sm text-slate-500">
<div className="flex items-center gap-2">
<Calendar size={14} className="text-slate-400"/>
{new Date(prop.issueDate).toLocaleDateString('pt-BR')}
</div>
</td>
<td className="p-4 font-bold text-slate-700">R$ {prop.totalValue.toLocaleString('pt-BR')}</td>
<td className="p-4 text-center">
<span className={`px-2 py-1 rounded text-[10px] font-bold uppercase tracking-wide ${
prop.status === 'accepted' ? 'bg-green-50 text-green-600' :
prop.status === 'sent' ? 'bg-blue-50 text-blue-600' :
prop.status === 'rejected' ? 'bg-red-50 text-red-600' :
'bg-slate-100 text-slate-500'
}`}>
{prop.status === 'accepted' ? 'Aceito' : prop.status === 'sent' ? 'Enviado' : prop.status === 'rejected' ? 'Rejeitado' : 'Rascunho'}
</span>
</td>
<td className="p-4 text-right">
<div className="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => openEdit(prop)} className="p-2 text-slate-400 hover:text-primary-500 hover:bg-primary-50 rounded-lg transition-colors" title="Editar / Visualizar">
<Edit2 size={18} />
</button>
<button onClick={() => handleDelete(prop.id)} className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors" title="Excluir">
<Trash2 size={18} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{proposals.length === 0 && (
<div className="p-10 text-center text-slate-400">
<FileText size={32} className="mx-auto mb-2 opacity-20"/>
<p>Nenhuma proposta registrada.</p>
</div>
)}
</div>
</div>
</div>
);
};

255
components/SettingsView.tsx Normal file
View File

@@ -0,0 +1,255 @@
import React, { useState, useRef } from 'react';
import { useComFi } from '../contexts/ComFiContext';
import { useToast } from '../contexts/ToastContext';
import {
Building2, Save, UploadCloud, Camera, Palette, Tag,
Plus, Trash2, Bell, Lock, UserCog, Mail, Key
} from 'lucide-react';
import { Category } from '../types';
import { CustomSelect } from './CustomSelect';
export const SettingsView: React.FC = () => {
const { tenant, setTenant, categories, setCategories, currentUser } = useComFi();
const { addToast } = useToast();
const [activeTab, setActiveTab] = useState<'company' | 'categories' | 'security'>('company');
// Organization State
const [orgForm, setOrgForm] = useState(tenant);
const logoInputRef = useRef<HTMLInputElement>(null);
// Categories State
const [newCategory, setNewCategory] = useState({ name: '', type: 'expense' as 'expense' | 'income' });
// Handle Organization Save
const handleSaveOrg = () => {
setTenant(orgForm);
addToast({ type: 'success', title: 'Configurações Salvas', message: 'Dados da empresa atualizados.' });
};
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
setOrgForm({ ...orgForm, logo: reader.result as string });
};
reader.readAsDataURL(file);
}
};
// Handle Categories
const handleAddCategory = () => {
if (!newCategory.name) return;
const category: Category = {
id: Math.random().toString(36).substr(2, 9),
name: newCategory.name,
type: newCategory.type,
color: newCategory.type === 'income' ? 'bg-green-50 text-green-600' : 'bg-red-50 text-red-600'
};
setCategories([...categories, category]);
setNewCategory({ name: '', type: 'expense' });
addToast({ type: 'success', title: 'Categoria Adicionada' });
};
const handleDeleteCategory = (id: string) => {
setCategories(categories.filter(c => c.id !== id));
addToast({ type: 'info', title: 'Categoria Removida' });
};
const inputClass = "w-full p-3 bg-white border border-slate-200 rounded-xl focus:ring-2 focus:ring-primary-200 outline-none text-slate-800 text-sm";
const labelClass = "block text-xs font-bold text-slate-700 mb-1 uppercase tracking-wide";
return (
<div className="animate-fade-in max-w-4xl mx-auto pb-10">
<h1 className="text-2xl font-bold text-slate-800 mb-2">Configurações & Personalização</h1>
<p className="text-slate-500 mb-6">Gerencie dados da empresa, categorias financeiras e preferências.</p>
{/* Tabs */}
<div className="flex border-b border-slate-200 mb-6 overflow-x-auto">
<button
onClick={() => setActiveTab('company')}
className={`px-6 py-3 text-sm font-bold border-b-2 transition-colors whitespace-nowrap flex items-center gap-2 ${activeTab === 'company' ? 'border-primary-500 text-primary-600' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
>
<Building2 size={16}/> Minha Empresa
</button>
<button
onClick={() => setActiveTab('categories')}
className={`px-6 py-3 text-sm font-bold border-b-2 transition-colors whitespace-nowrap flex items-center gap-2 ${activeTab === 'categories' ? 'border-primary-500 text-primary-600' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
>
<Tag size={16}/> Categorias Financeiras
</button>
<button
onClick={() => setActiveTab('security')}
className={`px-6 py-3 text-sm font-bold border-b-2 transition-colors whitespace-nowrap flex items-center gap-2 ${activeTab === 'security' ? 'border-primary-500 text-primary-600' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
>
<Lock size={16}/> Segurança & Preferências
</button>
</div>
<div className="bg-white rounded-[2rem] shadow-sm border border-slate-100 p-8">
{/* ORGANIZATION SETTINGS */}
{activeTab === 'company' && (
<div className="space-y-6">
<div className="flex items-start gap-6">
<div className="flex flex-col items-center">
<div
onClick={() => logoInputRef.current?.click()}
className="w-32 h-32 rounded-2xl bg-slate-50 border-2 border-dashed border-slate-200 flex items-center justify-center cursor-pointer overflow-hidden hover:border-primary-300 relative group transition-colors"
>
{orgForm.logo ? (
<img src={orgForm.logo} alt="Logo" className="w-full h-full object-contain p-2" />
) : (
<div className="text-center">
<UploadCloud size={24} className="mx-auto text-slate-400 mb-1" />
<span className="text-[10px] text-slate-400 font-bold uppercase">Upload Logo</span>
</div>
)}
<div className="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<Camera className="text-white" size={24}/>
</div>
</div>
<input type="file" ref={logoInputRef} className="hidden" accept="image/*" onChange={handleLogoUpload} />
</div>
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="col-span-2">
<label className={labelClass}>Nome da Empresa (Razão Social)</label>
<input className={inputClass} value={orgForm.name} onChange={e => setOrgForm({...orgForm, name: e.target.value})} placeholder="Minha Empresa S.A." />
</div>
<div>
<label className={labelClass}>CNPJ</label>
<input className={inputClass} value={orgForm.cnpj} onChange={e => setOrgForm({...orgForm, cnpj: e.target.value})} placeholder="00.000.000/0000-00" />
</div>
<div>
<label className={labelClass}>Telefone Comercial</label>
<input className={inputClass} value={orgForm.phone} onChange={e => setOrgForm({...orgForm, phone: e.target.value})} />
</div>
<div className="col-span-2">
<label className={labelClass}>Endereço Completo</label>
<input className={inputClass} value={orgForm.address} onChange={e => setOrgForm({...orgForm, address: e.target.value})} placeholder="Rua, Número, Bairro, Cidade - UF" />
</div>
<div className="col-span-2">
<label className={labelClass}>Email Financeiro</label>
<input className={inputClass} value={orgForm.email} onChange={e => setOrgForm({...orgForm, email: e.target.value})} />
</div>
</div>
</div>
<div className="pt-6 border-t border-slate-50 flex justify-end">
<button onClick={handleSaveOrg} className="px-6 py-3 bg-primary-500 text-white font-bold rounded-xl hover:bg-primary-600 shadow-lg shadow-primary-200 flex items-center gap-2">
<Save size={18} /> Salvar Alterações
</button>
</div>
</div>
)}
{/* CATEGORIES SETTINGS */}
{activeTab === 'categories' && (
<div className="space-y-6">
<div className="flex gap-4 items-end bg-slate-50 p-4 rounded-xl border border-slate-100">
<div className="flex-1">
<label className={labelClass}>Nova Categoria</label>
<input className={inputClass} value={newCategory.name} onChange={e => setNewCategory({...newCategory, name: e.target.value})} placeholder="Ex: Transporte, Freelancers..." />
</div>
<div className="w-40">
<label className={labelClass}>Tipo</label>
<div className="relative">
<CustomSelect
value={newCategory.type}
onChange={(val) => setNewCategory({...newCategory, type: val})}
options={[
{ value: 'expense', label: 'Despesa' },
{ value: 'income', label: 'Receita' }
]}
/>
</div>
</div>
<button onClick={handleAddCategory} className="px-4 py-3 bg-slate-800 text-white rounded-xl hover:bg-slate-900 font-bold flex items-center justify-center">
<Plus size={20} />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
<h3 className="font-bold text-slate-800 mb-3 flex items-center gap-2 text-sm"><span className="w-2 h-2 rounded-full bg-red-500"></span> Despesas</h3>
<div className="space-y-2">
{categories.filter(c => c.type === 'expense').map(cat => (
<div key={cat.id} className="flex justify-between items-center p-3 bg-white border border-slate-100 rounded-xl hover:bg-slate-50 transition-colors group">
<span className="text-sm font-medium text-slate-700">{cat.name}</span>
<button onClick={() => handleDeleteCategory(cat.id)} className="text-slate-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all">
<Trash2 size={16} />
</button>
</div>
))}
</div>
</div>
<div>
<h3 className="font-bold text-slate-800 mb-3 flex items-center gap-2 text-sm"><span className="w-2 h-2 rounded-full bg-green-500"></span> Receitas</h3>
<div className="space-y-2">
{categories.filter(c => c.type === 'income').map(cat => (
<div key={cat.id} className="flex justify-between items-center p-3 bg-white border border-slate-100 rounded-xl hover:bg-slate-50 transition-colors group">
<span className="text-sm font-medium text-slate-700">{cat.name}</span>
<button onClick={() => handleDeleteCategory(cat.id)} className="text-slate-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all">
<Trash2 size={16} />
</button>
</div>
))}
</div>
</div>
</div>
</div>
)}
{/* SECURITY SETTINGS */}
{activeTab === 'security' && (
<div className="space-y-6 max-w-2xl">
<div className="bg-slate-50 p-6 rounded-2xl border border-slate-100">
<div className="flex items-center gap-4 mb-6">
<div className="w-16 h-16 rounded-full bg-white border-4 border-white shadow-sm overflow-hidden">
<img src={currentUser.avatar} alt="User" className="w-full h-full object-cover" />
</div>
<div>
<h3 className="font-bold text-slate-800 text-lg">{currentUser.name}</h3>
<p className="text-sm text-slate-500">{currentUser.email}</p>
</div>
</div>
<div className="space-y-4">
<div>
<label className={labelClass}>Alterar Senha</label>
<div className="flex gap-2">
<div className="relative flex-1">
<Key className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
<input type="password" className={`${inputClass} pl-10`} placeholder="Nova senha..." />
</div>
<button className="px-4 py-2 bg-slate-800 text-white rounded-xl text-sm font-bold hover:bg-slate-900">Atualizar</button>
</div>
</div>
</div>
</div>
<div className="space-y-3">
<h3 className="font-bold text-slate-800 text-sm uppercase tracking-wide">Notificações</h3>
<div className="flex items-center justify-between p-4 border border-slate-100 rounded-xl">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-blue-50 text-blue-500 flex items-center justify-center"><Bell size={20}/></div>
<div>
<p className="font-bold text-slate-800 text-sm">Alertas por Email</p>
<p className="text-xs text-slate-400">Receba resumos semanais.</p>
</div>
</div>
<div className="relative inline-block w-12 mr-2 align-middle select-none transition duration-200 ease-in">
<input type="checkbox" name="toggle" id="toggle" className="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer border-slate-200 checked:right-0 checked:border-green-400"/>
<label htmlFor="toggle" className="toggle-label block overflow-hidden h-6 rounded-full bg-slate-200 cursor-pointer checked:bg-green-400"></label>
</div>
</div>
</div>
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,309 @@
import React, { useState } from 'react';
import { AppUser, ViewState } from '../types';
import {
Plus, Search, Shield, ShieldAlert, CheckCircle2,
XCircle, Edit2, Trash2, X, Save, User as UserIcon, Lock
} from 'lucide-react';
interface UserManagementViewProps {
users: AppUser[];
setUsers: (users: AppUser[]) => void;
availableModules: { id: ViewState; label: string }[];
currentUser: AppUser;
}
const ToggleSwitch: React.FC<{ checked: boolean, onChange: (val: boolean) => void, label: string }> = ({ checked, onChange, label }) => (
<div className="flex items-center justify-between py-3 border-b border-slate-50 last:border-0">
<span className="text-sm font-medium text-slate-700">{label}</span>
<button
onClick={() => onChange(!checked)}
className={`relative w-11 h-6 rounded-full transition-colors duration-200 ease-in-out focus:outline-none ${
checked ? 'bg-primary-500' : 'bg-slate-200'
}`}
>
<span
className={`inline-block w-4 h-4 transform bg-white rounded-full shadow transition-transform duration-200 ease-in-out mt-1 ml-1 ${
checked ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
);
export const UserManagementView: React.FC<UserManagementViewProps> = ({ users, setUsers, availableModules, currentUser }) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingUser, setEditingUser] = useState<Partial<AppUser>>({
role: 'user',
active: true,
permissions: []
});
const handleSaveUser = () => {
if (!editingUser.name || !editingUser.email) return;
if (editingUser.id) {
// Editar existente
setUsers(users.map(u => u.id === editingUser.id ? { ...u, ...editingUser } as AppUser : u));
} else {
// Criar novo
const newUser: AppUser = {
...editingUser,
id: Math.random().toString(36).substr(2, 9),
avatar: `https://ui-avatars.com/api/?name=${editingUser.name}&background=random`,
permissions: editingUser.role === 'super_admin' ? availableModules.map(m => m.id) : (editingUser.permissions || [])
} as AppUser;
setUsers([...users, newUser]);
}
setIsModalOpen(false);
setEditingUser({ role: 'user', active: true, permissions: [] });
};
const handleDeleteUser = (id: string) => {
if (id === currentUser.id) {
alert("Você não pode excluir a si mesmo.");
return;
}
if (window.confirm("Tem certeza que deseja remover este usuário?")) {
setUsers(users.filter(u => u.id !== id));
}
};
const togglePermission = (moduleId: ViewState) => {
const currentPermissions = editingUser.permissions || [];
if (currentPermissions.includes(moduleId)) {
setEditingUser({ ...editingUser, permissions: currentPermissions.filter(p => p !== moduleId) });
} else {
setEditingUser({ ...editingUser, permissions: [...currentPermissions, moduleId] });
}
};
const openModal = (user?: AppUser) => {
if (user) {
setEditingUser(user);
} else {
setEditingUser({ role: 'user', active: true, permissions: [], name: '', email: '' });
}
setIsModalOpen(true);
};
const inputClass = "w-full p-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-primary-200 outline-none text-slate-800 text-sm";
const labelClass = "block text-xs font-bold text-slate-700 mb-1 uppercase tracking-wide";
return (
<div className="space-y-6 animate-fade-in">
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-800">Gerenciamento de Usuários</h1>
<p className="text-slate-500">Controle de acesso e permissões do sistema.</p>
</div>
<button
onClick={() => openModal()}
className="flex items-center gap-2 px-5 py-3 bg-primary-500 text-white rounded-xl shadow-lg shadow-primary-200/50 hover:bg-primary-600 font-bold transition-all"
>
<Plus size={20} /> Novo Usuário
</button>
</div>
<div className="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-slate-50/50 border-b border-slate-100">
<tr>
<th className="p-6 text-xs font-bold uppercase text-slate-500">Usuário</th>
<th className="p-6 text-xs font-bold uppercase text-slate-500">Função</th>
<th className="p-6 text-xs font-bold uppercase text-slate-500">Status</th>
<th className="p-6 text-xs font-bold uppercase text-slate-500">Permissões</th>
<th className="p-6 text-right"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{users.map(user => (
<tr key={user.id} className="hover:bg-slate-50 transition-colors">
<td className="p-6">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-slate-200 overflow-hidden">
<img src={user.avatar} alt={user.name} className="w-full h-full object-cover" />
</div>
<div>
<div className="font-bold text-slate-800">{user.name}</div>
<div className="text-xs text-slate-400">{user.email}</div>
</div>
</div>
</td>
<td className="p-6">
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold uppercase ${
user.role === 'super_admin' ? 'bg-indigo-50 text-indigo-600' : 'bg-slate-100 text-slate-600'
}`}>
{user.role === 'super_admin' ? <ShieldAlert size={14}/> : <UserIcon size={14}/>}
{user.role === 'super_admin' ? 'Super Admin' : 'Usuário'}
</span>
</td>
<td className="p-6">
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold uppercase ${
user.active ? 'bg-green-50 text-green-600' : 'bg-red-50 text-red-600'
}`}>
{user.active ? <CheckCircle2 size={14}/> : <XCircle size={14}/>}
{user.active ? 'Ativo' : 'Inativo'}
</span>
</td>
<td className="p-6">
{user.role === 'super_admin' ? (
<span className="text-xs text-indigo-500 font-bold">Acesso Total</span>
) : (
<div className="flex flex-wrap gap-1 max-w-xs">
{user.permissions.length === 0 && <span className="text-xs text-slate-400">Sem acesso</span>}
{user.permissions.slice(0, 3).map(p => (
<span key={p} className="px-2 py-0.5 bg-slate-100 border border-slate-200 rounded text-[10px] text-slate-500 capitalize">
{availableModules.find(m => m.id === p)?.label || p}
</span>
))}
{user.permissions.length > 3 && (
<span className="px-2 py-0.5 bg-slate-100 border border-slate-200 rounded text-[10px] text-slate-500">
+{user.permissions.length - 3}
</span>
)}
</div>
)}
</td>
<td className="p-6 text-right">
<div className="flex justify-end gap-2">
<button onClick={() => openModal(user)} className="p-2 text-slate-400 hover:text-primary-500 hover:bg-primary-50 rounded-lg transition-colors">
<Edit2 size={18} />
</button>
{user.id !== currentUser.id && (
<button onClick={() => handleDeleteUser(user.id)} className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors">
<Trash2 size={18} />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Modal User Edit/Create */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm" onClick={() => setIsModalOpen(false)}></div>
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-2xl overflow-hidden animate-scale-up flex flex-col max-h-[90vh]">
<div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center bg-slate-50">
<h3 className="font-bold text-slate-800 text-lg flex items-center gap-2">
{editingUser.id ? 'Editar Usuário' : 'Novo Usuário'}
</h3>
<button onClick={() => setIsModalOpen(false)} className="text-slate-400 hover:text-slate-600"><X size={20}/></button>
</div>
<div className="p-8 overflow-y-auto flex-1">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div className="col-span-2 md:col-span-1">
<label className={labelClass}>Nome Completo</label>
<input
type="text"
className={inputClass}
value={editingUser.name || ''}
onChange={e => setEditingUser({...editingUser, name: e.target.value})}
placeholder="Ex: João Silva"
/>
</div>
<div className="col-span-2 md:col-span-1">
<label className={labelClass}>Email</label>
<input
type="email"
className={inputClass}
value={editingUser.email || ''}
onChange={e => setEditingUser({...editingUser, email: e.target.value})}
placeholder="joao@empresa.com"
/>
</div>
<div className="col-span-2 md:col-span-1">
<label className={labelClass}>Nível de Acesso</label>
<div className="flex gap-2">
<button
onClick={() => setEditingUser({...editingUser, role: 'user'})}
className={`flex-1 py-2 px-3 rounded-xl border text-sm font-medium transition-all flex items-center justify-center gap-2 ${
editingUser.role === 'user' ? 'bg-primary-50 border-primary-200 text-primary-700' : 'bg-white border-slate-200 text-slate-500'
}`}
>
<UserIcon size={16}/> Usuário
</button>
<button
onClick={() => setEditingUser({...editingUser, role: 'super_admin'})}
className={`flex-1 py-2 px-3 rounded-xl border text-sm font-medium transition-all flex items-center justify-center gap-2 ${
editingUser.role === 'super_admin' ? 'bg-indigo-50 border-indigo-200 text-indigo-700' : 'bg-white border-slate-200 text-slate-500'
}`}
>
<Shield size={16}/> Admin
</button>
</div>
</div>
<div className="col-span-2 md:col-span-1">
<label className={labelClass}>Status da Conta</label>
<div className="flex gap-2">
<button
onClick={() => setEditingUser({...editingUser, active: true})}
className={`flex-1 py-2 px-3 rounded-xl border text-sm font-medium transition-all flex items-center justify-center gap-2 ${
editingUser.active ? 'bg-green-50 border-green-200 text-green-700' : 'bg-white border-slate-200 text-slate-500'
}`}
>
<CheckCircle2 size={16}/> Ativo
</button>
<button
onClick={() => setEditingUser({...editingUser, active: false})}
className={`flex-1 py-2 px-3 rounded-xl border text-sm font-medium transition-all flex items-center justify-center gap-2 ${
!editingUser.active ? 'bg-red-50 border-red-200 text-red-700' : 'bg-white border-slate-200 text-slate-500'
}`}
>
<XCircle size={16}/> Bloqueado
</button>
</div>
</div>
</div>
{/* Permissions Area */}
<div className="bg-slate-50 rounded-2xl p-6 border border-slate-100">
<h4 className="font-bold text-slate-800 mb-4 flex items-center gap-2">
<Lock size={18} className="text-slate-400"/>
Permissões de Acesso
</h4>
{editingUser.role === 'super_admin' ? (
<div className="text-center py-6 text-indigo-600 bg-indigo-50 rounded-xl border border-indigo-100">
<ShieldAlert size={32} className="mx-auto mb-2"/>
<p className="font-bold">Acesso Irrestrito</p>
<p className="text-xs opacity-75">Super Admins têm acesso a todos os módulos.</p>
</div>
) : (
<div className="space-y-1">
{availableModules.map(module => (
<ToggleSwitch
key={module.id}
label={module.label}
checked={(editingUser.permissions || []).includes(module.id)}
onChange={() => togglePermission(module.id)}
/>
))}
</div>
)}
</div>
</div>
<div className="px-8 py-5 bg-slate-50 border-t border-slate-100 flex justify-end gap-3">
<button onClick={() => setIsModalOpen(false)} className="px-4 py-2 text-slate-600 font-medium hover:bg-slate-200 rounded-xl">Cancelar</button>
<button onClick={handleSaveUser} className="px-6 py-2 bg-primary-500 text-white font-bold rounded-xl hover:bg-primary-600 flex items-center gap-2 shadow-lg shadow-primary-200">
<Save size={18} /> Salvar Usuário
</button>
</div>
</div>
</div>
)}
</div>
);
};

195
contexts/ComFiContext.tsx Normal file
View File

@@ -0,0 +1,195 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { Company, Expense, Receivable, Service, AppUser, Client, FinancialSummary, TenantProfile, Category, Proposal } from '../types';
import { useStickyState } from '../hooks/useStickyState';
// --- MOCK DATA GENERATORS (Moved from App.tsx) ---
const getDynamicDate = (daysOffset: number) => {
const date = new Date();
date.setDate(date.getDate() + daysOffset);
return date.toISOString().split('T')[0];
};
const initialServices: Service[] = [
{ id: '1', name: 'Consultoria Financeira', category: 'Consultoria', price: 2500, active: true, description: 'Análise completa.', billingType: 'one-time' },
{ id: '2', name: 'Gestão de Mídias', category: 'Marketing', price: 1500, active: true, description: 'Postagens mensais.', billingType: 'recurring' },
{ id: '3', name: 'Suporte TI', category: 'TI', price: 800, active: true, description: 'SLA 24h.', billingType: 'recurring' },
{ id: '4', name: 'Desenvolvimento Web', category: 'Tecnologia', price: 5000, active: true, description: 'Site institucional.', billingType: 'one-time' },
];
const initialCompanies: Company[] = [
{
id: '1', name: 'Uda Studios Tecnologia LTDA', fantasyName: 'Uda Studios', cnpj: '12.345.678/0001-90', ie: '123.456.789.000', city: 'São Paulo - SP', logo: 'https://i.pravatar.cc/150?u=uda', status: 'active', industry: 'Tecnologia', email: 'financeiro@uda.com', phone: '(11) 3344-5566', address: 'Av. Paulista, 1000, Bela Vista', since: '2022', description: 'Estúdio de software.', contacts: [{ id: 'c1', name: 'Roberto Silva', role: 'CEO', email: 'roberto@uda.com', phone: '11999998888', avatar: 'https://i.pravatar.cc/150?u=1' }], documents: [], activeServices: [initialServices[1], initialServices[3]]
},
{
id: '2', name: 'Angels Healthcare S.A.', fantasyName: 'Angels Health', cnpj: '98.765.432/0001-10', city: 'Rio de Janeiro - RJ', logo: 'https://i.pravatar.cc/150?u=angels', status: 'active', industry: 'Saúde', email: 'contato@angels.com', phone: '(21) 2233-4455', address: 'Rua da Saúde, 500', since: '2021', description: 'Rede de clínicas.', contacts: [], documents: [], activeServices: [initialServices[0], initialServices[2]]
},
{
id: '3', name: 'Padaria do João MEI', fantasyName: 'Padaria do João', cnpj: '11.111.111/0001-11', city: 'Belo Horizonte - MG', logo: 'https://i.pravatar.cc/150?u=joao', status: 'overdue', industry: 'Varejo', email: 'joao@padaria.com', phone: '(31) 3333-3333', address: 'Rua do Pão, 10', since: '2023', description: 'Padaria artesanal.', contacts: [], documents: [], activeServices: [initialServices[1]]
}
];
const initialExpenses: Expense[] = [
{ id: 'e1', title: 'Aluguel Escritório', category: 'Operacional', amount: 3500, dueDate: getDynamicDate(-30), status: 'paid', type: 'fixed' },
{ id: 'e2', title: 'Servidor AWS', category: 'TI', amount: 850, dueDate: getDynamicDate(-28), status: 'paid', type: 'variable' },
{ id: 'e3', title: 'Energia Elétrica', category: 'Operacional', amount: 450, dueDate: getDynamicDate(-2), status: 'paid', type: 'variable' },
{ id: 'e4', title: 'Licença Software CRM', category: 'TI', amount: 200, dueDate: getDynamicDate(5), status: 'pending', type: 'fixed' },
{ id: 'e5', title: 'Folha de Pagamento', category: 'Pessoal', amount: 15000, dueDate: getDynamicDate(10), status: 'pending', type: 'fixed' },
{ id: 'e6', title: 'DAS Simples Nacional', category: 'Impostos', amount: 1200, dueDate: getDynamicDate(15), status: 'pending', type: 'variable' },
{ id: 'e7', title: 'Aluguel Escritório', category: 'Operacional', amount: 3500, dueDate: getDynamicDate(30), status: 'pending', type: 'fixed' },
];
const initialProposals: Proposal[] = [
{
id: 'p1', number: 'PROP-001', clientId: '1', clientName: 'Uda Studios', clientEmail: 'roberto@uda.com',
issueDate: getDynamicDate(-2), validUntil: getDynamicDate(5), status: 'sent', totalValue: 7500,
items: [
{ id: 'i1', description: 'Consultoria Financeira', quantity: 1, unitPrice: 2500, total: 2500 },
{ id: 'i2', description: 'Desenvolvimento Web', quantity: 1, unitPrice: 5000, total: 5000 }
],
notes: 'Pagamento 50% na entrada e 50% na entrega.'
}
];
const initialSuperAdmin: AppUser = { id: 'u1', name: 'Nella Vita', email: 'admin@comfi.com', role: 'super_admin', active: true, avatar: 'https://i.pravatar.cc/150?u=admin', permissions: [] };
const initialUsers: AppUser[] = [
initialSuperAdmin,
{ id: 'u2', name: 'João Comercial', email: 'vendas@comfi.com', role: 'user', active: true, avatar: 'https://i.pravatar.cc/150?u=joao', permissions: ['crm', 'kanban', 'calendar', 'proposals'] },
{ id: 'u3', name: 'Maria Financeiro', email: 'fin@comfi.com', role: 'user', active: true, avatar: 'https://i.pravatar.cc/150?u=maria', permissions: ['dashboard', 'receivables', 'payables', 'invoicing'] }
];
const initialTenant: TenantProfile = {
name: 'Minha Empresa S.A.',
cnpj: '00.000.000/0001-00',
email: 'contato@minhaempresa.com',
phone: '(11) 99999-9999',
address: 'Av. Brigadeiro Faria Lima, 1234, SP',
logo: '',
primaryColor: '#f97316'
};
const initialCategories: Category[] = [
{ id: 'c1', name: 'Operacional', type: 'expense', color: 'bg-red-50 text-red-600' },
{ id: 'c2', name: 'Administrativo', type: 'expense', color: 'bg-blue-50 text-blue-600' },
{ id: 'c3', name: 'Marketing', type: 'expense', color: 'bg-purple-50 text-purple-600' },
{ id: 'c4', name: 'Pessoal', type: 'expense', color: 'bg-yellow-50 text-yellow-600' },
{ id: 'c5', name: 'Impostos', type: 'expense', color: 'bg-slate-50 text-slate-600' },
{ id: 'c6', name: 'Serviços', type: 'income', color: 'bg-green-50 text-green-600' },
];
const generateInitialReceivables = (companies: Company[]): Receivable[] => {
const receivables: Receivable[] = [];
companies.forEach((company, cIndex) => {
company.activeServices.forEach((service, sIndex) => {
receivables.push({ id: `r-${company.id}-${sIndex}-past`, description: service.name, companyName: company.fantasyName || company.name, category: service.category, value: service.price, dueDate: getDynamicDate(-30 - (cIndex * 2)), status: 'paid', type: service.billingType === 'recurring' ? 'recurring' : 'one-time' });
const isOverdue = company.status === 'overdue' && sIndex === 0;
receivables.push({ id: `r-${company.id}-${sIndex}-curr`, description: service.name, companyName: company.fantasyName || company.name, category: service.category, value: service.price, dueDate: getDynamicDate(isOverdue ? -5 : 10 + (cIndex * 3)), status: isOverdue ? 'overdue' : 'pending', type: service.billingType === 'recurring' ? 'recurring' : 'one-time' });
if (service.billingType === 'recurring') {
receivables.push({ id: `r-${company.id}-${sIndex}-next`, description: service.name, companyName: company.fantasyName || company.name, category: service.category, value: service.price, dueDate: getDynamicDate(40 + (cIndex * 2)), status: 'pending', type: 'recurring' });
}
});
});
return receivables;
};
// --- CONTEXT INTERFACE ---
interface ComFiContextData {
services: Service[];
setServices: (data: Service[]) => void;
companies: Company[];
setCompanies: (data: Company[]) => void;
expenses: Expense[];
setExpenses: (data: Expense[]) => void;
clients: Client[];
setClients: (data: Client[]) => void;
receivables: Receivable[];
setReceivables: React.Dispatch<React.SetStateAction<Receivable[]>>;
proposals: Proposal[];
setProposals: (data: Proposal[]) => void;
currentUser: AppUser;
setCurrentUser: (user: AppUser) => void;
users: AppUser[];
setUsers: (users: AppUser[]) => void;
financialSummary: FinancialSummary;
addReceivable: (receivable: Receivable) => void;
tenant: TenantProfile;
setTenant: (tenant: TenantProfile) => void;
categories: Category[];
setCategories: (categories: Category[]) => void;
}
const ComFiContext = createContext<ComFiContextData>({} as ComFiContextData);
export const ComFiProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
// State Initialization
const [services, setServices] = useStickyState<Service[]>(initialServices, 'comfi_services');
const [companies, setCompanies] = useStickyState<Company[]>(initialCompanies, 'comfi_companies');
const [expenses, setExpenses] = useStickyState<Expense[]>(initialExpenses, 'comfi_expenses');
const [clients, setClients] = useStickyState<Client[]>([], 'comfi_clients');
const [currentUser, setCurrentUser] = useStickyState<AppUser>(initialSuperAdmin, 'comfi_current_user');
const [users, setUsers] = useStickyState<AppUser[]>(initialUsers, 'comfi_users');
const [tenant, setTenant] = useStickyState<TenantProfile>(initialTenant, 'comfi_tenant');
const [categories, setCategories] = useStickyState<Category[]>(initialCategories, 'comfi_categories');
const [proposals, setProposals] = useStickyState<Proposal[]>(initialProposals, 'comfi_proposals');
const [receivables, setReceivables] = useState<Receivable[]>(() => {
try {
const saved = window.localStorage.getItem('comfi_receivables');
if (saved) return JSON.parse(saved);
return generateInitialReceivables(initialCompanies);
} catch (e) {
return generateInitialReceivables(initialCompanies);
}
});
useEffect(() => {
window.localStorage.setItem('comfi_receivables', JSON.stringify(receivables));
}, [receivables]);
// Derived State: Financial Summary
const financialSummary = useMemo(() => {
const currentMonth = new Date().getMonth();
const currentYear = new Date().getFullYear();
const isCurrentMonth = (dateStr: string) => dateStr.startsWith(`${currentYear}-${String(currentMonth + 1).padStart(2, '0')}`);
const currentReceivables = receivables.filter(r => isCurrentMonth(r.dueDate));
const currentExpenses = expenses.filter(e => isCurrentMonth(e.dueDate));
const totalRevenue = currentReceivables.reduce((acc, r) => acc + r.value, 0);
const totalExpenses = currentExpenses.reduce((acc, expense) => acc + expense.amount, 0);
const profit = totalRevenue - totalExpenses;
const payablePending = expenses.filter(e => e.status !== 'paid').reduce((acc, expense) => acc + expense.amount, 0);
const receivablePending = receivables.filter(r => r.status !== 'paid').reduce((acc, r) => acc + r.value, 0);
return { totalRevenue, totalExpenses, profit, payablePending, receivablePending };
}, [receivables, expenses]);
const addReceivable = (newRec: Receivable) => {
setReceivables(prev => [...prev, newRec]);
};
return (
<ComFiContext.Provider value={{
services, setServices,
companies, setCompanies,
expenses, setExpenses,
clients, setClients,
receivables, setReceivables,
currentUser, setCurrentUser,
users, setUsers,
financialSummary,
addReceivable,
tenant, setTenant,
categories, setCategories,
proposals, setProposals
}}>
{children}
</ComFiContext.Provider>
);
};
export const useComFi = () => {
const context = useContext(ComFiContext);
if (!context) throw new Error('useComFi must be used within a ComFiProvider');
return context;
};

82
contexts/ToastContext.tsx Normal file
View File

@@ -0,0 +1,82 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
import { Toast } from '../types';
import { X, CheckCircle2, AlertCircle, Info, AlertTriangle } from 'lucide-react';
interface ToastContextData {
addToast: (toast: Omit<Toast, 'id'>) => void;
removeToast: (id: string) => void;
}
const ToastContext = createContext<ToastContextData>({} as ToastContextData);
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [toasts, setToasts] = useState<Toast[]>([]);
const addToast = useCallback(({ type, title, message, duration = 4000 }: Omit<Toast, 'id'>) => {
const id = Math.random().toString(36).substring(2, 9);
const newToast = { id, type, title, message, duration };
setToasts((state) => [...state, newToast]);
if (duration > 0) {
setTimeout(() => {
removeToast(id);
}, duration);
}
}, []);
const removeToast = useCallback((id: string) => {
setToasts((state) => state.filter((toast) => toast.id !== id));
}, []);
return (
<ToastContext.Provider value={{ addToast, removeToast }}>
{children}
{/* Toast Container Rendered Here Globally */}
<div className="fixed top-4 right-4 z-[9999] flex flex-col gap-2 pointer-events-none">
{toasts.map((toast) => (
<div
key={toast.id}
className={`pointer-events-auto min-w-[300px] max-w-sm rounded-xl p-4 shadow-xl border flex items-start gap-3 animate-slide-in-right transform transition-all duration-300 ${
toast.type === 'success' ? 'bg-white border-green-100' :
toast.type === 'error' ? 'bg-white border-red-100' :
toast.type === 'warning' ? 'bg-white border-amber-100' :
'bg-white border-blue-100'
}`}
>
<div className={`mt-0.5 ${
toast.type === 'success' ? 'text-green-500' :
toast.type === 'error' ? 'text-red-500' :
toast.type === 'warning' ? 'text-amber-500' :
'text-blue-500'
}`}>
{toast.type === 'success' && <CheckCircle2 size={20} />}
{toast.type === 'error' && <AlertCircle size={20} />}
{toast.type === 'warning' && <AlertTriangle size={20} />}
{toast.type === 'info' && <Info size={20} />}
</div>
<div className="flex-1">
<h4 className={`text-sm font-bold ${
toast.type === 'success' ? 'text-green-800' :
toast.type === 'error' ? 'text-red-800' :
toast.type === 'warning' ? 'text-amber-800' :
'text-blue-800'
}`}>{toast.title}</h4>
{toast.message && <p className="text-xs text-slate-500 mt-1 leading-relaxed">{toast.message}</p>}
</div>
<button onClick={() => removeToast(toast.id)} className="text-slate-400 hover:text-slate-600">
<X size={16} />
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
};
export const useToast = () => {
const context = useContext(ToastContext);
if (!context) throw new Error('useToast must be used within a ToastProvider');
return context;
};

18
hooks/useStickyState.ts Normal file
View File

@@ -0,0 +1,18 @@
import React, { useState, useEffect } from 'react';
export const useStickyState = <T,>(defaultValue: T, key: string): [T, React.Dispatch<React.SetStateAction<T>>] => {
const [value, setValue] = useState<T>(() => {
try {
const stickyValue = window.localStorage.getItem(key);
return stickyValue !== null ? JSON.parse(stickyValue) : defaultValue;
} catch (e) {
return defaultValue;
}
});
useEffect(() => {
window.localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
};

81
index.html Normal file
View File

@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ComFi</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
@font-face {
font-family: 'Akzidenz Grotesk';
src: local('Akzidenz Grotesk'), local('AkzidenzGrotesk'), local('Helvetica Neue'), local('Helvetica'), local('Arial'), sans-serif;
}
body {
font-family: 'Akzidenz Grotesk', 'Helvetica Neue', Helvetica, Arial, sans-serif;
background-color: #F8FAFC; /* Slate 50 */
}
/* Custom scrollbar for a cleaner look */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #CBD5E1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #94A3B8;
}
</style>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['Akzidenz Grotesk', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif'],
},
colors: {
primary: {
50: '#fff7ed',
100: '#ffedd5',
200: '#fed7aa',
300: '#fdba74',
400: '#fb923c',
500: '#f97316', // Orange-500 main accent
600: '#ea580c',
},
secondary: {
900: '#0f172a', // Dark slate for text
800: '#1e293b',
}
},
borderRadius: {
'xl': '1rem',
'2xl': '1.5rem',
'3xl': '2rem',
}
}
}
}
</script>
<script type="importmap">
{
"imports": {
"react-dom/": "https://esm.sh/react-dom@^19.2.3/",
"lucide-react": "https://esm.sh/lucide-react@^0.562.0",
"react/": "https://esm.sh/react@^19.2.3/",
"react": "https://esm.sh/react@^19.2.3",
"recharts": "https://esm.sh/recharts@^3.6.0",
"@google/genai": "https://esm.sh/@google/genai@^1.39.0"
}
}
</script>
</head>
<body>
<div id="root"></div>
</body>
</html>

15
index.tsx Normal file
View File

@@ -0,0 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

5
metadata.json Normal file
View File

@@ -0,0 +1,5 @@
{
"name": "ComFi",
"description": "Sistema completo de gestão empresarial com relatórios contábeis (BP, DFC, DVA) e dashboard em tempo real.",
"requestFramePermissions": []
}

24
package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "comfi",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react-dom": "^19.2.3",
"lucide-react": "^0.562.0",
"react": "^19.2.3",
"recharts": "^3.6.0",
"@google/genai": "^1.39.0"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

29
tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

194
types.ts Normal file
View File

@@ -0,0 +1,194 @@
export type ViewState =
| 'dashboard'
| 'crm'
| 'kanban'
| 'invoicing'
| 'services'
| 'receivables'
| 'payables'
| 'proposals' // Novo módulo
| 'user'
| 'calendar'
| 'todolist'
| 'chat'
| 'activity'
| 'settings';
export type FinancialReportType = 'DRE' | 'BP' | 'DFC' | 'DLPA' | 'DMPL' | 'DRA' | 'DVA';
export interface FinancialSummary {
totalRevenue: number;
totalExpenses: number;
profit: number;
payablePending: number;
receivablePending: number;
}
export interface AppUser {
id: string;
name: string;
email: string;
role: 'super_admin' | 'user';
avatar?: string;
permissions: ViewState[];
active: boolean;
}
export interface TenantProfile {
name: string;
cnpj: string;
email: string;
phone: string;
address: string;
logo: string;
primaryColor: string;
}
export interface Category {
id: string;
name: string;
type: 'income' | 'expense';
color: string;
}
export interface ContactPerson {
id: string;
name: string;
role: string;
email: string;
phone: string;
avatar?: string;
}
export interface CompanyDocument {
id: string;
title: string;
type: 'contract' | 'briefing' | 'invoice' | 'other';
date: string;
size: string;
}
export interface Service {
id: string;
name: string;
category: string;
price: number;
active: boolean;
description?: string;
billingType: 'one-time' | 'recurring';
}
export interface Company {
id: string;
name: string;
fantasyName: string;
cnpj: string;
ie?: string;
city: string;
logo: string;
status: 'active' | 'inactive' | 'pending' | 'overdue';
industry: string;
email: string;
phone: string;
address: string;
website?: string;
since: string;
description?: string;
contacts: ContactPerson[];
documents: CompanyDocument[];
activeServices: Service[];
}
export interface Receivable {
id: string;
description: string;
companyName: string;
category: string;
value: number;
dueDate: string;
status: 'paid' | 'pending' | 'overdue';
type: 'recurring' | 'one-time';
}
export interface Expense {
id: string;
title: string;
category: string;
amount: number;
dueDate: string;
status: 'paid' | 'pending' | 'overdue';
type: 'fixed' | 'variable';
description?: string;
}
export interface ProposalItem {
id: string;
serviceId?: string; // Opcional, caso seja item avulso
description: string;
quantity: number;
unitPrice: number;
total: number;
}
export interface Proposal {
id: string;
number: string; // Ex: PROP-2024-001
clientId: string;
clientName: string;
clientEmail?: string;
issueDate: string;
validUntil: string;
status: 'draft' | 'sent' | 'accepted' | 'rejected';
items: ProposalItem[];
totalValue: number;
notes?: string;
}
export interface CalendarEvent {
id: string;
title: string;
date: string;
type: 'meeting' | 'deadline' | 'payment';
description?: string;
completed: boolean;
}
export interface KanbanTask {
id: string;
title: string;
priority: 'low' | 'medium' | 'high';
dueDate: string;
assignee?: string;
description?: string;
value?: number;
clientId?: string;
}
export interface KanbanColumn {
id: string;
title: string;
tasks: KanbanTask[];
}
export interface Client {
id: string;
name: string;
company: string;
email: string;
phone: string;
status?: 'active' | 'pending' | 'inactive';
lastActivity?: string;
value?: number;
address?: string;
role?: string;
avatar?: string;
}
export interface Toast {
id: string;
type: 'success' | 'error' | 'info' | 'warning';
title: string;
message?: string;
duration?: number;
}

23
vite.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
server: {
port: 3000,
host: '0.0.0.0',
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});