feat: Initialize ComFi project with Vite

Setup project structure, dependencies, and basic configuration for the ComFi application. Includes initial setup for Vite, React, TypeScript, Tailwind CSS, and essential development tools. Defines core types and provides a basic README for local development.
This commit is contained in:
MMrp89
2026-02-09 20:28:37 -03:00
parent 1e6a56d866
commit 1a57ac7754
28 changed files with 6070 additions and 8 deletions

View File

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