From afc208a7b703d81f3f0646bf629a9d7660c9f33a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Consultor=C3=ADa=20Alcaraz=20Salazar?= Date: Thu, 17 Sep 2026 07:25:16 +0000 Subject: [PATCH] UX Dashboard: fila Hoy en la clinica, KPIs clickeables, deltas; Cumpleanos wa.me E.164; Mis ventas con grafica diaria --- frontend/src/lib/whatsapp.ts | 20 ++ frontend/src/pages/Cumpleanos.tsx | 21 +- frontend/src/pages/Dashboard.tsx | 270 ++++++++++++++++-- frontend/src/pages/MisVentas.tsx | 64 ++++- frontend/src/services/odoo.ts | 4 + .../skeen_whatsapp/controllers/frontend.py | 38 ++- 6 files changed, 382 insertions(+), 35 deletions(-) create mode 100644 frontend/src/lib/whatsapp.ts diff --git a/frontend/src/lib/whatsapp.ts b/frontend/src/lib/whatsapp.ts new file mode 100644 index 0000000..ed37e49 --- /dev/null +++ b/frontend/src/lib/whatsapp.ts @@ -0,0 +1,20 @@ +// WhatsApp: enlace wa.me con número en formato E.164 (México = 52 + número, +// sin "+", sin espacios). Devuelve null si no hay número utilizable. +export const waNumber = (raw?: string | null): string | null => { + if (!raw) return null; + const digits = raw.replace(/\D/g, ''); + if (!digits) return null; + // Ya trae lada del país (52155..., 52661..., etc.) + if (digits.startsWith('52')) return digits; + // Número local de 10 dígitos: anteponer lada de México + if (digits.length === 10) return `52${digits}`; + // Otros formatos: quitar ceros a la izquierda (ej. "01" de larga distancia) + return digits.replace(/^0+/, '') || null; +}; + +export const waLink = (raw?: string | null, text?: string): string | null => { + const number = waNumber(raw); + if (!number) return null; + const q = text ? `?text=${encodeURIComponent(text)}` : ''; + return `https://wa.me/${number}${q}`; +}; diff --git a/frontend/src/pages/Cumpleanos.tsx b/frontend/src/pages/Cumpleanos.tsx index 2c8c7a4..94ee1da 100644 --- a/frontend/src/pages/Cumpleanos.tsx +++ b/frontend/src/pages/Cumpleanos.tsx @@ -5,6 +5,7 @@ import Layout from '../components/Layout'; import { Card, PageHeader, Button, toast } from '../components/ui'; import { odooApi } from '../services/odoo'; import type { Birthday } from '../services/odoo'; +import { waLink } from '../lib/whatsapp'; import { exportToExcel } from '../lib/exporter'; type Period = 'today' | 'week' | 'month'; @@ -24,6 +25,9 @@ const fmtDate = (s: string | null) => { return d.toLocaleDateString('es-MX', { day: '2-digit', month: 'short' }); }; +const waBirthdayText = (name: string) => + `¡Feliz cumpleaños ${(name || '').split(' ')[0]}! Le saluda el equipo de SKEEN Derma Experts.`; + const Cumpleanos: FC = () => { const [period, setPeriod] = useState('month'); const [items, setItems] = useState([]); @@ -156,17 +160,24 @@ const Cumpleanos: FC = () => { {fmtMoney(b.amount_due)} - {b.phone && ( + {waLink(b.whatsapp || b.phone, waBirthdayText(b.name)) ? ( WhatsApp + ) : ( + + WhatsApp + )} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 3554f21..11618e2 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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 = ({ title, value, icon, trend, loading }) => { +const StatCard: FC = ({ title, value, icon, trend, loading, onOpen, delta, deltaLabel }) => { if (loading) return ; + const clickable = typeof onOpen === 'function'; + const deltaColor = + delta?.direction === 'up' + ? 'text-emerald-600' + : delta?.direction === 'down' + ? 'text-rose-600' + : 'text-theme-muted'; return ( - - -
-
- {icon} +
{ + 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', + } + : {})} + > + + +
+
+ {icon} +
+ {trend && ( + + {trend} + + )}
- {trend && ( - - {trend} - +

{title}

+

{value}

+ {delta && ( +

+ + {delta.direction === 'up' && } + {delta.direction === 'down' && } + {Math.abs(delta.pct)}% + + {deltaLabel || 'vs período anterior'} +

)} -
-

{title}

-

{value}

- - + + +
); }; +interface TodayTileProps { + label: string; + icon: ReactNode; + onClick: () => void; + children: ReactNode; + className?: string; + ariaLabel: string; +} + +const TodayTile: FC = ({ label, icon, onClick, children, className, ariaLabel }) => ( + +); + 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([]); 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: , trend: stats ? `${stats.appointments_month} este mes` : undefined, + to: '/agenda', }, { title: 'Pacientes', value: stats?.total_patients ?? 0, icon: , trend: stats ? `+${stats.new_patients_month} nuevos` : undefined, + to: '/pacientes', }, { title: 'Ingresos Mes', value: stats ? formatCurrency(stats.revenue_month) : '$0', icon: , + 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: , + to: '/ventas', + delta: buildDelta(stats?.sales_today, stats?.prev_sales_today), + deltaLabel: 'vs ayer', }, { title: 'Pendientes', value: stats?.pending_payments ?? 0, icon: , + to: '/reportes?tab=adeudos', }, { title: 'Puntos Monedero', value: stats?.total_wallet_points ?? 0, icon: , + 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 */} + + +
+

Hoy en la clínica

+

{todayLabel}

+
+
+ +
+ } + onClick={() => navigate('/agenda')} + ariaLabel="Ver próximas citas en la agenda" + className="md:col-span-2" + > + {upcomingToday.length === 0 ? ( +

Sin citas activas para hoy.

+ ) : ( +
+ {upcomingToday.map((apt) => ( +
+ {apt.time} + {apt.patient} + + {apt.doctor || 'Sin médico'} + +
+ ))} +
+ )} +
+ + } + onClick={() => navigate('/agenda')} + ariaLabel="Ver pacientes en sala de espera" + > +

+ {todayInfo ? (todayInfo.waiting ?? '—') : '—'} +

+

+ {todayInfo?.waiting != null + ? todayInfo.waiting === 1 + ? 'paciente en espera' + : 'pacientes en espera' + : 'no disponible'} +

+
+ + } + onClick={() => navigate('/cortes')} + ariaLabel="Ir a cortes de caja" + > +

{cajaStatus.label}

+

+ {cajaStatus.ref} +

+
+ + } + onClick={() => navigate('/ventas')} + ariaLabel="Ver cobros del día en ventas" + > +

+ {todayInfo ? (todayInfo.cobros != null ? formatCurrency(todayInfo.cobros) : '—') : '—'} +

+

pagos completados hoy

+
+
+
+
+ {/* Stats grid */}
{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} /> ))}
@@ -379,7 +615,7 @@ const Dashboard: FC = () => { {(b.whatsapp || b.phone) && ( `$${n.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; +// Gráfica de barras diaria sin librerías: columnas div con altura +// proporcional y tooltip con fecha y monto. +const DailyBars: FC<{ series: { date: string; total: number }[] }> = ({ series }) => { + const max = series.reduce((m, d) => Math.max(m, d.total), 0); + if (series.length === 0 || max <= 0) { + return ; + } + return ( +
+
+ {series.map((d) => ( +
+
0 ? 'bg-theme-accent/60 group-hover:bg-theme-accent' : 'bg-theme-border'}`} + style={{ height: `${Math.max((d.total / max) * 100, d.total > 0 ? 2 : 1)}%` }} + /> + {d.total > 0 && ( +
+ {d.date} · {mxn(d.total)} +
+ )} +
+ ))} +
+
+ {series.map((d, i) => ( + + {i % 5 === 0 || i === series.length - 1 ? d.date.slice(8) : ''} + + ))} +
+
+ ); +}; + const MisVentas: FC = () => { const [range, setRange] = useState<'30d' | 'month' | 'last_month'>('30d'); const [data, setData] = useState(null); @@ -87,9 +121,17 @@ const MisVentas: FC = () => { -
+

Total MXN

{mxn(kpis?.total_mxn ?? 0)}

+

+ + Comisión estimada: {mxn(data.commission_estimate)} +

+

+ {data.commission_pct}% {data.commission_source === 'empleado' ? '(tu porcentaje asignado)' : '(porcentaje global)'} + {data.date_from && data.date_to ? ` · ${data.date_from} a ${data.date_to}` : ''} +

@@ -115,20 +157,18 @@ const MisVentas: FC = () => {
- {/* Comisión estimada */} + {/* Gráfica de ventas por día */} - - -
-

- Comisión estimada: {mxn(data.commission_estimate)} -

+ +
+

Ventas por día

- {data.commission_pct}% {data.commission_source === 'empleado' ? '(tu porcentaje asignado)' : '(porcentaje global)'} - {' '}sobre {mxn(kpis?.total_mxn ?? 0)} del período - {data.date_from && data.date_to ? ` · ${data.date_from} a ${data.date_to}` : ''} + {data.date_from && data.date_to ? `${data.date_from} a ${data.date_to}` : 'Rango seleccionado'}

+
+ + diff --git a/frontend/src/services/odoo.ts b/frontend/src/services/odoo.ts index e481dbb..67605a6 100644 --- a/frontend/src/services/odoo.ts +++ b/frontend/src/services/odoo.ts @@ -42,6 +42,9 @@ export interface DashboardStats { pending_payments: number; total_wallet_points: number; sales_today: number; + // Comparativas del período anterior equivalente (deltas) + prev_revenue_month?: number; + prev_sales_today?: number; } export interface Appointment { @@ -407,6 +410,7 @@ export interface MySalesResponse { date_to?: string; kpis: { ventas: number; total_mxn: number; total_usd: number | null; total_card: number }; sales: MySaleItem[]; + daily_breakdown?: { date: string; total: number }[]; commission_pct: number; commission_source?: string; commission_estimate: number; diff --git a/odoo-addons/skeen_whatsapp/controllers/frontend.py b/odoo-addons/skeen_whatsapp/controllers/frontend.py index 6040803..64fad97 100644 --- a/odoo-addons/skeen_whatsapp/controllers/frontend.py +++ b/odoo-addons/skeen_whatsapp/controllers/frontend.py @@ -268,6 +268,22 @@ class SkeenFrontendController(http.Controller): puntos_totales = sum(m.points for m in Monederos.search([])) ventas_hoy = Ventas.search_count([('date', '=', today), ('state', 'in', ('paid', 'partial'))]) + # Comparativas contra el período anterior equivalente (deltas): + # - Ingresos Mes: mes anterior recortado al mismo día de corte (MTD) + # - Ventas Hoy: mismo conteo del día de ayer + prev_month_last = first_day - timedelta(days=1) + prev_month_first = prev_month_last.replace(day=1) + prev_month_end = prev_month_first.replace(day=min(today.day, prev_month_last.day)) + prev_revenue_month = sum(v.total for v in Ventas.search([ + ('date', '>=', prev_month_first), + ('date', '<=', prev_month_end), + ('state', 'in', ('paid', 'partial')), + ])) + prev_sales_today = Ventas.search_count([ + ('date', '=', today - timedelta(days=1)), + ('state', 'in', ('paid', 'partial')), + ]) + return json_response({ 'status': 'success', 'stats': { @@ -279,6 +295,8 @@ class SkeenFrontendController(http.Controller): 'pending_payments': pendientes_pago, 'total_wallet_points': puntos_totales, 'sales_today': ventas_hoy, + 'prev_revenue_month': prev_revenue_month, + 'prev_sales_today': prev_sales_today, } }) except Exception as e: @@ -2111,7 +2129,8 @@ class SkeenFrontendController(http.Controller): emp = self._linked_employee(user) if not emp: return json_response({'status': 'success', 'medico_sin_employee': True, - 'sales': [], 'kpis': {'ventas': 0, 'total_mxn': 0.0, 'total_usd': None, 'total_card': 0.0}, + 'sales': [], 'daily_breakdown': [], + 'kpis': {'ventas': 0, 'total_mxn': 0.0, 'total_usd': None, 'total_card': 0.0}, 'commission_pct': 0.0, 'commission_estimate': 0.0}) today = date.today() @@ -2158,6 +2177,22 @@ class SkeenFrontendController(http.Controller): total_mxn += r['subtotal'] or 0.0 lines_by_venta.setdefault(vid, []).append(r) + # Serie diaria del rango (para la gráfica de Mis ventas): suma de + # líneas atribuidas por fecha de venta, con días sin ventas en 0. + date_by_venta = {v.id: v.date for v in ventas} + totals_by_date = {} + for r in rows: + vdate = date_by_venta.get(r['venta_id'][0]) + if vdate: + key = vdate.strftime('%Y-%m-%d') + totals_by_date[key] = totals_by_date.get(key, 0.0) + (r['subtotal'] or 0.0) + daily_breakdown = [] + day = date_from + while day <= date_to: + key = day.strftime('%Y-%m-%d') + daily_breakdown.append({'date': key, 'total': round(totals_by_date.get(key, 0.0), 2)}) + day += timedelta(days=1) + def metodo_pago(v): paid = v.amount_paid or 0.0 card = card_by_venta.get(v.id, 0.0) @@ -2205,6 +2240,7 @@ class SkeenFrontendController(http.Controller): 'total_card': round(sum(card_by_venta.values()), 2), }, 'sales': sales, + 'daily_breakdown': daily_breakdown, 'commission_pct': pct, 'commission_source': pct_source, 'commission_estimate': round(total_mxn * pct / 100.0, 2),