From 1a57ac7754eb5176120f8a9effe692985e7627e3 Mon Sep 17 00:00:00 2001 From: MMrp89 Date: Mon, 9 Feb 2026 20:28:37 -0300 Subject: [PATCH] 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. --- .gitignore | 24 + App.tsx | 216 +++++++++ README.md | 25 +- components/AIChatAssistant.tsx | 265 +++++++++++ components/AIInsightsWidget.tsx | 159 +++++++ components/AccountsPayableView.tsx | 318 +++++++++++++ components/AccountsReceivableView.tsx | 393 ++++++++++++++++ components/CRMView.tsx | 648 ++++++++++++++++++++++++++ components/CalendarView.tsx | 466 ++++++++++++++++++ components/ContactsView.tsx | 90 ++++ components/CustomSelect.tsx | 85 ++++ components/DashboardView.tsx | 389 ++++++++++++++++ components/FinancialReportsView.tsx | 467 +++++++++++++++++++ components/KanbanView.tsx | 498 ++++++++++++++++++++ components/ManagementView.tsx | 353 ++++++++++++++ components/ProposalsView.tsx | 452 ++++++++++++++++++ components/SettingsView.tsx | 255 ++++++++++ components/UserManagementView.tsx | 309 ++++++++++++ contexts/ComFiContext.tsx | 195 ++++++++ contexts/ToastContext.tsx | 82 ++++ hooks/useStickyState.ts | 18 + index.html | 81 ++++ index.tsx | 15 + metadata.json | 5 + package.json | 24 + tsconfig.json | 29 ++ types.ts | 194 ++++++++ vite.config.ts | 23 + 28 files changed, 6070 insertions(+), 8 deletions(-) create mode 100644 .gitignore create mode 100644 App.tsx create mode 100644 components/AIChatAssistant.tsx create mode 100644 components/AIInsightsWidget.tsx create mode 100644 components/AccountsPayableView.tsx create mode 100644 components/AccountsReceivableView.tsx create mode 100644 components/CRMView.tsx create mode 100644 components/CalendarView.tsx create mode 100644 components/ContactsView.tsx create mode 100644 components/CustomSelect.tsx create mode 100644 components/DashboardView.tsx create mode 100644 components/FinancialReportsView.tsx create mode 100644 components/KanbanView.tsx create mode 100644 components/ManagementView.tsx create mode 100644 components/ProposalsView.tsx create mode 100644 components/SettingsView.tsx create mode 100644 components/UserManagementView.tsx create mode 100644 contexts/ComFiContext.tsx create mode 100644 contexts/ToastContext.tsx create mode 100644 hooks/useStickyState.ts create mode 100644 index.html create mode 100644 index.tsx create mode 100644 metadata.json create mode 100644 package.json create mode 100644 tsconfig.json create mode 100644 types.ts create mode 100644 vite.config.ts 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, '.'), + } + } + }; +});