327 lines
14 KiB
TypeScript
327 lines
14 KiB
TypeScript
'use client';
|
|
import { useState, useEffect } from 'react';
|
|
import { Calendar, Users, DollarSign, TrendingUp, Clock, ArrowUpRight, Play, CheckSquare, Loader } from 'lucide-react';
|
|
|
|
interface DashboardProps {
|
|
onNavigate: (page: string) => void;
|
|
}
|
|
|
|
const statusMap: Record<string, { label: string; cls: string }> = {
|
|
confirmed: { label: 'Confirmado', cls: 'badge-info' },
|
|
pending: { label: 'Pendente', cls: 'badge-warning' },
|
|
completed: { label: 'Concluído', cls: 'badge-success' },
|
|
cancelled: { label: 'Cancelado', cls: 'badge-danger' },
|
|
serving: { label: 'Em Atendimento', cls: 'badge-purple' },
|
|
in_progress: { label: 'Em Atendimento', cls: 'badge-purple' },
|
|
};
|
|
|
|
export default function Dashboard({ onNavigate }: DashboardProps) {
|
|
const [appointments, setAppointments] = useState<any[]>([]);
|
|
const [kpis, setKpis] = useState({ appointmentsToday: 0, revenueToday: 0, totalClients: 0, occupancy: 0, weekChange: 0 });
|
|
const [weeklyPerformance, setWeeklyPerformance] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [currentServingId, setCurrentServingId] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
const fetchDashboard = async () => {
|
|
const slug = localStorage.getItem('agendapro_tenant_slug') || 'barbearia-premium';
|
|
try {
|
|
const res = await fetch(`/api/tenant/dashboard?slug=${slug}`);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setAppointments(data.appointments || []);
|
|
setKpis(data.kpis || { appointmentsToday: 0, revenueToday: 0, totalClients: 0, occupancy: 0, weekChange: 0 });
|
|
setWeeklyPerformance(data.weeklyPerformance || []);
|
|
}
|
|
} catch (err) {
|
|
console.error('Erro ao carregar dashboard:', err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchDashboard();
|
|
}, []);
|
|
|
|
const handleStartServing = async (id: string) => {
|
|
setCurrentServingId(id);
|
|
setAppointments(prev => prev.map(a => a.id === id ? { ...a, status: 'in_progress' } : a));
|
|
|
|
// Atualiza no banco
|
|
try {
|
|
const slug = localStorage.getItem('agendapro_tenant_slug') || 'barbearia-premium';
|
|
await fetch('/api/tenant/agenda', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id, status: 'in_progress', slug })
|
|
});
|
|
} catch (err) {
|
|
console.error('Erro ao iniciar atendimento:', err);
|
|
}
|
|
};
|
|
|
|
const handleFinishServing = async (id: string) => {
|
|
setCurrentServingId(null);
|
|
setAppointments(prev => prev.map(a => a.id === id ? { ...a, status: 'completed' } : a));
|
|
|
|
// Atualiza no banco
|
|
try {
|
|
const slug = localStorage.getItem('agendapro_tenant_slug') || 'barbearia-premium';
|
|
await fetch('/api/tenant/agenda', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id, status: 'completed', slug })
|
|
});
|
|
} catch (err) {
|
|
console.error('Erro ao finalizar atendimento:', err);
|
|
}
|
|
};
|
|
|
|
const servingApt = appointments.find(a => a.id === currentServingId || a.status === 'in_progress');
|
|
const activeQueue = appointments.filter(a => a.status !== 'completed' && a.status !== 'cancelled' && a.id !== servingApt?.id);
|
|
const nextInQueue = activeQueue[0];
|
|
|
|
const weekChangeLabel = kpis.weekChange > 0 ? `↑ ${kpis.weekChange}% vs semana anterior` : kpis.weekChange < 0 ? `↓ ${Math.abs(kpis.weekChange)}% vs semana anterior` : 'Sem dados da semana anterior';
|
|
|
|
if (loading) {
|
|
return (
|
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '300px', color: 'var(--text-muted)' }}>
|
|
<Loader size={24} className="spin" style={{ marginRight: 8 }} /> Carregando dashboard...
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="slide-up">
|
|
<style>{`
|
|
@keyframes pulse {
|
|
0% { opacity: 0.6; }
|
|
50% { opacity: 1; }
|
|
100% { opacity: 0.6; }
|
|
}
|
|
.pulse {
|
|
animation: pulse 1.5s infinite !important;
|
|
background: #a855f7 !important;
|
|
color: white !important;
|
|
}
|
|
.spin {
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
@keyframes spin {
|
|
from { transform: rotate(0deg); }
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
`}</style>
|
|
|
|
{/* Painel de Fila de Atendimento */}
|
|
<div className="card mb-6" style={{ background: 'var(--bg-secondary)', borderLeft: '4px solid var(--accent)', padding: '20px' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '20px', flexWrap: 'wrap' }}>
|
|
<div>
|
|
<span className="badge badge-purple" style={{ marginBottom: '8px', fontSize: '11px', fontWeight: 700 }}>Fila de Atendimento</span>
|
|
{servingApt ? (
|
|
<div>
|
|
<h3 style={{ fontSize: '18px', fontWeight: 800, color: 'var(--text-primary)' }}>
|
|
Atendendo agora: <span style={{ color: 'var(--accent)' }}>{servingApt.client}</span>
|
|
</h3>
|
|
<p style={{ fontSize: '13px', color: 'var(--text-secondary)', marginTop: '4px' }}>
|
|
{servingApt.service} com {servingApt.professional} • R$ {Number(servingApt.price).toFixed(2)}
|
|
</p>
|
|
</div>
|
|
) : nextInQueue ? (
|
|
<div>
|
|
<h3 style={{ fontSize: '18px', fontWeight: 800, color: 'var(--text-primary)' }}>
|
|
Próximo da Fila: <span style={{ color: 'var(--accent)' }}>{nextInQueue.client}</span>
|
|
</h3>
|
|
<p style={{ fontSize: '13px', color: 'var(--text-secondary)', marginTop: '4px' }}>
|
|
Agendado para {nextInQueue.time} ({nextInQueue.service})
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<h3 style={{ fontSize: '16px', fontWeight: 700, color: 'var(--text-muted)' }}>
|
|
{appointments.length === 0 ? 'Nenhum agendamento para hoje' : 'Todos os atendimentos do dia foram concluídos'}
|
|
</h3>
|
|
<p style={{ fontSize: '13px', color: 'var(--text-muted)', marginTop: '4px' }}>
|
|
{appointments.length === 0 ? 'Acesse a Agenda para criar novos agendamentos.' : 'Parabéns! Todos os clientes foram atendidos.'}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
{servingApt ? (
|
|
<button className="btn btn-danger" style={{ gap: '8px' }} onClick={() => handleFinishServing(servingApt.id)}>
|
|
<CheckSquare size={16} /> Finalizar Atendimento
|
|
</button>
|
|
) : nextInQueue ? (
|
|
<button className="btn btn-primary" style={{ gap: '8px' }} onClick={() => handleStartServing(nextInQueue.id)}>
|
|
<Play size={16} /> Iniciar Atendimento
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
{servingApt && (
|
|
<div style={{ marginTop: '16px', paddingTop: '12px', borderTop: '1px solid var(--border-color)', display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '13px', flexWrap: 'wrap', gap: '10px' }}>
|
|
<span style={{ color: 'var(--text-secondary)' }}>
|
|
<strong>Próximo da Fila:</strong> {activeQueue[0] ? `${activeQueue[0].client} (às ${activeQueue[0].time})` : 'Ninguém aguardando'}
|
|
</span>
|
|
{activeQueue[0] && (
|
|
<button className="btn btn-ghost btn-sm" style={{ padding: '2px 8px', fontSize: '12px', color: 'var(--accent)', height: 'auto', minHeight: 'unset' }} onClick={() => {
|
|
handleFinishServing(servingApt.id);
|
|
handleStartServing(activeQueue[0].id);
|
|
}}>
|
|
Chamar Próximo
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="stats-grid">
|
|
<div className="stat-card">
|
|
<div className="stat-icon purple"><Calendar size={24} /></div>
|
|
<div>
|
|
<div className="stat-value">{kpis.appointmentsToday}</div>
|
|
<div className="stat-label">Agendamentos Hoje</div>
|
|
<div className="stat-change up">{weekChangeLabel}</div>
|
|
</div>
|
|
</div>
|
|
<div className="stat-card">
|
|
<div className="stat-icon green"><DollarSign size={24} /></div>
|
|
<div>
|
|
<div className="stat-value">R$ {kpis.revenueToday.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</div>
|
|
<div className="stat-label">Receita do Dia</div>
|
|
<div className="stat-change up">Valores confirmados</div>
|
|
</div>
|
|
</div>
|
|
<div className="stat-card">
|
|
<div className="stat-icon orange"><Users size={24} /></div>
|
|
<div>
|
|
<div className="stat-value">{kpis.totalClients}</div>
|
|
<div className="stat-label">Clientes Cadastrados</div>
|
|
<div className="stat-change up">Total ativo</div>
|
|
</div>
|
|
</div>
|
|
<div className="stat-card">
|
|
<div className="stat-icon blue"><TrendingUp size={24} /></div>
|
|
<div>
|
|
<div className="stat-value">{kpis.occupancy}%</div>
|
|
<div className="stat-label">Taxa de Ocupação</div>
|
|
<div className="stat-change up">Hoje</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 360px', gap: '24px' }}>
|
|
<div className="card">
|
|
<div className="card-header">
|
|
<h2 className="card-title" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
<Clock size={18} /> Agendamentos de Hoje
|
|
</h2>
|
|
<button className="btn btn-sm btn-secondary" onClick={() => onNavigate('agenda')}>
|
|
Ver Agenda <ArrowUpRight size={14} />
|
|
</button>
|
|
</div>
|
|
<div className="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Horário</th>
|
|
<th>Cliente</th>
|
|
<th>Serviço</th>
|
|
<th>Profissional</th>
|
|
<th>Status</th>
|
|
<th>Valor</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{appointments.map(apt => {
|
|
const isServing = apt.status === 'in_progress';
|
|
const isCompleted = apt.status === 'completed';
|
|
const st = statusMap[apt.status] || { label: apt.status, cls: 'badge-secondary' };
|
|
return (
|
|
<tr key={apt.id} style={{ opacity: isCompleted ? 0.5 : 1, transition: 'opacity 0.3s' }}>
|
|
<td style={{ fontWeight: 600 }}>{apt.time}</td>
|
|
<td>
|
|
<div className="flex-gap gap-sm">
|
|
<div className="avatar" style={{ width: 28, height: 28, fontSize: 11 }}>
|
|
{apt.client.split(' ').map((n: string) => n[0]).join('').substring(0, 2)}
|
|
</div>
|
|
{apt.client}
|
|
</div>
|
|
</td>
|
|
<td>{apt.service}</td>
|
|
<td>{apt.professional}</td>
|
|
<td>
|
|
<span className={`badge ${st.cls} ${isServing ? 'pulse' : ''}`}>
|
|
{st.label}
|
|
</span>
|
|
</td>
|
|
<td style={{ fontWeight: 600 }}>R$ {Number(apt.price).toFixed(2)}</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
{appointments.length === 0 && (
|
|
<tr>
|
|
<td colSpan={6} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-muted)' }}>
|
|
Nenhum agendamento para hoje. Crie um novo na Agenda.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
|
<div className="card">
|
|
<h3 className="card-title mb-4">Desempenho Semanal</h3>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
|
{weeklyPerformance.length > 0 ? weeklyPerformance.map((d: any) => (
|
|
<div key={d.label} style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
|
<span style={{ width: '30px', fontSize: '12px', color: 'var(--text-secondary)' }}>{d.label}</span>
|
|
<div style={{ flex: 1, height: '8px', background: 'var(--bg-elevated)', borderRadius: '4px', overflow: 'hidden' }}>
|
|
<div style={{
|
|
width: `${d.value}%`, height: '100%',
|
|
background: 'var(--gradient-primary)',
|
|
borderRadius: '4px',
|
|
transition: 'width 1s ease'
|
|
}} />
|
|
</div>
|
|
<span style={{ fontSize: '12px', fontWeight: 600, width: '35px' }}>{d.count || 0}</span>
|
|
</div>
|
|
)) : (
|
|
<p style={{ fontSize: '13px', color: 'var(--text-muted)', textAlign: 'center' }}>Sem dados suficientes</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<h3 className="card-title mb-4">Ações Rápidas</h3>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
|
{[
|
|
{ label: 'Novo Agendamento', desc: 'Agendar um novo cliente', page: 'agenda' },
|
|
{ label: 'Cadastrar Cliente', desc: 'Adicionar cliente à base', page: 'clients' },
|
|
{ label: 'Novo Serviço', desc: 'Cadastrar um novo serviço', page: 'services' },
|
|
].map((action, i) => (
|
|
<div key={i} style={{
|
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
|
padding: '10px 14px', background: 'var(--bg-primary)', borderRadius: 'var(--radius-sm)',
|
|
border: '1px solid var(--border-color)'
|
|
}}>
|
|
<div>
|
|
<div style={{ fontSize: '14px', fontWeight: 600 }}>{action.label}</div>
|
|
<div style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>{action.desc}</div>
|
|
</div>
|
|
<button className="btn btn-sm btn-primary" onClick={() => onNavigate(action.page)}>Ir</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|