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.
389 lines
19 KiB
TypeScript
389 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}; |