2f3c0420e3
Remove all window.location.hash assignments and hashchange events. Navigate with history.pushState; listen on popstate. Fix base href to '/' so assets resolve correctly on deep paths. Update magic link URL. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
289 lines
12 KiB
TypeScript
289 lines
12 KiB
TypeScript
import React, { useMemo } from 'react';
|
|
import {
|
|
Users,
|
|
DollarSign,
|
|
TrendingUp,
|
|
Megaphone,
|
|
Calendar,
|
|
ChevronRight,
|
|
ArrowUpRight,
|
|
ArrowDownRight,
|
|
Plus,
|
|
Clock,
|
|
ArrowRight
|
|
} from 'lucide-react';
|
|
import { PageHeader } from '../components/PageHeader.tsx';
|
|
import { useHybridBackend } from '../hooks/useHybridBackend.ts';
|
|
import { HybridBackend } from '../services/backend.ts';
|
|
import { NotificationCenter } from '../components/NotificationCenter.tsx';
|
|
|
|
const StatCard: React.FC<{
|
|
title: string;
|
|
value: string | number;
|
|
icon: React.ReactNode;
|
|
trend?: string;
|
|
isPositive?: boolean;
|
|
color: 'blue' | 'green' | 'purple' | 'amber';
|
|
onClick?: () => void;
|
|
}> = ({ title, value, icon, trend, isPositive, color, onClick }) => {
|
|
const bgLight = {
|
|
blue: 'bg-blue-50 text-blue-600',
|
|
green: 'bg-emerald-50 text-emerald-600',
|
|
purple: 'bg-indigo-50 text-indigo-600',
|
|
amber: 'bg-amber-50 text-amber-600'
|
|
};
|
|
|
|
return (
|
|
<div
|
|
onClick={onClick}
|
|
className={`bg-white rounded-[2rem] p-8 border border-gray-100 shadow-[0_10px_40px_-15px_rgba(0,0,0,0.05)] hover:shadow-[0_20px_50px_-20px_rgba(0,0,0,0.1)] transition-all duration-500 group relative overflow-hidden ${onClick ? 'cursor-pointer' : ''}`}
|
|
>
|
|
<div className="absolute top-0 right-0 w-32 h-32 opacity-[0.03] -mr-8 -mt-8 rounded-full bg-current transform group-hover:scale-110 transition-transform duration-700"></div>
|
|
|
|
<div className="flex justify-between items-start relative z-10">
|
|
<div className="space-y-4">
|
|
<div className={`w-12 h-12 ${bgLight[color]} rounded-2xl flex items-center justify-center mb-2 group-hover:scale-110 transition-transform`}>
|
|
{icon}
|
|
</div>
|
|
<div>
|
|
<p className="text-[10px] font-black text-gray-400 uppercase tracking-[0.2em]">{title}</p>
|
|
<h3 className="text-3xl font-black text-gray-900 tracking-tight mt-1">{value}</h3>
|
|
</div>
|
|
{trend && (
|
|
<div className={`flex items-center gap-1.5 text-xs font-bold ${isPositive ? 'text-emerald-600' : 'text-rose-600'}`}>
|
|
<div className={`p-0.5 rounded-full ${isPositive ? 'bg-emerald-100' : 'bg-rose-100'}`}>
|
|
{isPositive ? <ArrowUpRight size={12} /> : <ArrowDownRight size={12} />}
|
|
</div>
|
|
{trend}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const Dashboard: React.FC = () => {
|
|
const { data, isLoading } = useHybridBackend<any>(HybridBackend.getDashboardStats);
|
|
|
|
const growthData = useMemo(() => {
|
|
if (!data?.growth || data.growth.length === 0) {
|
|
return Array(6).fill(null).map((_, i) => ({
|
|
label: `MÊS ${i + 1}`,
|
|
receita: 0,
|
|
despesa: 0,
|
|
heightP: '0%',
|
|
heightR: '0%'
|
|
}));
|
|
}
|
|
|
|
const maxVal = Math.max(...data.growth.map((g: any) => Math.max(parseFloat(g.receita), parseFloat(g.despesa))), 1000);
|
|
|
|
return data.growth.map((g: any) => {
|
|
const [year, month] = g.mes.split('-');
|
|
const date = new Date(parseInt(year), parseInt(month) - 1);
|
|
const label = date.toLocaleString('pt-BR', { month: 'short' }).toUpperCase();
|
|
|
|
return {
|
|
label,
|
|
receita: parseFloat(g.receita),
|
|
despesa: parseFloat(g.despesa),
|
|
// Proporção para o gráfico
|
|
heightR: `${(parseFloat(g.receita) / maxVal) * 100}%`,
|
|
heightD: `${(parseFloat(g.despesa) / maxVal) * 100}%`
|
|
};
|
|
}).slice(-6); // Garantir últimos 6
|
|
}, [data]);
|
|
|
|
const currentRole = HybridBackend.getCurrentRole();
|
|
const showFinancials = ['admin', 'donoclinica', 'funcionario'].includes(currentRole);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-96">
|
|
<div className="w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const stats = data?.stats || {
|
|
totalPacientes: 0,
|
|
totalAgendamentos: 0,
|
|
totalLeads: 0,
|
|
faturamentoTotal: 0,
|
|
despesasTotal: 0
|
|
};
|
|
|
|
const recent = data?.recentAgendamentos || [];
|
|
|
|
return (
|
|
<div className="space-y-10 pb-10">
|
|
<header className="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
|
<div>
|
|
<PageHeader
|
|
title="DASHBOARD ANALÍTICO"
|
|
description="Informações consolidadas em tempo real da sua clínica."
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{(currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'funcionario') && (
|
|
<button
|
|
onClick={() => history.pushState({}, '', '/relatorios')}
|
|
className="bg-white text-gray-900 border border-gray-200 px-6 py-3 rounded-2xl text-xs font-black uppercase tracking-widest hover:bg-gray-50 transition-all flex items-center gap-2 shadow-sm"
|
|
>
|
|
<Clock size={16} /> Relatórios
|
|
</button>
|
|
)}
|
|
{(currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'funcionario') && (
|
|
<button
|
|
onClick={() => history.pushState({}, '', '/pacientes')}
|
|
className="bg-blue-600 text-white px-6 py-3 rounded-2xl text-xs font-black uppercase tracking-widest hover:bg-blue-700 hover:shadow-xl hover:shadow-blue-200 transition-all flex items-center gap-2 shadow-lg shadow-blue-100"
|
|
>
|
|
<Plus size={16} /> Novo Paciente
|
|
</button>
|
|
)}
|
|
<NotificationCenter />
|
|
</div>
|
|
</header>
|
|
|
|
{/* Stats Grid */}
|
|
<div className={`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-${showFinancials ? '4' : '2'} gap-8`}>
|
|
<StatCard
|
|
title="Pacientes Ativos"
|
|
value={stats.totalPacientes}
|
|
icon={<Users size={24} />}
|
|
trend="+12% este mês"
|
|
isPositive={true}
|
|
color="blue"
|
|
onClick={() => history.pushState({}, '', '/pacientes')}
|
|
/>
|
|
{showFinancials && (
|
|
<StatCard
|
|
title="Faturamento Bruto"
|
|
value={new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(stats.faturamentoTotal)}
|
|
icon={<DollarSign size={24} />}
|
|
trend="+5.4% vs mês ant."
|
|
isPositive={true}
|
|
color="green"
|
|
onClick={() => history.pushState({}, '', '/financeiro')}
|
|
/>
|
|
)}
|
|
<StatCard
|
|
title="Agendamentos"
|
|
value={stats.totalAgendamentos}
|
|
icon={<Calendar size={24} />}
|
|
trend="82% de ocupação"
|
|
isPositive={true}
|
|
color="purple"
|
|
onClick={() => history.pushState({}, '', '/agenda')}
|
|
/>
|
|
{showFinancials && (
|
|
<StatCard
|
|
title="Novas Leads"
|
|
value={stats.totalLeads}
|
|
icon={<Megaphone size={24} />}
|
|
trend="-2% vs ontem"
|
|
isPositive={false}
|
|
color="amber"
|
|
onClick={() => history.pushState({}, '', '/leads')}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div className={`grid lg:grid-cols-${showFinancials ? '3' : '1'} gap-8`}>
|
|
{/* Growth Chart Area */}
|
|
{showFinancials && (
|
|
<div className="lg:col-span-2 bg-white rounded-[2.5rem] p-10 border border-gray-100 shadow-[0_20px_50px_-15px_rgba(0,0,0,0.03)]">
|
|
<div className="flex justify-between items-center mb-10">
|
|
<div>
|
|
<h4 className="text-xl font-black text-gray-900 uppercase tracking-tighter italic">Crescimento Mensal</h4>
|
|
<p className="text-xs font-bold text-gray-400 mt-1 uppercase">Receitas vs Despesas Reais</p>
|
|
</div>
|
|
<select
|
|
id="chart-period-dashboard"
|
|
name="chart-period"
|
|
className="bg-gray-50 border-none text-[10px] font-black uppercase tracking-widest px-4 py-2 rounded-xl focus:ring-2 focus:ring-blue-100 cursor-pointer"
|
|
>
|
|
<option value="6m">Últimos 6 meses</option>
|
|
<option value="year">Este Ano</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="h-64 flex items-end justify-between gap-4 px-4">
|
|
{growthData.map((d: any, i: number) => (
|
|
<div key={i} className="flex-1 flex flex-col items-center gap-3 group">
|
|
<div className="w-full flex justify-center gap-1 items-end h-[200px] bg-slate-50/50 rounded-2xl p-1">
|
|
{/* Barra Despesa */}
|
|
<div
|
|
title={`Despesa: R$ ${d.despesa}`}
|
|
className="w-1/3 bg-rose-200 rounded-lg group-hover:bg-rose-300 transition-all duration-500 origin-bottom"
|
|
style={{ height: d.heightD || '5%' }}
|
|
></div>
|
|
{/* Barra Receita */}
|
|
<div
|
|
title={`Receita: R$ ${d.receita}`}
|
|
className="w-1/3 bg-blue-500 rounded-lg group-hover:bg-blue-600 shadow-sm group-hover:shadow-blue-200 transition-all duration-500 origin-bottom"
|
|
style={{ height: d.heightR || '5%' }}
|
|
></div>
|
|
</div>
|
|
<span className="text-[10px] font-black text-gray-400 uppercase tracking-tighter">{d.label}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="flex justify-center gap-6 mt-6">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-3 h-3 bg-blue-500 rounded-sm"></div>
|
|
<span className="text-[10px] font-bold text-gray-500 uppercase">Receitas</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-3 h-3 bg-rose-200 rounded-sm"></div>
|
|
<span className="text-[10px] font-bold text-gray-500 uppercase">Despesas</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Recent Activity */}
|
|
<div className="bg-gray-900 rounded-[2.5rem] p-10 shadow-2xl shadow-blue-200 overflow-hidden relative">
|
|
<div className="absolute top-0 right-0 w-32 h-32 bg-blue-600/20 blur-[80px] rounded-full"></div>
|
|
<div className="relative z-10 h-full flex flex-col">
|
|
<h4 className="text-xl font-black text-white uppercase tracking-tighter italic mb-8">Últimos Agendamentos</h4>
|
|
|
|
<div className="space-y-6 flex-1 overflow-y-auto custom-scrollbar pr-2">
|
|
{recent.length > 0 ? recent.map((app: any) => (
|
|
<div
|
|
key={app.id}
|
|
onClick={() => history.pushState({}, '', '/agenda')}
|
|
className="flex items-center gap-4 group cursor-pointer"
|
|
>
|
|
<div className="w-12 h-12 rounded-2xl bg-white/10 flex items-center justify-center text-blue-400 group-hover:bg-blue-600 group-hover:text-white transition-all duration-300 flex-shrink-0">
|
|
<Calendar size={20} />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-black text-white uppercase tracking-tight truncate">{app.pacientenome || 'Paciente'}</div>
|
|
<div className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-0.5 truncate">
|
|
{app.dentistaNome || 'Dr. Dentista'} • {new Date(app.start_time).toLocaleDateString('pt-BR')} {new Date(app.start_time).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}
|
|
</div>
|
|
</div>
|
|
<ChevronRight size={16} className="text-gray-600 group-hover:text-white group-hover:translate-x-1 transition-all" />
|
|
</div>
|
|
)) : (
|
|
<div className="flex flex-col items-center justify-center py-10 opacity-40">
|
|
<Calendar size={40} className="text-white mb-2" />
|
|
<p className="text-white text-[10px] font-bold uppercase tracking-widest">Nenhum agendamento recente.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => history.pushState({}, '', '/agenda')}
|
|
className="w-full mt-8 bg-white/5 border border-white/10 text-white py-4 rounded-2xl text-[10px] font-black uppercase tracking-widest hover:bg-white/10 transition-all flex items-center justify-center gap-2 group"
|
|
>
|
|
Ver Agenda Completa <ArrowRight size={14} className="group-hover:translate-x-1 transition-transform" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}; |