import type { FC } from 'react'; import { useEffect, useMemo, useState } from 'react'; import { Mail, Phone, Stethoscope, Briefcase, Save, Percent, Eye, CalendarDays, FileText, ShoppingCart, Users, ClipboardList, CreditCard, DollarSign, Clock, Copy, Target } from 'lucide-react'; import Layout from '../components/Layout'; import { Card, Badge, EmptyState, PageHeader, Skeleton, MobileCard, Input, Button, Modal, Select, toast } from '../components/ui'; import { odooApi, type Doctor, type DoctorHistory, type HorarioDia } from '../services/odoo'; import { useGlosario } from '../lib/glosario'; const DIAS = ['Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo']; const hhmm = (h: number) => { const hh = Math.floor(h || 0); const mm = Math.round(((h || 0) - hh) * 60); return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`; }; const parseHM = (s: string) => { const [h, m] = (s || '').split(':').map((v) => parseInt(v, 10)); return (Number.isNaN(h) ? 0 : h) + (Number.isNaN(m) ? 0 : m) / 60; }; type DiaDraft = { works: boolean; from: string; to: string }; const diaDraftVacio = (): DiaDraft => ({ works: false, from: '09:00', to: '14:00' }); const draftDesdeHorario = (horario?: HorarioDia[]): Record => { const out: Record = {}; for (let d = 0; d < 7; d++) out[String(d)] = diaDraftVacio(); (horario || []).forEach((h) => { out[h.day_of_week] = { works: true, from: hhmm(h.hour_from), to: hhmm(h.hour_to) }; }); return out; }; /** Barra simple de progreso meta vs real del mes. */ const GoalBar: FC<{ goal: number; real: number }> = ({ goal, real }) => { const pct = goal > 0 ? Math.min((real / goal) * 100, 100) : 0; const cumplido = goal > 0 && real >= goal; return (
Meta del mes {goal > 0 ? `${fmtNum(pct, 0)}%` : 'sin meta'}

{fmtMoney(real)}{goal > 0 ? ` de ${fmtMoney(goal)}` : ''}

); }; const medicalTerms = ['doctor', 'dra', 'médico', 'medico', 'especialista', 'dermatólogo', 'dermatologo', 'skin', 'clínico', 'clinico']; const isMedicalTitle = (jobTitle?: string) => { if (!jobTitle) return false; const normalized = jobTitle.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, ''); return medicalTerms.some((term) => normalized.includes(term)); }; const fmtNum = (n: number | null | undefined, decimals = 2) => (n ?? 0).toLocaleString('es-MX', { maximumFractionDigits: decimals }); const fmtMoney = (n: number) => (n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }); const initials = (name: string) => name.split(' ').filter(Boolean).slice(0, 2).map((w) => w[0]).join('').toUpperCase(); type BadgeVariant = 'default' | 'success' | 'info' | 'pending' | 'cancelled'; const CITA_STATE: Record = { pending: { label: 'Pendiente', variant: 'pending' }, confirmed: { label: 'Confirmada', variant: 'info' }, arrived: { label: 'Llegó', variant: 'info' }, in_progress: { label: 'En progreso', variant: 'info' }, done: { label: 'Completada', variant: 'success' }, cancelled: { label: 'Cancelada', variant: 'cancelled' }, no_show: { label: 'No show', variant: 'cancelled' }, }; const VISITA_STATE: Record = { en_curso: { label: 'En curso', variant: 'info' }, completada: { label: 'Completada', variant: 'success' }, cancelada: { label: 'Cancelada', variant: 'cancelled' }, }; const VENTA_STATE: Record = { draft: { label: 'Borrador', variant: 'default' }, confirmed: { label: 'Confirmada', variant: 'info' }, paid: { label: 'Pagada', variant: 'success' }, partial: { label: 'Parcial', variant: 'pending' }, cancelled: { label: 'Cancelada', variant: 'cancelled' }, }; const StateBadge: FC<{ map: Record; state: string }> = ({ map, state }) => { const meta = map[state] || { label: state, variant: 'default' as BadgeVariant }; return {meta.label}; }; /** Badges de ventas 30d al estilo del sistema legacy: * píldora verde "N ventas" + gris "N recomendaciones" y * píldoras índigo apiladas: usd / m.n. / tarjeta m.n. */ const StatBadges: FC<{ d: Doctor }> = ({ d }) => (
{d.ventas_30d ?? 0} ventas
{d.total_30d_usd == null ? '—' : fmtNum(d.total_30d_usd)} usd {fmtNum(d.total_30d ?? 0, 0)} m.n. {fmtNum(d.card_30d ?? 0, 0)} tarjeta m.n.
); const Medicos: FC = () => { const { term } = useGlosario(); const [employees, setEmployees] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // Los dados de baja se cargan siempre: se ocultan en la tabla salvo que el // usuario pida verlos (para poder reactivarlos). const [mostrarBaja, setMostrarBaja] = useState(false); const [savingActive, setSavingActive] = useState(null); useEffect(() => { const load = async () => { try { setLoading(true); setError(null); const res = await odooApi.getDoctors({ includeInactive: true }); if (res.status === 'success') setEmployees(res.doctors); } catch (err) { setError('Error al cargar médicos'); toast.error('Error al cargar médicos'); console.error(err); } finally { setLoading(false); } }; load(); }, []); const doctors = useMemo(() => { const visibles = mostrarBaja ? employees : employees.filter((d) => d.active !== false); const medical = visibles.filter((d) => isMedicalTitle(d.job_title)); return medical.length > 0 ? medical : visibles; }, [employees, mostrarBaja]); const countBaja = employees.filter((d) => d.active === false).length; // Activar/desactivar: un médico dado de baja desaparece de la agenda, del // selector de pacientes y del de ventas (el backend lo valida también). const toggleActivo = async (d: Doctor) => { const nuevo = d.active === false; const mensaje = nuevo ? `¿Reactivar a ${d.name}? Volverá a aparecer en la agenda y en las asignaciones.` : `¿Dar de baja a ${d.name}? Dejará de aparecer en la agenda y no se le podrán asignar pacientes ni ventas. Su historial se conserva.`; if (!window.confirm(mensaje)) return; try { setSavingActive(d.id); await odooApi.updateDoctor(d.id, { active: nuevo }); setEmployees((prev) => prev.map((e) => (e.id === d.id ? { ...e, active: nuevo } : e))); toast.success(nuevo ? `${d.name} reactivado` : `${d.name} dado de baja`); } catch (err) { const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; toast.error(msg || 'No se pudo actualizar el estado'); } finally { setSavingActive(null); } }; const [pctDraft, setPctDraft] = useState>({}); const [savingPct, setSavingPct] = useState(null); // Modal de detalle del médico const [selected, setSelected] = useState(null); const [modalTab, setModalTab] = useState<'info' | 'historial' | 'horario'>('info'); const [hist, setHist] = useState(null); const [histLoading, setHistLoading] = useState(false); const [histSection, setHistSection] = useState<'citas' | 'visitas' | 'ventas'>('citas'); // Meta mensual (editable en el modal) y horario semanal const [goalDraft, setGoalDraft] = useState(''); const [savingGoal, setSavingGoal] = useState(false); const [dias, setDias] = useState>(diaDraftVacio7()); const [savingHorario, setSavingHorario] = useState(false); function diaDraftVacio7(): Record { const out: Record = {}; for (let d = 0; d < 7; d++) out[String(d)] = diaDraftVacio(); return out; } const openDoctor = (d: Doctor) => { setSelected(d); setModalTab('info'); setHistSection('citas'); setHist(null); setGoalDraft(String(d.monthly_goal ?? 0)); setDias(draftDesdeHorario(d.horario)); setHistLoading(true); odooApi.getDoctorHistory(d.id) .then((res) => { if (res.status === 'success') setHist(res); }) .catch((err) => { toast.error('No se pudo cargar el historial'); console.error(err); }) .finally(() => setHistLoading(false)); }; const saveGoal = async (d: Doctor) => { const meta = parseFloat(goalDraft || '0'); if (Number.isNaN(meta) || meta < 0) { toast.error('Meta inválida'); return; } setSavingGoal(true); try { await odooApi.updateDoctor(d.id, { monthly_goal: meta }); setEmployees((prev) => prev.map((e) => (e.id === d.id ? { ...e, monthly_goal: meta } : e))); setSelected((s) => (s && s.id === d.id ? { ...s, monthly_goal: meta } : s)); toast.success(`Meta de ${d.name}: ${fmtMoney(meta)}`); } catch { toast.error('No se pudo guardar la meta'); } finally { setSavingGoal(false); } }; const setDia = (day: string, patch: Partial) => setDias((prev) => ({ ...prev, [day]: { ...prev[day], ...patch } })); const copiarLunes = () => { const lunes = dias['0']; setDias((prev) => { const out: Record = {}; for (let d = 1; d < 7; d++) out[String(d)] = { ...lunes }; return { ...prev, ...out }; }); toast.success('Horario del lunes copiado a todos los días'); }; const saveHorario = async (d: Doctor) => { const lineas: HorarioDia[] = []; for (let i = 0; i < 7; i++) { const key = String(i); const dia = dias[key]; if (!dia?.works) continue; const hf = parseHM(dia.from); const ht = parseHM(dia.to); if (!(ht > hf)) { toast.error(`${DIAS[i]}: la hora fin debe ser mayor a la hora inicio`); return; } lineas.push({ day_of_week: key, hour_from: hf, hour_to: ht }); } setSavingHorario(true); try { const res = await odooApi.setDoctorHorario(d.id, lineas); setEmployees((prev) => prev.map((e) => (e.id === d.id ? { ...e, horario: res.horario, tiene_horario: res.tiene_horario, horario_resumen: res.horario_resumen } : e))); setSelected((s) => (s && s.id === d.id ? { ...s, horario: res.horario, tiene_horario: res.tiene_horario, horario_resumen: res.horario_resumen } : s)); toast.success(res.tiene_horario ? `Horario guardado: ${res.horario_resumen}` : 'Horario borrado: la agenda no restringirá horarios para este médico'); } catch (err) { const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; toast.error(msg || 'No se pudo guardar el horario'); } finally { setSavingHorario(false); } }; const pctValue = (d: Doctor) => (pctDraft[d.id] ?? String(d.commission_pct ?? 0)); const savePct = async (d: Doctor) => { const raw = pctDraft[d.id]; const pct = parseFloat(raw ?? String(d.commission_pct ?? 0)); if (Number.isNaN(pct) || pct < 0 || pct > 100) { toast.error('Porcentaje inválido (0-100)'); return; } setSavingPct(d.id); try { await odooApi.updateDoctor(d.id, { commission_pct: pct }); setEmployees((prev) => prev.map((e) => (e.id === d.id ? { ...e, commission_pct: pct } : e))); setPctDraft((prev) => { const n = { ...prev }; delete n[d.id]; return n; }); toast.success(`Comisión de ${d.name}: ${pct}%`); } catch { toast.error('No se pudo guardar la comisión'); } finally { setSavingPct(null); } }; // Tipo de staff (Doctor / Cosmetóloga): guardado in-place al cambiar el selector const [savingTypeId, setSavingTypeId] = useState(null); const saveStaffType = async (d: Doctor, staff_type: 'doctor' | 'cosmetologa') => { if ((d.staff_type ?? 'doctor') === staff_type) return; setSavingTypeId(d.id); try { await odooApi.updateDoctor(d.id, { staff_type }); setEmployees((prev) => prev.map((e) => (e.id === d.id ? { ...e, staff_type } : e))); toast.success(`${d.name}: tipo actualizado a ${staff_type === 'cosmetologa' ? 'Cosmetóloga' : 'Doctor'}`); } catch { toast.error('No se pudo guardar el tipo'); } finally { setSavingTypeId(null); } }; return ( {countBaja > 0 && ( )} {error &&

{error}

} {loading ? ( ) : doctors.length === 0 ? ( } /> ) : ( <>
{doctors.map((d) => ( openDoctor(d)} title="Ver detalle del médico"> ))}
Nombre Puesto Tipo Estado Teléfono Email % Comisión Últimos 30 días Meta del mes Horario
{d.name} {d.job_title || 'Sin puesto'} e.stopPropagation()}> e.stopPropagation()}> {d.work_phone || '-'} {d.work_email ? ( {d.work_email} ) : ( '-' )} e.stopPropagation()}>
setPctDraft((prev) => ({ ...prev, [d.id]: e.target.value }))} className="max-w-[92px]" />
{d.tiene_horario ? ( {d.horario_resumen} ) : ( Sin horario )}
{doctors.map((d) => ( {d.job_title || 'Sin puesto'} } rows={[ { label: 'Estado', value: ( ), }, { label: 'Teléfono', value: d.work_phone || '-' }, { label: 'Email', value: d.work_email || '-' }, { label: '% Comisión', value: `${d.commission_pct ?? 0}%` }, { label: 'Meta del mes', value: }, { label: 'Horario', value: d.tiene_horario ? d.horario_resumen : 'Sin horario' }, { label: 'Últimos 30 días', value: }, ]} actions={ } /> ))}
)}
{/* Modal detalle del médico */} setSelected(null)} title="" maxWidth="2xl"> {selected && (
{/* Header */}
{initials(selected.name)}

{selected.name}

{selected.job_title || 'Sin puesto'}

Pacientes

{fmtNum(selected.patient_count ?? 0, 0)}

Citas

{histLoading || !hist ? '…' : fmtNum(hist.totales.citas_total, 0)}

Visitas

{histLoading || !hist ? '…' : fmtNum(hist.totales.visitas_total, 0)}

{/* Tabs */}
{([{ key: 'info', label: 'Info' }, { key: 'horario', label: 'Horario' }, { key: 'historial', label: 'Historial' }] as const).map((t) => ( ))}
{modalTab === 'info' ? (

Resumen de actividad

Pacientes asignados

{fmtNum(selected.patient_count ?? 0, 0)}

Ventas 30d

{selected.ventas_30d ?? 0}

Total 30d

{fmtMoney(selected.total_30d ?? 0)}

Tarjeta 30d

{fmtMoney(selected.card_30d ?? 0)}

{selected.total_30d_usd == null ? '—' : `≈ ${fmtNum(selected.total_30d_usd)}`} usd

Contacto

Teléfono

{selected.work_phone || '—'}

Email

{selected.work_email || '—'}

Comisión

{selected.commission_pct ?? 0}%

Se edita inline en la tabla

Meta mensual

setGoalDraft(e.target.value)} />
) : modalTab === 'horario' ? (

Semana tipo (horario base)

Marca los días que atiende y sus horas. La agenda sólo ofrecerá esos horarios y rechazará citas nuevas fuera de ellos. Sin días marcados, la agenda no restringe.

{DIAS.map((nombre, i) => { const key = String(i); const dia = dias[key] || diaDraftVacio(); const activo = dia.works; return (
setDia(key, { from: e.target.value })} className="max-w-[130px]" /> a setDia(key, { to: e.target.value })} className="max-w-[130px]" />
{!activo && No atiende}
); })}

{selected.tiene_horario ? `Horario actual: ${selected.horario_resumen}` : 'Sin horario configurado: la agenda funciona como siempre (8:00 a 19:00).'}

) : (
{histLoading ? ( ) : !hist ? ( ) : ( <>

Citas totales

{fmtNum(hist.totales.citas_total, 0)}

Visitas totales

{fmtNum(hist.totales.visitas_total, 0)}

Líneas recetadas

{fmtNum(hist.totales.ventas_total_lineas, 0)}

Monto recetado

{fmtMoney(hist.totales.monto_total)}

{([ { key: 'citas', label: 'Citas', icon: }, { key: 'visitas', label: 'Visitas', icon: }, { key: 'ventas', label: 'Ventas recetadas', icon: }, ] as const).map((s) => ( ))}
{histSection === 'citas' && ( hist.citas.length === 0 ? ( ) : (
{hist.citas.map((c) => ( ))}
Fecha Hora Paciente Servicio Estado
{c.date || '—'} {c.time || '—'} {c.patient} {c.service}
) )} {histSection === 'visitas' && ( hist.visitas.length === 0 ? ( ) : (
{hist.visitas.map((v) => ( ))}
Fecha Paciente Motivo Estado
{v.date_start || '—'} {v.patient} {v.motivo || '—'}
) )} {histSection === 'ventas' && ( hist.ventas.length === 0 ? ( ) : (
{hist.ventas.map((l) => ( ))}
Folio Fecha Paciente Servicio / Artículo Subtotal Estado
{l.venta} {l.date || '—'} {l.patient} {l.item} {fmtMoney(l.subtotal)}
) )} )}
)}
)}
); }; export default Medicos;