feat: add dedicated Statistics tab with charts to dashboard
This commit is contained in:
@@ -14,7 +14,7 @@ import { currency } from '../lib/format';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { Product, BarberShop, ShopSchedule } from '../types';
|
||||
import { BarChart, Bar, CartesianGrid, ResponsiveContainer, Tooltip, XAxis } from 'recharts';
|
||||
import { BarChart, Bar, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis, PieChart, Pie, Cell, Legend, LineChart, Line } from 'recharts';
|
||||
import { CalendarWeekView } from '../components/CalendarWeekView';
|
||||
import {
|
||||
BarChart3,
|
||||
@@ -50,7 +50,7 @@ import {
|
||||
CheckCircle2,
|
||||
} from 'lucide-react';
|
||||
|
||||
type TabId = 'overview' | 'appointments' | 'history' | 'orders' | 'services' | 'products' | 'barbers' | 'settings';
|
||||
type TabId = 'overview' | 'appointments' | 'history' | 'orders' | 'services' | 'products' | 'barbers' | 'settings' | 'stats';
|
||||
|
||||
interface SidebarTab {
|
||||
id: TabId;
|
||||
@@ -375,6 +375,7 @@ function DashboardInner({ shop }: { shop: BarberShop }) {
|
||||
|
||||
const sidebarTabs: SidebarTab[] = [
|
||||
{ id: 'overview', label: 'Visão Geral', icon: BarChart3 },
|
||||
{ id: 'stats', label: 'Estatísticas', icon: TrendingUp },
|
||||
{ id: 'appointments', label: 'Agendamentos', icon: Calendar, badge: pendingAppts > 0 ? pendingAppts : undefined },
|
||||
{ id: 'history', label: 'Histórico', icon: History },
|
||||
{ id: 'orders', label: 'Pedidos', icon: ShoppingBag, badge: pendingOrders > 0 ? pendingOrders : undefined },
|
||||
@@ -431,46 +432,43 @@ function DashboardInner({ shop }: { shop: BarberShop }) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards Principais */}
|
||||
<div className="grid md:grid-cols-3 gap-3">
|
||||
<Card className="p-4 sm:p-6">
|
||||
<div className="flex items-center justify-between mb-2 sm:mb-3">
|
||||
<div className="p-2 sm:p-3 bg-indigo-500 rounded-lg text-white">
|
||||
<Calendar size={20} className="sm:hidden" />
|
||||
<Calendar size={24} className="hidden sm:block" />
|
||||
{/* Stats Cards Rápidos */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 bg-indigo-100 rounded-lg">
|
||||
<Calendar size={16} className="text-indigo-600" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-slate-600 mb-0.5 sm:mb-1">Total de reservas</p>
|
||||
<p className="text-2xl sm:text-3xl font-bold text-slate-900">{allShopAppointments.length}</p>
|
||||
<p className="text-[10px] sm:text-xs text-slate-500 mt-1.5 sm:mt-2">Reservas da plataforma: {allShopAppointments.length}</p>
|
||||
<p className="text-xs text-slate-500 mb-0.5">Total de Reservas</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{allShopAppointments.length}</p>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4 sm:p-6">
|
||||
<div className="flex items-center justify-between mb-2 sm:mb-3">
|
||||
<div className="p-2 sm:p-3 bg-blue-500 rounded-lg text-white">
|
||||
<TrendingUp size={20} className="sm:hidden" />
|
||||
<TrendingUp size={24} className="hidden sm:block" />
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 bg-green-100 rounded-lg">
|
||||
<CheckCircle2 size={16} className="text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-slate-600 mb-0.5 sm:mb-1">Faturação (Serviços + Produtos)</p>
|
||||
<p className="text-2xl sm:text-3xl font-bold text-slate-900">
|
||||
{currency(totalRevenue + allShopAppointments.filter(a => a.status === 'concluido' || a.status === 'confirmado').reduce((s, a) => s + (a.total || 0), 0))}
|
||||
</p>
|
||||
<p className="text-[10px] sm:text-xs text-slate-500 mt-1.5 sm:mt-2">Receita no período selecionado</p>
|
||||
<p className="text-xs text-slate-500 mb-0.5">Concluídas</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{allShopAppointments.filter(a => a.status === 'concluido').length}</p>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4 sm:p-6">
|
||||
<div className="flex items-center justify-between mb-2 sm:mb-3">
|
||||
<div className="p-2 sm:p-3 bg-green-500 rounded-lg text-white">
|
||||
<UserPlus size={20} className="sm:hidden" />
|
||||
<UserPlus size={24} className="hidden sm:block" />
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 bg-amber-100 rounded-lg">
|
||||
<Clock size={16} className="text-amber-600" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-slate-600 mb-0.5 sm:mb-1">Novos clientes</p>
|
||||
<p className="text-2xl sm:text-3xl font-bold text-slate-900">
|
||||
{new Set(allShopAppointments.map(a => a.customerId)).size}
|
||||
</p>
|
||||
<p className="text-[10px] sm:text-xs text-slate-500 mt-1.5 sm:mt-2">Clientes únicos</p>
|
||||
<p className="text-xs text-slate-500 mb-0.5">Pendentes</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{pendingAppts}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<UserPlus size={16} className="text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mb-0.5">Clientes Únicos</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{new Set(allShopAppointments.map(a => a.customerId)).size}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -527,35 +525,6 @@ function DashboardInner({ shop }: { shop: BarberShop }) {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Serviços Mais Procurados */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-bold text-slate-900 mb-4">Top Serviços</h3>
|
||||
{(() => {
|
||||
const map = new Map<string, { name: string; qty: number }>();
|
||||
allShopAppointments.forEach(a => {
|
||||
if (a.status === 'cancelado') return;
|
||||
const svc = shop.services.find(s => s.id === a.serviceId);
|
||||
if (!svc) return;
|
||||
const prev = map.get(a.serviceId)?.qty ?? 0;
|
||||
map.set(a.serviceId, { name: svc.name, qty: prev + 1 });
|
||||
});
|
||||
const top = Array.from(map.values()).sort((a, b) => b.qty - a.qty).slice(0, 4);
|
||||
|
||||
if (top.length === 0) return <p className="text-sm text-slate-500 text-center py-4">Sem dados suficientes</p>;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{top.map((t, i) => (
|
||||
<div key={i} className="flex items-center justify-between p-3 border border-slate-100 rounded-lg bg-slate-50">
|
||||
<span className="font-semibold text-slate-700 text-sm truncate pr-2">{t.name}</span>
|
||||
<Badge color="indigo" variant="soft">{t.qty} reservas</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</Card>
|
||||
|
||||
{/* Próximos Agendamentos */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-bold text-slate-900 mb-4">Seguinte</h3>
|
||||
@@ -610,6 +579,206 @@ function DashboardInner({ shop }: { shop: BarberShop }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'stats' && (() => {
|
||||
// --- Dados para gráficos ---
|
||||
const COLORS = ['#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
|
||||
|
||||
// 1. Reservas por dia da semana (últimos 90 dias)
|
||||
const diasPT = ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb'];
|
||||
const byDayOfWeek = Array.from({ length: 7 }, (_, i) => ({ name: diasPT[i], reservas: 0 }));
|
||||
appointments.filter(a => a.shopId === shop.id && a.status !== 'cancelado').forEach(a => {
|
||||
const d = new Date(a.date.replace(' ', 'T')).getDay();
|
||||
byDayOfWeek[d].reservas++;
|
||||
});
|
||||
|
||||
// 2. Serviços mais marcados (todos os tempos)
|
||||
const svcMap = new Map<string, { name: string; value: number }>();
|
||||
appointments.filter(a => a.shopId === shop.id && a.status !== 'cancelado').forEach(a => {
|
||||
const svc = shop.services.find(s => s.id === a.serviceId);
|
||||
if (!svc) return;
|
||||
const prev = svcMap.get(a.serviceId)?.value ?? 0;
|
||||
svcMap.set(a.serviceId, { name: svc.name, value: prev + 1 });
|
||||
});
|
||||
const topSvcsPie = Array.from(svcMap.values()).sort((a, b) => b.value - a.value).slice(0, 5);
|
||||
|
||||
// 3. Evolução semanal de reservas (últimas 8 semanas)
|
||||
const weeklyData: { name: string; reservas: number; concluidas: number }[] = [];
|
||||
for (let i = 7; i >= 0; i--) {
|
||||
const wStart = new Date();
|
||||
wStart.setDate(wStart.getDate() - i * 7 - 6);
|
||||
wStart.setHours(0, 0, 0, 0);
|
||||
const wEnd = new Date();
|
||||
wEnd.setDate(wEnd.getDate() - i * 7);
|
||||
wEnd.setHours(23, 59, 59, 999);
|
||||
const label = wStart.toLocaleDateString('pt-PT', { day: 'numeric', month: 'short' });
|
||||
const weekAppts = appointments.filter(a => {
|
||||
if (a.shopId !== shop.id) return false;
|
||||
const d = new Date(a.date.replace(' ', 'T'));
|
||||
return d >= wStart && d <= wEnd;
|
||||
});
|
||||
weeklyData.push({
|
||||
name: label,
|
||||
reservas: weekAppts.length,
|
||||
concluidas: weekAppts.filter(a => a.status === 'concluido').length,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Resumo KPI (todos os tempos)
|
||||
const totalAppts = appointments.filter(a => a.shopId === shop.id);
|
||||
const totalConcluidas = totalAppts.filter(a => a.status === 'concluido').length;
|
||||
const totalCanceladas = totalAppts.filter(a => a.status === 'cancelado').length;
|
||||
const taxaConclusao = totalAppts.length > 0 ? Math.round((totalConcluidas / totalAppts.length) * 100) : 0;
|
||||
const revenueServicos = totalAppts.filter(a => a.status === 'concluido').reduce((s, a) => s + (a.total || 0), 0);
|
||||
const revenueProdutos = orders.filter(o => o.shopId === shop.id && o.status === 'concluido').reduce((s, o) => s + o.total, 0);
|
||||
|
||||
// 5. Top barbeiros
|
||||
const barberMap = new Map<string, { name: string; qty: number }>();
|
||||
totalAppts.filter(a => a.status !== 'cancelado').forEach(a => {
|
||||
const b = shop.barbers.find(x => x.id === a.barberId);
|
||||
if (!b) return;
|
||||
const prev = barberMap.get(a.barberId)?.qty ?? 0;
|
||||
barberMap.set(a.barberId, { name: b.name, qty: prev + 1 });
|
||||
});
|
||||
const topBarbers = Array.from(barberMap.values()).sort((a, b) => b.qty - a.qty);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900">Estatísticas</h2>
|
||||
<p className="text-sm text-slate-500">Análise completa do desempenho da barbearia (todos os dados históricos)</p>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: 'Total de Reservas', value: totalAppts.length, color: 'bg-indigo-500', icon: Calendar },
|
||||
{ label: 'Concluídas', value: totalConcluidas, color: 'bg-green-500', icon: CheckCircle2 },
|
||||
{ label: 'Canceladas', value: totalCanceladas, color: 'bg-red-400', icon: AlertTriangle },
|
||||
{ label: 'Taxa Conclusão', value: `${taxaConclusao}%`, color: 'bg-amber-500', icon: TrendingUp },
|
||||
].map((kpi, i) => (
|
||||
<Card key={i} className="p-4">
|
||||
<div className={`w-8 h-8 ${kpi.color} rounded-lg flex items-center justify-center text-white mb-3`}>
|
||||
<kpi.icon size={16} />
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mb-1">{kpi.label}</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{kpi.value}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Receita */}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<Card className="p-5">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="p-1.5 bg-green-100 rounded-lg"><TrendingUp size={14} className="text-green-600" /></div>
|
||||
<h3 className="font-bold text-slate-900">Receita de Serviços</h3>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-green-600 mt-2">{currency(revenueServicos)}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Baseado nos agendamentos concluídos</p>
|
||||
</Card>
|
||||
<Card className="p-5">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="p-1.5 bg-blue-100 rounded-lg"><ShoppingBag size={14} className="text-blue-600" /></div>
|
||||
<h3 className="font-bold text-slate-900">Receita de Produtos</h3>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-blue-600 mt-2">{currency(revenueProdutos)}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Baseado nos pedidos concluídos</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Gráfico: Evolução semanal */}
|
||||
<Card className="p-5">
|
||||
<h3 className="font-bold text-slate-900 mb-1">Evolução de Reservas (últimas 8 semanas)</h3>
|
||||
<p className="text-xs text-slate-500 mb-4">Total vs. concluídas por semana</p>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<LineChart data={weeklyData} margin={{ top: 5, right: 10, left: -10, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 11, fill: '#94a3b8' }} />
|
||||
<YAxis tick={{ fontSize: 11, fill: '#94a3b8' }} allowDecimals={false} />
|
||||
<Tooltip contentStyle={{ borderRadius: 8, border: '1px solid #e2e8f0', fontSize: 12 }} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Line type="monotone" dataKey="reservas" name="Total" stroke="#6366f1" strokeWidth={2} dot={{ r: 3 }} />
|
||||
<Line type="monotone" dataKey="concluidas" name="Concluídas" stroke="#10b981" strokeWidth={2} dot={{ r: 3 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{/* Gráfico: Por dia da semana */}
|
||||
<Card className="p-5">
|
||||
<h3 className="font-bold text-slate-900 mb-1">Reservas por Dia da Semana</h3>
|
||||
<p className="text-xs text-slate-500 mb-4">Distribuição histórica</p>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={byDayOfWeek} margin={{ top: 5, right: 5, left: -20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 11, fill: '#94a3b8' }} />
|
||||
<YAxis tick={{ fontSize: 11, fill: '#94a3b8' }} allowDecimals={false} />
|
||||
<Tooltip contentStyle={{ borderRadius: 8, border: '1px solid #e2e8f0', fontSize: 12 }} />
|
||||
<Bar dataKey="reservas" name="Reservas" fill="#6366f1" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
|
||||
{/* Gráfico: Serviços (Pizza) */}
|
||||
<Card className="p-5">
|
||||
<h3 className="font-bold text-slate-900 mb-1">Serviços Mais Populares</h3>
|
||||
<p className="text-xs text-slate-500 mb-4">Distribuição por serviço</p>
|
||||
{topSvcsPie.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={topSvcsPie}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={50}
|
||||
outerRadius={80}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
paddingAngle={3}
|
||||
>
|
||||
{topSvcsPie.map((_, index) => (
|
||||
<Cell key={index} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v: any, n: any) => [v + ' reservas', n]} contentStyle={{ borderRadius: 8, border: '1px solid #e2e8f0', fontSize: 12 }} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-40 text-sm text-slate-400">Sem dados suficientes</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Top Barbeiros */}
|
||||
{topBarbers.length > 0 && (
|
||||
<Card className="p-5">
|
||||
<h3 className="font-bold text-slate-900 mb-4">Ranking de Profissionais</h3>
|
||||
<div className="space-y-3">
|
||||
{topBarbers.map((b, i) => {
|
||||
const maxQty = topBarbers[0].qty;
|
||||
const pct = maxQty > 0 ? Math.round((b.qty / maxQty) * 100) : 0;
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<span className="w-6 text-center text-sm font-bold text-slate-400">#{i + 1}</span>
|
||||
<span className="w-28 text-sm font-semibold text-slate-700 truncate">{b.name}</span>
|
||||
<div className="flex-1 h-3 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-700"
|
||||
style={{ width: `${pct}%`, backgroundColor: COLORS[i % COLORS.length] }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-slate-700 w-16 text-right">{b.qty} reservas</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{activeTab === 'appointments' && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
|
||||
Reference in New Issue
Block a user