306 lines
16 KiB
TypeScript
306 lines
16 KiB
TypeScript
|
|
import React, { useState, useMemo } from 'react';
|
|
import { useCRM } from '../context/CRMContext';
|
|
import {
|
|
BarChart3, TrendingUp, PieChart, Package, ArrowUpRight, ArrowDownRight,
|
|
Download, Calendar, Filter, DollarSign, Activity, AlertCircle, Boxes
|
|
} from 'lucide-react';
|
|
import {
|
|
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
|
LineChart, Line, AreaChart, Area
|
|
} from 'recharts';
|
|
import clsx from 'clsx';
|
|
|
|
const Reports: React.FC = () => {
|
|
const { orders, inventory, transactions, sales } = useCRM();
|
|
const [period, setPeriod] = useState<'30d' | '3m' | '6m' | 'ytd' | '1y' | 'all'>('30d');
|
|
const [activeTab, setActiveTab] = useState<'financial' | 'products' | 'inventory' | 'sales'>('financial');
|
|
|
|
// --- FILTERING LOGIC ---
|
|
const filteredSales = useMemo(() => {
|
|
const now = new Date();
|
|
const cutoff = new Date();
|
|
|
|
if (period === '30d') cutoff.setDate(now.getDate() - 30);
|
|
else if (period === '3m') cutoff.setMonth(now.getMonth() - 3);
|
|
else if (period === '6m') cutoff.setMonth(now.getMonth() - 6);
|
|
else if (period === 'ytd') cutoff.setMonth(0, 1); // Jan 1st of current year
|
|
else if (period === '1y') cutoff.setFullYear(now.getFullYear() - 1);
|
|
else if (period === 'all') return sales; // No filter
|
|
|
|
return sales.filter(s => new Date(s.date) >= cutoff);
|
|
}, [sales, period]);
|
|
|
|
// 1. Financial Metrics
|
|
const financialStats = useMemo(() => {
|
|
// Revenue
|
|
const totalRevenue = filteredSales.reduce((acc, s) => acc + s.total, 0);
|
|
|
|
// Cost of Goods Sold (COGS) based on captured costPrice at time of sale
|
|
const totalCost = filteredSales.reduce((acc, s) => {
|
|
const saleCost = s.items.reduce((iAcc, item) => iAcc + ((item.costPrice || 0) * item.quantity), 0);
|
|
return acc + saleCost;
|
|
}, 0);
|
|
|
|
const totalProfit = totalRevenue - totalCost;
|
|
const margin = totalRevenue > 0 ? (totalProfit / totalRevenue) * 100 : 0;
|
|
|
|
return { totalRevenue, totalCost, totalProfit, margin };
|
|
}, [filteredSales]);
|
|
|
|
// 2. Product/Arbitrage Metrics
|
|
const productStats = useMemo(() => {
|
|
const productPerformance: Record<string, { profit: number, revenue: number, quantity: number }> = {};
|
|
|
|
filteredSales.forEach(s => {
|
|
s.items.forEach(item => {
|
|
if (!productPerformance[item.name]) {
|
|
productPerformance[item.name] = { profit: 0, revenue: 0, quantity: 0 };
|
|
}
|
|
const itemRevenue = item.salePrice;
|
|
const itemCost = item.costPrice || 0;
|
|
|
|
productPerformance[item.name].profit += (itemRevenue - itemCost) * item.quantity;
|
|
productPerformance[item.name].revenue += itemRevenue * item.quantity;
|
|
productPerformance[item.name].quantity += item.quantity;
|
|
});
|
|
});
|
|
|
|
const sortedProducts = Object.entries(productPerformance)
|
|
.map(([name, stats]) => ({ name, ...stats }))
|
|
.sort((a, b) => b.profit - a.profit);
|
|
|
|
return {
|
|
topProducts: sortedProducts.slice(0, 5),
|
|
lowMarginProducts: sortedProducts.filter(p => p.revenue > 0 && (p.profit / p.revenue) < 0.10).slice(0, 5)
|
|
};
|
|
}, [filteredSales]);
|
|
|
|
// 3. Inventory Stats (Unchanged)
|
|
const inventoryStats = useMemo(() => {
|
|
const totalValue = inventory.reduce((acc, i) => acc + (i.avgCostBRL * i.quantity), 0);
|
|
const totalItems = inventory.reduce((acc, i) => acc + i.quantity, 0);
|
|
const deadStock = inventory.filter(i => i.quantity > 0).slice(0, 3);
|
|
return { totalValue, totalItems, deadStock };
|
|
}, [inventory]);
|
|
|
|
// 4. Chart Data - Responsive to Period
|
|
const chartData = useMemo(() => {
|
|
const dataMap: Record<string, { receita: number, lucro: number, date: number }> = {};
|
|
|
|
filteredSales.forEach(sale => {
|
|
if (sale.status === 'Cancelled' || sale.status === 'Returned') return;
|
|
const d = new Date(sale.date);
|
|
|
|
// Dynamic grouping key
|
|
let key = '';
|
|
if (period === '30d') {
|
|
key = d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' }); // Daily for 30d
|
|
} else {
|
|
key = d.toLocaleDateString('pt-BR', { month: 'short', year: '2-digit' }); // Monthly for others
|
|
}
|
|
|
|
if (!dataMap[key]) dataMap[key] = { receita: 0, lucro: 0, date: d.getTime() };
|
|
|
|
const saleCost = sale.items.reduce((acc, item) => acc + ((item.costPrice || 0) * item.quantity), 0);
|
|
|
|
dataMap[key].receita += sale.total;
|
|
dataMap[key].lucro += (sale.total - saleCost);
|
|
});
|
|
|
|
return Object.entries(dataMap)
|
|
.map(([name, vals]) => ({ name, ...vals }))
|
|
.sort((a, b) => a.date - b.date); // Sort chronologically
|
|
}, [filteredSales, period]); // Recalc on filter change
|
|
|
|
|
|
// --- EXPORT ---
|
|
const handleExport = () => {
|
|
const csvContent = "data:text/csv;charset=utf-8,"
|
|
+ "Categoria,Valor\n"
|
|
+ `Receita Total,${financialStats.totalRevenue}\n`
|
|
+ `Custo Total,${financialStats.totalCost}\n`
|
|
+ `Lucro Liquido,${financialStats.totalProfit}\n`;
|
|
|
|
const encodedUri = encodeURI(csvContent);
|
|
const link = document.createElement("a");
|
|
link.setAttribute("href", encodedUri);
|
|
link.setAttribute("download", "relatorio_arbitra.csv");
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
};
|
|
|
|
|
|
return (
|
|
<div className="space-y-8 animate-in fade-in duration-500 pb-20">
|
|
|
|
{/* HEADER */}
|
|
<div className="flex flex-col md:flex-row justify-between items-end border-b border-white/5 pb-6 gap-4">
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight text-white mb-1">Relatórios & Analytics</h1>
|
|
<p className="text-sm text-slate-400">Visão geral de performance, financeiro e estoque.</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<div className="bg-black/20 border border-white/10 rounded-lg p-1 flex">
|
|
{[
|
|
{ id: '30d', label: '30D' },
|
|
{ id: '3m', label: '3M' },
|
|
{ id: '6m', label: '6M' },
|
|
{ id: 'ytd', label: 'YTD' },
|
|
{ id: '1y', label: '1 Ano' },
|
|
{ id: 'all', label: 'Tudo' }
|
|
].map((p) => (
|
|
<button
|
|
key={p.id}
|
|
onClick={() => setPeriod(p.id as any)}
|
|
className={clsx(
|
|
"px-3 py-1.5 rounded text-[10px] font-bold uppercase tracking-wider transition-all",
|
|
period === p.id ? "bg-indigo-600 text-white shadow-sm" : "text-slate-500 hover:text-white"
|
|
)}
|
|
>
|
|
{p.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<button
|
|
onClick={handleExport}
|
|
className="bg-emerald-600 hover:bg-emerald-500 text-white px-4 py-2.5 rounded-lg font-bold text-sm shadow-lg shadow-emerald-900/20 active:scale-95 transition-all flex items-center gap-2"
|
|
>
|
|
<Download size={16} /> Exportar CSV
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* KPI CARDS */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
{[
|
|
{ label: 'Receita Total', value: `R$ ${financialStats.totalRevenue.toLocaleString('pt-BR')}`, icon: DollarSign, color: 'text-emerald-400', sub: '+12% vs. mês anterior' },
|
|
{ label: 'Lucro Líquido', value: `R$ ${financialStats.totalProfit.toLocaleString('pt-BR')}`, icon: TrendingUp, color: 'text-indigo-400', sub: `${financialStats.margin.toFixed(1)}% Margem` },
|
|
{ label: 'Valor em Estoque', value: `R$ ${inventoryStats.totalValue.toLocaleString('pt-BR')}`, icon: Package, color: 'text-blue-400', sub: `${inventoryStats.totalItems} iten(s)` },
|
|
{ label: 'Pedidos Realizados', value: orders.length, icon: Activity, color: 'text-amber-400', sub: 'Volume total' },
|
|
].map((stat, idx) => (
|
|
<div key={idx} className="glass-card p-6 rounded-2xl border border-white/5 relative overflow-hidden group">
|
|
<div className="flex justify-between items-start mb-4">
|
|
<div className="p-3 bg-white/5 rounded-xl border border-white/5">
|
|
<stat.icon size={20} className={stat.color} />
|
|
</div>
|
|
<span className="text-[10px] font-bold text-emerald-500 bg-emerald-500/10 px-2 py-1 rounded-full flex items-center gap-1">
|
|
<ArrowUpRight size={10} /> +2.5%
|
|
</span>
|
|
</div>
|
|
<h3 className="text-2xl font-bold text-white tracking-tight">{stat.value}</h3>
|
|
<p className="text-xs font-medium text-slate-500 uppercase tracking-widest mt-1">{stat.label}</p>
|
|
<p className="text-[10px] text-slate-600 mt-4 border-t border-white/5 pt-2">{stat.sub}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* TABS */}
|
|
<div className="flex gap-6 border-b border-white/5">
|
|
{[
|
|
{ id: 'financial', label: 'Financeiro', icon: DollarSign },
|
|
{ id: 'products', label: 'Produtos', icon: Package },
|
|
{ id: 'inventory', label: 'Estoque', icon: Boxes },
|
|
{ id: 'sales', label: 'Vendas', icon: BarChart3 }
|
|
].map(tab => (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => setActiveTab(tab.id as any)}
|
|
className={clsx(
|
|
"pb-4 flex items-center gap-2 text-sm font-bold transition-all relative",
|
|
activeTab === tab.id ? "text-white" : "text-slate-500 hover:text-slate-300"
|
|
)}
|
|
>
|
|
<tab.icon size={16} />
|
|
{tab.label}
|
|
{activeTab === tab.id && <div className="absolute bottom-0 left-0 w-full h-0.5 bg-indigo-500 shadow-[0_0_10px_rgba(99,102,241,0.5)]"></div>}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* CONTENT AREA */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
|
|
{/* LEFT: MAIN CHART using Recharts */}
|
|
<div className="lg:col-span-2 glass-card p-6 rounded-2xl border border-white/5 min-h-[400px]">
|
|
<h3 className="text-lg font-bold text-white mb-6">Performance Financeira</h3>
|
|
<div className="h-[350px] w-full">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<AreaChart data={chartData}>
|
|
<defs>
|
|
<linearGradient id="colorLucro" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.3} />
|
|
<stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
|
|
</linearGradient>
|
|
<linearGradient id="colorReceita" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
|
|
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#ffffff10" />
|
|
<XAxis dataKey="name" stroke="#64748b" fontSize={12} tickLine={false} axisLine={false} />
|
|
<YAxis stroke="#64748b" fontSize={12} tickLine={false} axisLine={false} tickFormatter={(value) => `R$${value / 1000}k`} />
|
|
<Tooltip
|
|
contentStyle={{ backgroundColor: '#0F1115', borderColor: '#ffffff20', borderRadius: '12px' }}
|
|
itemStyle={{ color: '#e2e8f0', fontSize: '12px' }}
|
|
/>
|
|
<Area type="monotone" dataKey="receita" stroke="#10b981" fillOpacity={1} fill="url(#colorReceita)" strokeWidth={2} />
|
|
<Area type="monotone" dataKey="lucro" stroke="#6366f1" fillOpacity={1} fill="url(#colorLucro)" strokeWidth={2} />
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
|
|
{/* RIGHT: METRIC LISTS */}
|
|
<div className="space-y-6">
|
|
{/* TOP PRODUCTS */}
|
|
<div className="glass-card p-6 rounded-2xl border border-white/5">
|
|
<h3 className="text-sm font-bold text-slate-400 uppercase tracking-widest mb-4">Produtos + Lucrativos</h3>
|
|
<div className="space-y-4">
|
|
{productStats.topProducts.length > 0 ? productStats.topProducts.map((p, i) => (
|
|
<div key={i} className="flex items-center justify-between group">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded-lg bg-indigo-500/10 text-indigo-400 flex items-center justify-center text-xs font-bold border border-indigo-500/20">
|
|
{i + 1}
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-bold text-slate-200 truncate max-w-[120px]">{p.name}</p>
|
|
<p className="text-[10px] text-slate-500">{p.quantity} vendidos</p>
|
|
</div>
|
|
</div>
|
|
<span className="text-emerald-400 font-mono text-xs font-bold">R$ {p.profit.toLocaleString('pt-BR', { maximumFractionDigits: 0 })}</span>
|
|
</div>
|
|
)) : (
|
|
<p className="text-xs text-slate-500 italic">Sem dados de venda.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ALERTS */}
|
|
<div className="glass-card p-6 rounded-2xl border border-white/5">
|
|
<h3 className="text-sm font-bold text-slate-400 uppercase tracking-widest mb-4">Alertas de Estoque</h3>
|
|
<div className="space-y-3">
|
|
<div className="flex items-start gap-3 p-3 bg-rose-500/10 border border-rose-500/20 rounded-xl">
|
|
<AlertCircle size={16} className="text-rose-400 shrink-0 mt-0.5" />
|
|
<div>
|
|
<p className="text-xs font-bold text-rose-200">Baixo Giro</p>
|
|
<p className="text-[10px] text-rose-400/80 mt-1">3 produtos não vendem há 30 dias.</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-start gap-3 p-3 bg-amber-500/10 border border-amber-500/20 rounded-xl">
|
|
<AlertCircle size={16} className="text-amber-400 shrink-0 mt-0.5" />
|
|
<div>
|
|
<p className="text-xs font-bold text-amber-200">Reabastecer</p>
|
|
<p className="text-[10px] text-amber-400/80 mt-1">5 SKUs abaixo do estoque mínimo.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Reports;
|