891 lines
36 KiB
TypeScript
891 lines
36 KiB
TypeScript
import type { FC, ReactNode, KeyboardEvent as ReactKeyboardEvent } from 'react';
|
|
import { useEffect, useState, useMemo } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import {
|
|
Calendar,
|
|
Users,
|
|
DollarSign,
|
|
ShoppingCart,
|
|
Clock,
|
|
Wallet,
|
|
Sparkles,
|
|
Activity,
|
|
TrendingUp,
|
|
ArrowRight,
|
|
ArrowUpRight,
|
|
ArrowDownRight,
|
|
CalendarClock,
|
|
Armchair,
|
|
Banknote,
|
|
Vault,
|
|
Cake,
|
|
MessageCircle,
|
|
} from 'lucide-react';
|
|
import {
|
|
AreaChart,
|
|
Area,
|
|
BarChart,
|
|
Bar,
|
|
PieChart,
|
|
Pie,
|
|
Cell,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip,
|
|
ResponsiveContainer,
|
|
} from 'recharts';
|
|
import Layout from '../components/Layout';
|
|
import {
|
|
Card,
|
|
Button,
|
|
Badge,
|
|
EmptyState,
|
|
PageHeader,
|
|
SkeletonStat,
|
|
Skeleton,
|
|
toast,
|
|
badgeForAppointmentState,
|
|
} from '../components/ui';
|
|
import { odooApi, type DashboardStats, type Appointment, type Service, type ChartPoint, type Birthday } from '../services/odoo';
|
|
import { usePatientDrawer } from '../components/PatientDrawer';
|
|
import { useTheme } from '../lib/theme';
|
|
import { useAuth } from '../lib/auth';
|
|
import { useGlosario } from '../lib/glosario';
|
|
import { waLink } from '../lib/whatsapp';
|
|
import { aptStateLabel } from './Agenda';
|
|
|
|
const formatCurrency = (value: number) =>
|
|
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
|
|
|
|
type StatDelta = { pct: number; direction: 'up' | 'down' | 'flat' } | null;
|
|
|
|
interface StatCardProps {
|
|
title: string;
|
|
value: string | number;
|
|
icon: ReactNode;
|
|
trend?: string;
|
|
loading?: boolean;
|
|
onOpen?: () => void;
|
|
delta?: StatDelta;
|
|
deltaLabel?: string;
|
|
}
|
|
|
|
const StatCard: FC<StatCardProps> = ({ title, value, icon, trend, loading, onOpen, delta, deltaLabel }) => {
|
|
if (loading) return <SkeletonStat />;
|
|
const clickable = typeof onOpen === 'function';
|
|
const deltaColor =
|
|
delta?.direction === 'up'
|
|
? 'text-emerald-600'
|
|
: delta?.direction === 'down'
|
|
? 'text-rose-600'
|
|
: 'text-theme-muted';
|
|
return (
|
|
<div
|
|
{...(clickable
|
|
? {
|
|
onClick: onOpen,
|
|
role: 'button',
|
|
tabIndex: 0,
|
|
'aria-label': `${title}. Ir al módulo`,
|
|
onKeyDown: (e: ReactKeyboardEvent) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
onOpen?.();
|
|
}
|
|
},
|
|
className: 'cursor-pointer group focus:outline-none focus-visible:ring-2 focus-visible:ring-theme-accent rounded-2xl',
|
|
}
|
|
: {})}
|
|
>
|
|
<Card
|
|
className={[
|
|
'h-full transition',
|
|
clickable
|
|
? 'group-hover:shadow-md group-hover:-translate-y-0.5 group-hover:border-theme-accent/40'
|
|
: 'hover:shadow-md',
|
|
].join(' ')}
|
|
>
|
|
<Card.Body>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="w-10 h-10 rounded-xl bg-theme-success/50 flex items-center justify-center text-theme-heading">
|
|
{icon}
|
|
</div>
|
|
{trend && (
|
|
<span className="text-xs font-medium text-theme-heading bg-[#CAE2C1] px-2 py-0.5 rounded-full">
|
|
{trend}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-sm text-theme-muted mb-1">{title}</p>
|
|
<p className="text-2xl font-heading font-semibold text-theme-heading">{value}</p>
|
|
{delta && (
|
|
<p className="mt-1.5 flex items-center gap-1 text-xs">
|
|
<span className={`inline-flex items-center gap-0.5 font-medium ${deltaColor}`}>
|
|
{delta.direction === 'up' && <ArrowUpRight size={12} />}
|
|
{delta.direction === 'down' && <ArrowDownRight size={12} />}
|
|
{Math.abs(delta.pct)}%
|
|
</span>
|
|
<span className="text-theme-muted">{deltaLabel || 'vs período anterior'}</span>
|
|
</p>
|
|
)}
|
|
</Card.Body>
|
|
</Card>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface TodayTileProps {
|
|
label: string;
|
|
icon: ReactNode;
|
|
onClick: () => void;
|
|
children: ReactNode;
|
|
className?: string;
|
|
ariaLabel: string;
|
|
}
|
|
|
|
const TodayTile: FC<TodayTileProps> = ({ label, icon, onClick, children, className, ariaLabel }) => (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
aria-label={ariaLabel}
|
|
className={`text-left rounded-xl border border-theme-border-strong bg-theme-surface p-3.5 cursor-pointer hover:bg-theme-bg hover:shadow-sm transition focus:outline-none focus-visible:ring-2 focus-visible:ring-theme-accent ${className || ''}`}
|
|
>
|
|
<p className="flex items-center gap-1.5 mb-2 text-[11px] font-medium uppercase tracking-wide text-theme-muted">
|
|
{icon}
|
|
{label}
|
|
</p>
|
|
{children}
|
|
</button>
|
|
);
|
|
|
|
const Dashboard: FC = () => {
|
|
const { theme } = useTheme();
|
|
const { user, brand } = useAuth();
|
|
const { term } = useGlosario();
|
|
const navigate = useNavigate();
|
|
const isHomeNest = theme === 'homenest';
|
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
|
const [chartData, setChartData] = useState<ChartPoint[]>([]);
|
|
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
|
const [services, setServices] = useState<Service[]>([]);
|
|
const [loadingStats, setLoadingStats] = useState(true);
|
|
const [loadingChart, setLoadingChart] = useState(true);
|
|
const [loadingAppointments, setLoadingAppointments] = useState(true);
|
|
const [loadingServices, setLoadingServices] = useState(true);
|
|
const [topServices, setTopServices] = useState<{ service: string; qty: number; revenue: number }[]>([]);
|
|
const [aptReport, setAptReport] = useState<{ total: number; by_state: Record<string, number> } | null>(null);
|
|
const [loadingExtras, setLoadingExtras] = useState(true);
|
|
const { openPatient } = usePatientDrawer();
|
|
// Cumpleaños del día (endpoint /reports/birthdays?period=today)
|
|
const [birthdays, setBirthdays] = useState<Birthday[]>([]);
|
|
const [loadingBirthdays, setLoadingBirthdays] = useState(true);
|
|
// Datos de "Hoy en la clínica": sala de espera, cobros del día y estado de caja
|
|
const [todayInfo, setTodayInfo] = useState<{
|
|
waiting: number | null;
|
|
cobros: number | null;
|
|
caja: { state: string; name: string } | null;
|
|
} | null>(null);
|
|
|
|
useEffect(() => {
|
|
const loadStats = async () => {
|
|
try {
|
|
setLoadingStats(true);
|
|
const res = await odooApi.getDashboard();
|
|
if (res.status === 'success' && res.stats) {
|
|
setStats(res.stats);
|
|
}
|
|
} catch (err) {
|
|
toast.error('Error al cargar estadísticas del dashboard');
|
|
console.error(err);
|
|
} finally {
|
|
setLoadingStats(false);
|
|
}
|
|
};
|
|
|
|
const loadChart = async () => {
|
|
try {
|
|
setLoadingChart(true);
|
|
const res = await odooApi.getWeeklyChart();
|
|
if (res.status === 'success' && res.data) {
|
|
setChartData(res.data);
|
|
}
|
|
} catch (err) {
|
|
toast.error('Error al cargar gráfica semanal');
|
|
console.error(err);
|
|
} finally {
|
|
setLoadingChart(false);
|
|
}
|
|
};
|
|
|
|
const loadAppointments = async () => {
|
|
try {
|
|
setLoadingAppointments(true);
|
|
const today = new Date().toISOString().split('T')[0];
|
|
const res = await odooApi.getAppointments({ date: today, limit: '5' });
|
|
if (res.status === 'success' && res.appointments) {
|
|
setAppointments(res.appointments.slice(0, 5));
|
|
}
|
|
} catch (err) {
|
|
toast.error('Error al cargar próximas citas');
|
|
console.error(err);
|
|
} finally {
|
|
setLoadingAppointments(false);
|
|
}
|
|
};
|
|
|
|
const loadServices = async () => {
|
|
try {
|
|
setLoadingServices(true);
|
|
const res = await odooApi.getServices();
|
|
if (res.status === 'success' && res.services) {
|
|
setServices(res.services.slice(0, 6));
|
|
}
|
|
} catch (err) {
|
|
toast.error('Error al cargar servicios');
|
|
console.error(err);
|
|
} finally {
|
|
setLoadingServices(false);
|
|
}
|
|
};
|
|
|
|
const loadExtras = async () => {
|
|
try {
|
|
setLoadingExtras(true);
|
|
const start = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().split('T')[0];
|
|
const end = new Date().toISOString().split('T')[0];
|
|
const [topRes, aptRes] = await Promise.all([
|
|
odooApi.getTopServices(start, end, 6),
|
|
odooApi.getAppointmentsReport(start, end),
|
|
]);
|
|
if (topRes.status === 'success') setTopServices(topRes.services || []);
|
|
if (aptRes.status === 'success') setAptReport({ total: aptRes.total, by_state: aptRes.by_state });
|
|
} catch (err) {
|
|
console.error(err);
|
|
} finally {
|
|
setLoadingExtras(false);
|
|
}
|
|
};
|
|
|
|
const loadBirthdays = async () => {
|
|
try {
|
|
setLoadingBirthdays(true);
|
|
const res = await odooApi.getBirthdays('today');
|
|
if (res.status === 'success') setBirthdays(res.birthdays || []);
|
|
} catch (err) {
|
|
console.error(err);
|
|
} finally {
|
|
setLoadingBirthdays(false);
|
|
}
|
|
};
|
|
|
|
const loadToday = async () => {
|
|
// Los endpoints de reportes/caja no están disponibles para todos los
|
|
// roles (p. ej. médico): ante error el tile muestra "—" sin molestar.
|
|
const today = new Date().toISOString().split('T')[0];
|
|
const [dailyRes, cashRes] = await Promise.allSettled([
|
|
odooApi.getDailyReport(today),
|
|
odooApi.getCashClosings(today),
|
|
]);
|
|
const next: { waiting: number | null; cobros: number | null; caja: { state: string; name: string } | null } = {
|
|
waiting: null,
|
|
cobros: null,
|
|
caja: null,
|
|
};
|
|
if (dailyRes.status === 'fulfilled' && dailyRes.value.status === 'success') {
|
|
next.waiting = dailyRes.value.citas?.by_state?.arrived ?? 0;
|
|
next.cobros = dailyRes.value.pagos?.total ?? 0;
|
|
}
|
|
if (cashRes.status === 'fulfilled' && cashRes.value.status === 'success') {
|
|
const closing = (cashRes.value.cash_closings || [])[0];
|
|
if (closing) next.caja = { state: closing.state, name: closing.name };
|
|
}
|
|
setTodayInfo(next);
|
|
};
|
|
|
|
loadStats();
|
|
loadChart();
|
|
loadAppointments();
|
|
loadServices();
|
|
loadExtras();
|
|
loadBirthdays();
|
|
loadToday();
|
|
}, []);
|
|
|
|
const buildDelta = (current?: number, prev?: number): StatDelta => {
|
|
if (current == null || prev == null || prev <= 0) return null;
|
|
const pct = Math.round(((current - prev) / prev) * 100);
|
|
if (pct === 0) return { pct: 0, direction: 'flat' };
|
|
return { pct: Math.abs(pct), direction: pct > 0 ? 'up' : 'down' };
|
|
};
|
|
|
|
const statsConfig = useMemo(
|
|
() => [
|
|
{
|
|
title: 'Citas Hoy',
|
|
value: stats?.appointments_today ?? 0,
|
|
icon: <Calendar size={20} />,
|
|
trend: stats ? `${stats.appointments_month} este mes` : undefined,
|
|
to: '/agenda',
|
|
},
|
|
{
|
|
title: 'Pacientes',
|
|
value: stats?.total_patients ?? 0,
|
|
icon: <Users size={20} />,
|
|
trend: stats ? `+${stats.new_patients_month} nuevos` : undefined,
|
|
to: '/pacientes',
|
|
},
|
|
{
|
|
title: 'Ingresos Mes',
|
|
value: stats ? formatCurrency(stats.revenue_month) : '$0',
|
|
icon: <DollarSign size={20} />,
|
|
to: '/reportes?tab=ingresos',
|
|
delta: buildDelta(stats?.revenue_month, stats?.prev_revenue_month),
|
|
deltaLabel: 'vs mes anterior',
|
|
},
|
|
{
|
|
title: 'Ventas Hoy',
|
|
value: stats?.sales_today ?? 0,
|
|
icon: <ShoppingCart size={20} />,
|
|
to: '/ventas',
|
|
delta: buildDelta(stats?.sales_today, stats?.prev_sales_today),
|
|
deltaLabel: 'vs ayer',
|
|
},
|
|
{
|
|
title: 'Pendientes',
|
|
value: stats?.pending_payments ?? 0,
|
|
icon: <Clock size={20} />,
|
|
to: '/reportes?tab=adeudos',
|
|
},
|
|
{
|
|
title: 'Puntos Monedero',
|
|
value: stats?.total_wallet_points ?? 0,
|
|
icon: <Wallet size={20} />,
|
|
to: '/monedero',
|
|
},
|
|
],
|
|
[stats]
|
|
);
|
|
|
|
// Próximas citas de hoy (hora >= ahora); si ya no queda ninguna, las
|
|
// primeras del día ordenadas por hora.
|
|
const upcomingToday = useMemo(() => {
|
|
const nowMin = new Date().getHours() * 60 + new Date().getMinutes();
|
|
const toMinutes = (t: string) => {
|
|
const [h, m] = String(t || '0:0').split(':').map(Number);
|
|
return (h || 0) * 60 + (m || 0);
|
|
};
|
|
const active = appointments.filter((a) => !['cancelled', 'done', 'no_show'].includes(a.state));
|
|
const sorted = [...active].sort((a, b) => toMinutes(a.time) - toMinutes(b.time));
|
|
const upcoming = sorted.filter((a) => toMinutes(a.time) >= nowMin);
|
|
return (upcoming.length > 0 ? upcoming : sorted).slice(0, 3);
|
|
}, [appointments]);
|
|
|
|
const todayLabel = new Date().toLocaleDateString('es-MX', {
|
|
weekday: 'long',
|
|
day: 'numeric',
|
|
month: 'long',
|
|
});
|
|
|
|
const cajaStatus = useMemo(() => {
|
|
if (!todayInfo) return { label: '—', ref: 'Consultando…', tone: 'text-theme-muted' };
|
|
if (!todayInfo.caja) return { label: 'Sin abrir', ref: 'Sin corte de caja hoy', tone: 'text-theme-muted' };
|
|
if (todayInfo.caja.state === 'open')
|
|
return { label: 'Abierta', ref: todayInfo.caja.name, tone: 'text-emerald-600' };
|
|
return { label: 'Cerrada', ref: todayInfo.caja.name, tone: 'text-theme-heading' };
|
|
}, [todayInfo]);
|
|
|
|
const activityItems = useMemo(() => {
|
|
return appointments.slice(0, 4).map((apt) => ({
|
|
id: apt.id,
|
|
title: `${apt.patient} — ${apt.service}`,
|
|
subtitle: `${apt.date} ${apt.time}`,
|
|
state: apt.state,
|
|
}));
|
|
}, [appointments]);
|
|
|
|
const attendanceData = useMemo(() => {
|
|
if (!aptReport) return [] as { name: string; value: number; color: string }[];
|
|
const s = aptReport.by_state || {};
|
|
const pick = (...keys: string[]) => keys.reduce((a, k) => a + (s[k] || 0), 0);
|
|
return [
|
|
{ name: 'Completadas', value: pick('done'), color: '#CAE2C1' },
|
|
{ name: 'Confirmadas', value: pick('confirmed', 'arrived', 'in_progress'), color: '#A4CCE2' },
|
|
{ name: 'Pendientes', value: pick('pending'), color: '#EDA588' },
|
|
{ name: 'No show', value: pick('no_show'), color: '#E57373' },
|
|
{ name: 'Canceladas', value: pick('cancelled'), color: '#E5E0DC' },
|
|
].filter((d) => d.value > 0);
|
|
}, [aptReport]);
|
|
|
|
const noShowRate = useMemo(() => {
|
|
if (!aptReport || !aptReport.total) return 0;
|
|
return Math.round(((aptReport.by_state?.no_show || 0) / aptReport.total) * 100);
|
|
}, [aptReport]);
|
|
|
|
return (
|
|
<Layout title="Dashboard" subtitle={`Panel de control de ${brand?.nombre || 'la clínica'}`}>
|
|
{isHomeNest ? (
|
|
<div className="rounded-3xl p-6 sm:p-10 mb-8 border shadow-soft" style={{ backgroundColor: 'var(--surface)', borderColor: 'var(--border)' }}>
|
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-6">
|
|
<div className="max-w-2xl">
|
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full text-xs font-medium mb-4" style={{ backgroundColor: 'var(--success)', color: 'var(--success-text)' }}>
|
|
<Sparkles size={14} /> Bienvenido de vuelta
|
|
</div>
|
|
<h1 className="font-display text-3xl sm:text-4xl lg:text-5xl mb-3" style={{ color: 'var(--text-heading)' }}>{brand?.nombre || 'Panel Clínico'}</h1>
|
|
<p className="text-base sm:text-lg leading-relaxed" style={{ color: 'var(--text-muted)' }}>
|
|
Ciencia, estética y cuidado personalizado en cada tratamiento. Gestiona tu clínica con calidez y confianza.
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-wrap gap-3 shrink-0">
|
|
<Button variant="primary" size="md" onClick={() => navigate('/agenda')}>
|
|
<Calendar size={16} className="mr-2" /> Nueva {term('appointment_singular').toLowerCase()}
|
|
</Button>
|
|
<Button variant="outline" size="md" onClick={() => navigate('/reportes')}>
|
|
<TrendingUp size={16} className="mr-2" /> Ver reportes
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-8 pt-8" style={{ borderTop: '1px solid var(--border)' }}>
|
|
{statsConfig.slice(0, 4).map((s) => (
|
|
<div key={s.title} className="text-center sm:text-left">
|
|
<p className="text-xs mb-1" style={{ color: 'var(--text-muted)' }}>{s.title}</p>
|
|
<p className="text-xl sm:text-2xl font-heading" style={{ color: 'var(--text-heading)' }}>{loadingStats ? '-' : s.value}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<PageHeader title="Dashboard" subtitle="Resumen general de la clínica">
|
|
<span className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium bg-theme-accent-bg text-theme-heading">
|
|
<Sparkles size={16} />
|
|
Bienvenido{user?.name ? `, ${user.name.split(' ')[0]}` : ''}
|
|
</span>
|
|
</PageHeader>
|
|
|
|
<div className="bg-gradient-to-r from-[#EDA588] to-[#C7BCDD] rounded-2xl p-6 sm:p-8 text-white mb-6 sm:mb-8 shadow-sm">
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
|
<div>
|
|
<h1 className="font-heading text-2xl sm:text-3xl mb-2">{brand?.nombre || 'Panel Clínico'}</h1>
|
|
<p className="text-white/90 text-sm sm:text-base max-w-xl">
|
|
Ciencia, estética y cuidado personalizado en cada tratamiento. Gestiona tu clínica con confianza.
|
|
</p>
|
|
</div>
|
|
<div className="shrink-0">
|
|
<Button variant="secondary" size="sm" className="bg-theme-surface/20 text-white border-white/30 hover:bg-theme-surface/30" onClick={() => navigate('/reportes')}>
|
|
<TrendingUp size={16} className="mr-2" />
|
|
Ver reportes
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Hoy en la clínica: acciones rápidas del día */}
|
|
<Card className="mb-6 sm:mb-8">
|
|
<Card.Header>
|
|
<div>
|
|
<h3 className="font-heading text-lg text-theme-heading">Hoy en la clínica</h3>
|
|
<p className="text-xs text-theme-muted capitalize">{todayLabel}</p>
|
|
</div>
|
|
</Card.Header>
|
|
<Card.Body>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-3">
|
|
<TodayTile
|
|
label="Próximas citas"
|
|
icon={<CalendarClock size={14} />}
|
|
onClick={() => navigate('/agenda')}
|
|
ariaLabel="Ver próximas citas en la agenda"
|
|
className="md:col-span-2"
|
|
>
|
|
{upcomingToday.length === 0 ? (
|
|
<p className="text-sm text-theme-muted">Sin citas activas para hoy.</p>
|
|
) : (
|
|
<div className="space-y-1.5">
|
|
{upcomingToday.map((apt) => (
|
|
<div key={apt.id} className="flex items-center gap-2 text-sm min-w-0">
|
|
<span className="font-medium text-theme-heading tabular-nums shrink-0">{apt.time}</span>
|
|
<span className="text-theme-heading truncate flex-1">{apt.patient}</span>
|
|
<span className="text-xs text-theme-muted truncate hidden sm:inline max-w-[120px]">
|
|
{apt.doctor || 'Sin médico'}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</TodayTile>
|
|
|
|
<TodayTile
|
|
label="Sala de espera"
|
|
icon={<Armchair size={14} />}
|
|
onClick={() => navigate('/agenda')}
|
|
ariaLabel="Ver pacientes en sala de espera"
|
|
>
|
|
<p className="text-2xl font-heading font-semibold text-theme-heading">
|
|
{todayInfo ? (todayInfo.waiting ?? '—') : '—'}
|
|
</p>
|
|
<p className="text-xs text-theme-muted">
|
|
{todayInfo?.waiting != null
|
|
? todayInfo.waiting === 1
|
|
? 'paciente en espera'
|
|
: 'pacientes en espera'
|
|
: 'no disponible'}
|
|
</p>
|
|
</TodayTile>
|
|
|
|
<TodayTile
|
|
label="Caja"
|
|
icon={<Vault size={14} />}
|
|
onClick={() => navigate('/cortes')}
|
|
ariaLabel="Ir a cortes de caja"
|
|
>
|
|
<p className={`text-xl font-heading font-semibold ${cajaStatus.tone}`}>{cajaStatus.label}</p>
|
|
<p className="text-xs text-theme-muted truncate" title={cajaStatus.ref}>
|
|
{cajaStatus.ref}
|
|
</p>
|
|
</TodayTile>
|
|
|
|
<TodayTile
|
|
label="Cobros del día"
|
|
icon={<Banknote size={14} />}
|
|
onClick={() => navigate('/ventas')}
|
|
ariaLabel="Ver cobros del día en ventas"
|
|
>
|
|
<p className="text-2xl font-heading font-semibold text-theme-heading">
|
|
{todayInfo ? (todayInfo.cobros != null ? formatCurrency(todayInfo.cobros) : '—') : '—'}
|
|
</p>
|
|
<p className="text-xs text-theme-muted">pagos completados hoy</p>
|
|
</TodayTile>
|
|
</div>
|
|
</Card.Body>
|
|
</Card>
|
|
|
|
{/* Stats grid */}
|
|
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-4 mb-6 sm:mb-8">
|
|
{statsConfig.map((s) => (
|
|
<StatCard
|
|
key={s.title}
|
|
title={s.title}
|
|
value={s.value}
|
|
icon={s.icon}
|
|
trend={s.trend}
|
|
loading={loadingStats}
|
|
onOpen={s.to ? () => navigate(s.to!) : undefined}
|
|
delta={'delta' in s ? (s.delta as StatDelta) : undefined}
|
|
deltaLabel={'deltaLabel' in s ? (s.deltaLabel as string) : undefined}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Cumpleaños de hoy */}
|
|
<Card className="mb-6 sm:mb-8">
|
|
<Card.Header>
|
|
<div>
|
|
<h3 className="font-heading text-lg text-theme-heading flex items-center gap-2">
|
|
<Cake size={18} className="text-pink-500" /> Cumpleaños de hoy
|
|
</h3>
|
|
<p className="text-xs text-theme-muted">Pacientes que cumplen años hoy — contáctalos</p>
|
|
</div>
|
|
<Button variant="ghost" size="sm" onClick={() => navigate('/cumpleanos')}>
|
|
Ver todos
|
|
<ArrowRight size={14} className="ml-1" />
|
|
</Button>
|
|
</Card.Header>
|
|
<Card.Body>
|
|
{loadingBirthdays ? (
|
|
<Skeleton count={2} className="h-10 w-full" />
|
|
) : birthdays.length === 0 ? (
|
|
<p className="text-sm text-theme-muted">Ningún paciente cumple años hoy.</p>
|
|
) : (
|
|
<div className="flex flex-wrap gap-2">
|
|
{birthdays.map((b) => (
|
|
<div
|
|
key={b.id}
|
|
className="flex items-center gap-2.5 rounded-xl border border-theme-border px-3 py-2"
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => openPatient(b.id)}
|
|
className="text-sm font-medium text-theme-heading hover:underline hover:text-theme-accent text-left"
|
|
title="Abrir ficha del paciente"
|
|
>
|
|
{b.name}
|
|
</button>
|
|
<span className="text-xs text-theme-muted">
|
|
cumple {b.turning_age}{b.is_vip ? ' · VIP' : ''}
|
|
</span>
|
|
{(b.whatsapp || b.phone) && (
|
|
<a
|
|
href={waLink(b.whatsapp || b.phone) || '#'}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="inline-flex items-center gap-1 text-xs text-theme-heading underline underline-offset-2"
|
|
title={`WhatsApp ${b.whatsapp || b.phone}`}
|
|
>
|
|
<MessageCircle size={12} /> {b.whatsapp || b.phone}
|
|
</a>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Card.Body>
|
|
</Card>
|
|
|
|
{/* Chart + appointments */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6 mb-6 sm:mb-8">
|
|
<Card>
|
|
<Card.Header>
|
|
<div>
|
|
<h3 className="font-heading text-lg text-theme-heading">Actividad semanal</h3>
|
|
<p className="text-xs text-theme-muted">Citas e ingresos de los últimos 7 días</p>
|
|
</div>
|
|
</Card.Header>
|
|
<Card.Body>
|
|
{loadingChart ? (
|
|
<Skeleton count={8} className="h-6 w-full" />
|
|
) : chartData.length === 0 ? (
|
|
<EmptyState title="Sin datos" subtitle="No hay información para la gráfica semanal." icon={<TrendingUp size={28} />} />
|
|
) : (
|
|
<div className="h-[300px] w-full">
|
|
<ResponsiveContainer width="100%" height={300}>
|
|
<AreaChart data={chartData} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
|
|
<defs>
|
|
<linearGradient id="colorAppointments" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#EDA588" stopOpacity={0.3} />
|
|
<stop offset="95%" stopColor="#EDA588" stopOpacity={0} />
|
|
</linearGradient>
|
|
<linearGradient id="colorRevenue" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#CAE2C1" stopOpacity={0.3} />
|
|
<stop offset="95%" stopColor="#CAE2C1" stopOpacity={0} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#E5E0DC" />
|
|
<XAxis dataKey="name" tick={{ fontSize: 12, fill: '#6B6B6B' }} axisLine={false} tickLine={false} />
|
|
<YAxis tick={{ fontSize: 12, fill: '#6B6B6B' }} axisLine={false} tickLine={false} />
|
|
<Tooltip
|
|
contentStyle={{ borderRadius: 12, border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }}
|
|
/>
|
|
<Area type="monotone" dataKey="appointments" stroke="#EDA588" fillOpacity={1} fill="url(#colorAppointments)" strokeWidth={2} />
|
|
<Area type="monotone" dataKey="revenue" stroke="#CAE2C1" fillOpacity={1} fill="url(#colorRevenue)" strokeWidth={2} />
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
)}
|
|
</Card.Body>
|
|
</Card>
|
|
|
|
<Card className="flex flex-col">
|
|
<Card.Header>
|
|
<div>
|
|
<h3 className="font-heading text-lg text-theme-heading">Próximas citas</h3>
|
|
<p className="text-xs text-theme-muted">Citas programadas para hoy</p>
|
|
</div>
|
|
<Button variant="ghost" size="sm" className="hidden sm:inline-flex">
|
|
Ver agenda
|
|
<ArrowRight size={14} className="ml-1" />
|
|
</Button>
|
|
</Card.Header>
|
|
<Card.Body className="flex-1">
|
|
{loadingAppointments ? (
|
|
<div className="space-y-3">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-16 w-full" />
|
|
))}
|
|
</div>
|
|
) : appointments.length === 0 ? (
|
|
<EmptyState title="Sin citas" subtitle="No hay citas programadas para hoy." icon={<Calendar size={28} />} />
|
|
) : (
|
|
<div className="space-y-3">
|
|
{appointments.map((apt) => (
|
|
<div
|
|
key={apt.id}
|
|
className="flex items-center justify-between p-3 border border-theme-border-strong rounded-xl hover:bg-theme-bg transition"
|
|
>
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium text-theme-heading truncate">{apt.patient}</p>
|
|
<p className="text-xs text-theme-muted truncate">{apt.service} • {apt.time}</p>
|
|
</div>
|
|
<Badge variant={badgeForAppointmentState(apt.state)}>{aptStateLabel(apt.state, term)}</Badge>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Card.Body>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Top servicios + Asistencia del mes */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6 mb-6 sm:mb-8">
|
|
<Card>
|
|
<Card.Header>
|
|
<div>
|
|
<h3 className="font-heading text-lg text-theme-heading">Top servicios del mes</h3>
|
|
<p className="text-xs text-theme-muted">Por ingresos generados</p>
|
|
</div>
|
|
</Card.Header>
|
|
<Card.Body>
|
|
{loadingExtras ? (
|
|
<Skeleton count={6} className="h-6 w-full" />
|
|
) : topServices.length === 0 ? (
|
|
<EmptyState title="Sin datos" subtitle="No hay ventas en el mes." icon={<TrendingUp size={28} />} />
|
|
) : (
|
|
<div className="h-[280px] w-full">
|
|
<ResponsiveContainer width="100%" height={280}>
|
|
<BarChart data={topServices} layout="vertical" margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
|
|
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="#E5E0DC" />
|
|
<XAxis type="number" tick={{ fontSize: 11, fill: '#6B6B6B' }} axisLine={false} tickLine={false} tickFormatter={(v: number) => formatCurrency(v)} />
|
|
<YAxis
|
|
type="category"
|
|
dataKey="service"
|
|
width={150}
|
|
tick={{ fontSize: 11, fill: '#1A1A1A' }}
|
|
axisLine={false}
|
|
tickLine={false}
|
|
tickFormatter={(v: string) => (v.length > 22 ? `${v.slice(0, 22)}…` : v)}
|
|
/>
|
|
<Tooltip
|
|
formatter={(v) => formatCurrency(Number(v))}
|
|
contentStyle={{ borderRadius: 12, border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }}
|
|
/>
|
|
<Bar dataKey="revenue" fill="#EDA588" radius={[0, 4, 4, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
)}
|
|
</Card.Body>
|
|
</Card>
|
|
|
|
<Card>
|
|
<Card.Header>
|
|
<div>
|
|
<h3 className="font-heading text-lg text-theme-heading">Asistencia del mes</h3>
|
|
<p className="text-xs text-theme-muted">
|
|
{aptReport ? `${aptReport.total} citas · tasa no-show ${noShowRate}%` : 'Distribución por estado'}
|
|
</p>
|
|
</div>
|
|
</Card.Header>
|
|
<Card.Body>
|
|
{loadingExtras ? (
|
|
<Skeleton count={6} className="h-6 w-full" />
|
|
) : attendanceData.length === 0 ? (
|
|
<EmptyState title="Sin datos" subtitle="No hay citas en el mes." icon={<Calendar size={28} />} />
|
|
) : (
|
|
<div className="flex flex-col sm:flex-row items-center gap-4">
|
|
<div className="h-[240px] w-full sm:w-1/2">
|
|
<ResponsiveContainer width="100%" height={240}>
|
|
<PieChart>
|
|
<Pie data={attendanceData} dataKey="value" nameKey="name" cx="50%" cy="50%" innerRadius={60} outerRadius={95} paddingAngle={2}>
|
|
{attendanceData.map((d, i) => (
|
|
<Cell key={`cell-${i}`} fill={d.color} />
|
|
))}
|
|
</Pie>
|
|
<Tooltip contentStyle={{ borderRadius: 12, border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }} />
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
<div className="flex-1 w-full space-y-2">
|
|
{attendanceData.map((d) => (
|
|
<div key={d.name} className="flex items-center justify-between text-sm">
|
|
<span className="flex items-center gap-2 text-theme-muted">
|
|
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: d.color }} />
|
|
{d.name}
|
|
</span>
|
|
<span className="font-medium text-theme-heading">{d.value}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Card.Body>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Services + activity */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
|
<Card>
|
|
<Card.Header>
|
|
<div>
|
|
<h3 className="font-heading text-lg text-theme-heading">Servicios destacados</h3>
|
|
<p className="text-xs text-theme-muted">Catálogo activo de tratamientos</p>
|
|
</div>
|
|
</Card.Header>
|
|
<Card.Body>
|
|
{loadingServices ? (
|
|
<div className="space-y-3">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-14 w-full" />
|
|
))}
|
|
</div>
|
|
) : services.length === 0 ? (
|
|
<EmptyState title="Sin servicios" subtitle="No hay servicios registrados." icon={<Sparkles size={28} />} />
|
|
) : (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
{services.map((s) => (
|
|
<div
|
|
key={s.id}
|
|
className="flex items-center gap-3 p-3 border border-theme-border-strong rounded-xl hover:bg-theme-bg transition"
|
|
>
|
|
<div
|
|
className="w-10 h-10 rounded-lg shrink-0"
|
|
style={{ backgroundColor: s.color || '#78716c' }}
|
|
/>
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium text-theme-heading truncate">{s.name}</p>
|
|
<p className="text-xs text-theme-muted">${s.price} • {s.duration_min} min</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Card.Body>
|
|
</Card>
|
|
|
|
<Card>
|
|
<Card.Header>
|
|
<div>
|
|
<h3 className="font-heading text-lg text-theme-heading">Actividad reciente</h3>
|
|
<p className="text-xs text-theme-muted">Últimos movimientos en la clínica</p>
|
|
</div>
|
|
</Card.Header>
|
|
<Card.Body>
|
|
{loadingAppointments ? (
|
|
<div className="space-y-3">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-14 w-full" />
|
|
))}
|
|
</div>
|
|
) : activityItems.length === 0 ? (
|
|
<EmptyState title="Sin actividad" subtitle="No hay movimientos recientes." icon={<Activity size={28} />} />
|
|
) : (
|
|
<div className="space-y-3">
|
|
{activityItems.map((item) => (
|
|
<div
|
|
key={item.id}
|
|
className="flex items-start gap-3 p-3 border border-theme-border-strong rounded-xl hover:bg-theme-bg transition"
|
|
>
|
|
<div className="w-9 h-9 rounded-full bg-theme-border flex items-center justify-center text-theme-muted shrink-0 mt-0.5">
|
|
<Activity size={16} />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium text-theme-heading truncate">{item.title}</p>
|
|
<p className="text-xs text-theme-muted">{item.subtitle}</p>
|
|
</div>
|
|
<Badge variant={badgeForAppointmentState(item.state)} className="shrink-0">
|
|
{item.state}
|
|
</Badge>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Card.Body>
|
|
</Card>
|
|
</div>
|
|
</Layout>
|
|
);
|
|
};
|
|
|
|
export default Dashboard;
|