UX Dashboard: fila Hoy en la clinica, KPIs clickeables, deltas; Cumpleanos wa.me E.164; Mis ventas con grafica diaria
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import type { FC, ReactNode, KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
@@ -12,6 +12,12 @@ import {
|
||||
Activity,
|
||||
TrendingUp,
|
||||
ArrowRight,
|
||||
ArrowUpRight,
|
||||
ArrowDownRight,
|
||||
CalendarClock,
|
||||
Armchair,
|
||||
Banknote,
|
||||
Vault,
|
||||
Cake,
|
||||
MessageCircle,
|
||||
} from 'lucide-react';
|
||||
@@ -45,40 +51,112 @@ import { odooApi, type DashboardStats, type Appointment, type Service, type Char
|
||||
import { usePatientDrawer } from '../components/PatientDrawer';
|
||||
import { useTheme } from '../lib/theme';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { waLink } from '../lib/whatsapp';
|
||||
|
||||
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 }) => {
|
||||
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 (
|
||||
<Card className="hover:shadow-md transition">
|
||||
<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
|
||||
{...(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>
|
||||
{trend && (
|
||||
<span className="text-xs font-medium text-theme-heading bg-[#CAE2C1] px-2 py-0.5 rounded-full">
|
||||
{trend}
|
||||
</span>
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</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 } = useAuth();
|
||||
@@ -99,6 +177,12 @@ const Dashboard: FC = () => {
|
||||
// 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 () => {
|
||||
@@ -192,14 +276,46 @@ const Dashboard: FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
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(
|
||||
() => [
|
||||
{
|
||||
@@ -207,37 +323,75 @@ const Dashboard: FC = () => {
|
||||
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,
|
||||
@@ -326,6 +480,85 @@ const Dashboard: FC = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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) => (
|
||||
@@ -336,6 +569,9 @@ const Dashboard: FC = () => {
|
||||
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>
|
||||
@@ -379,7 +615,7 @@ const Dashboard: FC = () => {
|
||||
</span>
|
||||
{(b.whatsapp || b.phone) && (
|
||||
<a
|
||||
href={`https://wa.me/${(b.whatsapp || b.phone).replace(/\D/g, '')}`}
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user