diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -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? diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000..edbe5b8 --- /dev/null +++ b/App.tsx @@ -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 = ({ icon: Icon, label, active, onClick, collapsed = false }) => ( + +); + +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('dashboard'); + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [isSearchOpen, setIsSearchOpen] = useState(false); + const searchRef = useRef(null); + const userMenuRef = useRef(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 ( +
+ + + {/* Sidebar */} + + + {/* Main Area */} +
+
+
+ +
+ + { 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 && ( +
+ {searchResults.map(r => ( +
{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"> +
+
{r.title}
{r.subtitle}
+
+ ))} +
+ )} +
+
+
+ +
+
setIsUserMenuOpen(!isUserMenuOpen)}> +
{currentUser.name}
{currentUser.role === 'super_admin' ? 'Administrator' : 'User'}
+
User
+ + {isUserMenuOpen && ( +
+

{currentUser.name}

{currentUser.email}

+ {users.map(u => ())} +
+ + +
+ )} +
+
+
+ +
+
+

{getPageTitle(currentView)}

Bem-vindo ao ComFi Dashboard.

+ {(() => { + const hasAccess = currentUser.role === 'super_admin' || currentUser.permissions.includes(currentView) || currentView === 'settings'; + if (!hasAccess && currentView !== 'dashboard' && currentView !== 'user') return

Acesso Restrito

; + + switch(currentView) { + case 'dashboard': return ; + case 'crm': return ; + case 'proposals': return ; + case 'services': return ; + case 'receivables': return ; + case 'payables': return ; + case 'calendar': return ; + case 'kanban': return ; + case 'invoicing': return ; + case 'user': return currentUser.role === 'super_admin' ? : null; + case 'settings': return ; + default: return
Em desenvolvimento.
; + } + })()} +
+
+
+ + {/* Mobile Menu */} + {isMobileMenuOpen && ( +
+
+
+

ComFi.

+
+ {allowedMenuItems.map((item) => ( {setCurrentView(item.id); toggleMobileMenu();}} />))} + {setCurrentView('settings'); toggleMobileMenu();}} /> + {currentUser.role === 'super_admin' && {setCurrentView('user'); toggleMobileMenu();}} />} +
+
+
+ )} +
+ ); +}; + +const App: React.FC = () => { + return ( + + + + + + ); +}; + +export default App; diff --git a/README.md b/README.md index 2241000..5b25c66 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,20 @@
- GHBanner - -

Built with AI Studio

- -

The fastest path from prompt to production with Gemini.

- - Start building -
+ +# 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` diff --git a/components/AIChatAssistant.tsx b/components/AIChatAssistant.tsx new file mode 100644 index 0000000..a1f310d --- /dev/null +++ b/components/AIChatAssistant.tsx @@ -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 = ({ userName, contextData }) => { + const [isOpen, setIsOpen] = useState(false); + const [isTyping, setIsTyping] = useState(false); + const [inputValue, setInputValue] = useState(''); + const [messages, setMessages] = useState([ + { + 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(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 ( +
+ + {parseBold(content)} +
+ ); + } + + // Processar linha vazia + if (line.trim() === '') { + return
; + } + + // Parágrafo normal + return

{parseBold(line)}

; + }); + }; + + const parseBold = (text: string) => { + return text.split(/(\*\*.*?\*\*)/).map((part, i) => { + if (part.startsWith('**') && part.endsWith('**')) { + return {part.slice(2, -2)}; + } + 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 */} + + + {/* Chat Window */} +
+ {/* Header */} +
+
+ +
+
+

+ ComFi Especialista AI +

+

+ Consultor Online +

+
+ +
+ + {/* Messages Area */} +
+ {messages.map((msg) => ( +
+
+ {msg.role === 'user' ? : } +
+ +
+ {msg.role === 'assistant' ? ( +
+ {renderFormattedText(msg.text)} +
+ ) : ( + msg.text + )} + +
+ {msg.timestamp.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} +
+
+
+ ))} + + {isTyping && ( +
+
+ +
+
+ + + +
+
+ )} +
+
+ + {/* Input Area */} +
+ 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" + /> + +
+
+ + ); +}; \ No newline at end of file diff --git a/components/AIInsightsWidget.tsx b/components/AIInsightsWidget.tsx new file mode 100644 index 0000000..1e0780a --- /dev/null +++ b/components/AIInsightsWidget.tsx @@ -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 = ({ 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 ( +
+ +
+
+
+ +
+
+

AI Insights

+

Análise financeira inteligente

+
+
+ +
+ +
+ {isLoading ? ( + // Skeleton Loading Style + [1, 2, 3].map(i => ( +
+
+
+
+
+ )) + ) : ( + insights.map((insight, idx) => ( +
+
+ {insight.type === 'success' && } + {insight.type === 'warning' && } + {insight.type === 'danger' && } + {insight.type === 'info' && } + +

{insight.title}

+
+

+ {insight.message} +

+
+ )) + )} +
+
+ ); +}; \ No newline at end of file diff --git a/components/AccountsPayableView.tsx b/components/AccountsPayableView.tsx new file mode 100644 index 0000000..1ffbfa1 --- /dev/null +++ b/components/AccountsPayableView.tsx @@ -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 = ({ expenses, setExpenses }) => { + const { addToast } = useToast(); + const [isModalOpen, setIsModalOpen] = useState(false); + const [filterType, setFilterType] = useState<'all' | 'fixed' | 'variable'>('all'); + + const [newExpense, setNewExpense] = useState>({ + type: 'fixed', + status: 'pending', + dueDate: new Date().toISOString().split('T')[0] + }); + + const [editingId, setEditingId] = useState(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 ( +
+ + {/* KPI Cards */} +
+
+
+ +
+
+

Total Previsto

+

R$ {totalPayable.toLocaleString('pt-BR')}

+
+
+
+
+ +
+
+

Total Pago

+

R$ {totalPaid.toLocaleString('pt-BR')}

+
+
+
+
+ +
+
+

A Pagar / Pendente

+

R$ {totalPending.toLocaleString('pt-BR')}

+
+
+
+ +
+
+

Contas a Pagar

+

Gerencie despesas fixas e variáveis.

+
+ +
+ +
+ {/* Toolbar */} +
+
+ + +
+
+ + + +
+
+ + {/* Table */} +
+ + + + + + + + + + + + + + {filteredExpenses.map(expense => ( + + + + + + + + + + ))} + +
DescriçãoCategoriaVencimentoValorTipoStatus
{expense.title} + {expense.category} + +
+ + {new Date(expense.dueDate).toLocaleDateString('pt-BR')} +
+
R$ {expense.amount.toLocaleString('pt-BR')} + + {expense.type === 'fixed' ? 'Fixa' : 'Variável'} + + + + +
+ + +
+
+ {filteredExpenses.length === 0 && ( +
+ +

Nenhuma despesa encontrada.

+
+ )} +
+
+ + {/* Modal */} + {isModalOpen && ( +
+
setIsModalOpen(false)}>
+
+
+

{editingId ? 'Editar Despesa' : 'Nova Despesa'}

+ +
+ +
+
+ + setNewExpense({...newExpense, title: e.target.value})} + /> +
+ +
+
+ + setNewExpense({...newExpense, amount: Number(e.target.value)})} + /> +
+
+ + setNewExpense({...newExpense, dueDate: e.target.value})} + /> +
+
+ +
+
+ + 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' }, + ]} + /> +
+
+ + setNewExpense({...newExpense, type: val})} + options={[ + { value: 'fixed', label: 'Fixa' }, + { value: 'variable', label: 'Variável' }, + ]} + /> +
+
+ + +
+
+
+ )} +
+ ); +}; diff --git a/components/AccountsReceivableView.tsx b/components/AccountsReceivableView.tsx new file mode 100644 index 0000000..3eab8b8 --- /dev/null +++ b/components/AccountsReceivableView.tsx @@ -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>; +} + +export const AccountsReceivableView: React.FC = ({ 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>({ + type: 'one-time', + status: 'pending', + dueDate: new Date().toISOString().split('T')[0] + }); + + const [editingId, setEditingId] = useState(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 ( +
+ + {/* KPI Cards */} +
+
+
+ +
+
+

Receita Total Prevista

+

R$ {totalReceivable.toLocaleString('pt-BR')}

+
+
+
+
+ +
+
+

Recebido

+

R$ {totalReceived.toLocaleString('pt-BR')}

+
+
+
+
+ +
+
+

A Receber

+

R$ {totalPending.toLocaleString('pt-BR')}

+
+
+
+ +
+
+

Contas a Receber

+

Gestão de faturas, contratos e recebimentos avulsos.

+
+
+ + +
+
+ +
+ {/* Toolbar */} +
+
+ + +
+
+ + + +
+
+ + {/* Table */} +
+ + + + + + + + + + + + + + {filteredList.map(item => ( + + + + + + + + + + ))} + +
Descrição / ClienteCategoriaVencimentoValorTipoStatus
+
{item.description}
+
{item.companyName}
+
+ {item.category} + +
+ + {new Date(item.dueDate).toLocaleDateString('pt-BR')} +
+
R$ {item.value.toLocaleString('pt-BR')} + + {item.type === 'recurring' ? 'Mensal' : 'Avulso'} + + + + +
+ + +
+
+ {filteredList.length === 0 && ( +
+ +

Nenhum lançamento encontrado.

+
+ )} +
+
+ + {/* Modal */} + {isModalOpen && ( +
+
setIsModalOpen(false)}>
+
+
+

{editingId ? 'Editar Recebimento' : 'Novo Recebimento'}

+ +
+ +
+
+ + setNewReceivable({...newReceivable, description: e.target.value})} + /> +
+ +
+ + setNewReceivable({...newReceivable, companyName: e.target.value})} + /> +
+ +
+
+ + setNewReceivable({...newReceivable, value: Number(e.target.value)})} + /> +
+
+ + setNewReceivable({...newReceivable, dueDate: e.target.value})} + /> +
+
+ +
+
+ + setNewReceivable({...newReceivable, category: val})} + options={[ + { value: 'Serviços', label: 'Serviços' }, + { value: 'Produtos', label: 'Produtos' }, + { value: 'Reembolso', label: 'Reembolso' }, + { value: 'Outros', label: 'Outros' } + ]} + /> +
+
+ + setNewReceivable({...newReceivable, type: val})} + options={[ + { value: 'one-time', label: 'Avulso' }, + { value: 'recurring', label: 'Recorrente' } + ]} + /> +
+
+ + +
+
+
+ )} +
+ ); +}; diff --git a/components/CRMView.tsx b/components/CRMView.tsx new file mode 100644 index 0000000..b18ad17 --- /dev/null +++ b/components/CRMView.tsx @@ -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 ( +
+
+
+
+

{title}

+ +
+
{children}
+
+
+ ); +}; + +export const CRMView: React.FC = ({ companies, setCompanies, availableServices }) => { + const { addToast } = useToast(); + const [viewMode, setViewMode] = useState<'list' | 'detail'>('list'); + const [selectedCompanyId, setSelectedCompanyId] = useState(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(null); + + // Forms State + const [newCompany, setNewCompany] = useState>({ status: 'active', industry: 'Outro', logo: '' }); + const [overviewForm, setOverviewForm] = useState>({}); + const [contactForm, setContactForm] = useState>({}); + const [selectedServiceId, setSelectedServiceId] = useState(''); + + // Refs for File Uploads + const logoInputRef = useRef(null); + const avatarInputRef = useRef(null); + const docInputRef = useRef(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, 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) => { + 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 ( +
+ setIsCompanyModalOpen(false)} title="Nova Empresa" maxWidth="max-w-2xl"> +
+ {/* Logo Upload */} +
+
logoInputRef.current?.click()} + > + {newCompany.logo ? ( + Logo + ) : ( + + )} +
+ Alterar +
+
+ Clique para adicionar logo + handleFileChange(e, (base64) => setNewCompany({...newCompany, logo: base64}))} + /> +
+ +
+ + setNewCompany({...newCompany, name: e.target.value})} placeholder="Ex: Tech Solutions LTDA" /> +
+
+ + setNewCompany({...newCompany, fantasyName: e.target.value})} placeholder="Ex: Tech Sol" /> +
+
+ + setNewCompany({...newCompany, cnpj: e.target.value})} placeholder="00.000.000/0001-00" /> +
+
+ + setNewCompany({...newCompany, ie: e.target.value})} /> +
+
+ + setNewCompany({...newCompany, city: e.target.value})} /> +
+
+ + setNewCompany({...newCompany, phone: e.target.value})} /> +
+
+ + setNewCompany({...newCompany, email: e.target.value})} /> +
+
+ + setNewCompany({...newCompany, address: e.target.value})} /> +
+
+ +
+ + +
+
+
+ +
+
+
+ +
+
+

Clientes & Empresas

+

Gerencie contratos, responsáveis e documentos.

+
+ +
+ +
+ {companies.map((company) => { + const totalValue = company.activeServices.reduce((acc, s) => acc + s.price, 0); + return ( +
{ 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' && ( +
+ EM ATRASO +
+ )} + +
+
+ {company.logo ? : company.name.substring(0,2)} +
+
+

{company.fantasyName || company.name}

+

{company.city || 'Local não informado'}

+
+ {company.status === 'active' && Ativo} + {company.status === 'pending' && Pendente} + {company.status === 'overdue' && Financeiro Pendente} +
+
+
+
+ {company.contacts.length} Resp. + R$ {totalValue.toLocaleString('pt-BR')} /mês +
+
+ ); + })} +
+
+ ); + } + + // DETALHE DA EMPRESA + if (selectedCompany) { + return ( +
+ + {/* Modal Contato (Novo/Edição) */} + setIsContactModalOpen(false)} title={editingContactId ? "Editar Responsável" : "Novo Responsável"}> +
+
+
avatarInputRef.current?.click()} + > + {contactForm.avatar ? ( + Avatar + ) : ( + + )} +
+ Foto +
+
+ handleFileChange(e, (base64) => setContactForm({...contactForm, avatar: base64}))} + /> +
+ +
+ + setContactForm({...contactForm, name: e.target.value})} /> +
+
+ + setContactForm({...contactForm, role: e.target.value})} /> +
+
+ + setContactForm({...contactForm, email: e.target.value})} /> +
+
+ + setContactForm({...contactForm, phone: e.target.value})} placeholder="(00) 00000-0000" /> +
+ +
+
+ + {/* Modal Adicionar Serviço */} + setIsServiceModalOpen(false)} title="Adicionar Serviço"> +
+ +
+ + +
+ +
+
+ + {/* Header Detalhe */} +
+ +
+

{selectedCompany.fantasyName || selectedCompany.name}

+

{selectedCompany.name} - {selectedCompany.cnpj}

+
+ {selectedCompany.status === 'overdue' && ( +
+ CLIENTE EM ATRASO +
+ )} +
+ + {/* Tabs */} +
+ {['overview', 'contacts', 'documents', 'services'].map(tab => ( + + ))} +
+ + {/* Tab Content */} +
+ {activeTab === 'overview' && ( +
+
+ {!isEditingOverview ? ( + + ) : ( +
+ + +
+ )} +
+ +
+
+

Dados Cadastrais

+
+
+ CNPJ + {isEditingOverview ? setOverviewForm({...overviewForm, cnpj: e.target.value})} /> : {selectedCompany.cnpj || '-'}} +
+
+ Inscrição Estadual + {isEditingOverview ? setOverviewForm({...overviewForm, ie: e.target.value})} /> : {selectedCompany.ie || 'Isento'}} +
+
+ Razão Social + {isEditingOverview ? setOverviewForm({...overviewForm, name: e.target.value})} /> : {selectedCompany.name}} +
+
+ Endereço + {isEditingOverview ? ( +
+ setOverviewForm({...overviewForm, address: e.target.value})} placeholder="Rua..." /> + setOverviewForm({...overviewForm, city: e.target.value})} placeholder="Cidade..." /> +
+ ) : ( + {selectedCompany.address}, {selectedCompany.city} + )} +
+
+
+
+

Contato Geral

+
+
+ + {isEditingOverview ? setOverviewForm({...overviewForm, phone: e.target.value})} /> : {selectedCompany.phone}} +
+
+ + {isEditingOverview ? setOverviewForm({...overviewForm, email: e.target.value})} /> : {selectedCompany.email}} +
+
+ + {isEditingOverview ? setOverviewForm({...overviewForm, website: e.target.value})} placeholder="Website" /> : {selectedCompany.website || 'Sem site'}} +
+
+
+
+
+ )} + + {activeTab === 'contacts' && ( +
+
+ +
+ {selectedCompany.contacts.length === 0 ? ( +
+ Nenhum responsável cadastrado. +
+ ) : ( +
+ {selectedCompany.contacts.map(contact => ( +
+
+
+ {contact.avatar ? : contact.name.charAt(0)} +
+
+

{contact.name}

+

{contact.role}

+

{contact.email}

+
+
+
+ + + +
+
+ ))} +
+ )} +
+ )} + + {activeTab === 'documents' && ( +
+ +
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" + > + +

Arraste arquivos aqui ou clique para anexar

+

PDFs, Contratos (DOCX), Briefings.

+
+ +
+ {selectedCompany.documents.map(doc => ( +
+
+
+ +
+
+

{doc.title}

+

{doc.date} • {doc.size}

+
+
+ +
+ ))} +
+
+ )} + + {activeTab === 'services' && ( +
+
+
+

Faturamento Total

+

R$ {clientRevenue.toLocaleString('pt-BR')} /mês

+
+ +
+ +
+ {selectedCompany.activeServices.map((service, idx) => ( +
+
+

{service.name}

+ {service.billingType === 'recurring' ? 'Recorrente' : 'Pontual'} +
+
+ R$ {service.price.toLocaleString('pt-BR')} + +
+
+ ))} +
+
+ )} +
+ +
+ ); + } + + return null; +}; diff --git a/components/CalendarView.tsx b/components/CalendarView.tsx new file mode 100644 index 0000000..de0af1b --- /dev/null +++ b/components/CalendarView.tsx @@ -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 = ({ expenses = [], receivables = [] }) => { + const { addToast } = useToast(); + const [currentDate, setCurrentDate] = useState(new Date()); + const [manualEvents, setManualEvents] = useState(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>({ type: 'meeting', date: new Date().toISOString().split('T')[0] }); + + // Detail Modal State + const [isDetailModalOpen, setIsDetailModalOpen] = useState(false); + const [selectedEvent, setSelectedEvent] = useState(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(
); + } + + 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( +
+
+ + {d} + + {dayEvents.length > 0 && {dayEvents.length}} +
+
+ {dayEvents.map(ev => ( +
{ 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} +
+ ))} +
+
+ ); + } + + // 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 ( +
+ + {/* Create Event Modal */} + {isModalOpen && ( +
+
setIsModalOpen(false)}>
+
+
+

Novo Evento

+ +
+
+
+ + setNewEvent({...newEvent, title: e.target.value})} + placeholder="Ex: Reunião com Cliente" + /> +
+
+
+ + setNewEvent({...newEvent, date: e.target.value})} + /> +
+
+ + setNewEvent({...newEvent, type: val})} + options={[ + { value: 'meeting', label: 'Reunião' }, + { value: 'deadline', label: 'Prazo / Tarefa' } + ]} + /> +
+
+
+ + +
+
+
+ + +
+
+
+ ); +}; + + +export const ManagementView: React.FC = ({ type, clientsData, setClientsData, servicesData, setServicesData }) => { + const { addToast } = useToast(); + + const [isClientModalOpen, setIsClientModalOpen] = useState(false); + const [editingClient, setEditingClient] = useState(null); + + const [isServiceModalOpen, setIsServiceModalOpen] = useState(false); + const [editingService, setEditingService] = useState(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 ( +
+ setIsClientModalOpen(false)} + client={editingClient} + onSave={handleSaveClient} + /> + setIsServiceModalOpen(false)} + service={editingService} + onSave={handleSaveService} + /> + +
+
+

+ {type === 'clients' ? 'Base de Clientes (Legado)' : 'Catálogo de Serviços'} +

+

+ {type === 'clients' ? 'Gestão simples de contatos.' : 'Gerencie preços e portfólio.'} +

+
+ +
+ +
+ {/* Toolbar */} +
+
+ + 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" + /> +
+
+ + {/* Table Content */} +
+ + + + + + + + + + + {type === 'services' && filteredServices.map(service => ( + + + + + + + ))} + + {type === 'clients' && filteredClients.map(client => ( + + + + + + + ))} + +
{type === 'clients' ? 'Nome / Empresa' : 'Serviço'}{type === 'clients' ? 'Contato' : 'Preço'}{type === 'clients' ? 'Status' : 'Tipo / Categoria'}
+
{service.name}
+ {service.description &&
{service.description}
} +
R$ {service.price.toLocaleString('pt-BR')} +
+ + {service.billingType === 'recurring' ? 'Assinatura' : 'Pontual'} + + + {service.category} + +
+
+
+ + +
+
+
{client.name}
+
{client.company}
+
+
{client.email}
+
{client.phone}
+
+ + Ativo + + +
+ + +
+
+ + {/* Empty State */} + {((type === 'services' && filteredServices.length === 0) || (type === 'clients' && filteredClients.length === 0)) && ( +
+ +

Nenhum registro encontrado.

+ +
+ )} +
+
+
+ ); +}; diff --git a/components/ProposalsView.tsx b/components/ProposalsView.tsx new file mode 100644 index 0000000..4808dde --- /dev/null +++ b/components/ProposalsView.tsx @@ -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>({ + 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 ( +
+ {/* Print Styles */} + + + {/* Header / Actions */} +
+ +
+ + +
+
+ +
+ + {/* Editor Panel (Left) */} +
+
+

Cliente & Detalhes

+
+
+ + setCurrentProposal({...currentProposal, clientId: val})} + placeholder="Selecione o Cliente" + options={companies.map(c => ({ value: c.id, label: c.fantasyName || c.name }))} + /> +
+
+
+ + setCurrentProposal({...currentProposal, issueDate: e.target.value})} /> +
+
+ + setCurrentProposal({...currentProposal, validUntil: e.target.value})} /> +
+
+
+ + setCurrentProposal({...currentProposal, status: val})} + options={[ + { value: 'draft', label: 'Rascunho' }, + { value: 'sent', label: 'Enviado' }, + { value: 'accepted', label: 'Aceito' }, + { value: 'rejected', label: 'Rejeitado' } + ]} + /> +
+
+
+ +
+

Adicionar Item

+
+
+ ({ value: s.id, label: `${s.name} (R$ ${s.price})` }))} + /> +
+ +
+
+ +
+

Notas & Observações

+ +
+
+ + {/* Preview Panel (Right) - The "Paper" */} +
+
+ + {/* Header Documento */} +
+
+ {tenant.logo ? ( + Logo + ) : ( +

{tenant.name}

+ )} +

{tenant.address}

+

{tenant.email} | {tenant.phone}

+
+
+

Proposta

+

{currentProposal.number || 'RASCUNHO'}

+

Data: {new Date(currentProposal.issueDate || '').toLocaleDateString('pt-BR')}

+

Válido até: {new Date(currentProposal.validUntil || '').toLocaleDateString('pt-BR')}

+
+
+ + {/* Info Cliente */} +
+

Preparado para:

+
+ {client ? ( + <> +

{client.fantasyName || client.name}

+

{client.address} - {client.city}

+

CNPJ: {client.cnpj}

+

{client.email}

+ + ) : ( +

Selecione um cliente...

+ )} +
+
+ + {/* Items Table */} +
+ + + + + + + + + + + + {(currentProposal.items || []).map((item) => ( + + + + + + + + ))} + {(currentProposal.items || []).length === 0 && ( + + )} + +
Descrição / ServiçoQtdValor Unit.Total
+ {item.description} + + handleUpdateItem(item.id, 'quantity', Number(e.target.value))} + /> + +
+ R$ + handleUpdateItem(item.id, 'unitPrice', Number(e.target.value))} + /> +
+
+ R$ {item.total.toLocaleString('pt-BR', { minimumFractionDigits: 2 })} + + +
Nenhum item adicionado
+
+ + {/* Totals */} +
+
+
+ Subtotal + R$ {calculateTotal(currentProposal.items || []).toLocaleString('pt-BR', { minimumFractionDigits: 2 })} +
+
+ Total Geral + R$ {calculateTotal(currentProposal.items || []).toLocaleString('pt-BR', { minimumFractionDigits: 2 })} +
+
+
+ + {/* Footer Notes */} +
+

Termos e Condições

+

{currentProposal.notes}

+
+ + {/* Signature Area */} +
+
+

{tenant.name}

+

Assinatura do Emissor

+
+
+

{client?.name || 'Cliente'}

+

De acordo

+
+
+ +
+
+
+
+ ); + } + + // LIST VIEW + return ( +
+
+
+

Propostas Comerciais

+

Crie orçamentos e gerencie negociações.

+
+ +
+ +
+ {/* Toolbar */} +
+
+ + 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" + /> +
+
+ + {/* Table */} +
+ + + + + + + + + + + + + {proposals.filter(p => p.clientName.toLowerCase().includes(searchTerm.toLowerCase()) || p.number.toLowerCase().includes(searchTerm.toLowerCase())).map(prop => ( + + + + + + + + + ))} + +
NúmeroClienteEmissãoValorStatus
{prop.number}{prop.clientName} +
+ + {new Date(prop.issueDate).toLocaleDateString('pt-BR')} +
+
R$ {prop.totalValue.toLocaleString('pt-BR')} + + {prop.status === 'accepted' ? 'Aceito' : prop.status === 'sent' ? 'Enviado' : prop.status === 'rejected' ? 'Rejeitado' : 'Rascunho'} + + +
+ + +
+
+ {proposals.length === 0 && ( +
+ +

Nenhuma proposta registrada.

+
+ )} +
+
+
+ ); +}; diff --git a/components/SettingsView.tsx b/components/SettingsView.tsx new file mode 100644 index 0000000..01cddf4 --- /dev/null +++ b/components/SettingsView.tsx @@ -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(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) => { + 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 ( +
+

Configurações & Personalização

+

Gerencie dados da empresa, categorias financeiras e preferências.

+ + {/* Tabs */} +
+ + + +
+ +
+ + {/* ORGANIZATION SETTINGS */} + {activeTab === 'company' && ( +
+
+
+
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 ? ( + Logo + ) : ( +
+ + Upload Logo +
+ )} +
+ +
+
+ +
+ +
+
+ + setOrgForm({...orgForm, name: e.target.value})} placeholder="Minha Empresa S.A." /> +
+
+ + setOrgForm({...orgForm, cnpj: e.target.value})} placeholder="00.000.000/0000-00" /> +
+
+ + setOrgForm({...orgForm, phone: e.target.value})} /> +
+
+ + setOrgForm({...orgForm, address: e.target.value})} placeholder="Rua, Número, Bairro, Cidade - UF" /> +
+
+ + setOrgForm({...orgForm, email: e.target.value})} /> +
+
+
+ +
+ +
+
+ )} + + {/* CATEGORIES SETTINGS */} + {activeTab === 'categories' && ( +
+
+
+ + setNewCategory({...newCategory, name: e.target.value})} placeholder="Ex: Transporte, Freelancers..." /> +
+
+ +
+ setNewCategory({...newCategory, type: val})} + options={[ + { value: 'expense', label: 'Despesa' }, + { value: 'income', label: 'Receita' } + ]} + /> +
+
+ +
+ +
+
+

Despesas

+
+ {categories.filter(c => c.type === 'expense').map(cat => ( +
+ {cat.name} + +
+ ))} +
+
+
+

Receitas

+
+ {categories.filter(c => c.type === 'income').map(cat => ( +
+ {cat.name} + +
+ ))} +
+
+
+
+ )} + + {/* SECURITY SETTINGS */} + {activeTab === 'security' && ( +
+
+
+
+ User +
+
+

{currentUser.name}

+

{currentUser.email}

+
+
+ +
+
+ +
+
+ + +
+ +
+
+
+
+ +
+

Notificações

+
+
+
+
+

Alertas por Email

+

Receba resumos semanais.

+
+
+
+ + +
+
+
+
+ )} + +
+
+ ); +}; diff --git a/components/UserManagementView.tsx b/components/UserManagementView.tsx new file mode 100644 index 0000000..dda3606 --- /dev/null +++ b/components/UserManagementView.tsx @@ -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 }) => ( +
+ {label} + +
+); + +export const UserManagementView: React.FC = ({ users, setUsers, availableModules, currentUser }) => { + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingUser, setEditingUser] = useState>({ + 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 ( +
+ +
+
+

Gerenciamento de Usuários

+

Controle de acesso e permissões do sistema.

+
+ +
+ +
+
+ + + + + + + + + + + + {users.map(user => ( + + + + + + + + ))} + +
UsuárioFunçãoStatusPermissões
+
+
+ {user.name} +
+
+
{user.name}
+
{user.email}
+
+
+
+ + {user.role === 'super_admin' ? : } + {user.role === 'super_admin' ? 'Super Admin' : 'Usuário'} + + + + {user.active ? : } + {user.active ? 'Ativo' : 'Inativo'} + + + {user.role === 'super_admin' ? ( + Acesso Total + ) : ( +
+ {user.permissions.length === 0 && Sem acesso} + {user.permissions.slice(0, 3).map(p => ( + + {availableModules.find(m => m.id === p)?.label || p} + + ))} + {user.permissions.length > 3 && ( + + +{user.permissions.length - 3} + + )} +
+ )} +
+
+ + {user.id !== currentUser.id && ( + + )} +
+
+
+
+ + {/* Modal User Edit/Create */} + {isModalOpen && ( +
+
setIsModalOpen(false)}>
+
+
+

+ {editingUser.id ? 'Editar Usuário' : 'Novo Usuário'} +

+ +
+ +
+
+
+ + setEditingUser({...editingUser, name: e.target.value})} + placeholder="Ex: João Silva" + /> +
+
+ + setEditingUser({...editingUser, email: e.target.value})} + placeholder="joao@empresa.com" + /> +
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+
+ + {/* Permissions Area */} +
+

+ + Permissões de Acesso +

+ + {editingUser.role === 'super_admin' ? ( +
+ +

Acesso Irrestrito

+

Super Admins têm acesso a todos os módulos.

+
+ ) : ( +
+ {availableModules.map(module => ( + togglePermission(module.id)} + /> + ))} +
+ )} +
+ +
+ +
+ + +
+
+
+ )} + +
+ ); +}; diff --git a/contexts/ComFiContext.tsx b/contexts/ComFiContext.tsx new file mode 100644 index 0000000..b9f0fc3 --- /dev/null +++ b/contexts/ComFiContext.tsx @@ -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>; + 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({} as ComFiContextData); + +export const ComFiProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + // State Initialization + const [services, setServices] = useStickyState(initialServices, 'comfi_services'); + const [companies, setCompanies] = useStickyState(initialCompanies, 'comfi_companies'); + const [expenses, setExpenses] = useStickyState(initialExpenses, 'comfi_expenses'); + const [clients, setClients] = useStickyState([], 'comfi_clients'); + const [currentUser, setCurrentUser] = useStickyState(initialSuperAdmin, 'comfi_current_user'); + const [users, setUsers] = useStickyState(initialUsers, 'comfi_users'); + const [tenant, setTenant] = useStickyState(initialTenant, 'comfi_tenant'); + const [categories, setCategories] = useStickyState(initialCategories, 'comfi_categories'); + const [proposals, setProposals] = useStickyState(initialProposals, 'comfi_proposals'); + + const [receivables, setReceivables] = useState(() => { + 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 ( + + {children} + + ); +}; + +export const useComFi = () => { + const context = useContext(ComFiContext); + if (!context) throw new Error('useComFi must be used within a ComFiProvider'); + return context; +}; diff --git a/contexts/ToastContext.tsx b/contexts/ToastContext.tsx new file mode 100644 index 0000000..95e0536 --- /dev/null +++ b/contexts/ToastContext.tsx @@ -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) => void; + removeToast: (id: string) => void; +} + +const ToastContext = createContext({} as ToastContextData); + +export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [toasts, setToasts] = useState([]); + + const addToast = useCallback(({ type, title, message, duration = 4000 }: Omit) => { + 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 ( + + {children} + {/* Toast Container Rendered Here Globally */} +
+ {toasts.map((toast) => ( +
+
+ {toast.type === 'success' && } + {toast.type === 'error' && } + {toast.type === 'warning' && } + {toast.type === 'info' && } +
+
+

{toast.title}

+ {toast.message &&

{toast.message}

} +
+ +
+ ))} +
+
+ ); +}; + +export const useToast = () => { + const context = useContext(ToastContext); + if (!context) throw new Error('useToast must be used within a ToastProvider'); + return context; +}; diff --git a/hooks/useStickyState.ts b/hooks/useStickyState.ts new file mode 100644 index 0000000..946b21e --- /dev/null +++ b/hooks/useStickyState.ts @@ -0,0 +1,18 @@ +import React, { useState, useEffect } from 'react'; + +export const useStickyState = (defaultValue: T, key: string): [T, React.Dispatch>] => { + const [value, setValue] = useState(() => { + 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]; +}; \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..fcbaaf2 --- /dev/null +++ b/index.html @@ -0,0 +1,81 @@ + + + + + + ComFi + + + + + + +
+ + \ No newline at end of file diff --git a/index.tsx b/index.tsx new file mode 100644 index 0000000..6ca5361 --- /dev/null +++ b/index.tsx @@ -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( + + + +); \ No newline at end of file diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..4dec51c --- /dev/null +++ b/metadata.json @@ -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": [] +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..a1b3839 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2c6eed5 --- /dev/null +++ b/tsconfig.json @@ -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 + } +} \ No newline at end of file diff --git a/types.ts b/types.ts new file mode 100644 index 0000000..48a9e15 --- /dev/null +++ b/types.ts @@ -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; +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..ee5fb8d --- /dev/null +++ b/vite.config.ts @@ -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, '.'), + } + } + }; +});