feat: Initialize ComFi project with Vite
Setup project structure, dependencies, and basic configuration for the ComFi application. Includes initial setup for Vite, React, TypeScript, Tailwind CSS, and essential development tools. Defines core types and provides a basic README for local development.
This commit is contained in:
265
components/AIChatAssistant.tsx
Normal file
265
components/AIChatAssistant.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { MessageSquare, X, Send, Bot, User, Sparkles, MinusCircle } from 'lucide-react';
|
||||
import { GoogleGenAI } from "@google/genai";
|
||||
|
||||
interface AIChatAssistantProps {
|
||||
userName: string;
|
||||
contextData: {
|
||||
revenue: number;
|
||||
expenses: number;
|
||||
profit: number;
|
||||
pendingReceivables: number;
|
||||
}
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
text: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export const AIChatAssistant: React.FC<AIChatAssistantProps> = ({ userName, contextData }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{
|
||||
id: 'welcome',
|
||||
role: 'assistant',
|
||||
text: `Olá ${userName}! 🤖\n\nSou seu consultor especialista no ComFi. Acompanho seus números em tempo real e posso ajudar com:\n\n- **Análise Financeira** (Lucro, Caixa, Despesas)\n- **Estratégias de Crescimento** (Marketing, Vendas)\n- **Gestão Operacional**\n\nComo posso ajudar seu negócio hoje?`,
|
||||
timestamp: new Date()
|
||||
}
|
||||
]);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, isOpen]);
|
||||
|
||||
// Função para processar formatação de texto (Negrito e Listas)
|
||||
const renderFormattedText = (text: string) => {
|
||||
return text.split('\n').map((line, index) => {
|
||||
// Processar item de lista
|
||||
if (line.trim().startsWith('- ')) {
|
||||
const content = line.trim().substring(2);
|
||||
return (
|
||||
<div key={index} className="flex items-start gap-2 mb-1 pl-1">
|
||||
<span className="min-w-[6px] h-[6px] rounded-full bg-current mt-1.5 opacity-60"></span>
|
||||
<span className="flex-1">{parseBold(content)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Processar linha vazia
|
||||
if (line.trim() === '') {
|
||||
return <div key={index} className="h-2"></div>;
|
||||
}
|
||||
|
||||
// Parágrafo normal
|
||||
return <p key={index} className="mb-1 last:mb-0 leading-relaxed">{parseBold(line)}</p>;
|
||||
});
|
||||
};
|
||||
|
||||
const parseBold = (text: string) => {
|
||||
return text.split(/(\*\*.*?\*\*)/).map((part, i) => {
|
||||
if (part.startsWith('**') && part.endsWith('**')) {
|
||||
return <strong key={i} className="font-bold">{part.slice(2, -2)}</strong>;
|
||||
}
|
||||
return part;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!inputValue.trim()) return;
|
||||
|
||||
const userMsg: Message = {
|
||||
id: Math.random().toString(),
|
||||
role: 'user',
|
||||
text: inputValue,
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
setInputValue('');
|
||||
setIsTyping(true);
|
||||
|
||||
try {
|
||||
// Inicializar Cliente Gemini
|
||||
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
|
||||
|
||||
// Construir Instrução do Sistema com Contexto Financeiro Atual
|
||||
const systemInstruction = `
|
||||
Você é o **ComFi Assistant**, um consultor de elite em gestão empresarial e marketing, integrado ao sistema ComFi.
|
||||
|
||||
**CONTEXTO FINANCEIRO ATUAL DO USUÁRIO:**
|
||||
- Receita: R$ ${contextData.revenue.toLocaleString('pt-BR')}
|
||||
- Despesas: R$ ${contextData.expenses.toLocaleString('pt-BR')}
|
||||
- Lucro Líquido: R$ ${contextData.profit.toLocaleString('pt-BR')}
|
||||
- A Receber (Pendente): R$ ${contextData.pendingReceivables.toLocaleString('pt-BR')}
|
||||
|
||||
**SUA MISSÃO:**
|
||||
Atuar como um estrategista sênior. Analise os dados e a pergunta do usuário para fornecer conselhos práticos, ideias de marketing criativas e insights financeiros.
|
||||
|
||||
**DIRETRIZES DE RESPOSTA (RIGOROSO):**
|
||||
1. **Formatação Limpa:** JAMAIS escreva blocos de texto longos. Use parágrafos curtos.
|
||||
2. **Uso de Listas:** Sempre que apresentar passos, ideias ou dados, use listas com marcadores (\`- \`).
|
||||
3. **Destaques:** Use **negrito** para números importantes e termos-chave.
|
||||
4. **Tom de Voz:** Profissional, especialista, motivador e direto ao ponto.
|
||||
5. **Foco em Ação:** Dê sugestões que o usuário possa implementar hoje.
|
||||
|
||||
Exemplo de formato ideal:
|
||||
"Baseado nos seus dados, aqui estão 3 ações:
|
||||
- **Ação 1**: Explicação breve.
|
||||
- **Ação 2**: Explicação breve."
|
||||
`;
|
||||
|
||||
// Chamada à API
|
||||
const response = await ai.models.generateContent({
|
||||
model: 'gemini-3-flash-preview',
|
||||
contents: [
|
||||
{ role: 'user', parts: [{ text: inputValue }] }
|
||||
],
|
||||
config: {
|
||||
systemInstruction: systemInstruction,
|
||||
temperature: 0.7, // Criatividade balanceada para marketing
|
||||
}
|
||||
});
|
||||
|
||||
const text = response.text || "Desculpe, não consegui gerar uma resposta no momento.";
|
||||
|
||||
const botMsg: Message = {
|
||||
id: Math.random().toString(),
|
||||
role: 'assistant',
|
||||
text: text,
|
||||
timestamp: new Date()
|
||||
};
|
||||
setMessages(prev => [...prev, botMsg]);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Erro ao chamar Gemini API:", error);
|
||||
const errorMsg: Message = {
|
||||
id: Math.random().toString(),
|
||||
role: 'assistant',
|
||||
text: "Desculpe, estou enfrentando uma instabilidade temporária na minha conexão neural. 🧠\n\nPor favor, tente novamente em alguns instantes.",
|
||||
timestamp: new Date()
|
||||
};
|
||||
setMessages(prev => [...prev, errorMsg]);
|
||||
} finally {
|
||||
setIsTyping(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') handleSend();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Floating Button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`fixed bottom-6 right-6 z-50 p-4 rounded-full shadow-2xl transition-all duration-300 hover:scale-110 flex items-center justify-center ${
|
||||
isOpen ? 'bg-slate-800 rotate-90' : 'bg-gradient-to-r from-primary-500 to-orange-600'
|
||||
}`}
|
||||
>
|
||||
{isOpen ? <X color="white" size={24} /> : <MessageSquare color="white" size={28} fill="currentColor" className="text-white/20" />}
|
||||
{!isOpen && (
|
||||
<span className="absolute top-0 right-0 w-3 h-3 bg-red-500 border-2 border-white rounded-full"></span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Chat Window */}
|
||||
<div
|
||||
className={`fixed bottom-24 right-6 w-96 max-w-[calc(100vw-3rem)] bg-white rounded-2xl shadow-2xl border border-slate-100 z-50 flex flex-col transition-all duration-300 origin-bottom-right overflow-hidden ${
|
||||
isOpen ? 'opacity-100 scale-100 translate-y-0' : 'opacity-0 scale-90 translate-y-10 pointer-events-none'
|
||||
}`}
|
||||
style={{ height: '550px' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="bg-slate-900 p-4 flex items-center gap-3 shadow-md relative z-10">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-primary-400 to-orange-600 flex items-center justify-center border-2 border-slate-700 shadow-inner">
|
||||
<Bot size={20} className="text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-white font-bold text-sm flex items-center gap-2">
|
||||
ComFi Especialista <span className="bg-primary-500 text-[10px] px-1.5 py-0.5 rounded text-white font-bold tracking-wide">AI</span>
|
||||
</h3>
|
||||
<p className="text-slate-400 text-xs flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span> Consultor Online
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setIsOpen(false)} className="text-slate-400 hover:text-white transition-colors"><MinusCircle size={18}/></button>
|
||||
</div>
|
||||
|
||||
{/* Messages Area */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-[#F8FAFC] scrollbar-thin">
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id} className={`flex gap-3 ${msg.role === 'user' ? 'flex-row-reverse' : ''} animate-fade-in`}>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 shadow-sm border border-black/5 ${
|
||||
msg.role === 'user' ? 'bg-white text-slate-600' : 'bg-primary-100 text-primary-600'
|
||||
}`}>
|
||||
{msg.role === 'user' ? <User size={14} /> : <Sparkles size={14} />}
|
||||
</div>
|
||||
|
||||
<div className={`max-w-[85%] p-3.5 rounded-2xl text-sm shadow-sm ${
|
||||
msg.role === 'user'
|
||||
? 'bg-white text-slate-700 rounded-tr-none border border-slate-100'
|
||||
: 'bg-white text-slate-800 rounded-tl-none border border-slate-100 shadow-md'
|
||||
}`}>
|
||||
{msg.role === 'assistant' ? (
|
||||
<div className="text-slate-600">
|
||||
{renderFormattedText(msg.text)}
|
||||
</div>
|
||||
) : (
|
||||
msg.text
|
||||
)}
|
||||
|
||||
<div className={`text-[10px] mt-2 text-right opacity-50 font-medium`}>
|
||||
{msg.timestamp.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isTyping && (
|
||||
<div className="flex gap-3 animate-pulse">
|
||||
<div className="w-8 h-8 rounded-full bg-primary-100 text-primary-600 flex items-center justify-center">
|
||||
<Sparkles size={14} />
|
||||
</div>
|
||||
<div className="bg-white border border-slate-100 p-4 rounded-2xl rounded-tl-none flex gap-1 items-center shadow-sm">
|
||||
<span className="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce"></span>
|
||||
<span className="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce" style={{animationDelay: '0.1s'}}></span>
|
||||
<span className="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce" style={{animationDelay: '0.2s'}}></span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="p-3 bg-white border-t border-slate-100 flex gap-2 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)] relative z-20">
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Pergunte sobre lucro, ideias de venda..."
|
||||
className="flex-1 bg-slate-50 border border-slate-200 rounded-xl px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-primary-200 text-slate-700 placeholder-slate-400 transition-all"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!inputValue.trim()}
|
||||
className="w-11 h-11 bg-primary-500 text-white rounded-xl flex items-center justify-center hover:bg-primary-600 disabled:opacity-50 disabled:hover:bg-primary-500 transition-all shadow-lg shadow-primary-200 active:scale-95"
|
||||
>
|
||||
<Send size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
159
components/AIInsightsWidget.tsx
Normal file
159
components/AIInsightsWidget.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Sparkles, TrendingUp, AlertTriangle, Lightbulb, RefreshCw } from 'lucide-react';
|
||||
import { FinancialSummary, Company, Expense } from '../types';
|
||||
|
||||
interface AIInsightsWidgetProps {
|
||||
financialSummary: FinancialSummary;
|
||||
topClients: Company[];
|
||||
expenses: Expense[];
|
||||
}
|
||||
|
||||
export const AIInsightsWidget: React.FC<AIInsightsWidgetProps> = ({ financialSummary, topClients, expenses }) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [insights, setInsights] = useState<{type: 'success' | 'warning' | 'info' | 'danger', title: string, message: string}[]>([]);
|
||||
|
||||
const generateInsights = () => {
|
||||
setIsLoading(true);
|
||||
// Simulating AI processing time
|
||||
setTimeout(() => {
|
||||
const newInsights: typeof insights = [];
|
||||
|
||||
// 1. Profitability Analysis
|
||||
const margin = financialSummary.totalRevenue > 0 ? (financialSummary.profit / financialSummary.totalRevenue) * 100 : 0;
|
||||
|
||||
if (margin > 20) {
|
||||
newInsights.push({
|
||||
type: 'success',
|
||||
title: 'Alta Rentabilidade',
|
||||
message: `Sua margem de lucro de ${margin.toFixed(1)}% está acima da média do mercado.`
|
||||
});
|
||||
} else if (margin < 5 && margin > 0) {
|
||||
newInsights.push({
|
||||
type: 'warning',
|
||||
title: 'Margem Apertada',
|
||||
message: 'Lucro abaixo de 5%. Recomendamos revisão imediata de custos fixos.'
|
||||
});
|
||||
} else if (margin <= 0) {
|
||||
newInsights.push({
|
||||
type: 'danger',
|
||||
title: 'Prejuízo Operacional',
|
||||
message: 'As despesas superaram as receitas. Verifique inadimplência e corte gastos.'
|
||||
});
|
||||
} else {
|
||||
newInsights.push({
|
||||
type: 'info',
|
||||
title: 'Estabilidade',
|
||||
message: 'Sua margem está estável. Busque novas fontes de receita para crescer.'
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Cash Flow Analysis
|
||||
if (financialSummary.receivablePending > financialSummary.payablePending) {
|
||||
newInsights.push({
|
||||
type: 'info',
|
||||
title: 'Caixa Saudável',
|
||||
message: `Previsão de entrada líquida de R$ ${(financialSummary.receivablePending - financialSummary.payablePending).toLocaleString('pt-BR')}.`
|
||||
});
|
||||
} else {
|
||||
newInsights.push({
|
||||
type: 'danger',
|
||||
title: 'Risco de Liquidez',
|
||||
message: 'Contas a pagar superam os recebíveis previstos. Aumente o esforço de cobrança.'
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Strategic / Growth
|
||||
if (topClients.length > 0) {
|
||||
newInsights.push({
|
||||
type: 'success',
|
||||
title: 'Oportunidade de Upsell',
|
||||
message: `O cliente ${topClients[0].fantasyName || topClients[0].name} tem alto potencial. Ofereça novos serviços.`
|
||||
});
|
||||
} else {
|
||||
newInsights.push({
|
||||
type: 'warning',
|
||||
title: 'Base de Clientes',
|
||||
message: 'Cadastre mais clientes para receber insights de vendas personalizados.'
|
||||
})
|
||||
}
|
||||
|
||||
setInsights(newInsights);
|
||||
setIsLoading(false);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
generateInsights();
|
||||
}, [financialSummary]);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-[1.5rem] p-6 border border-slate-100 shadow-sm relative overflow-hidden transition-all hover:shadow-md">
|
||||
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-orange-400 to-orange-600 flex items-center justify-center shadow-lg shadow-orange-200">
|
||||
<Sparkles size={20} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-slate-800 tracking-tight">AI Insights</h3>
|
||||
<p className="text-slate-400 text-xs font-medium">Análise financeira inteligente</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={generateInsights}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-50 text-slate-400 hover:text-orange-500 transition-all"
|
||||
title="Atualizar Análise"
|
||||
>
|
||||
<RefreshCw size={16} className={isLoading ? "animate-spin" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{isLoading ? (
|
||||
// Skeleton Loading Style
|
||||
[1, 2, 3].map(i => (
|
||||
<div key={i} className="bg-slate-50/50 rounded-2xl p-5 h-28 animate-pulse border border-slate-100">
|
||||
<div className="h-4 w-24 bg-slate-200 rounded mb-3"></div>
|
||||
<div className="h-3 w-full bg-slate-200 rounded mb-2"></div>
|
||||
<div className="h-3 w-2/3 bg-slate-200 rounded"></div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
insights.map((insight, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`rounded-2xl p-5 border transition-all duration-300 hover:-translate-y-1 ${
|
||||
insight.type === 'success' ? 'bg-emerald-50/50 border-emerald-100' :
|
||||
insight.type === 'warning' ? 'bg-amber-50/50 border-amber-100' :
|
||||
insight.type === 'danger' ? 'bg-red-50/50 border-red-100' :
|
||||
'bg-blue-50/50 border-blue-100'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{insight.type === 'success' && <TrendingUp size={16} className="text-emerald-500" />}
|
||||
{insight.type === 'warning' && <AlertTriangle size={16} className="text-amber-500" />}
|
||||
{insight.type === 'danger' && <AlertTriangle size={16} className="text-red-500" />}
|
||||
{insight.type === 'info' && <Lightbulb size={16} className="text-blue-500" />}
|
||||
|
||||
<h4 className={`font-bold text-sm ${
|
||||
insight.type === 'success' ? 'text-emerald-800' :
|
||||
insight.type === 'warning' ? 'text-amber-800' :
|
||||
insight.type === 'danger' ? 'text-red-800' :
|
||||
'text-blue-800'
|
||||
}`}>{insight.title}</h4>
|
||||
</div>
|
||||
<p className={`text-xs leading-relaxed font-medium ${
|
||||
insight.type === 'success' ? 'text-emerald-700/80' :
|
||||
insight.type === 'warning' ? 'text-amber-700/80' :
|
||||
insight.type === 'danger' ? 'text-red-700/80' :
|
||||
'text-blue-700/80'
|
||||
}`}>
|
||||
{insight.message}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
318
components/AccountsPayableView.tsx
Normal file
318
components/AccountsPayableView.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Search, Plus, DollarSign, Calendar, CheckCircle2, AlertCircle, Trash2, X, Pencil, Sparkles, ChevronDown } from 'lucide-react';
|
||||
import { Expense } from '../types';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { CustomSelect } from './CustomSelect';
|
||||
|
||||
interface AccountsPayableViewProps {
|
||||
expenses: Expense[];
|
||||
setExpenses: (expenses: Expense[]) => void;
|
||||
}
|
||||
|
||||
export const AccountsPayableView: React.FC<AccountsPayableViewProps> = ({ expenses, setExpenses }) => {
|
||||
const { addToast } = useToast();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [filterType, setFilterType] = useState<'all' | 'fixed' | 'variable'>('all');
|
||||
|
||||
const [newExpense, setNewExpense] = useState<Partial<Expense>>({
|
||||
type: 'fixed',
|
||||
status: 'pending',
|
||||
dueDate: new Date().toISOString().split('T')[0]
|
||||
});
|
||||
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
||||
// Totals Calculation
|
||||
const totalPayable = expenses.reduce((acc, curr) => acc + curr.amount, 0);
|
||||
const totalPaid = expenses.filter(e => e.status === 'paid').reduce((acc, curr) => acc + curr.amount, 0);
|
||||
const totalPending = expenses.filter(e => e.status === 'pending' || e.status === 'overdue').reduce((acc, curr) => acc + curr.amount, 0);
|
||||
|
||||
const filteredExpenses = expenses.filter(e => filterType === 'all' ? true : e.type === filterType);
|
||||
|
||||
const openEditModal = (expense: Expense) => {
|
||||
setNewExpense(expense);
|
||||
setEditingId(expense.id);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
setNewExpense({
|
||||
type: 'fixed',
|
||||
status: 'pending',
|
||||
dueDate: new Date().toISOString().split('T')[0]
|
||||
});
|
||||
setEditingId(null);
|
||||
setIsModalOpen(true);
|
||||
}
|
||||
|
||||
const handleSaveExpense = () => {
|
||||
if (!newExpense.title || !newExpense.amount) {
|
||||
addToast({ type: 'warning', title: 'Campos Obrigatórios', message: 'Preencha o título e valor da despesa.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (editingId) {
|
||||
// Edit existing
|
||||
setExpenses(expenses.map(e => e.id === editingId ? { ...newExpense, id: editingId, amount: Number(newExpense.amount) } as Expense : e));
|
||||
addToast({ type: 'success', title: 'Atualizado', message: 'Despesa atualizada com sucesso.' });
|
||||
} else {
|
||||
// Create new
|
||||
const expense: Expense = {
|
||||
...newExpense,
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
amount: Number(newExpense.amount)
|
||||
} as Expense;
|
||||
setExpenses([...expenses, expense]);
|
||||
addToast({ type: 'success', title: 'Registrado', message: 'Nova despesa adicionada.' });
|
||||
}
|
||||
|
||||
setIsModalOpen(false);
|
||||
setEditingId(null);
|
||||
setNewExpense({ type: 'fixed', status: 'pending', dueDate: new Date().toISOString().split('T')[0] });
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if(window.confirm("Excluir conta?")) {
|
||||
setExpenses(expenses.filter(e => e.id !== id));
|
||||
addToast({ type: 'info', title: 'Excluído', message: 'Despesa removida.' });
|
||||
}
|
||||
}
|
||||
|
||||
const toggleStatus = (id: string) => {
|
||||
setExpenses(expenses.map(e => {
|
||||
if(e.id === id) {
|
||||
const newStatus = e.status === 'paid' ? 'pending' : 'paid';
|
||||
if (newStatus === 'paid') addToast({ type: 'success', title: 'Pago!', message: 'Despesa marcada como paga.' });
|
||||
return { ...e, status: newStatus };
|
||||
}
|
||||
return e;
|
||||
}));
|
||||
}
|
||||
|
||||
const inputClass = "w-full p-3 bg-white border border-slate-200 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none text-slate-800";
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-white p-6 rounded-[2rem] border border-slate-50 shadow-sm flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-slate-100 flex items-center justify-center text-slate-500">
|
||||
<DollarSign size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-400 text-xs font-bold uppercase">Total Previsto</p>
|
||||
<h3 className="text-2xl font-bold text-slate-800">R$ {totalPayable.toLocaleString('pt-BR')}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-[2rem] border border-slate-50 shadow-sm flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-green-50 flex items-center justify-center text-green-600">
|
||||
<CheckCircle2 size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-400 text-xs font-bold uppercase">Total Pago</p>
|
||||
<h3 className="text-2xl font-bold text-green-600">R$ {totalPaid.toLocaleString('pt-BR')}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-[2rem] border border-slate-50 shadow-sm flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-red-50 flex items-center justify-center text-red-600">
|
||||
<AlertCircle size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-400 text-xs font-bold uppercase">A Pagar / Pendente</p>
|
||||
<h3 className="text-2xl font-bold text-red-600">R$ {totalPending.toLocaleString('pt-BR')}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800">Contas a Pagar</h1>
|
||||
<p className="text-slate-500">Gerencie despesas fixas e variáveis.</p>
|
||||
</div>
|
||||
<button onClick={openCreateModal} className="flex items-center gap-2 px-5 py-3 bg-red-500 text-white rounded-xl shadow-lg shadow-red-200/50 hover:bg-red-600 font-bold transition-all">
|
||||
<Plus size={20} /> Nova Despesa
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="p-4 border-b border-slate-100 flex flex-wrap gap-4 items-center">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar despesa..."
|
||||
className="w-full pl-10 pr-4 py-2 bg-slate-50 border-none rounded-xl focus:ring-2 focus:ring-primary-500 outline-none text-slate-600"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 bg-slate-50 p-1 rounded-xl">
|
||||
<button
|
||||
onClick={() => setFilterType('all')}
|
||||
className={`px-4 py-1.5 rounded-lg text-sm font-medium transition-colors ${filterType === 'all' ? 'bg-white shadow text-slate-800' : 'text-slate-500 hover:text-slate-700'}`}
|
||||
>
|
||||
Todas
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterType('fixed')}
|
||||
className={`px-4 py-1.5 rounded-lg text-sm font-medium transition-colors ${filterType === 'fixed' ? 'bg-white shadow text-slate-800' : 'text-slate-500 hover:text-slate-700'}`}
|
||||
>
|
||||
Fixas
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterType('variable')}
|
||||
className={`px-4 py-1.5 rounded-lg text-sm font-medium transition-colors ${filterType === 'variable' ? 'bg-white shadow text-slate-800' : 'text-slate-500 hover:text-slate-700'}`}
|
||||
>
|
||||
Variáveis
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-slate-50/50">
|
||||
<tr>
|
||||
<th className="p-4 text-xs font-bold uppercase text-slate-500">Descrição</th>
|
||||
<th className="p-4 text-xs font-bold uppercase text-slate-500">Categoria</th>
|
||||
<th className="p-4 text-xs font-bold uppercase text-slate-500">Vencimento</th>
|
||||
<th className="p-4 text-xs font-bold uppercase text-slate-500">Valor</th>
|
||||
<th className="p-4 text-xs font-bold uppercase text-slate-500 text-center">Tipo</th>
|
||||
<th className="p-4 text-xs font-bold uppercase text-slate-500 text-center">Status</th>
|
||||
<th className="p-4"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{filteredExpenses.map(expense => (
|
||||
<tr key={expense.id} className="hover:bg-slate-50 transition-colors group">
|
||||
<td className="p-4 font-bold text-slate-800">{expense.title}</td>
|
||||
<td className="p-4">
|
||||
<span className="px-2 py-1 bg-slate-100 rounded text-xs text-slate-600">{expense.category}</span>
|
||||
</td>
|
||||
<td className="p-4 text-sm text-slate-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar size={14} className="text-slate-400"/>
|
||||
{new Date(expense.dueDate).toLocaleDateString('pt-BR')}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 font-bold text-slate-800">R$ {expense.amount.toLocaleString('pt-BR')}</td>
|
||||
<td className="p-4 text-center">
|
||||
<span className={`text-[10px] font-bold uppercase px-2 py-0.5 rounded ${expense.type === 'fixed' ? 'bg-blue-50 text-blue-600' : 'bg-amber-50 text-amber-600'}`}>
|
||||
{expense.type === 'fixed' ? 'Fixa' : 'Variável'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4 text-center">
|
||||
<button onClick={() => toggleStatus(expense.id)} className={`px-3 py-1 rounded-full text-xs font-bold border transition-all ${
|
||||
expense.status === 'paid' ? 'bg-green-50 text-green-600 border-green-200 hover:bg-green-100' :
|
||||
expense.status === 'overdue' ? 'bg-red-50 text-red-600 border-red-200 hover:bg-red-100' :
|
||||
'bg-slate-50 text-slate-500 border-slate-200 hover:bg-slate-100'
|
||||
}`}>
|
||||
{expense.status === 'paid' ? 'PAGO' : expense.status === 'overdue' ? 'ATRASADO' : 'PENDENTE'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="p-4 text-right">
|
||||
<div className="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={() => openEditModal(expense)} className="p-2 text-slate-400 hover:text-primary-500 hover:bg-primary-50 rounded-lg transition-colors">
|
||||
<Pencil size={18} />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(expense.id)} className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors">
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{filteredExpenses.length === 0 && (
|
||||
<div className="p-10 text-center text-slate-400">
|
||||
<Sparkles size={32} className="mx-auto mb-2 opacity-20"/>
|
||||
<p>Nenhuma despesa encontrada.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm" onClick={() => setIsModalOpen(false)}></div>
|
||||
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-md animate-scale-up flex flex-col max-h-[90vh]">
|
||||
<div className="flex justify-between items-center px-6 py-4 border-b border-slate-100 flex-shrink-0">
|
||||
<h3 className="font-bold text-slate-800 text-lg">{editingId ? 'Editar Despesa' : 'Nova Despesa'}</h3>
|
||||
<button onClick={() => setIsModalOpen(false)}><X size={20} className="text-slate-400 hover:text-slate-600"/></button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4 overflow-y-auto">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-800 mb-1">Título</label>
|
||||
<input
|
||||
type="text"
|
||||
className={inputClass}
|
||||
placeholder="Ex: Aluguel Escritório"
|
||||
value={newExpense.title || ''}
|
||||
onChange={e => setNewExpense({...newExpense, title: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-800 mb-1">Valor (R$)</label>
|
||||
<input
|
||||
type="number"
|
||||
className={inputClass}
|
||||
placeholder="0,00"
|
||||
value={newExpense.amount || ''}
|
||||
onChange={e => setNewExpense({...newExpense, amount: Number(e.target.value)})}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-800 mb-1">Vencimento</label>
|
||||
<input
|
||||
type="date"
|
||||
className={inputClass}
|
||||
value={newExpense.dueDate}
|
||||
onChange={e => setNewExpense({...newExpense, dueDate: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-800 mb-1">Categoria</label>
|
||||
<CustomSelect
|
||||
value={newExpense.category || 'Operacional'}
|
||||
onChange={(val) => setNewExpense({...newExpense, category: val})}
|
||||
options={[
|
||||
{ value: 'Operacional', label: 'Operacional' },
|
||||
{ value: 'Administrativo', label: 'Administrativo' },
|
||||
{ value: 'Impostos', label: 'Impostos' },
|
||||
{ value: 'Marketing', label: 'Marketing' },
|
||||
{ value: 'Pessoal', label: 'Pessoal / Folha' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-800 mb-1">Tipo</label>
|
||||
<CustomSelect
|
||||
value={newExpense.type || 'fixed'}
|
||||
onChange={(val) => setNewExpense({...newExpense, type: val})}
|
||||
options={[
|
||||
{ value: 'fixed', label: 'Fixa' },
|
||||
{ value: 'variable', label: 'Variável' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onClick={handleSaveExpense} className="w-full py-3 bg-red-500 text-white font-bold rounded-xl mt-4 hover:bg-red-600 shadow-lg shadow-red-200">
|
||||
{editingId ? 'Salvar Alterações' : 'Registrar Despesa'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
393
components/AccountsReceivableView.tsx
Normal file
393
components/AccountsReceivableView.tsx
Normal file
@@ -0,0 +1,393 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Search, Plus, DollarSign, CheckCircle2, TrendingUp, Trash2, X, Calendar, Pencil, RefreshCw, Sparkles, ChevronDown } from 'lucide-react';
|
||||
import { Receivable } from '../types';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useComFi } from '../contexts/ComFiContext';
|
||||
import { CustomSelect } from './CustomSelect';
|
||||
|
||||
interface AccountsReceivableViewProps {
|
||||
receivables: Receivable[];
|
||||
setReceivables: React.Dispatch<React.SetStateAction<Receivable[]>>;
|
||||
}
|
||||
|
||||
export const AccountsReceivableView: React.FC<AccountsReceivableViewProps> = ({ receivables, setReceivables }) => {
|
||||
const { addToast } = useToast();
|
||||
const { companies } = useComFi(); // Acesso ao CRM para gerar recorrência
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [filterStatus, setFilterStatus] = useState<'all' | 'paid' | 'pending'>('all');
|
||||
|
||||
const [newReceivable, setNewReceivable] = useState<Partial<Receivable>>({
|
||||
type: 'one-time',
|
||||
status: 'pending',
|
||||
dueDate: new Date().toISOString().split('T')[0]
|
||||
});
|
||||
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
||||
// KPI Calculations
|
||||
const totalReceivable = receivables.reduce((acc, curr) => acc + curr.value, 0);
|
||||
const totalReceived = receivables.filter(r => r.status === 'paid').reduce((acc, curr) => acc + curr.value, 0);
|
||||
const totalPending = receivables.filter(r => r.status === 'pending' || r.status === 'overdue').reduce((acc, curr) => acc + curr.value, 0);
|
||||
|
||||
const filteredList = receivables.filter(r => filterStatus === 'all' ? true : r.status === (filterStatus === 'paid' ? 'paid' : 'pending'));
|
||||
|
||||
// --- ACTIONS ---
|
||||
|
||||
const handleGenerateRecurring = () => {
|
||||
const currentMonth = new Date().toISOString().slice(0, 7); // YYYY-MM
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
let generatedCount = 0;
|
||||
const newReceivables: Receivable[] = [];
|
||||
|
||||
companies.forEach(company => {
|
||||
if (company.status !== 'active') return;
|
||||
|
||||
company.activeServices.forEach(service => {
|
||||
if (service.billingType === 'recurring') {
|
||||
// Check duplicates for this month
|
||||
const exists = receivables.find(r =>
|
||||
r.companyName === (company.fantasyName || company.name) &&
|
||||
r.description === service.name &&
|
||||
r.dueDate.startsWith(currentMonth)
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
newReceivables.push({
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
description: service.name,
|
||||
companyName: company.fantasyName || company.name,
|
||||
category: service.category,
|
||||
value: service.price,
|
||||
dueDate: today, // Simplificação: gera para hoje ou data padrão de vencimento
|
||||
status: 'pending',
|
||||
type: 'recurring'
|
||||
});
|
||||
generatedCount++;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (generatedCount > 0) {
|
||||
setReceivables(prev => [...prev, ...newReceivables]);
|
||||
addToast({ type: 'success', title: 'Processamento Concluído', message: `${generatedCount} faturas recorrentes foram geradas.` });
|
||||
} else {
|
||||
addToast({ type: 'info', title: 'Tudo em dia', message: 'Todas as cobranças recorrentes deste mês já foram geradas.' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!newReceivable.description || !newReceivable.value) {
|
||||
addToast({ type: 'warning', title: 'Dados Incompletos', message: 'Preencha a descrição e o valor.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (editingId) {
|
||||
setReceivables(receivables.map(r => r.id === editingId ? {
|
||||
...newReceivable,
|
||||
id: editingId,
|
||||
value: Number(newReceivable.value),
|
||||
category: newReceivable.category || 'Outros',
|
||||
companyName: newReceivable.companyName || 'Avulso'
|
||||
} as Receivable : r));
|
||||
addToast({ type: 'success', title: 'Atualizado', message: 'Recebimento atualizado com sucesso.' });
|
||||
} else {
|
||||
const item: Receivable = {
|
||||
...newReceivable,
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
value: Number(newReceivable.value),
|
||||
category: newReceivable.category || 'Outros',
|
||||
companyName: newReceivable.companyName || 'Avulso'
|
||||
} as Receivable;
|
||||
setReceivables([...receivables, item]);
|
||||
addToast({ type: 'success', title: 'Criado', message: 'Novo recebimento registrado.' });
|
||||
}
|
||||
|
||||
setIsModalOpen(false);
|
||||
setEditingId(null);
|
||||
setNewReceivable({ type: 'one-time', status: 'pending', dueDate: new Date().toISOString().split('T')[0] });
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if(window.confirm("Excluir recebimento?")) {
|
||||
setReceivables(receivables.filter(r => r.id !== id));
|
||||
addToast({ type: 'info', title: 'Excluído', message: 'Registro removido.' });
|
||||
}
|
||||
}
|
||||
|
||||
const toggleStatus = (id: string) => {
|
||||
setReceivables(receivables.map(r => {
|
||||
if(r.id === id) {
|
||||
const newStatus = r.status === 'paid' ? 'pending' : 'paid';
|
||||
if (newStatus === 'paid') addToast({ type: 'success', title: 'Recebido!', message: `Valor de R$ ${r.value} confirmado.` });
|
||||
return { ...r, status: newStatus };
|
||||
}
|
||||
return r;
|
||||
}));
|
||||
}
|
||||
|
||||
const openEditModal = (item: Receivable) => {
|
||||
setNewReceivable(item);
|
||||
setEditingId(item.id);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
setNewReceivable({
|
||||
type: 'one-time',
|
||||
status: 'pending',
|
||||
dueDate: new Date().toISOString().split('T')[0]
|
||||
});
|
||||
setEditingId(null);
|
||||
setIsModalOpen(true);
|
||||
}
|
||||
|
||||
const inputClass = "w-full p-3 bg-white border border-slate-200 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none text-slate-800";
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-white p-6 rounded-[2rem] border border-slate-50 shadow-sm flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-slate-100 flex items-center justify-center text-slate-500">
|
||||
<DollarSign size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-400 text-xs font-bold uppercase">Receita Total Prevista</p>
|
||||
<h3 className="text-2xl font-bold text-slate-800">R$ {totalReceivable.toLocaleString('pt-BR')}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-[2rem] border border-slate-50 shadow-sm flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-green-50 flex items-center justify-center text-green-600">
|
||||
<CheckCircle2 size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-400 text-xs font-bold uppercase">Recebido</p>
|
||||
<h3 className="text-2xl font-bold text-green-600">R$ {totalReceived.toLocaleString('pt-BR')}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-[2rem] border border-slate-50 shadow-sm flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-amber-50 flex items-center justify-center text-amber-600">
|
||||
<TrendingUp size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-400 text-xs font-bold uppercase">A Receber</p>
|
||||
<h3 className="text-2xl font-bold text-amber-600">R$ {totalPending.toLocaleString('pt-BR')}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800">Contas a Receber</h1>
|
||||
<p className="text-slate-500">Gestão de faturas, contratos e recebimentos avulsos.</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleGenerateRecurring}
|
||||
className="flex items-center gap-2 px-5 py-3 bg-indigo-500 text-white rounded-xl shadow-lg shadow-indigo-200/50 hover:bg-indigo-600 font-bold transition-all"
|
||||
title="Gera cobranças baseadas nos serviços ativos do CRM"
|
||||
>
|
||||
<RefreshCw size={20} /> <span className="hidden sm:inline">Gerar Mensalidades</span>
|
||||
</button>
|
||||
<button onClick={openCreateModal} className="flex items-center gap-2 px-5 py-3 bg-green-500 text-white rounded-xl shadow-lg shadow-green-200/50 hover:bg-green-600 font-bold transition-all">
|
||||
<Plus size={20} /> <span className="hidden sm:inline">Novo Recebimento</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="p-4 border-b border-slate-100 flex flex-wrap gap-4 items-center">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar cliente ou descrição..."
|
||||
className="w-full pl-10 pr-4 py-2 bg-slate-50 border-none rounded-xl focus:ring-2 focus:ring-primary-500 outline-none text-slate-600"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 bg-slate-50 p-1 rounded-xl">
|
||||
<button
|
||||
onClick={() => setFilterStatus('all')}
|
||||
className={`px-4 py-1.5 rounded-lg text-sm font-medium transition-colors ${filterStatus === 'all' ? 'bg-white shadow text-slate-800' : 'text-slate-500 hover:text-slate-700'}`}
|
||||
>
|
||||
Todos
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterStatus('paid')}
|
||||
className={`px-4 py-1.5 rounded-lg text-sm font-medium transition-colors ${filterStatus === 'paid' ? 'bg-white shadow text-slate-800' : 'text-slate-500 hover:text-slate-700'}`}
|
||||
>
|
||||
Recebidos
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterStatus('pending')}
|
||||
className={`px-4 py-1.5 rounded-lg text-sm font-medium transition-colors ${filterStatus === 'pending' ? 'bg-white shadow text-slate-800' : 'text-slate-500 hover:text-slate-700'}`}
|
||||
>
|
||||
Pendentes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-slate-50/50">
|
||||
<tr>
|
||||
<th className="p-4 text-xs font-bold uppercase text-slate-500">Descrição / Cliente</th>
|
||||
<th className="p-4 text-xs font-bold uppercase text-slate-500">Categoria</th>
|
||||
<th className="p-4 text-xs font-bold uppercase text-slate-500">Vencimento</th>
|
||||
<th className="p-4 text-xs font-bold uppercase text-slate-500">Valor</th>
|
||||
<th className="p-4 text-xs font-bold uppercase text-slate-500 text-center">Tipo</th>
|
||||
<th className="p-4 text-xs font-bold uppercase text-slate-500 text-center">Status</th>
|
||||
<th className="p-4"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{filteredList.map(item => (
|
||||
<tr key={item.id} className="hover:bg-slate-50 transition-colors group">
|
||||
<td className="p-4">
|
||||
<div className="font-bold text-slate-800">{item.description}</div>
|
||||
<div className="text-xs text-slate-400">{item.companyName}</div>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<span className="px-2 py-1 bg-slate-100 rounded text-xs text-slate-600">{item.category}</span>
|
||||
</td>
|
||||
<td className="p-4 text-sm text-slate-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar size={14} className="text-slate-400"/>
|
||||
{new Date(item.dueDate).toLocaleDateString('pt-BR')}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 font-bold text-slate-800">R$ {item.value.toLocaleString('pt-BR')}</td>
|
||||
<td className="p-4 text-center">
|
||||
<span className={`text-[10px] font-bold uppercase px-2 py-0.5 rounded ${item.type === 'recurring' ? 'bg-indigo-50 text-indigo-600' : 'bg-slate-100 text-slate-600'}`}>
|
||||
{item.type === 'recurring' ? 'Mensal' : 'Avulso'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4 text-center">
|
||||
<button onClick={() => toggleStatus(item.id)} className={`px-3 py-1 rounded-full text-xs font-bold border transition-all ${
|
||||
item.status === 'paid' ? 'bg-green-50 text-green-600 border-green-200 hover:bg-green-100' :
|
||||
item.status === 'overdue' ? 'bg-red-50 text-red-600 border-red-200 hover:bg-red-100' :
|
||||
'bg-amber-50 text-amber-600 border-amber-200 hover:bg-amber-100'
|
||||
}`}>
|
||||
{item.status === 'paid' ? 'RECEBIDO' : item.status === 'overdue' ? 'ATRASADO' : 'PENDENTE'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="p-4 text-right">
|
||||
<div className="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={() => openEditModal(item)} className="p-2 text-slate-400 hover:text-primary-500 hover:bg-primary-50 rounded-lg transition-colors">
|
||||
<Pencil size={18} />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(item.id)} className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors">
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{filteredList.length === 0 && (
|
||||
<div className="p-10 text-center text-slate-400">
|
||||
<Sparkles size={32} className="mx-auto mb-2 opacity-20"/>
|
||||
<p>Nenhum lançamento encontrado.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm" onClick={() => setIsModalOpen(false)}></div>
|
||||
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-md animate-scale-up flex flex-col max-h-[90vh]">
|
||||
<div className="flex justify-between items-center px-6 py-4 border-b border-slate-100 flex-shrink-0">
|
||||
<h3 className="font-bold text-slate-800 text-lg">{editingId ? 'Editar Recebimento' : 'Novo Recebimento'}</h3>
|
||||
<button onClick={() => setIsModalOpen(false)}><X size={20} className="text-slate-400 hover:text-slate-600"/></button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4 overflow-y-auto">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-800 mb-1">Descrição</label>
|
||||
<input
|
||||
type="text"
|
||||
className={inputClass}
|
||||
placeholder="Ex: Consultoria Extra"
|
||||
value={newReceivable.description || ''}
|
||||
onChange={e => setNewReceivable({...newReceivable, description: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-800 mb-1">Cliente / Empresa</label>
|
||||
<input
|
||||
type="text"
|
||||
className={inputClass}
|
||||
placeholder="Nome do cliente"
|
||||
value={newReceivable.companyName || ''}
|
||||
onChange={e => setNewReceivable({...newReceivable, companyName: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-800 mb-1">Valor (R$)</label>
|
||||
<input
|
||||
type="number"
|
||||
className={inputClass}
|
||||
placeholder="0,00"
|
||||
value={newReceivable.value || ''}
|
||||
onChange={e => setNewReceivable({...newReceivable, value: Number(e.target.value)})}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-800 mb-1">Vencimento</label>
|
||||
<input
|
||||
type="date"
|
||||
className={inputClass}
|
||||
value={newReceivable.dueDate}
|
||||
onChange={e => setNewReceivable({...newReceivable, dueDate: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-800 mb-1">Categoria</label>
|
||||
<CustomSelect
|
||||
value={newReceivable.category || 'Serviços'}
|
||||
onChange={(val) => setNewReceivable({...newReceivable, category: val})}
|
||||
options={[
|
||||
{ value: 'Serviços', label: 'Serviços' },
|
||||
{ value: 'Produtos', label: 'Produtos' },
|
||||
{ value: 'Reembolso', label: 'Reembolso' },
|
||||
{ value: 'Outros', label: 'Outros' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-800 mb-1">Tipo</label>
|
||||
<CustomSelect
|
||||
value={newReceivable.type}
|
||||
onChange={(val) => setNewReceivable({...newReceivable, type: val})}
|
||||
options={[
|
||||
{ value: 'one-time', label: 'Avulso' },
|
||||
{ value: 'recurring', label: 'Recorrente' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onClick={handleSave} className="w-full py-3 bg-green-500 text-white font-bold rounded-xl mt-4 hover:bg-green-600 shadow-lg shadow-green-200">
|
||||
{editingId ? 'Salvar Alterações' : 'Salvar Recebimento'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
648
components/CRMView.tsx
Normal file
648
components/CRMView.tsx
Normal file
@@ -0,0 +1,648 @@
|
||||
|
||||
import React, { useState, useRef } from 'react';
|
||||
import {
|
||||
Building2, Phone, Mail, MapPin, Globe, Calendar,
|
||||
FileText, Users, Plus, ArrowLeft, MoreHorizontal,
|
||||
Download, Search, Filter, CheckCircle, Briefcase, ExternalLink, X, Save, UploadCloud, DollarSign, MessageCircle, AlertTriangle, Pencil, Camera, Trash2, ChevronDown
|
||||
} from 'lucide-react';
|
||||
import { Company, ContactPerson, CompanyDocument, Service } from '../types';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
|
||||
interface CRMViewProps {
|
||||
companies: Company[];
|
||||
setCompanies: (companies: Company[]) => void;
|
||||
availableServices: Service[];
|
||||
}
|
||||
|
||||
// Simple Modal (Reused)
|
||||
const Modal: React.FC<{isOpen: boolean; onClose: () => void; title: string; children: React.ReactNode; maxWidth?: string}> = ({ isOpen, onClose, title, children, maxWidth = 'max-w-lg' }) => {
|
||||
if (!isOpen) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-slate-900/50 backdrop-blur-sm" onClick={onClose}></div>
|
||||
<div className={`relative bg-white rounded-2xl shadow-xl w-full ${maxWidth} overflow-hidden animate-fade-in flex flex-col max-h-[90vh]`}>
|
||||
<div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center flex-shrink-0">
|
||||
<h3 className="font-bold text-slate-800 text-lg">{title}</h3>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-slate-600"><X size={20}/></button>
|
||||
</div>
|
||||
<div className="p-6 overflow-y-auto">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CRMView: React.FC<CRMViewProps> = ({ companies, setCompanies, availableServices }) => {
|
||||
const { addToast } = useToast();
|
||||
const [viewMode, setViewMode] = useState<'list' | 'detail'>('list');
|
||||
const [selectedCompanyId, setSelectedCompanyId] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'contacts' | 'documents' | 'services'>('overview');
|
||||
|
||||
// Modal States
|
||||
const [isCompanyModalOpen, setIsCompanyModalOpen] = useState(false);
|
||||
const [isContactModalOpen, setIsContactModalOpen] = useState(false);
|
||||
const [isServiceModalOpen, setIsServiceModalOpen] = useState(false);
|
||||
|
||||
// Editing States
|
||||
const [isEditingOverview, setIsEditingOverview] = useState(false);
|
||||
const [editingContactId, setEditingContactId] = useState<string | null>(null);
|
||||
|
||||
// Forms State
|
||||
const [newCompany, setNewCompany] = useState<Partial<Company>>({ status: 'active', industry: 'Outro', logo: '' });
|
||||
const [overviewForm, setOverviewForm] = useState<Partial<Company>>({});
|
||||
const [contactForm, setContactForm] = useState<Partial<ContactPerson>>({});
|
||||
const [selectedServiceId, setSelectedServiceId] = useState('');
|
||||
|
||||
// Refs for File Uploads
|
||||
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||
const avatarInputRef = useRef<HTMLInputElement>(null);
|
||||
const docInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const selectedCompany = companies.find(c => c.id === selectedCompanyId);
|
||||
const clientRevenue = selectedCompany ? selectedCompany.activeServices.reduce((acc, s) => acc + s.price, 0) : 0;
|
||||
|
||||
// --- HELPER: FILE TO BASE64 ---
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>, callback: (base64: string) => void) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
callback(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
// --- ACTIONS ---
|
||||
|
||||
const handleSaveCompany = () => {
|
||||
if (!newCompany.name) {
|
||||
addToast({ type: 'warning', title: 'Campos Obrigatórios', message: 'Por favor, informe ao menos a Razão Social.' });
|
||||
return;
|
||||
}
|
||||
const company: Company = {
|
||||
...newCompany,
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
logo: newCompany.logo || `https://ui-avatars.com/api/?name=${newCompany.name}&background=random`,
|
||||
contacts: [],
|
||||
documents: [],
|
||||
activeServices: []
|
||||
} as Company;
|
||||
setCompanies([...companies, company]);
|
||||
setIsCompanyModalOpen(false);
|
||||
setNewCompany({ status: 'active', industry: 'Outro', logo: '' });
|
||||
addToast({ type: 'success', title: 'Empresa Cadastrada', message: `${company.name} foi adicionada com sucesso.` });
|
||||
};
|
||||
|
||||
const handleUpdateOverview = () => {
|
||||
if (!selectedCompany || !overviewForm.name) return;
|
||||
|
||||
const updatedCompanies = companies.map(c => {
|
||||
if (c.id === selectedCompany.id) {
|
||||
return { ...c, ...overviewForm };
|
||||
}
|
||||
return c;
|
||||
});
|
||||
setCompanies(updatedCompanies);
|
||||
setIsEditingOverview(false);
|
||||
addToast({ type: 'success', title: 'Dados Atualizados', message: 'As informações da empresa foram salvas.' });
|
||||
};
|
||||
|
||||
const openContactModal = (contact?: ContactPerson) => {
|
||||
if (contact) {
|
||||
setEditingContactId(contact.id);
|
||||
setContactForm(contact);
|
||||
} else {
|
||||
setEditingContactId(null);
|
||||
setContactForm({ avatar: '' });
|
||||
}
|
||||
setIsContactModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveContact = () => {
|
||||
if (!selectedCompany || !contactForm.name) {
|
||||
addToast({ type: 'warning', title: 'Nome Obrigatório', message: 'Informe o nome do responsável.' });
|
||||
return;
|
||||
}
|
||||
|
||||
let updatedContacts = [...selectedCompany.contacts];
|
||||
|
||||
if (editingContactId) {
|
||||
// Edit existing
|
||||
updatedContacts = updatedContacts.map(c => c.id === editingContactId ? { ...c, ...contactForm } as ContactPerson : c);
|
||||
} else {
|
||||
// Create new
|
||||
const newContact: ContactPerson = {
|
||||
...contactForm,
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
avatar: contactForm.avatar || `https://ui-avatars.com/api/?name=${contactForm.name}&background=random`
|
||||
} as ContactPerson;
|
||||
updatedContacts.push(newContact);
|
||||
}
|
||||
|
||||
const updatedCompanies = companies.map(c => {
|
||||
if (c.id === selectedCompany.id) {
|
||||
return { ...c, contacts: updatedContacts };
|
||||
}
|
||||
return c;
|
||||
});
|
||||
setCompanies(updatedCompanies);
|
||||
setIsContactModalOpen(false);
|
||||
setContactForm({});
|
||||
setEditingContactId(null);
|
||||
addToast({ type: 'success', title: 'Responsável Salvo', message: 'Lista de contatos atualizada.' });
|
||||
};
|
||||
|
||||
const handleDeleteContact = (contactId: string) => {
|
||||
if (!selectedCompany || !window.confirm("Excluir responsável?")) return;
|
||||
const updatedCompanies = companies.map(c => {
|
||||
if (c.id === selectedCompany.id) {
|
||||
return { ...c, contacts: c.contacts.filter(ct => ct.id !== contactId) };
|
||||
}
|
||||
return c;
|
||||
});
|
||||
setCompanies(updatedCompanies);
|
||||
addToast({ type: 'info', title: 'Responsável Removido' });
|
||||
};
|
||||
|
||||
const handleUploadDocument = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !selectedCompany) return;
|
||||
|
||||
const newDoc: CompanyDocument = {
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
title: file.name,
|
||||
type: file.name.endsWith('.pdf') ? 'briefing' : 'other',
|
||||
date: new Date().toLocaleDateString('pt-BR'),
|
||||
size: `${(file.size / 1024 / 1024).toFixed(2)} MB`
|
||||
};
|
||||
|
||||
const updatedCompanies = companies.map(c => {
|
||||
if (c.id === selectedCompany.id) {
|
||||
return { ...c, documents: [...c.documents, newDoc] };
|
||||
}
|
||||
return c;
|
||||
});
|
||||
setCompanies(updatedCompanies);
|
||||
addToast({ type: 'success', title: 'Upload Concluído', message: 'Arquivo anexado com sucesso.' });
|
||||
};
|
||||
|
||||
const handleAddService = () => {
|
||||
if (!selectedCompany || !selectedServiceId) return;
|
||||
const serviceToAdd = availableServices.find(s => s.id === selectedServiceId);
|
||||
if (!serviceToAdd) return;
|
||||
|
||||
const updatedCompanies = companies.map(c => {
|
||||
if (c.id === selectedCompany.id) {
|
||||
return { ...c, activeServices: [...c.activeServices, serviceToAdd] };
|
||||
}
|
||||
return c;
|
||||
});
|
||||
setCompanies(updatedCompanies);
|
||||
setIsServiceModalOpen(false);
|
||||
setSelectedServiceId('');
|
||||
addToast({ type: 'success', title: 'Serviço Adicionado', message: 'Contrato atualizado.' });
|
||||
};
|
||||
|
||||
const handleRemoveService = (index: number) => {
|
||||
if (!selectedCompany) return;
|
||||
const updatedCompanies = companies.map(c => {
|
||||
if (c.id === selectedCompany.id) {
|
||||
const newServices = [...c.activeServices];
|
||||
newServices.splice(index, 1);
|
||||
return { ...c, activeServices: newServices };
|
||||
}
|
||||
return c;
|
||||
});
|
||||
setCompanies(updatedCompanies);
|
||||
addToast({ type: 'info', title: 'Serviço Removido' });
|
||||
};
|
||||
|
||||
const openWhatsApp = (phone: string) => {
|
||||
const cleanPhone = phone.replace(/\D/g, '');
|
||||
window.open(`https://wa.me/55${cleanPhone}`, '_blank');
|
||||
};
|
||||
|
||||
// Styles
|
||||
const inputClass = "w-full p-3 bg-white border border-slate-200 rounded-xl focus:ring-2 focus:ring-primary-200 outline-none text-slate-800 text-sm";
|
||||
const selectClass = "w-full bg-white border border-slate-200 rounded-xl px-4 py-3 pr-10 text-sm font-medium text-slate-700 outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 appearance-none transition-all cursor-pointer shadow-sm";
|
||||
const labelClass = "block text-xs font-bold text-slate-700 mb-1 uppercase tracking-wide";
|
||||
|
||||
// --- VIEWS ---
|
||||
|
||||
if (viewMode === 'list') {
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in">
|
||||
<Modal isOpen={isCompanyModalOpen} onClose={() => setIsCompanyModalOpen(false)} title="Nova Empresa" maxWidth="max-w-2xl">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Logo Upload */}
|
||||
<div className="col-span-2 flex flex-col items-center justify-center mb-4">
|
||||
<div
|
||||
className="w-24 h-24 rounded-2xl bg-slate-100 border-2 border-dashed border-slate-300 flex items-center justify-center cursor-pointer overflow-hidden hover:border-primary-400 transition-colors relative group"
|
||||
onClick={() => logoInputRef.current?.click()}
|
||||
>
|
||||
{newCompany.logo ? (
|
||||
<img src={newCompany.logo} alt="Logo" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Camera className="text-slate-400 group-hover:text-primary-500" size={32} />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="text-white text-xs font-bold">Alterar</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400 mt-2">Clique para adicionar logo</span>
|
||||
<input
|
||||
type="file"
|
||||
ref={logoInputRef}
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
onChange={(e) => handleFileChange(e, (base64) => setNewCompany({...newCompany, logo: base64}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<label className={labelClass}>Razão Social</label>
|
||||
<input type="text" className={inputClass} value={newCompany.name || ''} onChange={e => setNewCompany({...newCompany, name: e.target.value})} placeholder="Ex: Tech Solutions LTDA" />
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<label className={labelClass}>Nome Fantasia</label>
|
||||
<input type="text" className={inputClass} value={newCompany.fantasyName || ''} onChange={e => setNewCompany({...newCompany, fantasyName: e.target.value})} placeholder="Ex: Tech Sol" />
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<label className={labelClass}>CNPJ</label>
|
||||
<input type="text" className={inputClass} value={newCompany.cnpj || ''} onChange={e => setNewCompany({...newCompany, cnpj: e.target.value})} placeholder="00.000.000/0001-00" />
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<label className={labelClass}>Inscrição Estadual</label>
|
||||
<input type="text" className={inputClass} value={newCompany.ie || ''} onChange={e => setNewCompany({...newCompany, ie: e.target.value})} />
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<label className={labelClass}>Cidade - UF</label>
|
||||
<input type="text" className={inputClass} value={newCompany.city || ''} onChange={e => setNewCompany({...newCompany, city: e.target.value})} />
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<label className={labelClass}>Telefone Geral</label>
|
||||
<input type="text" className={inputClass} value={newCompany.phone || ''} onChange={e => setNewCompany({...newCompany, phone: e.target.value})} />
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<label className={labelClass}>Email Geral</label>
|
||||
<input type="email" className={inputClass} value={newCompany.email || ''} onChange={e => setNewCompany({...newCompany, email: e.target.value})} />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className={labelClass}>Endereço Completo</label>
|
||||
<input type="text" className={inputClass} value={newCompany.address || ''} onChange={e => setNewCompany({...newCompany, address: e.target.value})} />
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<label className={labelClass}>Status</label>
|
||||
<div className="relative">
|
||||
<select className={selectClass} value={newCompany.status} onChange={e => setNewCompany({...newCompany, status: e.target.value as any})}>
|
||||
<option value="active">Ativo</option>
|
||||
<option value="pending">Pendente</option>
|
||||
<option value="inactive">Inativo</option>
|
||||
<option value="overdue">Em Atraso</option>
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" size={18} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 pt-4">
|
||||
<button onClick={handleSaveCompany} className="w-full py-3 bg-primary-500 text-white font-bold rounded-xl shadow-lg hover:bg-primary-600 transition-colors">Cadastrar Empresa</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800">Clientes & Empresas</h1>
|
||||
<p className="text-slate-500">Gerencie contratos, responsáveis e documentos.</p>
|
||||
</div>
|
||||
<button onClick={() => setIsCompanyModalOpen(true)} className="flex items-center gap-2 px-5 py-2.5 bg-primary-500 text-white rounded-xl hover:bg-primary-600 shadow-lg shadow-primary-200/50 transition-all font-bold">
|
||||
<Plus size={18} /> Nova Empresa
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{companies.map((company) => {
|
||||
const totalValue = company.activeServices.reduce((acc, s) => acc + s.price, 0);
|
||||
return (
|
||||
<div
|
||||
key={company.id}
|
||||
onClick={() => { setSelectedCompanyId(company.id); setViewMode('detail'); }}
|
||||
className={`group bg-white p-6 rounded-[2rem] shadow-sm hover:shadow-lg hover:-translate-y-1 transition-all border cursor-pointer relative overflow-hidden ${company.status === 'overdue' ? 'border-red-200 ring-1 ring-red-100' : 'border-slate-50'}`}
|
||||
>
|
||||
{company.status === 'overdue' && (
|
||||
<div className="absolute top-0 right-0 bg-red-500 text-white text-[10px] font-bold px-3 py-1 rounded-bl-xl">
|
||||
EM ATRASO
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4 mb-4 mt-2">
|
||||
<div className="w-16 h-16 rounded-2xl bg-slate-100 flex items-center justify-center text-slate-400 font-bold text-xl uppercase overflow-hidden border border-slate-100">
|
||||
{company.logo ? <img src={company.logo} className="w-full h-full object-cover"/> : company.name.substring(0,2)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-slate-800 text-lg group-hover:text-primary-500 line-clamp-1">{company.fantasyName || company.name}</h3>
|
||||
<p className="text-xs text-slate-400">{company.city || 'Local não informado'}</p>
|
||||
<div className="mt-1 flex gap-2">
|
||||
{company.status === 'active' && <span className="inline-block px-2 py-0.5 bg-green-50 text-green-600 rounded text-[10px] font-bold uppercase">Ativo</span>}
|
||||
{company.status === 'pending' && <span className="inline-block px-2 py-0.5 bg-amber-50 text-amber-600 rounded text-[10px] font-bold uppercase">Pendente</span>}
|
||||
{company.status === 'overdue' && <span className="inline-block px-2 py-0.5 bg-red-50 text-red-600 rounded text-[10px] font-bold uppercase">Financeiro Pendente</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-slate-50 pt-4 flex justify-between items-center">
|
||||
<span className="text-sm text-slate-500 flex items-center gap-1"><Users size={14}/> {company.contacts.length} Resp.</span>
|
||||
<span className="font-bold text-slate-800 text-sm">R$ {totalValue.toLocaleString('pt-BR')} /mês</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// DETALHE DA EMPRESA
|
||||
if (selectedCompany) {
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in pb-10">
|
||||
|
||||
{/* Modal Contato (Novo/Edição) */}
|
||||
<Modal isOpen={isContactModalOpen} onClose={() => setIsContactModalOpen(false)} title={editingContactId ? "Editar Responsável" : "Novo Responsável"}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div
|
||||
className="w-20 h-20 rounded-full bg-slate-100 border-2 border-dashed border-slate-300 flex items-center justify-center cursor-pointer overflow-hidden hover:border-primary-400 relative group"
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
>
|
||||
{contactForm.avatar ? (
|
||||
<img src={contactForm.avatar} alt="Avatar" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Camera className="text-slate-400 group-hover:text-primary-500" size={24} />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity rounded-full">
|
||||
<span className="text-white text-[10px] font-bold">Foto</span>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
ref={avatarInputRef}
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
onChange={(e) => handleFileChange(e, (base64) => setContactForm({...contactForm, avatar: base64}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Nome Completo</label>
|
||||
<input type="text" className={inputClass} value={contactForm.name || ''} onChange={e => setContactForm({...contactForm, name: e.target.value})} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Cargo / Função</label>
|
||||
<input type="text" className={inputClass} value={contactForm.role || ''} onChange={e => setContactForm({...contactForm, role: e.target.value})} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Email</label>
|
||||
<input type="email" className={inputClass} value={contactForm.email || ''} onChange={e => setContactForm({...contactForm, email: e.target.value})} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Telefone / WhatsApp</label>
|
||||
<input type="text" className={inputClass} value={contactForm.phone || ''} onChange={e => setContactForm({...contactForm, phone: e.target.value})} placeholder="(00) 00000-0000" />
|
||||
</div>
|
||||
<button onClick={handleSaveContact} className="w-full py-3 bg-primary-500 text-white font-bold rounded-xl mt-4">Salvar Responsável</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Modal Adicionar Serviço */}
|
||||
<Modal isOpen={isServiceModalOpen} onClose={() => setIsServiceModalOpen(false)} title="Adicionar Serviço">
|
||||
<div className="space-y-4">
|
||||
<label className={labelClass}>Selecione o Serviço</label>
|
||||
<div className="relative">
|
||||
<select className={selectClass} value={selectedServiceId} onChange={e => setSelectedServiceId(e.target.value)}>
|
||||
<option value="">Selecione...</option>
|
||||
{availableServices.map(s => <option key={s.id} value={s.id}>{s.name} - R$ {s.price}</option>)}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" size={18} />
|
||||
</div>
|
||||
<button onClick={handleAddService} disabled={!selectedServiceId} className="w-full py-3 bg-primary-500 text-white font-bold rounded-xl mt-4 disabled:opacity-50">Adicionar</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Header Detalhe */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={() => setViewMode('list')} className="p-2 bg-white border border-slate-200 rounded-xl hover:bg-slate-50 transition-colors">
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold text-slate-800">{selectedCompany.fantasyName || selectedCompany.name}</h1>
|
||||
<p className="text-sm text-slate-500">{selectedCompany.name} - {selectedCompany.cnpj}</p>
|
||||
</div>
|
||||
{selectedCompany.status === 'overdue' && (
|
||||
<div className="px-4 py-2 bg-red-100 text-red-700 rounded-xl font-bold flex items-center gap-2 animate-pulse">
|
||||
<AlertTriangle size={18} /> CLIENTE EM ATRASO
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-slate-200">
|
||||
{['overview', 'contacts', 'documents', 'services'].map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab as any)}
|
||||
className={`px-6 py-3 text-sm font-bold border-b-2 transition-colors ${
|
||||
activeTab === tab
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{tab === 'overview' && 'Visão Geral'}
|
||||
{tab === 'contacts' && 'Responsáveis'}
|
||||
{tab === 'documents' && 'Arquivos'}
|
||||
{tab === 'services' && 'Serviços'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="animate-fade-in">
|
||||
{activeTab === 'overview' && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-end">
|
||||
{!isEditingOverview ? (
|
||||
<button onClick={() => { setOverviewForm(selectedCompany); setIsEditingOverview(true); }} className="flex items-center gap-2 px-4 py-2 text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-xl font-bold text-sm transition-colors">
|
||||
<Pencil size={16} /> Editar Dados
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setIsEditingOverview(false)} className="px-4 py-2 text-slate-500 hover:text-slate-700 font-medium">Cancelar</button>
|
||||
<button onClick={handleUpdateOverview} className="flex items-center gap-2 px-4 py-2 bg-green-500 text-white rounded-xl font-bold hover:bg-green-600 shadow-lg shadow-green-200">
|
||||
<Save size={16} /> Salvar Alterações
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-white p-6 rounded-[2rem] border border-slate-50 shadow-sm space-y-4">
|
||||
<h3 className="font-bold text-slate-800 text-lg border-b border-slate-50 pb-2">Dados Cadastrais</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span className="block text-xs text-slate-400">CNPJ</span>
|
||||
{isEditingOverview ? <input className={inputClass} value={overviewForm.cnpj || ''} onChange={e => setOverviewForm({...overviewForm, cnpj: e.target.value})} /> : <span className="text-sm font-medium text-slate-700">{selectedCompany.cnpj || '-'}</span>}
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-xs text-slate-400">Inscrição Estadual</span>
|
||||
{isEditingOverview ? <input className={inputClass} value={overviewForm.ie || ''} onChange={e => setOverviewForm({...overviewForm, ie: e.target.value})} /> : <span className="text-sm font-medium text-slate-700">{selectedCompany.ie || 'Isento'}</span>}
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="block text-xs text-slate-400">Razão Social</span>
|
||||
{isEditingOverview ? <input className={inputClass} value={overviewForm.name || ''} onChange={e => setOverviewForm({...overviewForm, name: e.target.value})} /> : <span className="text-sm font-medium text-slate-700">{selectedCompany.name}</span>}
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="block text-xs text-slate-400">Endereço</span>
|
||||
{isEditingOverview ? (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<input className={`${inputClass} col-span-2`} value={overviewForm.address || ''} onChange={e => setOverviewForm({...overviewForm, address: e.target.value})} placeholder="Rua..." />
|
||||
<input className={inputClass} value={overviewForm.city || ''} onChange={e => setOverviewForm({...overviewForm, city: e.target.value})} placeholder="Cidade..." />
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-slate-700">{selectedCompany.address}, {selectedCompany.city}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-[2rem] border border-slate-50 shadow-sm space-y-4">
|
||||
<h3 className="font-bold text-slate-800 text-lg border-b border-slate-50 pb-2">Contato Geral</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Phone size={18} className="text-slate-400"/>
|
||||
{isEditingOverview ? <input className={inputClass} value={overviewForm.phone || ''} onChange={e => setOverviewForm({...overviewForm, phone: e.target.value})} /> : <span className="text-sm text-slate-700">{selectedCompany.phone}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Mail size={18} className="text-slate-400"/>
|
||||
{isEditingOverview ? <input className={inputClass} value={overviewForm.email || ''} onChange={e => setOverviewForm({...overviewForm, email: e.target.value})} /> : <span className="text-sm text-slate-700">{selectedCompany.email}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Globe size={18} className="text-slate-400"/>
|
||||
{isEditingOverview ? <input className={inputClass} value={overviewForm.website || ''} onChange={e => setOverviewForm({...overviewForm, website: e.target.value})} placeholder="Website" /> : <span className="text-sm text-slate-700">{selectedCompany.website || 'Sem site'}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'contacts' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<button onClick={() => openContactModal()} className="flex items-center gap-2 px-4 py-2 bg-slate-800 text-white rounded-xl text-sm font-bold hover:bg-slate-900"><Plus size={16}/> Adicionar Responsável</button>
|
||||
</div>
|
||||
{selectedCompany.contacts.length === 0 ? (
|
||||
<div className="text-center py-10 text-slate-400 bg-white rounded-[2rem] border border-dashed border-slate-200">
|
||||
Nenhum responsável cadastrado.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{selectedCompany.contacts.map(contact => (
|
||||
<div key={contact.id} className="bg-white p-4 rounded-2xl border border-slate-100 flex items-center justify-between shadow-sm group">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center font-bold text-slate-500 overflow-hidden border border-slate-200">
|
||||
{contact.avatar ? <img src={contact.avatar} className="w-full h-full object-cover"/> : contact.name.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-slate-800">{contact.name}</h4>
|
||||
<p className="text-xs text-slate-500">{contact.role}</p>
|
||||
<p className="text-xs text-slate-400 mt-1">{contact.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => openContactModal(contact)} className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-colors">
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
<button onClick={() => handleDeleteContact(contact.id)} className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:bg-red-50 hover:text-red-500 transition-colors">
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openWhatsApp(contact.phone)}
|
||||
className="w-8 h-8 rounded-full bg-green-50 text-green-600 flex items-center justify-center hover:bg-green-500 hover:text-white transition-colors"
|
||||
title={`WhatsApp: ${contact.phone}`}
|
||||
>
|
||||
<MessageCircle size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'documents' && (
|
||||
<div className="space-y-6">
|
||||
<input
|
||||
type="file"
|
||||
ref={docInputRef}
|
||||
className="hidden"
|
||||
onChange={handleUploadDocument}
|
||||
/>
|
||||
<div
|
||||
onClick={() => docInputRef.current?.click()}
|
||||
className="bg-slate-50 border-2 border-dashed border-slate-200 rounded-2xl p-8 text-center hover:bg-slate-100 transition-colors cursor-pointer group"
|
||||
>
|
||||
<UploadCloud size={48} className="mx-auto text-slate-300 group-hover:text-primary-400 mb-2 transition-colors" />
|
||||
<h4 className="font-bold text-slate-600">Arraste arquivos aqui ou clique para anexar</h4>
|
||||
<p className="text-xs text-slate-400 mt-1">PDFs, Contratos (DOCX), Briefings.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{selectedCompany.documents.map(doc => (
|
||||
<div key={doc.id} className="flex items-center justify-between p-4 bg-white rounded-xl border border-slate-100 hover:shadow-sm transition-shadow">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${doc.type === 'contract' ? 'bg-blue-50 text-blue-500' : 'bg-orange-50 text-orange-500'}`}>
|
||||
<FileText size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-slate-800 text-sm">{doc.title}</h4>
|
||||
<p className="text-xs text-slate-400">{doc.date} • {doc.size}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="p-2 text-slate-400 hover:text-primary-500"><Download size={18}/></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'services' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center bg-white p-4 rounded-2xl border border-slate-100">
|
||||
<div>
|
||||
<p className="text-xs text-slate-400 uppercase font-bold">Faturamento Total</p>
|
||||
<p className="text-xl font-bold text-slate-800">R$ {clientRevenue.toLocaleString('pt-BR')} <span className="text-xs font-normal text-slate-400">/mês</span></p>
|
||||
</div>
|
||||
<button onClick={() => setIsServiceModalOpen(true)} className="px-4 py-2 bg-primary-500 text-white text-sm font-bold rounded-xl hover:bg-primary-600">Adicionar Serviço</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{selectedCompany.activeServices.map((service, idx) => (
|
||||
<div key={idx} className="flex justify-between items-center p-4 bg-white rounded-xl border border-slate-100">
|
||||
<div>
|
||||
<h4 className="font-bold text-slate-800 text-sm">{service.name}</h4>
|
||||
<span className="text-xs bg-slate-100 text-slate-500 px-2 py-0.5 rounded">{service.billingType === 'recurring' ? 'Recorrente' : 'Pontual'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="font-bold text-slate-700">R$ {service.price.toLocaleString('pt-BR')}</span>
|
||||
<button onClick={() => handleRemoveService(idx)} className="text-slate-300 hover:text-red-500"><X size={16}/></button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
466
components/CalendarView.tsx
Normal file
466
components/CalendarView.tsx
Normal file
@@ -0,0 +1,466 @@
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Clock, CheckCircle2, AlertCircle, Plus, X, Calendar as CalendarIcon, Trash2, List, Grid, DollarSign, ChevronDown } from 'lucide-react';
|
||||
import { CalendarEvent, Expense, Receivable } from '../types';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { CustomSelect } from './CustomSelect';
|
||||
|
||||
interface CalendarViewProps {
|
||||
expenses?: Expense[];
|
||||
receivables?: Receivable[];
|
||||
}
|
||||
|
||||
const initialManualEvents: CalendarEvent[] = [
|
||||
{ id: '1', title: 'Reunião Uda Studios', date: new Date().toISOString().split('T')[0], type: 'meeting', completed: false, description: 'Alinhamento mensal sobre o progresso do projeto de redesign.' },
|
||||
];
|
||||
|
||||
export const CalendarView: React.FC<CalendarViewProps> = ({ expenses = [], receivables = [] }) => {
|
||||
const { addToast } = useToast();
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [manualEvents, setManualEvents] = useState<CalendarEvent[]>(initialManualEvents);
|
||||
|
||||
// Combine Manual Events with Financial Data
|
||||
const events = useMemo(() => {
|
||||
const expenseEvents: CalendarEvent[] = expenses.map(e => ({
|
||||
id: `exp-${e.id}`,
|
||||
title: `Pagar: ${e.title}`,
|
||||
date: e.dueDate,
|
||||
type: 'payment',
|
||||
description: `Valor: R$ ${e.amount.toLocaleString('pt-BR')} - Categoria: ${e.category} - Status: ${e.status === 'paid' ? 'Pago' : 'Pendente'}`,
|
||||
completed: e.status === 'paid'
|
||||
}));
|
||||
|
||||
const receivableEvents: CalendarEvent[] = receivables.map(r => ({
|
||||
id: `rec-${r.id}`,
|
||||
title: `Receber: ${r.description}`,
|
||||
date: r.dueDate,
|
||||
type: 'deadline', // Usaremos deadline logicamente, mas com cor especial visualmente
|
||||
description: `Valor: R$ ${r.value.toLocaleString('pt-BR')} - Cliente: ${r.companyName} - Status: ${r.status === 'paid' ? 'Recebido' : 'Pendente'}`,
|
||||
completed: r.status === 'paid'
|
||||
}));
|
||||
|
||||
return [...manualEvents, ...expenseEvents, ...receivableEvents];
|
||||
}, [manualEvents, expenses, receivables]);
|
||||
|
||||
// View State
|
||||
const [viewMode, setViewMode] = useState<'month' | 'agenda'>('month');
|
||||
|
||||
// Create Modal State
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [newEvent, setNewEvent] = useState<Partial<CalendarEvent>>({ type: 'meeting', date: new Date().toISOString().split('T')[0] });
|
||||
|
||||
// Detail Modal State
|
||||
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
|
||||
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null);
|
||||
|
||||
const getDaysInMonth = (year: number, month: number) => new Date(year, month + 1, 0).getDate();
|
||||
const getFirstDayOfMonth = (year: number, month: number) => new Date(year, month, 1).getDay();
|
||||
|
||||
const daysInMonth = getDaysInMonth(currentDate.getFullYear(), currentDate.getMonth());
|
||||
const firstDay = getFirstDayOfMonth(currentDate.getFullYear(), currentDate.getMonth());
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
const handleCreateEvent = () => {
|
||||
if (!newEvent.title || !newEvent.date) return;
|
||||
const event: CalendarEvent = {
|
||||
...newEvent,
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
completed: false
|
||||
} as CalendarEvent;
|
||||
setManualEvents([...manualEvents, event]);
|
||||
setIsModalOpen(false);
|
||||
setNewEvent({ type: 'meeting', date: new Date().toISOString().split('T')[0] });
|
||||
addToast({ type: 'success', title: 'Evento Criado', message: 'Agendamento salvo com sucesso.' });
|
||||
};
|
||||
|
||||
const handleEventClick = (event: CalendarEvent) => {
|
||||
setSelectedEvent(event);
|
||||
setIsDetailModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteEvent = () => {
|
||||
if (!selectedEvent) return;
|
||||
// Prevent deleting financial data from calendar
|
||||
if (selectedEvent.id.startsWith('exp-') || selectedEvent.id.startsWith('rec-')) {
|
||||
addToast({ type: 'warning', title: 'Ação Bloqueada', message: "Para excluir este registro, acesse o módulo Financeiro." });
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.confirm('Deseja excluir este evento?')) {
|
||||
setManualEvents(manualEvents.filter(e => e.id !== selectedEvent.id));
|
||||
setIsDetailModalOpen(false);
|
||||
setSelectedEvent(null);
|
||||
addToast({ type: 'info', title: 'Evento Removido' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleComplete = () => {
|
||||
if (!selectedEvent) return;
|
||||
|
||||
// Prevent modifying financial data status from calendar (simplification for now)
|
||||
if (selectedEvent.id.startsWith('exp-') || selectedEvent.id.startsWith('rec-')) {
|
||||
addToast({ type: 'warning', title: 'Ação Bloqueada', message: "Para baixar este pagamento, vá ao módulo Financeiro." });
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedEvents = manualEvents.map(e =>
|
||||
e.id === selectedEvent.id ? { ...e, completed: !e.completed } : e
|
||||
);
|
||||
setManualEvents(updatedEvents);
|
||||
setSelectedEvent({ ...selectedEvent, completed: !selectedEvent.completed });
|
||||
addToast({ type: 'success', title: selectedEvent.completed ? 'Reaberto' : 'Concluído', message: 'Status do evento atualizado.' });
|
||||
};
|
||||
|
||||
const getEventStyle = (event: CalendarEvent) => {
|
||||
if (event.id.startsWith('rec-')) {
|
||||
return 'bg-green-50 border-green-400 text-green-700';
|
||||
}
|
||||
if (event.type === 'payment' || event.id.startsWith('exp-')) {
|
||||
return 'bg-red-50 border-red-400 text-red-700';
|
||||
}
|
||||
if (event.type === 'deadline') {
|
||||
return 'bg-amber-50 border-amber-400 text-amber-700';
|
||||
}
|
||||
return 'bg-blue-50 border-blue-400 text-blue-700';
|
||||
};
|
||||
|
||||
const getEventTypeLabel = (event: CalendarEvent) => {
|
||||
if (event.id.startsWith('rec-')) return 'Recebimento';
|
||||
if (event.type === 'payment' || event.id.startsWith('exp-')) return 'Pagamento';
|
||||
if (event.type === 'deadline') return 'Prazo';
|
||||
return 'Reunião';
|
||||
};
|
||||
|
||||
// --- Render Helpers ---
|
||||
|
||||
const handlePrevMonth = () => setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1));
|
||||
const handleNextMonth = () => setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1));
|
||||
|
||||
const days = [];
|
||||
// Empty slots
|
||||
for (let i = 0; i < firstDay; i++) {
|
||||
days.push(<div key={`empty-${i}`} className="h-32 bg-slate-50/50 border border-slate-100" />);
|
||||
}
|
||||
|
||||
const todayStr = new Date().toISOString().split('T')[0];
|
||||
|
||||
// Days
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const dateStr = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||
const dayEvents = events.filter(e => e.date === dateStr);
|
||||
const isToday = dateStr === todayStr;
|
||||
|
||||
days.push(
|
||||
<div key={d} className={`h-32 border border-slate-100 p-2 transition-colors hover:bg-slate-50 ${isToday ? 'bg-orange-50/30' : 'bg-white'}`}>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<span className={`text-sm font-bold w-7 h-7 flex items-center justify-center rounded-full ${isToday ? 'bg-primary-500 text-white' : 'text-slate-700'}`}>
|
||||
{d}
|
||||
</span>
|
||||
{dayEvents.length > 0 && <span className="text-[10px] bg-slate-100 px-1.5 rounded text-slate-500">{dayEvents.length}</span>}
|
||||
</div>
|
||||
<div className="space-y-1 overflow-y-auto max-h-[80px] scrollbar-thin">
|
||||
{dayEvents.map(ev => (
|
||||
<div
|
||||
key={ev.id}
|
||||
onClick={(e) => { e.stopPropagation(); handleEventClick(ev); }}
|
||||
className={`text-[10px] px-2 py-1 rounded border-l-2 truncate cursor-pointer transition-all hover:brightness-95 ${getEventStyle(ev)} ${ev.completed ? 'opacity-50 line-through' : ''}`}>
|
||||
{ev.title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Upcoming Deadlines Logic: Filter events from "today" onwards
|
||||
const upcomingEvents = events
|
||||
.filter(e => e.date >= todayStr && !e.completed)
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
const sidebarEvents = upcomingEvents.slice(0, 5);
|
||||
|
||||
// Styles for Inputs
|
||||
const labelClass = "block text-sm font-bold text-slate-800 mb-1";
|
||||
const inputClass = "w-full p-3 bg-white border border-slate-300 rounded-xl text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none transition-shadow";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col xl:flex-row gap-6 h-[calc(100vh-140px)] animate-fade-in relative">
|
||||
|
||||
{/* Create Event Modal */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm" onClick={() => setIsModalOpen(false)}></div>
|
||||
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-md p-6 animate-scale-up flex flex-col max-h-[90vh]">
|
||||
<div className="flex justify-between items-center mb-6 flex-shrink-0">
|
||||
<h3 className="font-bold text-slate-800 text-lg">Novo Evento</h3>
|
||||
<button onClick={() => setIsModalOpen(false)}><X size={20} className="text-slate-400 hover:text-slate-600"/></button>
|
||||
</div>
|
||||
<div className="space-y-4 overflow-y-auto">
|
||||
<div>
|
||||
<label className={labelClass}>Título</label>
|
||||
<input
|
||||
type="text"
|
||||
className={inputClass}
|
||||
value={newEvent.title || ''}
|
||||
onChange={e => setNewEvent({...newEvent, title: e.target.value})}
|
||||
placeholder="Ex: Reunião com Cliente"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>Data</label>
|
||||
<input
|
||||
type="date"
|
||||
className={inputClass}
|
||||
value={newEvent.date}
|
||||
onChange={e => setNewEvent({...newEvent, date: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Tipo</label>
|
||||
<CustomSelect
|
||||
value={newEvent.type || 'meeting'}
|
||||
onChange={(val) => setNewEvent({...newEvent, type: val})}
|
||||
options={[
|
||||
{ value: 'meeting', label: 'Reunião' },
|
||||
{ value: 'deadline', label: 'Prazo / Tarefa' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Descrição</label>
|
||||
<textarea
|
||||
className={inputClass}
|
||||
rows={3}
|
||||
value={newEvent.description || ''}
|
||||
onChange={e => setNewEvent({...newEvent, description: e.target.value})}
|
||||
placeholder="Detalhes adicionais..."
|
||||
/>
|
||||
</div>
|
||||
<button onClick={handleCreateEvent} className="w-full py-3 bg-primary-500 text-white font-bold rounded-xl mt-2 hover:bg-primary-600 transition-colors shadow-lg shadow-primary-200/50">
|
||||
Salvar Evento
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail Event Modal */}
|
||||
{isDetailModalOpen && selectedEvent && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm" onClick={() => setIsDetailModalOpen(false)}></div>
|
||||
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-md overflow-hidden animate-scale-up">
|
||||
<div className={`h-2 w-full ${selectedEvent.id.startsWith('rec-') ? 'bg-green-500' : selectedEvent.type === 'payment' ? 'bg-red-500' : selectedEvent.type === 'deadline' ? 'bg-amber-500' : 'bg-blue-500'}`} />
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<span className={`inline-block px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider mb-2 ${
|
||||
selectedEvent.id.startsWith('rec-') ? 'bg-green-50 text-green-600' :
|
||||
(selectedEvent.type === 'payment' || selectedEvent.id.startsWith('exp-')) ? 'bg-red-50 text-red-600' :
|
||||
selectedEvent.type === 'deadline' ? 'bg-amber-50 text-amber-600' :
|
||||
'bg-blue-50 text-blue-600'
|
||||
}`}>
|
||||
{getEventTypeLabel(selectedEvent)}
|
||||
</span>
|
||||
<h3 className={`font-bold text-xl text-slate-800 ${selectedEvent.completed ? 'line-through opacity-50' : ''}`}>
|
||||
{selectedEvent.title}
|
||||
</h3>
|
||||
</div>
|
||||
<button onClick={() => setIsDetailModalOpen(false)}><X size={20} className="text-slate-400 hover:text-slate-600"/></button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mb-8">
|
||||
<div className="flex items-center gap-3 text-slate-600">
|
||||
<CalendarIcon size={18} className="text-slate-400"/>
|
||||
<span className="font-medium">
|
||||
{new Date(selectedEvent.date + 'T12:00:00').toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
</span>
|
||||
</div>
|
||||
{selectedEvent.description ? (
|
||||
<div className="bg-slate-50 p-4 rounded-xl text-sm text-slate-600 border border-slate-100 whitespace-pre-line">
|
||||
{selectedEvent.description}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-400 italic">Sem descrição.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleToggleComplete}
|
||||
className={`flex-1 py-3 rounded-xl font-bold text-sm flex items-center justify-center gap-2 transition-colors ${
|
||||
selectedEvent.completed
|
||||
? 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||
: 'bg-green-500 text-white hover:bg-green-600 shadow-lg shadow-green-200'
|
||||
}`}
|
||||
>
|
||||
<CheckCircle2 size={18} />
|
||||
{selectedEvent.completed ? 'Reabrir' : 'Concluir'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteEvent}
|
||||
className="p-3 bg-red-50 text-red-500 rounded-xl hover:bg-red-100 transition-colors"
|
||||
title="Excluir"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Area */}
|
||||
<div className="flex-1 flex flex-col bg-white rounded-[2rem] shadow-sm border border-slate-50 overflow-hidden">
|
||||
{/* Calendar Header */}
|
||||
<div className="p-6 border-b border-slate-100 flex flex-col sm:flex-row justify-between items-center gap-4">
|
||||
<div className="flex gap-4 items-center">
|
||||
{viewMode === 'month' ? (
|
||||
<>
|
||||
<h2 className="text-xl font-bold text-slate-800 capitalize">
|
||||
{currentDate.toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' })}
|
||||
</h2>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={handlePrevMonth} className="p-1 hover:bg-slate-100 rounded-lg text-slate-500"><ChevronLeft size={20}/></button>
|
||||
<button onClick={handleNextMonth} className="p-1 hover:bg-slate-100 rounded-lg text-slate-500"><ChevronRight size={20}/></button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<h2 className="text-xl font-bold text-slate-800">Agenda Completa</h2>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex bg-slate-100 p-1 rounded-xl">
|
||||
<button
|
||||
onClick={() => setViewMode('month')}
|
||||
className={`p-2 rounded-lg transition-all ${viewMode === 'month' ? 'bg-white shadow text-primary-500' : 'text-slate-400 hover:text-slate-600'}`}
|
||||
title="Visão Mensal"
|
||||
>
|
||||
<Grid size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('agenda')}
|
||||
className={`p-2 rounded-lg transition-all ${viewMode === 'agenda' ? 'bg-white shadow text-primary-500' : 'text-slate-400 hover:text-slate-600'}`}
|
||||
title="Lista (Agenda)"
|
||||
>
|
||||
<List size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={() => setIsModalOpen(true)} className="flex items-center gap-2 px-4 py-2 bg-primary-500 text-white rounded-xl text-sm font-bold shadow-lg shadow-primary-200/50 hover:bg-primary-600">
|
||||
<Plus size={16} /> <span className="hidden sm:inline">Novo Evento</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewMode === 'month' ? (
|
||||
<>
|
||||
<div className="grid grid-cols-7 border-b border-slate-100 bg-slate-50/50">
|
||||
{['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb'].map(d => (
|
||||
<div key={d} className="py-3 text-center text-xs font-bold text-slate-400 uppercase tracking-wider">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 flex-1 overflow-y-auto">
|
||||
{days}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
{upcomingEvents.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-slate-400">
|
||||
<CheckCircle2 size={48} className="mb-4 opacity-20" />
|
||||
<p>Nenhum evento futuro encontrado.</p>
|
||||
</div>
|
||||
) : (
|
||||
upcomingEvents.map(event => (
|
||||
<div
|
||||
key={event.id}
|
||||
onClick={() => handleEventClick(event)}
|
||||
className="flex items-center gap-6 p-4 bg-slate-50 border border-slate-100 rounded-2xl hover:bg-white hover:shadow-md transition-all cursor-pointer group"
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center w-16 h-16 bg-white rounded-xl border border-slate-200 shrink-0">
|
||||
<span className="text-xs font-bold text-slate-400 uppercase">
|
||||
{new Date(event.date + 'T12:00:00').toLocaleDateString('pt-BR', { month: 'short' }).replace('.', '')}
|
||||
</span>
|
||||
<span className="text-xl font-bold text-slate-800">
|
||||
{new Date(event.date + 'T12:00:00').getDate()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h4 className={`font-bold text-lg text-slate-800 ${event.completed ? 'line-through opacity-50' : ''}`}>{event.title}</h4>
|
||||
<p className="text-sm text-slate-500 line-clamp-1">{event.description || 'Sem descrição'}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider ${
|
||||
event.id.startsWith('rec-') ? 'bg-green-50 text-green-600' :
|
||||
(event.type === 'payment' || event.id.startsWith('exp-')) ? 'bg-red-50 text-red-600' :
|
||||
event.type === 'deadline' ? 'bg-amber-50 text-amber-600' :
|
||||
'bg-blue-50 text-blue-600'
|
||||
}`}>
|
||||
{getEventTypeLabel(event)}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">
|
||||
{new Date(event.date + 'T12:00:00').toLocaleDateString('pt-BR', { weekday: 'long' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar: Upcoming & Deadlines */}
|
||||
<div className="w-full xl:w-80 flex flex-col gap-6">
|
||||
<div className="bg-white p-6 rounded-[2rem] shadow-sm border border-slate-50 flex-1 overflow-y-auto min-h-[300px]">
|
||||
<h3 className="font-bold text-slate-800 text-lg mb-4 flex items-center gap-2">
|
||||
<Clock size={20} className="text-primary-500"/>
|
||||
Próximos Vencimentos
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400 mb-6">Próximos eventos e pagamentos.</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{sidebarEvents.map(event => (
|
||||
<div key={event.id} onClick={() => handleEventClick(event)} className="flex gap-4 items-start p-3 hover:bg-slate-50 rounded-2xl transition-colors border border-transparent hover:border-slate-100 group cursor-pointer">
|
||||
<div className={`mt-1 w-3 h-3 rounded-full shrink-0 ${
|
||||
event.id.startsWith('rec-') ? 'bg-green-500 shadow-sm shadow-green-200' :
|
||||
(event.type === 'payment' || event.id.startsWith('exp-')) ? 'bg-red-500 shadow-sm shadow-red-200' :
|
||||
event.type === 'deadline' ? 'bg-amber-500 shadow-sm shadow-amber-200' :
|
||||
'bg-blue-500 shadow-sm shadow-blue-200'
|
||||
}`} />
|
||||
<div>
|
||||
<h4 className="font-bold text-slate-800 text-sm group-hover:text-primary-500 transition-colors">{event.title}</h4>
|
||||
<p className="text-xs text-slate-500 font-medium mt-0.5">
|
||||
{new Date(event.date + 'T12:00:00').toLocaleDateString('pt-BR', { day: '2-digit', month: 'long' })}
|
||||
</p>
|
||||
|
||||
<div className="mt-2 flex gap-2">
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded border uppercase font-bold tracking-wider ${
|
||||
event.id.startsWith('rec-') ? 'bg-green-50 border-green-100 text-green-600' :
|
||||
(event.type === 'payment' || event.id.startsWith('exp-')) ? 'bg-red-50 border-red-100 text-red-600' :
|
||||
event.type === 'deadline' ? 'bg-amber-50 border-amber-100 text-amber-600' :
|
||||
'bg-blue-50 border-blue-100 text-blue-600'
|
||||
}`}>
|
||||
{getEventTypeLabel(event)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{sidebarEvents.length === 0 && (
|
||||
<div className="text-center py-10 text-slate-400 text-sm">
|
||||
<CheckCircle2 size={32} className="mx-auto mb-2 opacity-20"/>
|
||||
Tudo tranquilo por aqui.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
90
components/ContactsView.tsx
Normal file
90
components/ContactsView.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import { Phone, Mail, MoreHorizontal, Plus, Grid, List } from 'lucide-react';
|
||||
import { Client } from '../types';
|
||||
|
||||
const contactsData: Client[] = [
|
||||
{ id: '1', name: 'Jordan Nico', role: 'Designer na', company: 'Uda Studios', phone: '(11) 93456-7890', email: 'jordan@email.com', avatar: 'https://i.pravatar.cc/150?u=1' },
|
||||
{ id: '2', name: 'Tony Soap', role: 'Desenvolvedor na', company: 'Uda Studios', phone: '(11) 93456-7890', email: 'tony@email.com', avatar: 'https://i.pravatar.cc/150?u=2' },
|
||||
{ id: '3', name: 'Karen Hope', role: 'Gerente na', company: 'Uda Studios', phone: '(11) 93456-7890', email: 'karen@email.com', avatar: 'https://i.pravatar.cc/150?u=3' },
|
||||
{ id: '4', name: 'Gabriel', role: 'Designer na', company: 'Angels Studios', phone: '(11) 93456-7890', email: 'gabriel@email.com', avatar: 'https://i.pravatar.cc/150?u=4' },
|
||||
{ id: '5', name: 'Tarmiel', role: 'Designer na', company: 'Heaven Studios', phone: '(11) 93456-7890', email: 'tarmiel@email.com', avatar: 'https://i.pravatar.cc/150?u=5' },
|
||||
{ id: '6', name: 'Sariel', role: 'Designer na', company: 'Sanctuary Studios', phone: '(11) 93456-7890', email: 'sariel@email.com', avatar: 'https://i.pravatar.cc/150?u=6' },
|
||||
{ id: '7', name: 'Cahaya Hikari', role: 'Designer na', company: 'Jekate Studios', phone: '(11) 93456-7890', email: 'cahaya@email.com', avatar: 'https://i.pravatar.cc/150?u=7' },
|
||||
{ id: '8', name: 'Nadila Adja', role: 'Designer na', company: 'FX Studios', phone: '(11) 93456-7890', email: 'nadila@email.com', avatar: 'https://i.pravatar.cc/150?u=8' },
|
||||
{ id: '9', name: 'Angelina Crispy', role: 'Designer na', company: 'Patlapan Studios', phone: '(11) 93456-7890', email: 'angelina@email.com', avatar: 'https://i.pravatar.cc/150?u=9' },
|
||||
];
|
||||
|
||||
export const ContactsView: React.FC = () => {
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in">
|
||||
{/* Header Controls */}
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div className="relative w-full md:w-96">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Pesquisar..."
|
||||
className="w-full pl-12 pr-4 py-3 bg-white border-none rounded-2xl shadow-sm text-slate-600 focus:ring-2 focus:ring-primary-200 outline-none"
|
||||
/>
|
||||
<svg className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button className="w-10 h-10 bg-[#2D3648] text-white rounded-xl flex items-center justify-center hover:bg-slate-700 transition-colors shadow-lg shadow-slate-200">
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
<button className="w-10 h-10 bg-white text-slate-400 rounded-xl flex items-center justify-center hover:text-primary-500 transition-colors shadow-sm">
|
||||
<List size={20} />
|
||||
</button>
|
||||
<button className="w-10 h-10 bg-white text-primary-500 rounded-xl flex items-center justify-center shadow-sm">
|
||||
<Grid size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cards Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{contactsData.map((contact) => (
|
||||
<div key={contact.id} className="bg-white p-6 rounded-[2rem] shadow-sm hover:shadow-md transition-shadow border border-slate-50">
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div className="flex gap-4">
|
||||
<div className="w-16 h-16 rounded-2xl overflow-hidden bg-slate-100">
|
||||
<img src={contact.avatar} alt={contact.name} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-slate-800 text-lg">{contact.name}</h3>
|
||||
<p className="text-slate-400 text-xs mt-1">{contact.role}</p>
|
||||
<p className="font-semibold text-slate-700 text-sm">{contact.company}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="text-slate-300 hover:text-slate-600">
|
||||
<MoreHorizontal size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-xs text-slate-400 mb-1 ml-1">Telefone</p>
|
||||
<button className="w-full py-2 px-4 rounded-2xl border border-slate-200 flex items-center gap-3 text-slate-700 font-medium hover:bg-primary-50 hover:border-primary-200 hover:text-primary-600 transition-colors group">
|
||||
<div className="w-8 h-8 rounded-full bg-primary-100/50 flex items-center justify-center text-primary-500 group-hover:bg-primary-500 group-hover:text-white transition-colors">
|
||||
<Phone size={14} />
|
||||
</div>
|
||||
{contact.phone}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-slate-400 mb-1 ml-1">E-mail</p>
|
||||
<button className="w-full py-2 px-4 rounded-2xl border border-slate-200 flex items-center gap-3 text-slate-700 font-medium hover:bg-primary-50 hover:border-primary-200 hover:text-primary-600 transition-colors group">
|
||||
<div className="w-8 h-8 rounded-full bg-primary-100/50 flex items-center justify-center text-primary-500 group-hover:bg-primary-500 group-hover:text-white transition-colors">
|
||||
<Mail size={14} />
|
||||
</div>
|
||||
{contact.email}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
85
components/CustomSelect.tsx
Normal file
85
components/CustomSelect.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { ChevronDown, Check } from 'lucide-react';
|
||||
|
||||
export interface Option {
|
||||
value: string | number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface CustomSelectProps {
|
||||
value: string | number;
|
||||
onChange: (value: any) => void;
|
||||
options: Option[];
|
||||
placeholder?: string;
|
||||
icon?: React.ReactNode;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = 'Selecione...',
|
||||
icon,
|
||||
className = '',
|
||||
disabled = false
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selectedOption = options.find(o => o.value === value);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`} ref={containerRef}>
|
||||
<div
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
className={`w-full p-3 bg-white border rounded-xl flex justify-between items-center cursor-pointer text-sm transition-all shadow-sm
|
||||
${isOpen ? 'border-primary-500 ring-2 ring-primary-100' : 'border-slate-200 hover:border-primary-300'}
|
||||
${disabled ? 'opacity-50 cursor-not-allowed bg-slate-50' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-2 truncate pr-2">
|
||||
{icon && <span className="text-slate-400 shrink-0">{icon}</span>}
|
||||
<span className={`truncate ${!selectedOption ? 'text-slate-400' : 'font-medium text-slate-700'}`}>
|
||||
{selectedOption ? selectedOption.label : placeholder}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown size={18} className={`text-slate-400 shrink-0 transition-transform duration-200 ${isOpen ? 'rotate-180 text-primary-500' : ''}`} />
|
||||
</div>
|
||||
|
||||
{isOpen && !disabled && (
|
||||
<div className="absolute top-full left-0 w-full mt-2 bg-white border border-slate-100 rounded-xl shadow-2xl z-50 max-h-60 overflow-y-auto animate-fade-in">
|
||||
{options.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
onClick={() => { onChange(option.value); setIsOpen(false); }}
|
||||
className={`p-3 text-sm cursor-pointer flex justify-between items-center transition-colors border-b border-slate-50 last:border-0
|
||||
${option.value === value
|
||||
? 'bg-primary-50 text-primary-700 font-bold'
|
||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'}
|
||||
`}
|
||||
>
|
||||
<span className="truncate">{option.label}</span>
|
||||
{option.value === value && <Check size={16} className="text-primary-500 shrink-0 ml-2" />}
|
||||
</div>
|
||||
))}
|
||||
{options.length === 0 && (
|
||||
<div className="p-3 text-sm text-slate-400 text-center italic">Sem opções</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
389
components/DashboardView.tsx
Normal file
389
components/DashboardView.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer
|
||||
} from 'recharts';
|
||||
import {
|
||||
TrendingUp, TrendingDown, AlertCircle, ArrowUpRight,
|
||||
Calendar as CalendarIcon, Trophy, Award, ShoppingBag, ChevronLeft, ChevronRight, CheckCircle2, PieChart, Wallet
|
||||
} from 'lucide-react';
|
||||
import { Company, Expense, Receivable, FinancialSummary } from '../types';
|
||||
import { AIInsightsWidget } from './AIInsightsWidget';
|
||||
|
||||
interface DashboardViewProps {
|
||||
financialSummary: FinancialSummary;
|
||||
companies: Company[];
|
||||
expenses: Expense[];
|
||||
receivables: Receivable[];
|
||||
}
|
||||
|
||||
const StatCard = ({ icon: Icon, label, value, subtext, type = 'neutral' }: any) => {
|
||||
// Cores adaptadas para o estilo "Larkon": fundos suaves e cores de destaque
|
||||
const iconStyle =
|
||||
type === 'success' ? 'bg-orange-500 text-white shadow-lg shadow-orange-500/30' :
|
||||
type === 'danger' ? 'bg-red-500 text-white shadow-lg shadow-red-500/30' :
|
||||
type === 'warning' ? 'bg-blue-500 text-white shadow-lg shadow-blue-500/30' :
|
||||
'bg-slate-800 text-white shadow-lg shadow-slate-800/30';
|
||||
|
||||
const trendColor =
|
||||
type === 'success' ? 'text-green-500' :
|
||||
type === 'danger' ? 'text-red-500' : 'text-slate-400';
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-3xl border border-slate-100 shadow-sm hover:shadow-md transition-all flex flex-col justify-between h-full relative overflow-hidden group">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className={`w-12 h-12 rounded-2xl flex items-center justify-center ${iconStyle} transition-transform group-hover:scale-110`}>
|
||||
<Icon size={22} strokeWidth={2.5} />
|
||||
</div>
|
||||
{/* Mock Sparkline or Trend Indicator */}
|
||||
<div className={`text-xs font-bold px-2 py-1 rounded-full bg-slate-50 ${trendColor} flex items-center gap-1`}>
|
||||
{type === 'success' ? <TrendingUp size={12}/> : <TrendingDown size={12}/>}
|
||||
{type === 'success' ? '+12%' : type === 'danger' ? '-5%' : '0%'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-3xl font-bold text-slate-800 mb-1 tracking-tight">{value}</h3>
|
||||
<p className="text-slate-400 text-sm font-medium">{label}</p>
|
||||
{subtext && <p className="text-xs mt-2 text-slate-400 opacity-80">{subtext}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DashboardView: React.FC<DashboardViewProps> = ({ financialSummary, companies, expenses, receivables }) => {
|
||||
const [currentDate, setCurrentDate] = useState(new Date()); // Inicia com a data atual real
|
||||
|
||||
// --- LÓGICA DO GRÁFICO DINÂMICO ---
|
||||
const chartData = useMemo(() => {
|
||||
const data = [];
|
||||
const today = new Date();
|
||||
|
||||
// Gerar os últimos 3 meses e próximos 2 meses
|
||||
for (let i = -3; i <= 2; i++) {
|
||||
const d = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
||||
const monthKey = d.toLocaleString('pt-BR', { month: 'short' });
|
||||
const yearIso = String(d.getFullYear());
|
||||
const monthIso = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const ym = `${yearIso}-${monthIso}`;
|
||||
|
||||
// Somar Receitas do Mês (Estritamente baseada em Receivables)
|
||||
const monthlyRevenue = receivables
|
||||
.filter(r => r.dueDate.startsWith(ym))
|
||||
.reduce((sum, r) => sum + r.value, 0);
|
||||
|
||||
// Somar Despesas do Mês
|
||||
const monthlyExpense = expenses
|
||||
.filter(e => e.dueDate.startsWith(ym))
|
||||
.reduce((sum, e) => sum + e.amount, 0);
|
||||
|
||||
data.push({
|
||||
name: monthKey.charAt(0).toUpperCase() + monthKey.slice(1),
|
||||
fullDate: ym,
|
||||
receita: monthlyRevenue,
|
||||
despesa: monthlyExpense,
|
||||
isCurrent: i === 0
|
||||
});
|
||||
}
|
||||
return data;
|
||||
}, [expenses, receivables]);
|
||||
|
||||
// --- LOGICA DE RANKING (Estritamente Financeira) ---
|
||||
|
||||
// 1. Top Clientes (Baseado APENAS em Contas a Receber existentes)
|
||||
const topClients = useMemo(() => {
|
||||
// Agrupar recebíveis por empresa
|
||||
const clientMap: Record<string, { name: string, total: number, count: number }> = {};
|
||||
|
||||
receivables.forEach(r => {
|
||||
if (!clientMap[r.companyName]) {
|
||||
clientMap[r.companyName] = { name: r.companyName, total: 0, count: 0 };
|
||||
}
|
||||
clientMap[r.companyName].total += r.value;
|
||||
clientMap[r.companyName].count += 1;
|
||||
});
|
||||
|
||||
return Object.values(clientMap)
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, 5);
|
||||
}, [receivables]); // Depende apenas de receivables
|
||||
|
||||
// 2. Serviços Mais Vendidos (Baseado APENAS em Contas a Receber existentes)
|
||||
const topServices = useMemo(() => {
|
||||
const serviceCounts: Record<string, {name: string, count: number, revenue: number, category: string}> = {};
|
||||
|
||||
receivables.forEach(r => {
|
||||
// Usamos a descrição como chave, pois o ID do serviço original não é salvo no receivable (simplificação)
|
||||
const key = r.description;
|
||||
if (!serviceCounts[key]) {
|
||||
serviceCounts[key] = { name: r.description, count: 0, revenue: 0, category: r.category };
|
||||
}
|
||||
serviceCounts[key].count += 1;
|
||||
serviceCounts[key].revenue += r.value;
|
||||
});
|
||||
|
||||
return Object.values(serviceCounts).sort((a, b) => b.count - a.count).slice(0, 5);
|
||||
}, [receivables]); // Depende apenas de receivables
|
||||
|
||||
// 3. Menor Inadimplência / Fidelidade (Mantido lógica de empresa pois é atributo de cadastro)
|
||||
const bestPayers = useMemo(() => {
|
||||
return [...companies]
|
||||
.filter(c => c.status === 'active')
|
||||
.sort((a, b) => parseInt(a.since) - parseInt(b.since))
|
||||
.slice(0, 5);
|
||||
}, [companies]);
|
||||
|
||||
|
||||
// --- LOGICA DO CALENDÁRIO ---
|
||||
|
||||
const handlePrevMonth = () => setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1));
|
||||
const handleNextMonth = () => setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1));
|
||||
|
||||
const getDaysInMonth = (year: number, month: number) => new Date(year, month + 1, 0).getDate();
|
||||
const getFirstDayOfMonth = (year: number, month: number) => new Date(year, month, 1).getDay();
|
||||
|
||||
const daysInMonth = getDaysInMonth(currentDate.getFullYear(), currentDate.getMonth());
|
||||
const firstDay = getFirstDayOfMonth(currentDate.getFullYear(), currentDate.getMonth());
|
||||
|
||||
const days = [];
|
||||
for (let i = 0; i < firstDay; i++) {
|
||||
days.push(<div key={`empty-${i}`} className="h-24 bg-slate-50/30" />);
|
||||
}
|
||||
|
||||
const todayStr = new Date().toISOString().split('T')[0];
|
||||
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const dateStr = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||
const isToday = dateStr === todayStr;
|
||||
|
||||
// Mapeando eventos (Despesas Reais e Recebimentos Reais)
|
||||
const dayExpenses = expenses.filter(e => e.dueDate === dateStr);
|
||||
const dayIncomes = receivables.filter(r => r.dueDate === dateStr);
|
||||
|
||||
days.push(
|
||||
<div key={d} className={`h-24 border-t border-r border-slate-100 p-2 transition-colors hover:bg-slate-50 flex flex-col justify-between group ${isToday ? 'bg-orange-50/50' : 'bg-white'}`}>
|
||||
<span className={`text-xs font-bold w-6 h-6 flex items-center justify-center rounded-full ${isToday ? 'bg-orange-500 text-white' : 'text-slate-400'}`}>
|
||||
{d}
|
||||
</span>
|
||||
<div className="space-y-1 overflow-y-auto scrollbar-hide">
|
||||
{dayExpenses.map(exp => (
|
||||
<div key={exp.id} className="text-[9px] px-1 py-0.5 rounded bg-red-50 text-red-600 truncate font-medium cursor-help" title={`Pagar: ${exp.title} (${exp.amount.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })})`}>
|
||||
-{exp.amount.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 })}
|
||||
</div>
|
||||
))}
|
||||
{dayIncomes.map(inc => (
|
||||
<div key={inc.id} className={`text-[9px] px-1 py-0.5 rounded truncate font-medium cursor-help ${inc.status === 'paid' ? 'bg-green-50 text-green-600' : 'bg-blue-50 text-blue-600'}`} title={`Receber: ${inc.description} (${inc.value.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })})`}>
|
||||
+{inc.value.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 })}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Verifica se há dados para exibir no gráfico
|
||||
const hasChartData = chartData.some(d => d.receita > 0 || d.despesa > 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
|
||||
{/* AI Insights Widget */}
|
||||
<AIInsightsWidget
|
||||
financialSummary={financialSummary}
|
||||
topClients={companies} // Passando empresas (usado apenas para nomes no insight)
|
||||
expenses={expenses}
|
||||
/>
|
||||
|
||||
{/* KPI Cards Row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6">
|
||||
<StatCard
|
||||
icon={Wallet}
|
||||
label="Receita Prevista"
|
||||
value={`R$ ${financialSummary.totalRevenue.toLocaleString('pt-BR')}`}
|
||||
subtext="Contas a Receber (Mês Atual)"
|
||||
type="success"
|
||||
/>
|
||||
<StatCard
|
||||
icon={ShoppingBag}
|
||||
label="Contas a Pagar"
|
||||
value={`R$ ${financialSummary.totalExpenses.toLocaleString('pt-BR')}`}
|
||||
subtext="Total de despesas (Mês Atual)"
|
||||
type="danger"
|
||||
/>
|
||||
<StatCard
|
||||
icon={TrendingUp}
|
||||
label="Lucro Previsto"
|
||||
value={`R$ ${financialSummary.profit.toLocaleString('pt-BR')}`}
|
||||
subtext="Receita - Despesas"
|
||||
type={financialSummary.profit >= 0 ? 'success' : 'danger'}
|
||||
/>
|
||||
<StatCard
|
||||
icon={AlertCircle}
|
||||
label="A Receber"
|
||||
value={`R$ ${financialSummary.receivablePending.toLocaleString('pt-BR')}`}
|
||||
subtext="Pendências (Total Global)"
|
||||
type="warning"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
{/* Left Column (Main Chart) */}
|
||||
<div className="xl:col-span-2 space-y-6">
|
||||
|
||||
{/* Financial Trend Chart */}
|
||||
<div className="bg-white p-6 rounded-3xl shadow-sm border border-slate-100 relative">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-slate-800">Fluxo de Caixa</h2>
|
||||
<p className="text-xs text-slate-400">Desempenho financeiro em tempo real</p>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs font-medium">
|
||||
<span className="flex items-center gap-1 text-slate-600"><span className="w-2.5 h-2.5 rounded-full bg-orange-500"></span> Receitas</span>
|
||||
<span className="flex items-center gap-1 text-slate-400"><span className="w-2.5 h-2.5 rounded-full bg-slate-300"></span> Despesas</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-80 relative">
|
||||
{hasChartData ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorReceita" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#f97316" stopOpacity={0.1}/>
|
||||
<stop offset="95%" stopColor="#f97316" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
<linearGradient id="colorDespesa" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#cbd5e1" stopOpacity={0.2}/>
|
||||
<stop offset="95%" stopColor="#cbd5e1" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} stroke="#f1f5f9" />
|
||||
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 12}} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 12}} />
|
||||
<Tooltip
|
||||
contentStyle={{borderRadius: '16px', border: 'none', boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1)'}}
|
||||
formatter={(value: number) => [`R$ ${value.toLocaleString('pt-BR')}`, '']}
|
||||
/>
|
||||
{/* Orange for Revenue (Primary), Grey for Expenses (Secondary) */}
|
||||
<Area type="monotone" dataKey="receita" name="Receitas" stroke="#f97316" strokeWidth={3} fillOpacity={1} fill="url(#colorReceita)" />
|
||||
<Area type="monotone" dataKey="despesa" name="Despesas" stroke="#94a3b8" strokeWidth={3} strokeDasharray="5 5" fillOpacity={1} fill="url(#colorDespesa)" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-slate-300">
|
||||
<PieChart size={48} className="mb-2 opacity-20" />
|
||||
<p className="text-sm">Sem movimentações no período</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CALENDÁRIO FINANCEIRO */}
|
||||
<div className="bg-white rounded-3xl shadow-sm border border-slate-100 overflow-hidden">
|
||||
<div className="p-6 border-b border-slate-100 flex justify-between items-center">
|
||||
<h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
|
||||
<CalendarIcon size={20} className="text-orange-500" />
|
||||
Agenda Financeira
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-bold text-slate-600 capitalize w-32 text-center">{currentDate.toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' })}</span>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={handlePrevMonth} className="p-1.5 hover:bg-slate-100 rounded-lg text-slate-400"><ChevronLeft size={16}/></button>
|
||||
<button onClick={handleNextMonth} className="p-1.5 hover:bg-slate-100 rounded-lg text-slate-400"><ChevronRight size={16}/></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 border-b border-slate-100 bg-slate-50">
|
||||
{['D', 'S', 'T', 'Q', 'Q', 'S', 'S'].map(d => (
|
||||
<div key={d} className="py-3 text-center text-[10px] font-bold text-slate-400 uppercase">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 bg-slate-50 border-l border-slate-100">
|
||||
{days}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: RANKINGS & Profit */}
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Top Products/Services Widget */}
|
||||
<div className="bg-white p-6 rounded-3xl shadow-sm border border-slate-100">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="font-bold text-slate-800 text-lg">Top Serviços</h3>
|
||||
<button className="text-slate-400 hover:text-orange-500 transition-colors">
|
||||
<ArrowUpRight size={18}/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
{topServices.length > 0 ? topServices.map((srv, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between group cursor-pointer">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-orange-50 flex items-center justify-center text-orange-500 group-hover:bg-orange-500 group-hover:text-white transition-colors">
|
||||
<Award size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-bold text-slate-800 line-clamp-1">{srv.name}</div>
|
||||
<div className="text-xs text-slate-400">{srv.count} vendas</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-bold text-slate-700">R$ {srv.revenue.toLocaleString('pt-BR')}</div>
|
||||
</div>
|
||||
)) : (
|
||||
<p className="text-sm text-slate-400 text-center py-4">Nenhum serviço faturado</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button className="w-full py-3 mt-6 text-sm font-bold text-orange-500 bg-orange-50 rounded-xl hover:bg-orange-100 transition-colors">
|
||||
Ver Relatório Completo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Ranking: Top Clientes */}
|
||||
<div className="bg-white p-6 rounded-3xl shadow-sm border border-slate-100">
|
||||
<h3 className="font-bold text-slate-800 mb-4 flex items-center gap-2">
|
||||
<Trophy size={18} className="text-yellow-500" />
|
||||
Melhores Clientes
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{topClients.length > 0 ? topClients.map((client, idx) => (
|
||||
<div key={idx} className="flex items-center gap-3 p-3 rounded-xl hover:bg-slate-50 transition-colors">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-bold text-xs ${idx === 0 ? 'bg-yellow-100 text-yellow-700' : 'bg-slate-100 text-slate-500'}`}>
|
||||
{idx + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-bold text-slate-800 line-clamp-1">{client.name}</div>
|
||||
<div className="w-full bg-slate-100 rounded-full h-1.5 mt-1.5 overflow-hidden">
|
||||
<div className="h-full bg-orange-500 rounded-full" style={{width: `${Math.min(100, (client.total / (topClients[0].total || 1)) * 100)}%`}}></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-bold text-slate-700">R$ {client.total.toLocaleString('pt-BR')}</div>
|
||||
</div>
|
||||
)) : (
|
||||
<p className="text-sm text-slate-400 text-center py-4">Sem dados</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ranking: Menor Inadimplência (Fidelidade) */}
|
||||
<div className="bg-slate-900 p-6 rounded-3xl shadow-lg text-white relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-white/5 rounded-full blur-2xl -translate-y-1/2 translate-x-1/2"></div>
|
||||
|
||||
<h3 className="font-bold text-white mb-4 flex items-center gap-2 relative z-10">
|
||||
<CheckCircle2 size={18} className="text-green-400" />
|
||||
Clientes Confiáveis
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400 mb-4 relative z-10">Baseado no histórico de pagamentos.</p>
|
||||
<div className="flex flex-wrap gap-2 relative z-10">
|
||||
{bestPayers.map((client) => (
|
||||
<span key={client.id} className="px-3 py-1 bg-white/10 hover:bg-white/20 text-white rounded-lg text-xs font-medium border border-white/5 transition-colors cursor-default">
|
||||
{client.fantasyName || client.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
467
components/FinancialReportsView.tsx
Normal file
467
components/FinancialReportsView.tsx
Normal file
@@ -0,0 +1,467 @@
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { FinancialReportType, Expense, Receivable } from '../types';
|
||||
import { Download, Printer, Filter, ChevronRight, X, Calendar, Building2, CheckCircle2, FileText } from 'lucide-react';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { CustomSelect } from './CustomSelect';
|
||||
|
||||
interface FinancialReportsViewProps {
|
||||
expenses: Expense[];
|
||||
receivables: Receivable[];
|
||||
}
|
||||
|
||||
const ReportTab = ({ active, label, onClick }: { active: boolean, label: string, onClick: () => void }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`px-5 py-2.5 rounded-xl text-sm font-medium transition-all duration-300 ${
|
||||
active
|
||||
? 'bg-primary-500 text-white shadow-lg shadow-primary-200'
|
||||
: 'text-slate-500 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
const TableRow = ({ label, value, indent = 0, isTotal = false, isHeader = false }: { label: string, value: string | React.ReactNode, indent?: number, isTotal?: boolean, isHeader?: boolean }) => (
|
||||
<div className={`flex justify-between items-center py-3 border-b border-slate-50 hover:bg-slate-50 transition-colors px-4
|
||||
${isHeader ? 'bg-slate-50 font-bold text-slate-800 border-t mt-2' : ''}
|
||||
${isTotal ? 'bg-slate-50/50 font-bold' : ''}`}>
|
||||
<span className={`text-slate-700 ${indent === 1 ? 'pl-4' : indent === 2 ? 'pl-8' : ''} ${isTotal ? 'text-slate-900' : ''}`}>
|
||||
{label}
|
||||
</span>
|
||||
<span className={`${isTotal ? 'text-primary-600' : 'text-slate-600'} ${isHeader ? 'text-slate-800' : ''}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const FinancialReportsView: React.FC<FinancialReportsViewProps> = ({ expenses, receivables }) => {
|
||||
const { addToast } = useToast();
|
||||
const [activeTab, setActiveTab] = useState<FinancialReportType>('DRE');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [filters, setFilters] = useState({
|
||||
period: 'mensal',
|
||||
costCenter: 'Todos'
|
||||
});
|
||||
|
||||
const handleExport = () => {
|
||||
addToast({ type: 'info', title: 'Gerando PDF...', message: 'O download iniciará em instantes.', duration: 2000 });
|
||||
setTimeout(() => {
|
||||
addToast({ type: 'success', title: 'Exportação Concluída', message: `Relatório ${activeTab} salvo com sucesso.` });
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
// --- ACCOUNTING ENGINE (Cálculos Reais) ---
|
||||
const reportData = useMemo(() => {
|
||||
// 1. Data Preparation
|
||||
const paidRevenue = receivables.filter(r => r.status === 'paid').reduce((sum, r) => sum + r.value, 0);
|
||||
const pendingRevenue = receivables.filter(r => r.status === 'pending').reduce((sum, r) => sum + r.value, 0);
|
||||
const totalRevenue = paidRevenue + pendingRevenue; // Competência
|
||||
|
||||
const paidExpenses = expenses.filter(e => e.status === 'paid');
|
||||
const pendingExpenses = expenses.filter(e => e.status === 'pending');
|
||||
|
||||
// Categorização de Despesas Pagas
|
||||
const taxExpenses = paidExpenses.filter(e => e.category === 'Impostos').reduce((sum, e) => sum + e.amount, 0);
|
||||
const costExpenses = paidExpenses.filter(e => e.category === 'Operacional').reduce((sum, e) => sum + e.amount, 0); // Mocking Op as CMV/CPV
|
||||
const adminExpenses = paidExpenses.filter(e => e.category === 'Administrativo' || e.category === 'TI').reduce((sum, e) => sum + e.amount, 0);
|
||||
const salesExpenses = paidExpenses.filter(e => e.category === 'Marketing').reduce((sum, e) => sum + e.amount, 0);
|
||||
const personnelExpenses = paidExpenses.filter(e => e.category === 'Pessoal').reduce((sum, e) => sum + e.amount, 0);
|
||||
|
||||
// Totais
|
||||
const totalDeductions = taxExpenses; // Simplificação
|
||||
const netRevenue = paidRevenue - totalDeductions;
|
||||
const grossProfit = netRevenue - costExpenses;
|
||||
const totalOpExpenses = adminExpenses + salesExpenses + personnelExpenses;
|
||||
const netIncome = grossProfit - totalOpExpenses;
|
||||
|
||||
// 2. Balance Sheet (BP) Calculations (Estimated)
|
||||
// Assets
|
||||
const cashAndEquivalents = 50000 + (netIncome > 0 ? netIncome : 0); // Mock Start Cash + Profit
|
||||
const accountsReceivable = pendingRevenue;
|
||||
const fixedAssets = 150000; // Mocked Fixed Assets (Computers, Furniture)
|
||||
const totalCurrentAssets = cashAndEquivalents + accountsReceivable;
|
||||
const totalNonCurrentAssets = fixedAssets;
|
||||
const totalAssets = totalCurrentAssets + totalNonCurrentAssets;
|
||||
|
||||
// Liabilities
|
||||
const accountsPayable = pendingExpenses.reduce((sum, e) => sum + e.amount, 0);
|
||||
const loansShortTerm = 20000; // Mock
|
||||
const loansLongTerm = 100000; // Mock
|
||||
const totalCurrentLiabilities = accountsPayable + loansShortTerm;
|
||||
const totalNonCurrentLiabilities = loansLongTerm;
|
||||
|
||||
// Equity
|
||||
const shareCapital = 50000; // Mock
|
||||
const retainedEarnings = totalAssets - (totalCurrentLiabilities + totalNonCurrentLiabilities + shareCapital); // Balancing figure
|
||||
const totalEquity = shareCapital + retainedEarnings;
|
||||
|
||||
// 3. Cash Flow (DFC) - Direct Method Simplified
|
||||
const cashInflow = paidRevenue;
|
||||
const cashOutflowOp = paidExpenses.reduce((sum, e) => sum + e.amount, 0);
|
||||
const netCashOperating = cashInflow - cashOutflowOp;
|
||||
|
||||
// 4. Value Added (DVA)
|
||||
const inputs = costExpenses + adminExpenses + salesExpenses; // Consumo de terceiros
|
||||
const grossValueAdded = paidRevenue - inputs;
|
||||
const netValueAdded = grossValueAdded; // Assuming no depreciation for simplicity
|
||||
|
||||
return {
|
||||
dre: {
|
||||
grossRevenue: paidRevenue,
|
||||
taxes: taxExpenses,
|
||||
netRevenue,
|
||||
costs: costExpenses,
|
||||
grossProfit,
|
||||
adminExpenses,
|
||||
salesExpenses,
|
||||
personnelExpenses,
|
||||
netIncome
|
||||
},
|
||||
bp: {
|
||||
cashAndEquivalents,
|
||||
accountsReceivable,
|
||||
totalCurrentAssets,
|
||||
fixedAssets,
|
||||
totalAssets,
|
||||
accountsPayable,
|
||||
loansShortTerm,
|
||||
totalCurrentLiabilities,
|
||||
loansLongTerm,
|
||||
shareCapital,
|
||||
retainedEarnings,
|
||||
totalEquityAndLiabilities: totalCurrentLiabilities + totalNonCurrentLiabilities + totalEquity
|
||||
},
|
||||
dfc: {
|
||||
netCashOperating,
|
||||
cashInflow,
|
||||
cashOutflowOp
|
||||
},
|
||||
dva: {
|
||||
grossRevenue: paidRevenue,
|
||||
inputs,
|
||||
grossValueAdded,
|
||||
personnelExpenses,
|
||||
taxExpenses,
|
||||
rentals: 0,
|
||||
equityRemuneration: netIncome
|
||||
}
|
||||
};
|
||||
}, [expenses, receivables]);
|
||||
|
||||
const formatCurrency = (val: number) => val.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||
const formatNegative = (val: number) => `(${formatCurrency(val)})`;
|
||||
|
||||
const periodLabel = filters.period === 'anual' ? '2024' : filters.period === 'trimestral' ? '1º Trimestre 2024' : 'Maio/2024';
|
||||
|
||||
const renderContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'DRE':
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 p-0 overflow-hidden animate-fade-in max-w-4xl mx-auto">
|
||||
<div className="p-6 border-b border-slate-100 flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-slate-800">Demonstração do Resultado do Exercício (DRE)</h3>
|
||||
<p className="text-slate-400 text-sm">Período: {periodLabel} | Centro de Custo: {filters.costCenter}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 bg-primary-50 rounded-lg flex items-center justify-center text-primary-500"><FileText size={20}/></div>
|
||||
</div>
|
||||
|
||||
<TableRow label="RECEITA OPERACIONAL BRUTA" value="" isHeader />
|
||||
<TableRow label="Receita de Serviços / Vendas" value={formatCurrency(reportData.dre.grossRevenue)} indent={1} />
|
||||
|
||||
<TableRow label="DEDUÇÕES DA RECEITA BRUTA" value="" isHeader />
|
||||
<TableRow label="(-) Impostos sobre Vendas" value={formatNegative(reportData.dre.taxes)} indent={1} />
|
||||
|
||||
<TableRow label="= RECEITA OPERACIONAL LÍQUIDA" value={formatCurrency(reportData.dre.netRevenue)} isTotal />
|
||||
|
||||
<TableRow label="CUSTOS OPERACIONAIS" value="" isHeader />
|
||||
<TableRow label="(-) Custos dos Serviços Prestados (CSP)" value={formatNegative(reportData.dre.costs)} indent={1} />
|
||||
|
||||
<TableRow label="= LUCRO BRUTO" value={formatCurrency(reportData.dre.grossProfit)} isTotal />
|
||||
|
||||
<TableRow label="DESPESAS OPERACIONAIS" value="" isHeader />
|
||||
<TableRow label="(-) Despesas com Pessoal" value={formatNegative(reportData.dre.personnelExpenses)} indent={1} />
|
||||
<TableRow label="(-) Despesas Administrativas & TI" value={formatNegative(reportData.dre.adminExpenses)} indent={1} />
|
||||
<TableRow label="(-) Despesas Comerciais / Mkt" value={formatNegative(reportData.dre.salesExpenses)} indent={1} />
|
||||
|
||||
<div className="p-4 bg-primary-50 mt-4 flex justify-between font-bold text-primary-700 rounded-b-2xl">
|
||||
<span>= LUCRO / PREJUÍZO LÍQUIDO</span>
|
||||
<span>{formatCurrency(reportData.dre.netIncome)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'BP':
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{/* ATIVO */}
|
||||
<div className="bg-white rounded-2xl border border-slate-100 p-0 overflow-hidden h-fit">
|
||||
<div className="bg-slate-50 p-4 border-b border-slate-100 font-bold text-slate-700">ATIVO</div>
|
||||
|
||||
<TableRow label="ATIVO CIRCULANTE" value={formatCurrency(reportData.bp.totalCurrentAssets)} isHeader />
|
||||
<TableRow label="Caixa e Equivalentes" value={formatCurrency(reportData.bp.cashAndEquivalents)} indent={1} />
|
||||
<TableRow label="Contas a Receber (Clientes)" value={formatCurrency(reportData.bp.accountsReceivable)} indent={1} />
|
||||
|
||||
<TableRow label="ATIVO NÃO CIRCULANTE" value={formatCurrency(reportData.bp.fixedAssets)} isHeader />
|
||||
<TableRow label="Imobilizado (Móveis/Equip.)" value={formatCurrency(reportData.bp.fixedAssets)} indent={1} />
|
||||
|
||||
<div className="p-4 bg-primary-50 mt-4 flex justify-between font-bold text-primary-700">
|
||||
<span>TOTAL DO ATIVO</span>
|
||||
<span>{formatCurrency(reportData.bp.totalAssets)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PASSIVO */}
|
||||
<div className="bg-white rounded-2xl border border-slate-100 p-0 overflow-hidden h-fit">
|
||||
<div className="bg-slate-50 p-4 border-b border-slate-100 font-bold text-slate-700">PASSIVO E PATRIMÔNIO LÍQUIDO</div>
|
||||
|
||||
<TableRow label="PASSIVO CIRCULANTE" value={formatCurrency(reportData.bp.totalCurrentLiabilities)} isHeader />
|
||||
<TableRow label="Fornecedores a Pagar" value={formatCurrency(reportData.bp.accountsPayable)} indent={1} />
|
||||
<TableRow label="Empréstimos Curto Prazo" value={formatCurrency(reportData.bp.loansShortTerm)} indent={1} />
|
||||
|
||||
<TableRow label="PASSIVO NÃO CIRCULANTE" value={formatCurrency(reportData.bp.loansLongTerm)} isHeader />
|
||||
<TableRow label="Empréstimos Longo Prazo" value={formatCurrency(reportData.bp.loansLongTerm)} indent={1} />
|
||||
|
||||
<TableRow label="PATRIMÔNIO LÍQUIDO" value={formatCurrency(reportData.bp.shareCapital + reportData.bp.retainedEarnings)} isHeader />
|
||||
<TableRow label="Capital Social" value={formatCurrency(reportData.bp.shareCapital)} indent={1} />
|
||||
<TableRow label="Lucros/Prejuízos Acumulados" value={formatCurrency(reportData.bp.retainedEarnings)} indent={1} />
|
||||
|
||||
<div className="p-4 bg-primary-50 mt-4 flex justify-between font-bold text-primary-700">
|
||||
<span>TOTAL PASSIVO + PL</span>
|
||||
<span>{formatCurrency(reportData.bp.totalEquityAndLiabilities)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'DFC':
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 p-0 overflow-hidden animate-fade-in max-w-4xl mx-auto">
|
||||
<div className="p-6 border-b border-slate-100">
|
||||
<h3 className="text-lg font-bold text-slate-800">Demonstração do Fluxo de Caixa (Método Direto)</h3>
|
||||
<p className="text-slate-400 text-sm">Período: {periodLabel}</p>
|
||||
</div>
|
||||
|
||||
<TableRow label="ATIVIDADES OPERACIONAIS" value="" isHeader />
|
||||
<TableRow label="(+) Recebimento de Clientes" value={formatCurrency(reportData.dfc.cashInflow)} indent={1} />
|
||||
<TableRow label="(-) Pagamento a Fornecedores/Despesas" value={formatNegative(reportData.dfc.cashOutflowOp)} indent={1} />
|
||||
<TableRow label="(=) Caixa Líquido das Atividades Operacionais" value={formatCurrency(reportData.dfc.netCashOperating)} isTotal indent={1} />
|
||||
|
||||
<TableRow label="ATIVIDADES DE INVESTIMENTO" value="" isHeader />
|
||||
<TableRow label="(-) Aquisição de Imobilizado" value={formatCurrency(0)} indent={1} />
|
||||
<TableRow label="(=) Caixa Líquido das Atividades de Investimento" value={formatCurrency(0)} isTotal indent={1} />
|
||||
|
||||
<TableRow label="ATIVIDADES DE FINANCIAMENTO" value="" isHeader />
|
||||
<TableRow label="(+) Novos Empréstimos" value={formatCurrency(0)} indent={1} />
|
||||
<TableRow label="(=) Caixa Líquido das Atividades de Financiamento" value={formatCurrency(0)} isTotal indent={1} />
|
||||
|
||||
<div className={`p-4 mt-4 flex justify-between font-bold rounded-b-2xl ${reportData.dfc.netCashOperating >= 0 ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'}`}>
|
||||
<span>AUMENTO/REDUÇÃO LÍQUIDA DE CAIXA</span>
|
||||
<span>{formatCurrency(reportData.dfc.netCashOperating)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'DLPA':
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 p-0 overflow-hidden animate-fade-in max-w-4xl mx-auto">
|
||||
<div className="p-6 border-b border-slate-100">
|
||||
<h3 className="text-lg font-bold text-slate-800">Demonstração de Lucros ou Prejuízos Acumulados (DLPA)</h3>
|
||||
</div>
|
||||
<TableRow label="Saldo Inicial" value={formatCurrency(reportData.bp.retainedEarnings - reportData.dre.netIncome)} />
|
||||
<TableRow label="(+) Ajustes de Exercícios Anteriores" value={formatCurrency(0)} />
|
||||
<TableRow label="(+) Lucro Líquido do Exercício" value={formatCurrency(reportData.dre.netIncome)} isTotal />
|
||||
<TableRow label="(-) Transferências para Reservas" value={formatCurrency(0)} />
|
||||
<TableRow label="(-) Dividendos Distribuídos" value={formatCurrency(0)} />
|
||||
<div className="p-4 bg-primary-50 mt-4 flex justify-between font-bold text-primary-700">
|
||||
<span>SALDO FINAL</span>
|
||||
<span>{formatCurrency(reportData.bp.retainedEarnings)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'DMPL':
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 p-0 overflow-hidden animate-fade-in max-w-5xl mx-auto overflow-x-auto">
|
||||
<div className="p-6 border-b border-slate-100">
|
||||
<h3 className="text-lg font-bold text-slate-800">Demonstração das Mutações do Patrimônio Líquido (DMPL)</h3>
|
||||
</div>
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-slate-50 text-slate-700 font-bold">
|
||||
<tr>
|
||||
<th className="p-4">Histórico</th>
|
||||
<th className="p-4 text-right">Capital Social</th>
|
||||
<th className="p-4 text-right">Reservas de Lucro</th>
|
||||
<th className="p-4 text-right">Lucros Acumulados</th>
|
||||
<th className="p-4 text-right">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
<tr>
|
||||
<td className="p-4 font-medium text-slate-700">Saldo Inicial</td>
|
||||
<td className="p-4 text-right">{formatCurrency(reportData.bp.shareCapital)}</td>
|
||||
<td className="p-4 text-right">R$ 0,00</td>
|
||||
<td className="p-4 text-right">{formatCurrency(reportData.bp.retainedEarnings - reportData.dre.netIncome)}</td>
|
||||
<td className="p-4 text-right font-bold">{formatCurrency(reportData.bp.shareCapital + (reportData.bp.retainedEarnings - reportData.dre.netIncome))}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-4 font-medium text-slate-700">Lucro Líquido do Período</td>
|
||||
<td className="p-4 text-right">-</td>
|
||||
<td className="p-4 text-right">-</td>
|
||||
<td className="p-4 text-right text-green-600">{formatCurrency(reportData.dre.netIncome)}</td>
|
||||
<td className="p-4 text-right font-bold text-green-600">{formatCurrency(reportData.dre.netIncome)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot className="bg-slate-50 font-bold text-slate-800">
|
||||
<tr>
|
||||
<td className="p-4">Saldo Final</td>
|
||||
<td className="p-4 text-right">{formatCurrency(reportData.bp.shareCapital)}</td>
|
||||
<td className="p-4 text-right">R$ 0,00</td>
|
||||
<td className="p-4 text-right">{formatCurrency(reportData.bp.retainedEarnings)}</td>
|
||||
<td className="p-4 text-right text-primary-600">{formatCurrency(reportData.bp.shareCapital + reportData.bp.retainedEarnings)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'DRA':
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 p-0 overflow-hidden animate-fade-in max-w-4xl mx-auto">
|
||||
<div className="p-6 border-b border-slate-100">
|
||||
<h3 className="text-lg font-bold text-slate-800">Demonstração do Resultado Abrangente (DRA)</h3>
|
||||
</div>
|
||||
<TableRow label="Lucro Líquido do Exercício" value={formatCurrency(reportData.dre.netIncome)} isTotal />
|
||||
<TableRow label="Outros Resultados Abrangentes" value="" isHeader />
|
||||
<TableRow label="(+/-) Ajustes de Avaliação Patrimonial" value={formatCurrency(0)} indent={1} />
|
||||
<TableRow label="(+/-) Variação Cambial de Investimentos" value={formatCurrency(0)} indent={1} />
|
||||
<div className="p-4 bg-primary-50 mt-4 flex justify-between font-bold text-primary-700">
|
||||
<span>RESULTADO ABRANGENTE TOTAL</span>
|
||||
<span>{formatCurrency(reportData.dre.netIncome)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'DVA':
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 p-0 overflow-hidden animate-fade-in max-w-4xl mx-auto">
|
||||
<div className="p-6 border-b border-slate-100">
|
||||
<h3 className="text-lg font-bold text-slate-800">Demonstração do Valor Adicionado (DVA)</h3>
|
||||
</div>
|
||||
|
||||
<TableRow label="1. RECEITAS" value={formatCurrency(reportData.dva.grossRevenue)} isHeader />
|
||||
<TableRow label="Vendas de Mercadorias, Produtos e Serviços" value={formatCurrency(reportData.dva.grossRevenue)} indent={1} />
|
||||
|
||||
<TableRow label="2. INSUMOS ADQUIRIDOS DE TERCEIROS" value={formatNegative(reportData.dva.inputs)} isHeader />
|
||||
<TableRow label="Custos Operacionais e Materiais" value={formatNegative(reportData.dva.inputs)} indent={1} />
|
||||
|
||||
<TableRow label="3. VALOR ADICIONADO BRUTO (1-2)" value={formatCurrency(reportData.dva.grossValueAdded)} isTotal />
|
||||
|
||||
<TableRow label="4. RETENÇÕES" value={formatCurrency(0)} isHeader />
|
||||
<TableRow label="Depreciação, Amortização e Exaustão" value={formatCurrency(0)} indent={1} />
|
||||
|
||||
<TableRow label="5. VALOR ADICIONADO LÍQUIDO (3-4)" value={formatCurrency(reportData.dva.grossValueAdded)} isTotal />
|
||||
|
||||
<div className="bg-slate-50 p-4 border-b border-slate-100 border-t font-bold text-slate-800 mt-4">6. DISTRIBUIÇÃO DO VALOR ADICIONADO</div>
|
||||
|
||||
<TableRow label="Pessoal (Salários e Benefícios)" value={formatCurrency(reportData.dva.personnelExpenses)} indent={1} />
|
||||
<TableRow label="Impostos, Taxas e Contribuições" value={formatCurrency(reportData.dva.taxExpenses)} indent={1} />
|
||||
<TableRow label="Remuneração de Capitais de Terceiros (Aluguéis/Juros)" value={formatCurrency(reportData.dva.rentals)} indent={1} />
|
||||
<TableRow label="Remuneração de Capitais Próprios (Lucros)" value={formatCurrency(reportData.dva.equityRemuneration)} indent={1} />
|
||||
|
||||
<div className="p-4 bg-primary-50 mt-4 flex justify-between font-bold text-primary-700">
|
||||
<span>TOTAL DISTRIBUÍDO</span>
|
||||
<span>{formatCurrency(reportData.dva.personnelExpenses + reportData.dva.taxExpenses + reportData.dva.rentals + reportData.dva.equityRemuneration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800">Demonstrações Contábeis</h1>
|
||||
<p className="text-slate-500">Relatórios gerados em tempo real com base nos lançamentos.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className={`flex items-center gap-2 px-4 py-2 border rounded-xl transition-colors ${showFilters ? 'bg-slate-100 border-slate-300 text-slate-800' : 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'}`}
|
||||
>
|
||||
<Filter size={16} />
|
||||
{showFilters ? 'Ocultar Filtros' : 'Filtros'}
|
||||
</button>
|
||||
<button onClick={handleExport} className="flex items-center gap-2 px-4 py-2 bg-primary-500 text-white rounded-xl hover:bg-primary-600 shadow-lg shadow-primary-200/50">
|
||||
<Download size={16} /> Exportar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Panel */}
|
||||
{showFilters && (
|
||||
<div className="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm grid grid-cols-1 md:grid-cols-3 gap-6 animate-slide-down">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-500 mb-2 uppercase flex items-center gap-2">
|
||||
<Calendar size={14} /> Período
|
||||
</label>
|
||||
<div className="relative">
|
||||
<CustomSelect
|
||||
value={filters.period}
|
||||
onChange={(val) => setFilters({...filters, period: val})}
|
||||
options={[
|
||||
{ value: 'mensal', label: 'Mensal (Mês Atual)' },
|
||||
{ value: 'trimestral', label: 'Trimestral' },
|
||||
{ value: 'semestral', label: 'Semestral' },
|
||||
{ value: 'anual', label: 'Anual (Acumulado)' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-500 mb-2 uppercase flex items-center gap-2">
|
||||
<Building2 size={14} /> Centro de Custo
|
||||
</label>
|
||||
<div className="relative">
|
||||
<CustomSelect
|
||||
value={filters.costCenter}
|
||||
onChange={(val) => setFilters({...filters, costCenter: val})}
|
||||
options={[
|
||||
{ value: 'Todos', label: 'Todos' },
|
||||
{ value: 'Administrativo', label: 'Administrativo' },
|
||||
{ value: 'Comercial', label: 'Comercial / Vendas' },
|
||||
{ value: 'Operacional', label: 'Operacional' },
|
||||
{ value: 'TI', label: 'Tecnologia (TI)' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button className="w-full py-2.5 bg-slate-800 text-white rounded-xl hover:bg-slate-900 transition-colors font-medium flex items-center justify-center gap-2">
|
||||
<CheckCircle2 size={16}/> Aplicar Filtros
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex flex-wrap gap-2 pb-2">
|
||||
<ReportTab active={activeTab === 'DRE'} label="DRE" onClick={() => setActiveTab('DRE')} />
|
||||
<ReportTab active={activeTab === 'BP'} label="Balanço (BP)" onClick={() => setActiveTab('BP')} />
|
||||
<ReportTab active={activeTab === 'DFC'} label="Fluxo de Caixa (DFC)" onClick={() => setActiveTab('DFC')} />
|
||||
<ReportTab active={activeTab === 'DLPA'} label="DLPA" onClick={() => setActiveTab('DLPA')} />
|
||||
<ReportTab active={activeTab === 'DMPL'} label="DMPL" onClick={() => setActiveTab('DMPL')} />
|
||||
<ReportTab active={activeTab === 'DRA'} label="DRA" onClick={() => setActiveTab('DRA')} />
|
||||
<ReportTab active={activeTab === 'DVA'} label="DVA" onClick={() => setActiveTab('DVA')} />
|
||||
</div>
|
||||
|
||||
{renderContent()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
498
components/KanbanView.tsx
Normal file
498
components/KanbanView.tsx
Normal file
@@ -0,0 +1,498 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, MoreHorizontal, Clock, UserCircle, X, DollarSign, GripVertical, Trash2, Building2 } from 'lucide-react';
|
||||
import { KanbanColumn, KanbanTask, Company, Receivable } from '../types';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { CustomSelect } from './CustomSelect';
|
||||
|
||||
const initialColumns: KanbanColumn[] = [
|
||||
{
|
||||
id: 'todo',
|
||||
title: 'A Fazer',
|
||||
tasks: [
|
||||
{ id: 't1', title: 'Criar contrato Uda Studios', priority: 'high', dueDate: '2024-05-15', value: 12000, description: 'Negociação referente ao projeto de redesign completo.' },
|
||||
{ id: 't2', title: 'Revisar balanço trimestral', priority: 'medium', dueDate: '2024-05-20', value: 0, description: 'Verificar lançamentos de março e abril.' },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'progress',
|
||||
title: 'Em Progresso',
|
||||
tasks: [
|
||||
{ id: 't3', title: 'Design do Dashboard', priority: 'high', dueDate: '2024-05-18', value: 5000 },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'review',
|
||||
title: 'Revisão',
|
||||
tasks: [
|
||||
{ id: 't4', title: 'Aprovação de Orçamento', priority: 'low', dueDate: '2024-05-12', value: 3500 },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'done',
|
||||
title: 'Concluído',
|
||||
tasks: [
|
||||
{ id: 't5', title: 'Onboarding Angels Healthcare', priority: 'medium', dueDate: '2024-05-10', value: 15000 },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
interface KanbanViewProps {
|
||||
companies: Company[];
|
||||
onAddReceivable: (receivable: Receivable) => void;
|
||||
}
|
||||
|
||||
export const KanbanView: React.FC<KanbanViewProps> = ({ companies, onAddReceivable }) => {
|
||||
const { addToast } = useToast();
|
||||
const [columns, setColumns] = useState<KanbanColumn[]>(initialColumns);
|
||||
|
||||
// States para Modais
|
||||
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
|
||||
const [isColumnModalOpen, setIsColumnModalOpen] = useState(false);
|
||||
|
||||
// State de Edição/Criação
|
||||
const [currentTask, setCurrentTask] = useState<Partial<KanbanTask>>({});
|
||||
const [currentColumnId, setCurrentColumnId] = useState<string>('todo');
|
||||
const [newColumnTitle, setNewColumnTitle] = useState('');
|
||||
const [draggedTaskId, setDraggedTaskId] = useState<string | null>(null);
|
||||
const [draggedSourceColumnId, setDraggedSourceColumnId] = useState<string | null>(null);
|
||||
|
||||
// --- Drag and Drop Logic ---
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, taskId: string, columnId: string) => {
|
||||
setDraggedTaskId(taskId);
|
||||
setDraggedSourceColumnId(columnId);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault(); // Necessary to allow dropping
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, targetColumnId: string) => {
|
||||
e.preventDefault();
|
||||
if (!draggedTaskId || !draggedSourceColumnId) return;
|
||||
|
||||
if (draggedSourceColumnId === targetColumnId) {
|
||||
setDraggedTaskId(null);
|
||||
setDraggedSourceColumnId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Move logic
|
||||
const sourceCol = columns.find(c => c.id === draggedSourceColumnId);
|
||||
const targetCol = columns.find(c => c.id === targetColumnId);
|
||||
const taskToMove = sourceCol?.tasks.find(t => t.id === draggedTaskId);
|
||||
|
||||
if (sourceCol && targetCol && taskToMove) {
|
||||
const newColumns = columns.map(col => {
|
||||
if (col.id === draggedSourceColumnId) {
|
||||
return { ...col, tasks: col.tasks.filter(t => t.id !== draggedTaskId) };
|
||||
}
|
||||
if (col.id === targetColumnId) {
|
||||
return { ...col, tasks: [...col.tasks, taskToMove] };
|
||||
}
|
||||
return col;
|
||||
});
|
||||
setColumns(newColumns);
|
||||
|
||||
// INTEGRAÇÃO FINANCEIRA: Se moveu para "Concluído" (done), tem valor e cliente, sugere faturar
|
||||
if (targetColumnId === 'done' && taskToMove.value && taskToMove.value > 0 && taskToMove.clientId) {
|
||||
const client = companies.find(c => c.id === taskToMove.clientId);
|
||||
|
||||
// Use toast with action instead of window.confirm for better UI?
|
||||
// For now, simpler confirmation but using toast for success
|
||||
if (window.confirm(`A tarefa "${taskToMove.title}" foi concluída.\n\nDeseja gerar automaticamente uma Conta a Receber no valor de R$ ${taskToMove.value.toLocaleString('pt-BR')}?`)) {
|
||||
|
||||
const newReceivable: Receivable = {
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
description: taskToMove.title,
|
||||
companyName: client?.fantasyName || client?.name || 'Cliente Kanban',
|
||||
category: 'Serviços', // Default category
|
||||
value: taskToMove.value,
|
||||
dueDate: taskToMove.dueDate || new Date().toISOString().split('T')[0],
|
||||
status: 'pending',
|
||||
type: 'one-time'
|
||||
};
|
||||
|
||||
onAddReceivable(newReceivable);
|
||||
addToast({ type: 'success', title: 'Faturamento Gerado', message: `Conta a receber criada para ${client?.fantasyName}.` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setDraggedTaskId(null);
|
||||
setDraggedSourceColumnId(null);
|
||||
};
|
||||
|
||||
// --- CRUD Logic ---
|
||||
|
||||
const openNewTaskModal = () => {
|
||||
setCurrentTask({ priority: 'medium', dueDate: new Date().toISOString().split('T')[0], value: 0, description: '' });
|
||||
setCurrentColumnId(columns[0].id); // Default to first column
|
||||
setIsTaskModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditTaskModal = (task: KanbanTask, colId: string) => {
|
||||
setCurrentTask(task);
|
||||
setCurrentColumnId(colId);
|
||||
setIsTaskModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveTask = () => {
|
||||
if (!currentTask.title) {
|
||||
addToast({ type: 'warning', title: 'Título Obrigatório', message: 'Dê um nome para a tarefa.' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if updating existing or creating new
|
||||
if (currentTask.id) {
|
||||
// Logic to update existing task (potentially moving columns)
|
||||
const newColumns = columns.map(col => {
|
||||
// Remove from all columns first (in case it moved)
|
||||
const filteredTasks = col.tasks.filter(t => t.id !== currentTask.id);
|
||||
|
||||
// If this is the target column, add the updated task
|
||||
if (col.id === currentColumnId) {
|
||||
return { ...col, tasks: [...filteredTasks, currentTask as KanbanTask] };
|
||||
}
|
||||
return { ...col, tasks: filteredTasks };
|
||||
});
|
||||
setColumns(newColumns);
|
||||
addToast({ type: 'success', title: 'Tarefa Atualizada' });
|
||||
} else {
|
||||
// Create new
|
||||
const newTask: KanbanTask = {
|
||||
...currentTask,
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
} as KanbanTask;
|
||||
|
||||
const newColumns = columns.map(col => {
|
||||
if (col.id === currentColumnId) {
|
||||
return { ...col, tasks: [newTask, ...col.tasks] }; // Add to top
|
||||
}
|
||||
return col;
|
||||
});
|
||||
setColumns(newColumns);
|
||||
addToast({ type: 'success', title: 'Tarefa Criada' });
|
||||
}
|
||||
setIsTaskModalOpen(false);
|
||||
};
|
||||
|
||||
const handleDeleteTask = () => {
|
||||
if (!currentTask.id) return;
|
||||
if (window.confirm('Tem certeza que deseja excluir esta tarefa?')) {
|
||||
const newColumns = columns.map(col => ({
|
||||
...col,
|
||||
tasks: col.tasks.filter(t => t.id !== currentTask.id)
|
||||
}));
|
||||
setColumns(newColumns);
|
||||
setIsTaskModalOpen(false);
|
||||
addToast({ type: 'info', title: 'Tarefa Excluída' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateColumn = () => {
|
||||
if (!newColumnTitle) return;
|
||||
const newCol: KanbanColumn = {
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
title: newColumnTitle,
|
||||
tasks: []
|
||||
};
|
||||
setColumns([...columns, newCol]);
|
||||
setNewColumnTitle('');
|
||||
setIsColumnModalOpen(false);
|
||||
addToast({ type: 'success', title: 'Coluna Adicionada' });
|
||||
};
|
||||
|
||||
// Styles for Inputs (High Contrast)
|
||||
const labelClass = "block text-sm font-bold text-slate-800 mb-1";
|
||||
const inputClass = "w-full p-2.5 bg-white border border-slate-300 rounded-xl text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none transition-shadow";
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col animate-fade-in relative">
|
||||
|
||||
{/* Task Modal (Create & Edit) */}
|
||||
{isTaskModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm" onClick={() => setIsTaskModalOpen(false)}></div>
|
||||
<div className="relative bg-white rounded-2xl shadow-2xl w-full max-w-lg p-0 animate-scale-up flex flex-col max-h-[90vh]">
|
||||
<div className="flex justify-between items-center px-6 py-4 border-b border-slate-100 bg-slate-50 flex-shrink-0">
|
||||
<h3 className="font-bold text-slate-800 text-lg">
|
||||
{currentTask.id ? 'Detalhes da Tarefa' : 'Nova Tarefa'}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{currentTask.id && (
|
||||
<button onClick={handleDeleteTask} className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors">
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setIsTaskModalOpen(false)} className="p-2 text-slate-400 hover:text-slate-600 rounded-lg transition-colors">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-5 overflow-y-auto">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className={labelClass}>Título da Tarefa</label>
|
||||
<input
|
||||
type="text"
|
||||
className={inputClass}
|
||||
value={currentTask.title || ''}
|
||||
onChange={e => setCurrentTask({...currentTask, title: e.target.value})}
|
||||
placeholder="Ex: Reunião com Cliente X"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status & Priority Row */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>Coluna (Status)</label>
|
||||
<CustomSelect
|
||||
value={currentColumnId}
|
||||
onChange={(val) => setCurrentColumnId(val)}
|
||||
options={columns.map(col => ({ value: col.id, label: col.title }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Prioridade</label>
|
||||
<CustomSelect
|
||||
value={currentTask.priority || 'medium'}
|
||||
onChange={(val) => setCurrentTask({...currentTask, priority: val})}
|
||||
options={[
|
||||
{ value: 'low', label: 'Baixa' },
|
||||
{ value: 'medium', label: 'Média' },
|
||||
{ value: 'high', label: 'Alta' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CRM Linkage */}
|
||||
<div>
|
||||
<label className={labelClass}>Vincular Cliente (CRM)</label>
|
||||
<CustomSelect
|
||||
value={currentTask.clientId || ''}
|
||||
onChange={(val) => setCurrentTask({...currentTask, clientId: val})}
|
||||
placeholder="Sem vínculo"
|
||||
icon={<Building2 size={16}/>}
|
||||
options={[
|
||||
{ value: '', label: 'Sem vínculo' },
|
||||
...companies.map(c => ({ value: c.id, label: c.fantasyName || c.name }))
|
||||
]}
|
||||
/>
|
||||
<p className="text-[10px] text-slate-400 mt-1">Ao concluir a tarefa, o sistema oferecerá gerar cobrança para este cliente.</p>
|
||||
</div>
|
||||
|
||||
{/* Commercial Data Section */}
|
||||
<div className="bg-slate-50 p-4 rounded-xl border border-slate-100 space-y-4">
|
||||
<h4 className="font-bold text-slate-700 text-sm border-b border-slate-200 pb-2 mb-2 flex items-center gap-2">
|
||||
Dados Comerciais & Agenda
|
||||
</h4>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>Valor da Negociação</label>
|
||||
<div className="relative">
|
||||
<DollarSign size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||
<input
|
||||
type="number"
|
||||
className={`${inputClass} pl-9`}
|
||||
value={currentTask.value || ''}
|
||||
onChange={e => setCurrentTask({...currentTask, value: Number(e.target.value)})}
|
||||
placeholder="0,00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Prazo / Data</label>
|
||||
<div className="relative">
|
||||
<Clock size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||
<input
|
||||
type="date"
|
||||
className={`${inputClass} pl-10`}
|
||||
value={currentTask.dueDate || ''}
|
||||
onChange={e => setCurrentTask({...currentTask, dueDate: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Descrição / Notas</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
className={inputClass}
|
||||
value={currentTask.description || ''}
|
||||
onChange={e => setCurrentTask({...currentTask, description: e.target.value})}
|
||||
placeholder="Detalhes sobre a negociação, pauta da reunião, etc..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 bg-slate-50 border-t border-slate-100 flex justify-end gap-3 flex-shrink-0 rounded-b-2xl">
|
||||
<button onClick={() => setIsTaskModalOpen(false)} className="px-4 py-2 text-slate-600 font-medium hover:bg-slate-200 rounded-xl transition-colors">
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveTask}
|
||||
className="px-6 py-2 bg-primary-500 text-white font-bold rounded-xl hover:bg-primary-600 shadow-lg shadow-primary-200/50 transition-all"
|
||||
>
|
||||
{currentTask.id ? 'Salvar Alterações' : 'Criar Tarefa'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New Column Modal */}
|
||||
{isColumnModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm" onClick={() => setIsColumnModalOpen(false)}></div>
|
||||
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 animate-scale-up">
|
||||
<h3 className="font-bold text-slate-800 text-lg mb-4">Nova Coluna</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className={labelClass}>Nome da Coluna</label>
|
||||
<input
|
||||
type="text"
|
||||
className={inputClass}
|
||||
value={newColumnTitle}
|
||||
onChange={e => setNewColumnTitle(e.target.value)}
|
||||
placeholder="Ex: Em Aprovação"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button onClick={() => setIsColumnModalOpen(false)} className="px-4 py-2 text-slate-500 font-medium hover:bg-slate-100 rounded-xl">Cancelar</button>
|
||||
<button onClick={handleCreateColumn} className="px-4 py-2 bg-primary-500 text-white font-bold rounded-xl hover:bg-primary-600">Criar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Kanban Header */}
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800">Projetos & Tarefas</h1>
|
||||
<p className="text-slate-500">Gestão visual do fluxo de trabalho.</p>
|
||||
</div>
|
||||
<button onClick={openNewTaskModal} className="flex items-center gap-2 px-5 py-3 bg-primary-500 text-white rounded-xl shadow-lg shadow-primary-200/50 hover:bg-primary-600 font-bold transition-all">
|
||||
<Plus size={20} /> Nova Tarefa
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Kanban Board Area */}
|
||||
<div className="flex-1 overflow-x-auto">
|
||||
<div className="flex gap-6 h-full pb-4">
|
||||
{columns.map(col => {
|
||||
// Calculate total value for the column
|
||||
const totalValue = col.tasks.reduce((sum, task) => sum + (task.value || 0), 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={col.id}
|
||||
className="flex-shrink-0 flex flex-col w-[300px]"
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, col.id)}
|
||||
>
|
||||
{/* Column Header */}
|
||||
<div className="flex justify-between items-start mb-4 p-1">
|
||||
<div>
|
||||
<h3 className="font-bold text-slate-700 flex items-center gap-2 truncate">
|
||||
{col.title}
|
||||
<span className="bg-slate-200 text-slate-600 text-[10px] px-2 py-0.5 rounded-full">{col.tasks.length}</span>
|
||||
</h3>
|
||||
{totalValue > 0 && (
|
||||
<div className="text-xs font-bold text-slate-400 mt-1 pl-1">
|
||||
Total: R$ {totalValue.toLocaleString('pt-BR')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button className="text-slate-400 hover:text-slate-600"><MoreHorizontal size={18} /></button>
|
||||
</div>
|
||||
|
||||
{/* Tasks Container */}
|
||||
<div className="bg-slate-100/50 rounded-2xl p-2 flex-1 border border-slate-100/50 overflow-y-auto">
|
||||
<div className="space-y-3 min-h-[50px]">
|
||||
{col.tasks.map(task => (
|
||||
<div
|
||||
key={task.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, task.id, col.id)}
|
||||
onClick={() => openEditTaskModal(task, col.id)}
|
||||
className={`bg-white p-4 rounded-xl shadow-sm border border-slate-100 hover:shadow-md hover:border-primary-200 cursor-pointer transition-all group relative ${draggedTaskId === task.id ? 'opacity-50 border-dashed border-slate-400' : ''}`}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<span className={`text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded ${
|
||||
task.priority === 'high' ? 'bg-red-50 text-red-600' :
|
||||
task.priority === 'medium' ? 'bg-amber-50 text-amber-600' :
|
||||
'bg-blue-50 text-blue-600'
|
||||
}`}>
|
||||
{task.priority === 'high' ? 'Alta' : task.priority === 'medium' ? 'Média' : 'Baixa'}
|
||||
</span>
|
||||
<div className="opacity-0 group-hover:opacity-100 text-slate-300 cursor-grab active:cursor-grabbing">
|
||||
<GripVertical size={16}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 className="font-bold text-slate-800 text-sm mb-3 line-clamp-2">{task.title}</h4>
|
||||
|
||||
{/* Optional: Show Value if present */}
|
||||
{task.value && task.value > 0 && (
|
||||
<div className="mb-3 text-xs font-semibold text-slate-600 bg-slate-50 px-2 py-1 rounded inline-block">
|
||||
R$ {task.value.toLocaleString('pt-BR')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show Linked Client Badge */}
|
||||
{task.clientId && (
|
||||
<div className="mb-3 flex items-center gap-1 text-[10px] text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded w-fit">
|
||||
<Building2 size={10} /> {companies.find(c => c.id === task.clientId)?.fantasyName || 'Cliente'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center pt-3 border-t border-slate-50">
|
||||
<div className="flex -space-x-2">
|
||||
<div className="w-6 h-6 rounded-full bg-slate-200 border-2 border-white flex items-center justify-center text-[10px] font-bold text-slate-500">
|
||||
<UserCircle size={16} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-slate-400">
|
||||
<Clock size={12} /> {task.dueDate ? new Date(task.dueDate).toLocaleDateString('pt-BR', {day:'2-digit', month:'short'}) : 'Sem data'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button onClick={openNewTaskModal} className="w-full py-2 text-slate-400 text-sm font-medium hover:bg-slate-200/50 rounded-xl transition-colors flex items-center justify-center gap-2 border border-transparent hover:border-slate-200/50">
|
||||
<Plus size={16} /> Adicionar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Add Column Button */}
|
||||
<div className="flex-shrink-0 w-[50px] pt-10">
|
||||
<button
|
||||
onClick={() => setIsColumnModalOpen(true)}
|
||||
className="w-full h-[50px] bg-white border border-dashed border-slate-300 rounded-2xl flex items-center justify-center text-slate-400 hover:text-primary-500 hover:border-primary-300 hover:bg-primary-50 transition-all group tooltip-container"
|
||||
title="Nova Coluna"
|
||||
>
|
||||
<Plus size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
353
components/ManagementView.tsx
Normal file
353
components/ManagementView.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Search, Plus, User, Briefcase, Pencil, X, Sparkles, Trash2 } from 'lucide-react';
|
||||
import { Client, Service } from '../types';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { CustomSelect } from './CustomSelect';
|
||||
|
||||
interface ManagementViewProps {
|
||||
type: 'clients' | 'services';
|
||||
clientsData: Client[];
|
||||
setClientsData: (data: Client[]) => void;
|
||||
servicesData: Service[];
|
||||
setServicesData: (data: Service[]) => void;
|
||||
}
|
||||
|
||||
// --- MODAIS ---
|
||||
|
||||
const ClientModal: React.FC<{ isOpen: boolean; onClose: () => void; client: Client | null; onSave: (client: Client) => void }> = ({ isOpen, onClose, client, onSave }) => {
|
||||
const [formData, setFormData] = useState<Partial<Client>>(
|
||||
client || { name: '', company: '', email: '', phone: '', address: '', status: 'active', value: 0 }
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (client) setFormData(client);
|
||||
else setFormData({ name: '', company: '', email: '', phone: '', address: '', status: 'active', value: 0 });
|
||||
}, [client]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
const inputClass = "w-full p-3 bg-white border border-slate-200 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none text-slate-700 text-sm transition-all shadow-sm placeholder:text-slate-400";
|
||||
const labelClass = "block text-xs font-bold text-slate-600 mb-1.5 uppercase tracking-wide";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm" onClick={onClose}></div>
|
||||
<div className="relative bg-white rounded-2xl shadow-2xl w-full max-w-lg animate-scale-up flex flex-col max-h-[90vh]">
|
||||
<div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center bg-white flex-shrink-0">
|
||||
<h3 className="text-lg font-bold text-slate-800">{client ? 'Editar Cliente' : 'Novo Cliente'}</h3>
|
||||
<button onClick={onClose}><X size={20} className="text-slate-400 hover:text-slate-600" /></button>
|
||||
</div>
|
||||
<div className="p-6 space-y-5 overflow-y-auto">
|
||||
<div>
|
||||
<label className={labelClass}>Nome do Contato</label>
|
||||
<input type="text" value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} className={inputClass} placeholder="Ex: João Silva"/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Empresa</label>
|
||||
<input type="text" value={formData.company} onChange={e => setFormData({...formData, company: e.target.value})} className={inputClass} placeholder="Ex: Empresa X"/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>Email</label>
|
||||
<input type="email" value={formData.email} onChange={e => setFormData({...formData, email: e.target.value})} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Telefone</label>
|
||||
<input type="text" value={formData.phone} onChange={e => setFormData({...formData, phone: e.target.value})} className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 bg-slate-50 border-t border-slate-100 flex justify-end gap-3 flex-shrink-0 rounded-b-2xl">
|
||||
<button onClick={onClose} className="px-4 py-2 text-slate-600 font-medium hover:bg-slate-200 rounded-xl transition-colors">Cancelar</button>
|
||||
<button onClick={() => onSave(formData as Client)} className="px-6 py-2 bg-primary-500 text-white font-bold rounded-xl hover:bg-primary-600 shadow-lg shadow-primary-200">Salvar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ServiceModal: React.FC<{ isOpen: boolean; onClose: () => void; service: Service | null; onSave: (service: Service) => void }> = ({ isOpen, onClose, service, onSave }) => {
|
||||
const [formData, setFormData] = useState<Partial<Service>>(
|
||||
service || { name: '', category: 'Consultoria', price: 0, active: true, description: '', billingType: 'one-time' }
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (service) setFormData(service);
|
||||
else setFormData({ name: '', category: 'Consultoria', price: 0, active: true, description: '', billingType: 'one-time' });
|
||||
}, [service]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
const inputClass = "w-full p-3 bg-white border border-slate-200 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none text-slate-700 text-sm transition-all shadow-sm placeholder:text-slate-400";
|
||||
const labelClass = "block text-xs font-bold text-slate-600 mb-1.5 uppercase tracking-wide";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm" onClick={onClose}></div>
|
||||
<div className="relative bg-white rounded-2xl shadow-2xl w-full max-w-lg animate-scale-up flex flex-col max-h-[90vh]">
|
||||
<div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center bg-white flex-shrink-0">
|
||||
<h3 className="text-lg font-bold text-slate-800">{service ? 'Editar Serviço' : 'Novo Serviço'}</h3>
|
||||
<button onClick={onClose}><X size={20} className="text-slate-400 hover:text-slate-600" /></button>
|
||||
</div>
|
||||
<div className="p-6 space-y-5 overflow-y-auto">
|
||||
<div>
|
||||
<label className={labelClass}>Nome do Serviço</label>
|
||||
<input type="text" value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} className={inputClass} placeholder="Ex: Consultoria SEO"/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>Preço (R$)</label>
|
||||
<input type="number" value={formData.price} onChange={e => setFormData({...formData, price: Number(e.target.value)})} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Cobrança</label>
|
||||
<CustomSelect
|
||||
value={formData.billingType || 'one-time'}
|
||||
onChange={(val) => setFormData({...formData, billingType: val})}
|
||||
options={[
|
||||
{ value: 'one-time', label: 'Pontual (Única)' },
|
||||
{ value: 'recurring', label: 'Assinatura (Mensal)' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Categoria</label>
|
||||
<CustomSelect
|
||||
value={formData.category || 'Consultoria'}
|
||||
onChange={(val) => setFormData({...formData, category: val})}
|
||||
options={[
|
||||
{ value: 'Consultoria', label: 'Consultoria' },
|
||||
{ value: 'TI', label: 'TI / Desenvolvimento' },
|
||||
{ value: 'Marketing', label: 'Marketing / Design' },
|
||||
{ value: 'Outro', label: 'Outro' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Descrição</label>
|
||||
<textarea rows={3} value={formData.description} onChange={e => setFormData({...formData, description: e.target.value})} className={inputClass} placeholder="O que está incluso..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 bg-slate-50 border-t border-slate-100 flex justify-end gap-3 flex-shrink-0 rounded-b-2xl">
|
||||
<button onClick={onClose} className="px-4 py-2 text-slate-600 font-medium hover:bg-slate-200 rounded-xl transition-colors">Cancelar</button>
|
||||
<button onClick={() => onSave(formData as Service)} className="px-6 py-2 bg-primary-500 text-white font-bold rounded-xl hover:bg-primary-600 shadow-lg shadow-primary-200">Salvar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export const ManagementView: React.FC<ManagementViewProps> = ({ type, clientsData, setClientsData, servicesData, setServicesData }) => {
|
||||
const { addToast } = useToast();
|
||||
|
||||
const [isClientModalOpen, setIsClientModalOpen] = useState(false);
|
||||
const [editingClient, setEditingClient] = useState<Client | null>(null);
|
||||
|
||||
const [isServiceModalOpen, setIsServiceModalOpen] = useState(false);
|
||||
const [editingService, setEditingService] = useState<Service | null>(null);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// --- SERVICE ACTIONS ---
|
||||
const handleSaveService = (serviceData: Service) => {
|
||||
if (!serviceData.name || serviceData.price < 0) {
|
||||
addToast({ type: 'warning', title: 'Dados Inválidos', message: 'Verifique o nome e o preço.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (editingService) {
|
||||
setServicesData(servicesData.map(s => s.id === serviceData.id ? serviceData : s));
|
||||
addToast({ type: 'success', title: 'Serviço Atualizado' });
|
||||
} else {
|
||||
const newService = { ...serviceData, id: Math.random().toString(36).substr(2, 9) };
|
||||
setServicesData([...servicesData, newService]);
|
||||
addToast({ type: 'success', title: 'Serviço Criado' });
|
||||
}
|
||||
setIsServiceModalOpen(false);
|
||||
setEditingService(null);
|
||||
};
|
||||
|
||||
const handleDeleteService = (id: string) => {
|
||||
if (window.confirm('Excluir este serviço?')) {
|
||||
setServicesData(servicesData.filter(s => s.id !== id));
|
||||
addToast({ type: 'info', title: 'Serviço Removido' });
|
||||
}
|
||||
};
|
||||
|
||||
// --- CLIENT ACTIONS ---
|
||||
const handleSaveClient = (clientData: Client) => {
|
||||
if (!clientData.name) {
|
||||
addToast({ type: 'warning', title: 'Nome Obrigatório' });
|
||||
return;
|
||||
}
|
||||
|
||||
if(editingClient) {
|
||||
setClientsData(clientsData.map(c => c.id === clientData.id ? clientData : c));
|
||||
addToast({ type: 'success', title: 'Cliente Atualizado' });
|
||||
} else {
|
||||
setClientsData([...clientsData, {...clientData, id: Math.random().toString().substr(2, 9)}]);
|
||||
addToast({ type: 'success', title: 'Cliente Cadastrado' });
|
||||
}
|
||||
setIsClientModalOpen(false);
|
||||
setEditingClient(null);
|
||||
};
|
||||
|
||||
const handleDeleteClient = (id: string) => {
|
||||
if (window.confirm('Excluir este cliente?')) {
|
||||
setClientsData(clientsData.filter(c => c.id !== id));
|
||||
addToast({ type: 'info', title: 'Cliente Removido' });
|
||||
}
|
||||
};
|
||||
|
||||
// --- FILTERING ---
|
||||
const filteredServices = servicesData.filter(s => s.name.toLowerCase().includes(searchTerm.toLowerCase()) || s.category.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
const filteredClients = clientsData.filter(c => c.name.toLowerCase().includes(searchTerm.toLowerCase()) || c.company.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in relative pb-10">
|
||||
<ClientModal
|
||||
isOpen={isClientModalOpen}
|
||||
onClose={() => setIsClientModalOpen(false)}
|
||||
client={editingClient}
|
||||
onSave={handleSaveClient}
|
||||
/>
|
||||
<ServiceModal
|
||||
isOpen={isServiceModalOpen}
|
||||
onClose={() => setIsServiceModalOpen(false)}
|
||||
service={editingService}
|
||||
onSave={handleSaveService}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800">
|
||||
{type === 'clients' ? 'Base de Clientes (Legado)' : 'Catálogo de Serviços'}
|
||||
</h1>
|
||||
<p className="text-slate-500">
|
||||
{type === 'clients' ? 'Gestão simples de contatos.' : 'Gerencie preços e portfólio.'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (type === 'clients') { setEditingClient(null); setIsClientModalOpen(true); }
|
||||
else { setEditingService(null); setIsServiceModalOpen(true); }
|
||||
}}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-primary-500 text-white rounded-xl hover:bg-primary-600 shadow-lg shadow-primary-200/50 transition-all font-bold"
|
||||
>
|
||||
<Plus size={18} />
|
||||
{type === 'clients' ? 'Novo Cliente' : 'Novo Serviço'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="p-4 border-b border-slate-100">
|
||||
<div className="relative max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={type === 'clients' ? "Buscar cliente..." : "Buscar serviço..."}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-slate-50 border-none rounded-xl focus:ring-2 focus:ring-primary-500 outline-none text-slate-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table Content */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="bg-slate-50/50">
|
||||
<tr>
|
||||
<th className="p-4 text-xs font-bold uppercase text-slate-500 tracking-wide">{type === 'clients' ? 'Nome / Empresa' : 'Serviço'}</th>
|
||||
<th className="p-4 text-xs font-bold uppercase text-slate-500 tracking-wide">{type === 'clients' ? 'Contato' : 'Preço'}</th>
|
||||
<th className="p-4 text-xs font-bold uppercase text-slate-500 tracking-wide">{type === 'clients' ? 'Status' : 'Tipo / Categoria'}</th>
|
||||
<th className="p-4 text-right"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{type === 'services' && filteredServices.map(service => (
|
||||
<tr key={service.id} className="hover:bg-slate-50 transition-colors group">
|
||||
<td className="p-4">
|
||||
<div className="font-bold text-slate-800">{service.name}</div>
|
||||
{service.description && <div className="text-xs text-slate-400 line-clamp-1">{service.description}</div>}
|
||||
</td>
|
||||
<td className="p-4 font-bold text-slate-700">R$ {service.price.toLocaleString('pt-BR')}</td>
|
||||
<td className="p-4">
|
||||
<div className="flex gap-2">
|
||||
<span className={`px-2 py-1 rounded text-[10px] font-bold uppercase tracking-wide ${service.billingType === 'recurring' ? 'bg-indigo-50 text-indigo-600' : 'bg-slate-100 text-slate-600'}`}>
|
||||
{service.billingType === 'recurring' ? 'Assinatura' : 'Pontual'}
|
||||
</span>
|
||||
<span className="px-2 py-1 rounded text-[10px] font-bold uppercase tracking-wide bg-slate-100 text-slate-500">
|
||||
{service.category}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 text-right">
|
||||
<div className="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={() => { setEditingService(service); setIsServiceModalOpen(true); }} className="p-2 text-slate-400 hover:text-primary-500 hover:bg-primary-50 rounded-lg transition-colors">
|
||||
<Pencil size={18} />
|
||||
</button>
|
||||
<button onClick={() => handleDeleteService(service.id)} className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors">
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{type === 'clients' && filteredClients.map(client => (
|
||||
<tr key={client.id} className="hover:bg-slate-50 transition-colors group">
|
||||
<td className="p-4">
|
||||
<div className="font-bold text-slate-800">{client.name}</div>
|
||||
<div className="text-xs text-slate-400">{client.company}</div>
|
||||
</td>
|
||||
<td className="p-4 text-sm text-slate-600">
|
||||
<div>{client.email}</div>
|
||||
<div className="text-xs text-slate-400">{client.phone}</div>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<span className="px-2 py-1 rounded text-[10px] font-bold uppercase bg-green-50 text-green-600">
|
||||
Ativo
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4 text-right">
|
||||
<div className="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={() => { setEditingClient(client); setIsClientModalOpen(true); }} className="p-2 text-slate-400 hover:text-primary-500 hover:bg-primary-50 rounded-lg transition-colors">
|
||||
<Pencil size={18} />
|
||||
</button>
|
||||
<button onClick={() => handleDeleteClient(client.id)} className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors">
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Empty State */}
|
||||
{((type === 'services' && filteredServices.length === 0) || (type === 'clients' && filteredClients.length === 0)) && (
|
||||
<div className="p-10 text-center text-slate-400 flex flex-col items-center">
|
||||
<Sparkles size={32} className="mb-2 opacity-20"/>
|
||||
<p>Nenhum registro encontrado.</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (type === 'clients') { setEditingClient(null); setIsClientModalOpen(true); }
|
||||
else { setEditingService(null); setIsServiceModalOpen(true); }
|
||||
}}
|
||||
className="mt-4 text-sm font-bold text-primary-500 hover:underline"
|
||||
>
|
||||
Criar primeiro registro
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
452
components/ProposalsView.tsx
Normal file
452
components/ProposalsView.tsx
Normal file
@@ -0,0 +1,452 @@
|
||||
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Proposal, ProposalItem } from '../types';
|
||||
import { useComFi } from '../contexts/ComFiContext';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { CustomSelect } from './CustomSelect';
|
||||
import {
|
||||
FileText, Plus, Trash2, Printer, Edit2, CheckCircle2,
|
||||
Send, X, Search, ChevronLeft, Building2, Calendar, DollarSign, Save
|
||||
} from 'lucide-react';
|
||||
|
||||
export const ProposalsView: React.FC = () => {
|
||||
const { proposals, setProposals, companies, services, tenant } = useComFi();
|
||||
const { addToast } = useToast();
|
||||
const [viewMode, setViewMode] = useState<'list' | 'create' | 'edit'>('list');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// Form State
|
||||
const [currentProposal, setCurrentProposal] = useState<Partial<Proposal>>({
|
||||
items: [],
|
||||
issueDate: new Date().toISOString().split('T')[0],
|
||||
validUntil: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
status: 'draft',
|
||||
notes: 'Validade da proposta: 15 dias.\nPagamento: 50% entrada, 50% na entrega.'
|
||||
});
|
||||
|
||||
// Items Management
|
||||
const [newItemServiceId, setNewItemServiceId] = useState('');
|
||||
|
||||
const calculateTotal = (items: ProposalItem[]) => items.reduce((acc, item) => acc + item.total, 0);
|
||||
|
||||
const handleAddItem = () => {
|
||||
if (!newItemServiceId) return;
|
||||
const service = services.find(s => s.id === newItemServiceId);
|
||||
if (!service) return;
|
||||
|
||||
const newItem: ProposalItem = {
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
serviceId: service.id,
|
||||
description: service.name,
|
||||
quantity: 1,
|
||||
unitPrice: service.price,
|
||||
total: service.price
|
||||
};
|
||||
|
||||
const updatedItems = [...(currentProposal.items || []), newItem];
|
||||
setCurrentProposal({
|
||||
...currentProposal,
|
||||
items: updatedItems,
|
||||
totalValue: calculateTotal(updatedItems)
|
||||
});
|
||||
setNewItemServiceId('');
|
||||
};
|
||||
|
||||
const handleRemoveItem = (itemId: string) => {
|
||||
const updatedItems = (currentProposal.items || []).filter(i => i.id !== itemId);
|
||||
setCurrentProposal({
|
||||
...currentProposal,
|
||||
items: updatedItems,
|
||||
totalValue: calculateTotal(updatedItems)
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateItem = (itemId: string, field: 'quantity' | 'unitPrice', value: number) => {
|
||||
const updatedItems = (currentProposal.items || []).map(item => {
|
||||
if (item.id === itemId) {
|
||||
const updatedItem = { ...item, [field]: value };
|
||||
updatedItem.total = updatedItem.quantity * updatedItem.unitPrice;
|
||||
return updatedItem;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
setCurrentProposal({
|
||||
...currentProposal,
|
||||
items: updatedItems,
|
||||
totalValue: calculateTotal(updatedItems)
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!currentProposal.clientId || !currentProposal.items?.length) {
|
||||
addToast({ type: 'warning', title: 'Dados Incompletos', message: 'Selecione um cliente e adicione itens.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const client = companies.find(c => c.id === currentProposal.clientId);
|
||||
const proposalToSave: Proposal = {
|
||||
...currentProposal,
|
||||
id: currentProposal.id || Math.random().toString(36).substr(2, 9),
|
||||
number: currentProposal.number || `PROP-${new Date().getFullYear()}-${Math.floor(Math.random() * 1000)}`,
|
||||
clientName: client?.fantasyName || client?.name || 'Cliente',
|
||||
clientEmail: client?.email,
|
||||
totalValue: calculateTotal(currentProposal.items || [])
|
||||
} as Proposal;
|
||||
|
||||
if (viewMode === 'edit') {
|
||||
setProposals(proposals.map(p => p.id === proposalToSave.id ? proposalToSave : p));
|
||||
addToast({ type: 'success', title: 'Proposta Atualizada' });
|
||||
} else {
|
||||
setProposals([...proposals, proposalToSave]);
|
||||
addToast({ type: 'success', title: 'Proposta Criada' });
|
||||
}
|
||||
setViewMode('list');
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (window.confirm('Excluir esta proposta?')) {
|
||||
setProposals(proposals.filter(p => p.id !== id));
|
||||
addToast({ type: 'info', title: 'Proposta Removida' });
|
||||
}
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setCurrentProposal({
|
||||
items: [],
|
||||
issueDate: new Date().toISOString().split('T')[0],
|
||||
validUntil: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
status: 'draft',
|
||||
notes: 'Validade da proposta: 15 dias.\nPagamento: 50% entrada, 50% na entrega.'
|
||||
});
|
||||
setViewMode('create');
|
||||
};
|
||||
|
||||
const openEdit = (proposal: Proposal) => {
|
||||
setCurrentProposal(proposal);
|
||||
setViewMode('edit');
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
window.print();
|
||||
};
|
||||
|
||||
// --- RENDER ---
|
||||
|
||||
if (viewMode === 'create' || viewMode === 'edit') {
|
||||
const client = companies.find(c => c.id === currentProposal.clientId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in relative">
|
||||
{/* Print Styles */}
|
||||
<style>{`
|
||||
@media print {
|
||||
body * { visibility: hidden; }
|
||||
#proposal-document, #proposal-document * { visibility: visible; }
|
||||
#proposal-document { position: absolute; left: 0; top: 0; width: 100%; margin: 0; padding: 0; background: white; box-shadow: none; border: none; }
|
||||
.no-print { display: none !important; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Header / Actions */}
|
||||
<div className="flex justify-between items-center no-print">
|
||||
<button onClick={() => setViewMode('list')} className="flex items-center gap-2 px-4 py-2 bg-white border border-slate-200 rounded-xl hover:bg-slate-50 transition-colors text-slate-600 font-medium">
|
||||
<ChevronLeft size={18} /> Voltar
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handlePrint} className="flex items-center gap-2 px-4 py-2 bg-slate-800 text-white rounded-xl hover:bg-slate-900 font-bold transition-all shadow-lg">
|
||||
<Printer size={18} /> Imprimir / PDF
|
||||
</button>
|
||||
<button onClick={handleSave} className="flex items-center gap-2 px-6 py-2 bg-primary-500 text-white rounded-xl hover:bg-primary-600 font-bold transition-all shadow-lg shadow-primary-200">
|
||||
<Save size={18} /> Salvar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
|
||||
{/* Editor Panel (Left) */}
|
||||
<div className="lg:col-span-1 space-y-6 no-print">
|
||||
<div className="bg-white p-6 rounded-[2rem] shadow-sm border border-slate-100">
|
||||
<h3 className="font-bold text-slate-800 text-lg mb-4 flex items-center gap-2"><Building2 size={18}/> Cliente & Detalhes</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-500 mb-1 uppercase">Cliente</label>
|
||||
<CustomSelect
|
||||
value={currentProposal.clientId || ''}
|
||||
onChange={(val) => setCurrentProposal({...currentProposal, clientId: val})}
|
||||
placeholder="Selecione o Cliente"
|
||||
options={companies.map(c => ({ value: c.id, label: c.fantasyName || c.name }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-500 mb-1 uppercase">Emissão</label>
|
||||
<input type="date" className="w-full p-3 bg-white border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-200" value={currentProposal.issueDate} onChange={e => setCurrentProposal({...currentProposal, issueDate: e.target.value})} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-500 mb-1 uppercase">Validade</label>
|
||||
<input type="date" className="w-full p-3 bg-white border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-200" value={currentProposal.validUntil} onChange={e => setCurrentProposal({...currentProposal, validUntil: e.target.value})} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-500 mb-1 uppercase">Status</label>
|
||||
<CustomSelect
|
||||
value={currentProposal.status || 'draft'}
|
||||
onChange={(val) => setCurrentProposal({...currentProposal, status: val})}
|
||||
options={[
|
||||
{ value: 'draft', label: 'Rascunho' },
|
||||
{ value: 'sent', label: 'Enviado' },
|
||||
{ value: 'accepted', label: 'Aceito' },
|
||||
{ value: 'rejected', label: 'Rejeitado' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-[2rem] shadow-sm border border-slate-100">
|
||||
<h3 className="font-bold text-slate-800 text-lg mb-4 flex items-center gap-2"><Plus size={18}/> Adicionar Item</h3>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<div className="flex-1">
|
||||
<CustomSelect
|
||||
value={newItemServiceId}
|
||||
onChange={setNewItemServiceId}
|
||||
placeholder="Selecione um serviço..."
|
||||
options={services.map(s => ({ value: s.id, label: `${s.name} (R$ ${s.price})` }))}
|
||||
/>
|
||||
</div>
|
||||
<button onClick={handleAddItem} className="bg-slate-800 text-white p-3 rounded-xl hover:bg-slate-900 transition-colors"><Plus size={20}/></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-[2rem] shadow-sm border border-slate-100">
|
||||
<h3 className="font-bold text-slate-800 text-lg mb-4">Notas & Observações</h3>
|
||||
<textarea
|
||||
className="w-full p-3 bg-slate-50 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-200 h-32"
|
||||
value={currentProposal.notes}
|
||||
onChange={e => setCurrentProposal({...currentProposal, notes: e.target.value})}
|
||||
placeholder="Termos de pagamento, prazos, etc."
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview Panel (Right) - The "Paper" */}
|
||||
<div className="lg:col-span-2">
|
||||
<div id="proposal-document" className="bg-white w-full max-w-[210mm] mx-auto min-h-[297mm] p-[15mm] shadow-2xl rounded-sm text-slate-800 relative">
|
||||
|
||||
{/* Header Documento */}
|
||||
<div className="flex justify-between items-start border-b-2 border-primary-500 pb-8 mb-8">
|
||||
<div>
|
||||
{tenant.logo ? (
|
||||
<img src={tenant.logo} className="h-16 object-contain mb-2" alt="Logo" />
|
||||
) : (
|
||||
<h1 className="text-3xl font-bold text-slate-900">{tenant.name}</h1>
|
||||
)}
|
||||
<p className="text-sm text-slate-500 max-w-xs">{tenant.address}</p>
|
||||
<p className="text-sm text-slate-500">{tenant.email} | {tenant.phone}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<h2 className="text-4xl font-bold text-slate-200 uppercase tracking-widest">Proposta</h2>
|
||||
<p className="text-lg font-bold text-primary-600 mt-2">{currentProposal.number || 'RASCUNHO'}</p>
|
||||
<p className="text-sm text-slate-500 mt-1">Data: {new Date(currentProposal.issueDate || '').toLocaleDateString('pt-BR')}</p>
|
||||
<p className="text-sm text-slate-500">Válido até: {new Date(currentProposal.validUntil || '').toLocaleDateString('pt-BR')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Cliente */}
|
||||
<div className="mb-10">
|
||||
<h3 className="text-xs font-bold text-slate-400 uppercase mb-2">Preparado para:</h3>
|
||||
<div className="bg-slate-50 p-4 rounded-xl border border-slate-100">
|
||||
{client ? (
|
||||
<>
|
||||
<h4 className="text-xl font-bold text-slate-800">{client.fantasyName || client.name}</h4>
|
||||
<p className="text-sm text-slate-600">{client.address} - {client.city}</p>
|
||||
<p className="text-sm text-slate-600">CNPJ: {client.cnpj}</p>
|
||||
<p className="text-sm text-slate-600">{client.email}</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-slate-400 italic">Selecione um cliente...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items Table */}
|
||||
<div className="mb-8">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-slate-100">
|
||||
<th className="py-3 text-xs font-bold text-slate-500 uppercase w-1/2">Descrição / Serviço</th>
|
||||
<th className="py-3 text-xs font-bold text-slate-500 uppercase text-center">Qtd</th>
|
||||
<th className="py-3 text-xs font-bold text-slate-500 uppercase text-right">Valor Unit.</th>
|
||||
<th className="py-3 text-xs font-bold text-slate-500 uppercase text-right">Total</th>
|
||||
<th className="py-3 w-8 no-print"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(currentProposal.items || []).map((item) => (
|
||||
<tr key={item.id} className="border-b border-slate-50 group">
|
||||
<td className="py-4">
|
||||
<span className="font-bold text-slate-700 block">{item.description}</span>
|
||||
</td>
|
||||
<td className="py-4 text-center">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
className="w-16 text-center bg-transparent border border-transparent hover:border-slate-200 rounded p-1 outline-none focus:border-primary-300 transition-colors"
|
||||
value={item.quantity}
|
||||
onChange={(e) => handleUpdateItem(item.id, 'quantity', Number(e.target.value))}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-4 text-right">
|
||||
<div className="flex justify-end items-center gap-1">
|
||||
<span className="text-xs text-slate-400">R$</span>
|
||||
<input
|
||||
type="number"
|
||||
className="w-24 text-right bg-transparent border border-transparent hover:border-slate-200 rounded p-1 outline-none focus:border-primary-300 transition-colors"
|
||||
value={item.unitPrice}
|
||||
onChange={(e) => handleUpdateItem(item.id, 'unitPrice', Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 text-right font-bold text-slate-800">
|
||||
R$ {item.total.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}
|
||||
</td>
|
||||
<td className="py-4 text-right no-print">
|
||||
<button onClick={() => handleRemoveItem(item.id)} className="text-slate-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all"><Trash2 size={16}/></button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(currentProposal.items || []).length === 0 && (
|
||||
<tr><td colSpan={5} className="py-8 text-center text-slate-300 italic">Nenhum item adicionado</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Totals */}
|
||||
<div className="flex justify-end mb-12">
|
||||
<div className="w-1/2">
|
||||
<div className="flex justify-between items-center py-2 border-b border-slate-100">
|
||||
<span className="text-slate-500 font-medium">Subtotal</span>
|
||||
<span className="text-slate-800 font-bold">R$ {calculateTotal(currentProposal.items || []).toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-4">
|
||||
<span className="text-lg text-slate-800 font-bold">Total Geral</span>
|
||||
<span className="text-2xl text-primary-600 font-bold">R$ {calculateTotal(currentProposal.items || []).toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Notes */}
|
||||
<div className="border-t-2 border-slate-100 pt-6">
|
||||
<h4 className="text-sm font-bold text-slate-800 mb-2">Termos e Condições</h4>
|
||||
<p className="text-sm text-slate-500 whitespace-pre-line">{currentProposal.notes}</p>
|
||||
</div>
|
||||
|
||||
{/* Signature Area */}
|
||||
<div className="absolute bottom-[15mm] left-[15mm] right-[15mm] flex justify-between mt-20">
|
||||
<div className="w-1/3 border-t border-slate-300 pt-2 text-center">
|
||||
<p className="text-xs font-bold text-slate-600">{tenant.name}</p>
|
||||
<p className="text-[10px] text-slate-400">Assinatura do Emissor</p>
|
||||
</div>
|
||||
<div className="w-1/3 border-t border-slate-300 pt-2 text-center">
|
||||
<p className="text-xs font-bold text-slate-600">{client?.name || 'Cliente'}</p>
|
||||
<p className="text-[10px] text-slate-400">De acordo</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// LIST VIEW
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800">Propostas Comerciais</h1>
|
||||
<p className="text-slate-500">Crie orçamentos e gerencie negociações.</p>
|
||||
</div>
|
||||
<button onClick={openCreate} className="flex items-center gap-2 px-5 py-2.5 bg-primary-500 text-white rounded-xl hover:bg-primary-600 shadow-lg shadow-primary-200/50 transition-all font-bold">
|
||||
<Plus size={18} /> Nova Proposta
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="p-4 border-b border-slate-100 flex gap-4">
|
||||
<div className="relative max-w-md flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar proposta..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-slate-50 border-none rounded-xl focus:ring-2 focus:ring-primary-500 outline-none text-slate-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-slate-50/50">
|
||||
<tr>
|
||||
<th className="p-4 text-xs font-bold uppercase text-slate-500">Número</th>
|
||||
<th className="p-4 text-xs font-bold uppercase text-slate-500">Cliente</th>
|
||||
<th className="p-4 text-xs font-bold uppercase text-slate-500">Emissão</th>
|
||||
<th className="p-4 text-xs font-bold uppercase text-slate-500">Valor</th>
|
||||
<th className="p-4 text-xs font-bold uppercase text-slate-500 text-center">Status</th>
|
||||
<th className="p-4"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{proposals.filter(p => p.clientName.toLowerCase().includes(searchTerm.toLowerCase()) || p.number.toLowerCase().includes(searchTerm.toLowerCase())).map(prop => (
|
||||
<tr key={prop.id} className="hover:bg-slate-50 transition-colors group">
|
||||
<td className="p-4 font-bold text-slate-800">{prop.number}</td>
|
||||
<td className="p-4 text-sm text-slate-700">{prop.clientName}</td>
|
||||
<td className="p-4 text-sm text-slate-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar size={14} className="text-slate-400"/>
|
||||
{new Date(prop.issueDate).toLocaleDateString('pt-BR')}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 font-bold text-slate-700">R$ {prop.totalValue.toLocaleString('pt-BR')}</td>
|
||||
<td className="p-4 text-center">
|
||||
<span className={`px-2 py-1 rounded text-[10px] font-bold uppercase tracking-wide ${
|
||||
prop.status === 'accepted' ? 'bg-green-50 text-green-600' :
|
||||
prop.status === 'sent' ? 'bg-blue-50 text-blue-600' :
|
||||
prop.status === 'rejected' ? 'bg-red-50 text-red-600' :
|
||||
'bg-slate-100 text-slate-500'
|
||||
}`}>
|
||||
{prop.status === 'accepted' ? 'Aceito' : prop.status === 'sent' ? 'Enviado' : prop.status === 'rejected' ? 'Rejeitado' : 'Rascunho'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4 text-right">
|
||||
<div className="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={() => openEdit(prop)} className="p-2 text-slate-400 hover:text-primary-500 hover:bg-primary-50 rounded-lg transition-colors" title="Editar / Visualizar">
|
||||
<Edit2 size={18} />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(prop.id)} className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors" title="Excluir">
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{proposals.length === 0 && (
|
||||
<div className="p-10 text-center text-slate-400">
|
||||
<FileText size={32} className="mx-auto mb-2 opacity-20"/>
|
||||
<p>Nenhuma proposta registrada.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
255
components/SettingsView.tsx
Normal file
255
components/SettingsView.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { useComFi } from '../contexts/ComFiContext';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import {
|
||||
Building2, Save, UploadCloud, Camera, Palette, Tag,
|
||||
Plus, Trash2, Bell, Lock, UserCog, Mail, Key
|
||||
} from 'lucide-react';
|
||||
import { Category } from '../types';
|
||||
import { CustomSelect } from './CustomSelect';
|
||||
|
||||
export const SettingsView: React.FC = () => {
|
||||
const { tenant, setTenant, categories, setCategories, currentUser } = useComFi();
|
||||
const { addToast } = useToast();
|
||||
const [activeTab, setActiveTab] = useState<'company' | 'categories' | 'security'>('company');
|
||||
|
||||
// Organization State
|
||||
const [orgForm, setOrgForm] = useState(tenant);
|
||||
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Categories State
|
||||
const [newCategory, setNewCategory] = useState({ name: '', type: 'expense' as 'expense' | 'income' });
|
||||
|
||||
// Handle Organization Save
|
||||
const handleSaveOrg = () => {
|
||||
setTenant(orgForm);
|
||||
addToast({ type: 'success', title: 'Configurações Salvas', message: 'Dados da empresa atualizados.' });
|
||||
};
|
||||
|
||||
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setOrgForm({ ...orgForm, logo: reader.result as string });
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Categories
|
||||
const handleAddCategory = () => {
|
||||
if (!newCategory.name) return;
|
||||
const category: Category = {
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
name: newCategory.name,
|
||||
type: newCategory.type,
|
||||
color: newCategory.type === 'income' ? 'bg-green-50 text-green-600' : 'bg-red-50 text-red-600'
|
||||
};
|
||||
setCategories([...categories, category]);
|
||||
setNewCategory({ name: '', type: 'expense' });
|
||||
addToast({ type: 'success', title: 'Categoria Adicionada' });
|
||||
};
|
||||
|
||||
const handleDeleteCategory = (id: string) => {
|
||||
setCategories(categories.filter(c => c.id !== id));
|
||||
addToast({ type: 'info', title: 'Categoria Removida' });
|
||||
};
|
||||
|
||||
const inputClass = "w-full p-3 bg-white border border-slate-200 rounded-xl focus:ring-2 focus:ring-primary-200 outline-none text-slate-800 text-sm";
|
||||
const labelClass = "block text-xs font-bold text-slate-700 mb-1 uppercase tracking-wide";
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in max-w-4xl mx-auto pb-10">
|
||||
<h1 className="text-2xl font-bold text-slate-800 mb-2">Configurações & Personalização</h1>
|
||||
<p className="text-slate-500 mb-6">Gerencie dados da empresa, categorias financeiras e preferências.</p>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-slate-200 mb-6 overflow-x-auto">
|
||||
<button
|
||||
onClick={() => setActiveTab('company')}
|
||||
className={`px-6 py-3 text-sm font-bold border-b-2 transition-colors whitespace-nowrap flex items-center gap-2 ${activeTab === 'company' ? 'border-primary-500 text-primary-600' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
|
||||
>
|
||||
<Building2 size={16}/> Minha Empresa
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('categories')}
|
||||
className={`px-6 py-3 text-sm font-bold border-b-2 transition-colors whitespace-nowrap flex items-center gap-2 ${activeTab === 'categories' ? 'border-primary-500 text-primary-600' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
|
||||
>
|
||||
<Tag size={16}/> Categorias Financeiras
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('security')}
|
||||
className={`px-6 py-3 text-sm font-bold border-b-2 transition-colors whitespace-nowrap flex items-center gap-2 ${activeTab === 'security' ? 'border-primary-500 text-primary-600' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
|
||||
>
|
||||
<Lock size={16}/> Segurança & Preferências
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-[2rem] shadow-sm border border-slate-100 p-8">
|
||||
|
||||
{/* ORGANIZATION SETTINGS */}
|
||||
{activeTab === 'company' && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start gap-6">
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
onClick={() => logoInputRef.current?.click()}
|
||||
className="w-32 h-32 rounded-2xl bg-slate-50 border-2 border-dashed border-slate-200 flex items-center justify-center cursor-pointer overflow-hidden hover:border-primary-300 relative group transition-colors"
|
||||
>
|
||||
{orgForm.logo ? (
|
||||
<img src={orgForm.logo} alt="Logo" className="w-full h-full object-contain p-2" />
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<UploadCloud size={24} className="mx-auto text-slate-400 mb-1" />
|
||||
<span className="text-[10px] text-slate-400 font-bold uppercase">Upload Logo</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Camera className="text-white" size={24}/>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" ref={logoInputRef} className="hidden" accept="image/*" onChange={handleLogoUpload} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className={labelClass}>Nome da Empresa (Razão Social)</label>
|
||||
<input className={inputClass} value={orgForm.name} onChange={e => setOrgForm({...orgForm, name: e.target.value})} placeholder="Minha Empresa S.A." />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>CNPJ</label>
|
||||
<input className={inputClass} value={orgForm.cnpj} onChange={e => setOrgForm({...orgForm, cnpj: e.target.value})} placeholder="00.000.000/0000-00" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Telefone Comercial</label>
|
||||
<input className={inputClass} value={orgForm.phone} onChange={e => setOrgForm({...orgForm, phone: e.target.value})} />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className={labelClass}>Endereço Completo</label>
|
||||
<input className={inputClass} value={orgForm.address} onChange={e => setOrgForm({...orgForm, address: e.target.value})} placeholder="Rua, Número, Bairro, Cidade - UF" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className={labelClass}>Email Financeiro</label>
|
||||
<input className={inputClass} value={orgForm.email} onChange={e => setOrgForm({...orgForm, email: e.target.value})} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-6 border-t border-slate-50 flex justify-end">
|
||||
<button onClick={handleSaveOrg} className="px-6 py-3 bg-primary-500 text-white font-bold rounded-xl hover:bg-primary-600 shadow-lg shadow-primary-200 flex items-center gap-2">
|
||||
<Save size={18} /> Salvar Alterações
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CATEGORIES SETTINGS */}
|
||||
{activeTab === 'categories' && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex gap-4 items-end bg-slate-50 p-4 rounded-xl border border-slate-100">
|
||||
<div className="flex-1">
|
||||
<label className={labelClass}>Nova Categoria</label>
|
||||
<input className={inputClass} value={newCategory.name} onChange={e => setNewCategory({...newCategory, name: e.target.value})} placeholder="Ex: Transporte, Freelancers..." />
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<label className={labelClass}>Tipo</label>
|
||||
<div className="relative">
|
||||
<CustomSelect
|
||||
value={newCategory.type}
|
||||
onChange={(val) => setNewCategory({...newCategory, type: val})}
|
||||
options={[
|
||||
{ value: 'expense', label: 'Despesa' },
|
||||
{ value: 'income', label: 'Receita' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={handleAddCategory} className="px-4 py-3 bg-slate-800 text-white rounded-xl hover:bg-slate-900 font-bold flex items-center justify-center">
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div>
|
||||
<h3 className="font-bold text-slate-800 mb-3 flex items-center gap-2 text-sm"><span className="w-2 h-2 rounded-full bg-red-500"></span> Despesas</h3>
|
||||
<div className="space-y-2">
|
||||
{categories.filter(c => c.type === 'expense').map(cat => (
|
||||
<div key={cat.id} className="flex justify-between items-center p-3 bg-white border border-slate-100 rounded-xl hover:bg-slate-50 transition-colors group">
|
||||
<span className="text-sm font-medium text-slate-700">{cat.name}</span>
|
||||
<button onClick={() => handleDeleteCategory(cat.id)} className="text-slate-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all">
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-slate-800 mb-3 flex items-center gap-2 text-sm"><span className="w-2 h-2 rounded-full bg-green-500"></span> Receitas</h3>
|
||||
<div className="space-y-2">
|
||||
{categories.filter(c => c.type === 'income').map(cat => (
|
||||
<div key={cat.id} className="flex justify-between items-center p-3 bg-white border border-slate-100 rounded-xl hover:bg-slate-50 transition-colors group">
|
||||
<span className="text-sm font-medium text-slate-700">{cat.name}</span>
|
||||
<button onClick={() => handleDeleteCategory(cat.id)} className="text-slate-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all">
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SECURITY SETTINGS */}
|
||||
{activeTab === 'security' && (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div className="bg-slate-50 p-6 rounded-2xl border border-slate-100">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="w-16 h-16 rounded-full bg-white border-4 border-white shadow-sm overflow-hidden">
|
||||
<img src={currentUser.avatar} alt="User" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-slate-800 text-lg">{currentUser.name}</h3>
|
||||
<p className="text-sm text-slate-500">{currentUser.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className={labelClass}>Alterar Senha</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Key className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
|
||||
<input type="password" className={`${inputClass} pl-10`} placeholder="Nova senha..." />
|
||||
</div>
|
||||
<button className="px-4 py-2 bg-slate-800 text-white rounded-xl text-sm font-bold hover:bg-slate-900">Atualizar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-bold text-slate-800 text-sm uppercase tracking-wide">Notificações</h3>
|
||||
<div className="flex items-center justify-between p-4 border border-slate-100 rounded-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-50 text-blue-500 flex items-center justify-center"><Bell size={20}/></div>
|
||||
<div>
|
||||
<p className="font-bold text-slate-800 text-sm">Alertas por Email</p>
|
||||
<p className="text-xs text-slate-400">Receba resumos semanais.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative inline-block w-12 mr-2 align-middle select-none transition duration-200 ease-in">
|
||||
<input type="checkbox" name="toggle" id="toggle" className="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer border-slate-200 checked:right-0 checked:border-green-400"/>
|
||||
<label htmlFor="toggle" className="toggle-label block overflow-hidden h-6 rounded-full bg-slate-200 cursor-pointer checked:bg-green-400"></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
309
components/UserManagementView.tsx
Normal file
309
components/UserManagementView.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import React, { useState } from 'react';
|
||||
import { AppUser, ViewState } from '../types';
|
||||
import {
|
||||
Plus, Search, Shield, ShieldAlert, CheckCircle2,
|
||||
XCircle, Edit2, Trash2, X, Save, User as UserIcon, Lock
|
||||
} from 'lucide-react';
|
||||
|
||||
interface UserManagementViewProps {
|
||||
users: AppUser[];
|
||||
setUsers: (users: AppUser[]) => void;
|
||||
availableModules: { id: ViewState; label: string }[];
|
||||
currentUser: AppUser;
|
||||
}
|
||||
|
||||
const ToggleSwitch: React.FC<{ checked: boolean, onChange: (val: boolean) => void, label: string }> = ({ checked, onChange, label }) => (
|
||||
<div className="flex items-center justify-between py-3 border-b border-slate-50 last:border-0">
|
||||
<span className="text-sm font-medium text-slate-700">{label}</span>
|
||||
<button
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors duration-200 ease-in-out focus:outline-none ${
|
||||
checked ? 'bg-primary-500' : 'bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block w-4 h-4 transform bg-white rounded-full shadow transition-transform duration-200 ease-in-out mt-1 ml-1 ${
|
||||
checked ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const UserManagementView: React.FC<UserManagementViewProps> = ({ users, setUsers, availableModules, currentUser }) => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<Partial<AppUser>>({
|
||||
role: 'user',
|
||||
active: true,
|
||||
permissions: []
|
||||
});
|
||||
|
||||
const handleSaveUser = () => {
|
||||
if (!editingUser.name || !editingUser.email) return;
|
||||
|
||||
if (editingUser.id) {
|
||||
// Editar existente
|
||||
setUsers(users.map(u => u.id === editingUser.id ? { ...u, ...editingUser } as AppUser : u));
|
||||
} else {
|
||||
// Criar novo
|
||||
const newUser: AppUser = {
|
||||
...editingUser,
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
avatar: `https://ui-avatars.com/api/?name=${editingUser.name}&background=random`,
|
||||
permissions: editingUser.role === 'super_admin' ? availableModules.map(m => m.id) : (editingUser.permissions || [])
|
||||
} as AppUser;
|
||||
setUsers([...users, newUser]);
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
setEditingUser({ role: 'user', active: true, permissions: [] });
|
||||
};
|
||||
|
||||
const handleDeleteUser = (id: string) => {
|
||||
if (id === currentUser.id) {
|
||||
alert("Você não pode excluir a si mesmo.");
|
||||
return;
|
||||
}
|
||||
if (window.confirm("Tem certeza que deseja remover este usuário?")) {
|
||||
setUsers(users.filter(u => u.id !== id));
|
||||
}
|
||||
};
|
||||
|
||||
const togglePermission = (moduleId: ViewState) => {
|
||||
const currentPermissions = editingUser.permissions || [];
|
||||
if (currentPermissions.includes(moduleId)) {
|
||||
setEditingUser({ ...editingUser, permissions: currentPermissions.filter(p => p !== moduleId) });
|
||||
} else {
|
||||
setEditingUser({ ...editingUser, permissions: [...currentPermissions, moduleId] });
|
||||
}
|
||||
};
|
||||
|
||||
const openModal = (user?: AppUser) => {
|
||||
if (user) {
|
||||
setEditingUser(user);
|
||||
} else {
|
||||
setEditingUser({ role: 'user', active: true, permissions: [], name: '', email: '' });
|
||||
}
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const inputClass = "w-full p-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-primary-200 outline-none text-slate-800 text-sm";
|
||||
const labelClass = "block text-xs font-bold text-slate-700 mb-1 uppercase tracking-wide";
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800">Gerenciamento de Usuários</h1>
|
||||
<p className="text-slate-500">Controle de acesso e permissões do sistema.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => openModal()}
|
||||
className="flex items-center gap-2 px-5 py-3 bg-primary-500 text-white rounded-xl shadow-lg shadow-primary-200/50 hover:bg-primary-600 font-bold transition-all"
|
||||
>
|
||||
<Plus size={20} /> Novo Usuário
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-slate-50/50 border-b border-slate-100">
|
||||
<tr>
|
||||
<th className="p-6 text-xs font-bold uppercase text-slate-500">Usuário</th>
|
||||
<th className="p-6 text-xs font-bold uppercase text-slate-500">Função</th>
|
||||
<th className="p-6 text-xs font-bold uppercase text-slate-500">Status</th>
|
||||
<th className="p-6 text-xs font-bold uppercase text-slate-500">Permissões</th>
|
||||
<th className="p-6 text-right"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{users.map(user => (
|
||||
<tr key={user.id} className="hover:bg-slate-50 transition-colors">
|
||||
<td className="p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-full bg-slate-200 overflow-hidden">
|
||||
<img src={user.avatar} alt={user.name} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-slate-800">{user.name}</div>
|
||||
<div className="text-xs text-slate-400">{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-6">
|
||||
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold uppercase ${
|
||||
user.role === 'super_admin' ? 'bg-indigo-50 text-indigo-600' : 'bg-slate-100 text-slate-600'
|
||||
}`}>
|
||||
{user.role === 'super_admin' ? <ShieldAlert size={14}/> : <UserIcon size={14}/>}
|
||||
{user.role === 'super_admin' ? 'Super Admin' : 'Usuário'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-6">
|
||||
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold uppercase ${
|
||||
user.active ? 'bg-green-50 text-green-600' : 'bg-red-50 text-red-600'
|
||||
}`}>
|
||||
{user.active ? <CheckCircle2 size={14}/> : <XCircle size={14}/>}
|
||||
{user.active ? 'Ativo' : 'Inativo'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-6">
|
||||
{user.role === 'super_admin' ? (
|
||||
<span className="text-xs text-indigo-500 font-bold">Acesso Total</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1 max-w-xs">
|
||||
{user.permissions.length === 0 && <span className="text-xs text-slate-400">Sem acesso</span>}
|
||||
{user.permissions.slice(0, 3).map(p => (
|
||||
<span key={p} className="px-2 py-0.5 bg-slate-100 border border-slate-200 rounded text-[10px] text-slate-500 capitalize">
|
||||
{availableModules.find(m => m.id === p)?.label || p}
|
||||
</span>
|
||||
))}
|
||||
{user.permissions.length > 3 && (
|
||||
<span className="px-2 py-0.5 bg-slate-100 border border-slate-200 rounded text-[10px] text-slate-500">
|
||||
+{user.permissions.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-6 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={() => openModal(user)} className="p-2 text-slate-400 hover:text-primary-500 hover:bg-primary-50 rounded-lg transition-colors">
|
||||
<Edit2 size={18} />
|
||||
</button>
|
||||
{user.id !== currentUser.id && (
|
||||
<button onClick={() => handleDeleteUser(user.id)} className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors">
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal User Edit/Create */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm" onClick={() => setIsModalOpen(false)}></div>
|
||||
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-2xl overflow-hidden animate-scale-up flex flex-col max-h-[90vh]">
|
||||
<div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center bg-slate-50">
|
||||
<h3 className="font-bold text-slate-800 text-lg flex items-center gap-2">
|
||||
{editingUser.id ? 'Editar Usuário' : 'Novo Usuário'}
|
||||
</h3>
|
||||
<button onClick={() => setIsModalOpen(false)} className="text-slate-400 hover:text-slate-600"><X size={20}/></button>
|
||||
</div>
|
||||
|
||||
<div className="p-8 overflow-y-auto flex-1">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
<label className={labelClass}>Nome Completo</label>
|
||||
<input
|
||||
type="text"
|
||||
className={inputClass}
|
||||
value={editingUser.name || ''}
|
||||
onChange={e => setEditingUser({...editingUser, name: e.target.value})}
|
||||
placeholder="Ex: João Silva"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
<label className={labelClass}>Email</label>
|
||||
<input
|
||||
type="email"
|
||||
className={inputClass}
|
||||
value={editingUser.email || ''}
|
||||
onChange={e => setEditingUser({...editingUser, email: e.target.value})}
|
||||
placeholder="joao@empresa.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
<label className={labelClass}>Nível de Acesso</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setEditingUser({...editingUser, role: 'user'})}
|
||||
className={`flex-1 py-2 px-3 rounded-xl border text-sm font-medium transition-all flex items-center justify-center gap-2 ${
|
||||
editingUser.role === 'user' ? 'bg-primary-50 border-primary-200 text-primary-700' : 'bg-white border-slate-200 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
<UserIcon size={16}/> Usuário
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingUser({...editingUser, role: 'super_admin'})}
|
||||
className={`flex-1 py-2 px-3 rounded-xl border text-sm font-medium transition-all flex items-center justify-center gap-2 ${
|
||||
editingUser.role === 'super_admin' ? 'bg-indigo-50 border-indigo-200 text-indigo-700' : 'bg-white border-slate-200 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
<Shield size={16}/> Admin
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
<label className={labelClass}>Status da Conta</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setEditingUser({...editingUser, active: true})}
|
||||
className={`flex-1 py-2 px-3 rounded-xl border text-sm font-medium transition-all flex items-center justify-center gap-2 ${
|
||||
editingUser.active ? 'bg-green-50 border-green-200 text-green-700' : 'bg-white border-slate-200 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
<CheckCircle2 size={16}/> Ativo
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingUser({...editingUser, active: false})}
|
||||
className={`flex-1 py-2 px-3 rounded-xl border text-sm font-medium transition-all flex items-center justify-center gap-2 ${
|
||||
!editingUser.active ? 'bg-red-50 border-red-200 text-red-700' : 'bg-white border-slate-200 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
<XCircle size={16}/> Bloqueado
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Permissions Area */}
|
||||
<div className="bg-slate-50 rounded-2xl p-6 border border-slate-100">
|
||||
<h4 className="font-bold text-slate-800 mb-4 flex items-center gap-2">
|
||||
<Lock size={18} className="text-slate-400"/>
|
||||
Permissões de Acesso
|
||||
</h4>
|
||||
|
||||
{editingUser.role === 'super_admin' ? (
|
||||
<div className="text-center py-6 text-indigo-600 bg-indigo-50 rounded-xl border border-indigo-100">
|
||||
<ShieldAlert size={32} className="mx-auto mb-2"/>
|
||||
<p className="font-bold">Acesso Irrestrito</p>
|
||||
<p className="text-xs opacity-75">Super Admins têm acesso a todos os módulos.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{availableModules.map(module => (
|
||||
<ToggleSwitch
|
||||
key={module.id}
|
||||
label={module.label}
|
||||
checked={(editingUser.permissions || []).includes(module.id)}
|
||||
onChange={() => togglePermission(module.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="px-8 py-5 bg-slate-50 border-t border-slate-100 flex justify-end gap-3">
|
||||
<button onClick={() => setIsModalOpen(false)} className="px-4 py-2 text-slate-600 font-medium hover:bg-slate-200 rounded-xl">Cancelar</button>
|
||||
<button onClick={handleSaveUser} className="px-6 py-2 bg-primary-500 text-white font-bold rounded-xl hover:bg-primary-600 flex items-center gap-2 shadow-lg shadow-primary-200">
|
||||
<Save size={18} /> Salvar Usuário
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user