import type { FC } from 'react'; import { useEffect, useState, useCallback, useRef, useMemo } from 'react'; import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, PackageCheck, Sparkles, RotateCcw, LayoutGrid, List, Rows3, CalendarRange, CalendarClock, ChevronLeft, ChevronRight, Ban, Phone, Cake, CreditCard, Pencil, AlertTriangle, Printer, PhoneCall, Wallet, ChevronDown, CalendarOff, Users, Play, Check, Globe, Bell } from 'lucide-react'; import Layout from '../components/Layout'; import { usePatientDrawer } from '../components/PatientDrawer'; import AgendaPrint from '../components/AgendaPrint'; import PatientCombobox from '../components/PatientCombobox'; import RecargarSaldoModal from '../components/RecargarSaldoModal'; import ReciboPrint from '../components/ReciboPrint'; import BloqueosModal, { quickCloseOptions, type BloqueoQuickClose } from '../components/agenda/BloqueosModal'; import { ListaEsperaPanel, ListaEsperaModal, whatsappLink } from '../components/agenda/ListaEspera'; import { EstadoChecklist, CHECKLIST_STEPS, stepIndex } from '../components/agenda/EstadoChecklist'; import { Card, Button, Input, Select, TextArea, Modal, Badge, EmptyState, MobileCard, PageHeader, Skeleton, toast, badgeForAppointmentState, } from '../components/ui'; import { useSearchParams, useNavigate } from 'react-router-dom'; import { odooApi, type Appointment, type AppointmentStateLog, type Patient, type Service, type Doctor, type Bloqueo, type ListaEspera, type PatientWallet, type ReceiptData, type AgendaPerm, type GlossaryKey, type Notificacion, AGENDA_PERM_ITEMS } from '../services/odoo'; import { useAuth } from '../lib/auth'; import { useGlosario } from '../lib/glosario'; import { useClinicSettings } from '../lib/clinic-settings'; import { useSucursales } from '../lib/sucursales'; import { todayLocal } from '../lib/utils'; import { useAutoRefresh, AutoRefreshBadge } from '../lib/autoRefresh'; // Duración por defecto cuando ni la cita ni el servicio la definen const DEFAULT_DURATION = 30; // Color del badge según el estado de una notificación. const notifBadge = (estado: string) => { switch (estado) { case 'enviada': return 'success' as const; case 'simulada': return 'info' as const; case 'pendiente': return 'pending' as const; case 'error': return 'danger' as const; default: return 'done' as const; } }; // Opciones de duración de una cita (el servicio propone la suya) const DURATION_OPTIONS = [ { value: '15', label: '15 min' }, { value: '30', label: '30 min' }, { value: '45', label: '45 min' }, { value: '60', label: '1 hora' }, { value: '90', label: '1 h 30 min' }, ]; // Etiquetas visibles de los estados con el vocabulario de la clínica export const APT_STATE_LABELS: Record = { pending: 'Agendada', confirmed: 'Confirmada', arrived: 'En cabina', in_progress: 'En consulta', done: 'Completada', cancelled: 'Canceló', no_show: 'No se presentó', waitlist: 'Lista de espera', }; /** Etiqueta de estado de cita traducida ('En cabina' sigue el término de sala). */ export const aptStateLabel = (state: string, term: (key: GlossaryKey) => string): string => state === 'arrived' ? `En ${term('room_singular').toLowerCase()}` : (APT_STATE_LABELS[state] || state); const stateOptions = [ { value: '', label: 'Todos los estados' }, { value: 'pending', label: 'Agendada' }, { value: 'confirmed', label: 'Confirmada' }, { value: 'arrived', label: 'En cabina' }, { value: 'in_progress', label: 'En consulta' }, { value: 'done', label: 'Completada' }, { value: 'waitlist', label: 'Lista de espera' }, { value: 'cancelled', label: 'Canceló' }, { value: 'no_show', label: 'No se presentó' }, ]; // ---- Preferencias persistidas de la agenda (vista y filtros, NO la fecha) ---- const AGENDA_PREFS_KEY = 'skeen_agenda_prefs'; type AgendaViewMode = 'lista' | 'doctores' | 'horas' | 'semana'; interface AgendaPrefs { viewMode?: AgendaViewMode; doctorFilter?: string; staffTypeFilter?: '' | 'doctor' | 'cosmetologa'; } const readAgendaPrefs = (): AgendaPrefs => { try { const raw = localStorage.getItem(AGENDA_PREFS_KEY); return raw ? (JSON.parse(raw) as AgendaPrefs) : {}; } catch { return {}; } }; const VALID_VIEW_MODES: AgendaViewMode[] = ['lista', 'doctores', 'horas', 'semana']; // ---- Conflictos de agenda (solape / bloqueo) en lenguaje humano ---- export interface AgendaConflict { kind: 'overlap' | 'bloqueo'; message: string; conflicting?: { id: number; time: string; patient: string; doctor: string } | null; } interface ApiErrorShape { response?: { data?: { message?: string; conflicting_appointment?: { id: number; time: string; patient: string; doctor: string }; }; }; } // Traduce los mensajes técnicos del backend (validaciones de skeen.cita) a // texto entendible; null si el error no es un conflicto de agenda. const parseAgendaConflict = (err: unknown): AgendaConflict | null => { const data = (err as ApiErrorShape)?.response?.data; const msg = data?.message || ''; if (msg.includes('Ya existe una cita')) { return { kind: 'overlap', message: 'El doctor ya tiene una cita en ese horario', conflicting: data?.conflicting_appointment || null, }; } const m = /bloqueo \((.+?)\)/.exec(msg); if (m) { return { kind: 'bloqueo', message: `El doctor no puede atender en ese horario: ${m[1]}` }; } if (msg.includes('bloqueo')) { return { kind: 'bloqueo', message: 'El doctor no puede atender en ese horario: agenda bloqueada' }; } return null; }; // Opciones de hora en bloques de 15 min dentro del horario de agenda (8:00-19:00) const QUARTER_TIME_OPTIONS = (() => { const opts = [{ value: '', label: 'Seleccionar hora...' }]; for (let h = 8; h < 19; h++) { for (const m of [0, 15, 30, 45]) { const v = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; opts.push({ value: v, label: v }); } } return opts; })(); const Agenda: FC = () => { const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate(); const { user, canAgenda, can } = useAuth(); const { term } = useGlosario(); const { sucursales, defaultCode } = useSucursales(); const clinicSettings = useClinicSettings(); const { openPatient } = usePatientDrawer(); // Rol médico: agenda restringida a su propio hr.employee (filtro también en servidor). // Cosmetología ve la agenda completa (es personal clínico, sin la privacidad // restringida del médico), pero arranca en su propia columna si tiene // empleado vinculado. const isMedico = user?.role === 'medico'; const isCosmetologa = user?.role === 'cosmetologia'; const medicoEmployeeId = isMedico && user?.employee_id ? user.employee_id : null; const propiaColumnaId = isCosmetologa && user?.employee_id ? user.employee_id : null; // Permisos de acción dentro de la Agenda (selector por usuario, resueltos // en el backend). El guard real está en la API; aquí solo se oculta lo que // no se puede usar, para no ofrecer botones que devuelven 403. const puedeAgendar = canAgenda('agendar'); const puedeEditar = canAgenda('editar'); const puedeEliminar = canAgenda('eliminar'); const puedeVisitas = canAgenda('visitas'); // Aviso uniforme cuando una acción está recortada (el backend responde 403 // de todas formas; esto evita el round-trip y explica por qué). const exigePermiso = (perm: AgendaPerm): boolean => { if (canAgenda(perm)) return false; const etiqueta = AGENDA_PERM_ITEMS.find((p) => p.key === perm)?.label ?? perm; toast.error(`Sin permiso: ${etiqueta}`); return true; }; const [appointments, setAppointments] = useState([]); const [services, setServices] = useState([]); const [doctors, setDoctors] = useState([]); const [loading, setLoading] = useState(true); const [submitting, setSubmitting] = useState(false); const [updating, setUpdating] = useState(null); const [error, setError] = useState(null); const [date, setDate] = useState(() => searchParams.get('date') || todayLocal()); const [stateFilter, setStateFilter] = useState(''); const [doctorFilter, setDoctorFilter] = useState(() => { if (medicoEmployeeId) return String(medicoEmployeeId); if (propiaColumnaId) return String(propiaColumnaId); const saved = readAgendaPrefs().doctorFilter; return typeof saved === 'string' ? saved : ''; }); const [staffTypeFilter, setStaffTypeFilter] = useState<'' | 'doctor' | 'cosmetologa'>(() => { const saved = readAgendaPrefs().staffTypeFilter; return saved === 'doctor' || saved === 'cosmetologa' ? saved : ''; }); const [viewMode, setViewMode] = useState(() => { const saved = readAgendaPrefs().viewMode; return saved && VALID_VIEW_MODES.includes(saved) ? saved : 'doctores'; }); // Persistencia de vista y filtros (la fecha nunca se guarda: siempre arranca en hoy) useEffect(() => { try { localStorage.setItem(AGENDA_PREFS_KEY, JSON.stringify({ viewMode, staffTypeFilter, doctorFilter: isMedico ? undefined : doctorFilter, } as AgendaPrefs)); } catch { // localStorage no disponible: ignorar } }, [viewMode, staffTypeFilter, doctorFilter, isMedico]); const [quickFilter, setQuickFilter] = useState<'' | 'libres' | 'primera' | 'checkin' | 'noshow' | 'porconfirmar' | 'cancelled' | 'waitlist'>(''); // Confirmación telefónica en lote (solo vista Lista) const [selectedIds, setSelectedIds] = useState([]); const [bulkConfirming, setBulkConfirming] = useState(false); // Conflicto de agenda (solape / bloqueo) con acción "Ver esa cita" const [conflict, setConflict] = useState(null); // Impresión de la agenda del día const [printOpen, setPrintOpen] = useState(false); // Paciente precargado vía query param (?patient_id=X) desde la ficha const [preloadedPatient, setPreloadedPatient] = useState(null); // Paciente elegido/creado en el combobox del modal (para mostrar su nombre // sin depender de la búsqueda ni de la lista de citas cargadas). const [pickedPatient, setPickedPatient] = useState<{ id: string; name: string } | null>(null); const [detailApt, setDetailApt] = useState(null); const [editMode, setEditMode] = useState(false); const [editForm, setEditForm] = useState({ date: '', time: '', doctor_id: '', notes: '' }); const [savingEdit, setSavingEdit] = useState(false); const [createOpen, setCreateOpen] = useState(false); const [editingId, setEditingId] = useState(null); const [slots, setSlots] = useState<{ time: number; time_str: string }[]>([]); const [slotsLoading, setSlotsLoading] = useState(false); const [bloqueos, setBloqueos] = useState([]); const [bloqueosOpen, setBloqueosOpen] = useState(false); // Precarga del modal de bloqueos ("Cerrar agenda" desde la columna del médico) const [bloqueoQuick, setBloqueoQuick] = useState(null); // Menú rápido abierto en la cabecera de una columna (doctorId) const [quickCloseMenuFor, setQuickCloseMenuFor] = useState(null); // Lista de espera de pacientes (llega en el mismo payload que las citas) const [listaEspera, setListaEspera] = useState([]); const [listaModalOpen, setListaModalOpen] = useState(false); const [listaPrefill, setListaPrefill] = useState<{ patient?: Patient | { id: number; name: string } | null; doctorId?: string; serviceId?: string }>({}); const [listaAgendando] = useState(null); // Registro de lista de espera del que se abrió el modal de nueva cita const [waitingSourceId, setWaitingSourceId] = useState(null); // Aviso "se liberó un hueco" con los pacientes en espera compatibles const [huecoLibre, setHuecoLibre] = useState<{ texto: string; lista: ListaEspera[] } | null>(null); // Bitácora de estados de la cita abierta en el detalle const [stateHistory, setStateHistory] = useState([]); const [dragApt, setDragApt] = useState(null); const [dropTarget, setDropTarget] = useState(null); // Tooltip flotante de tarjeta de cita (hover en vistas de grid) const [tooltip, setTooltip] = useState<{ apt: Appointment; x: number; y: number; above: boolean } | null>(null); const gridScrollRef = useRef(null); const autoScrollKey = useRef(''); const [form, setForm] = useState({ patient_id: '', service_id: '', doctor_id: '', date: todayLocal(), time: '', notes: '', sessions: 1, duration: '', branch: '', }); const quickToggleClass = editingId ? 'hidden' : 'inline-flex items-center gap-2 text-sm text-theme-heading cursor-pointer'; const [crearNuevo, setCrearNuevo] = useState(false); const [nuevoPac, setNuevoPac] = useState({ name: '', phone: '' }); const [creandoPac, setCreandoPac] = useState(false); // Aviso de duplicado por teléfono en el mini-form "Crear paciente nuevo" const [nuevoPacDup, setNuevoPacDup] = useState<{ id: number; name: string } | null>(null); // Cita rápida: solo nombre y teléfono (+ fecha/hora/médico del contexto) const [quickMode, setQuickMode] = useState(false); const [quickForm, setQuickForm] = useState({ name: '', phone: '', doctor_id: '', date: todayLocal(), time: '', notes: '', duration: '', branch: '', }); const [pkgUpdating, setPkgUpdating] = useState(null); const [expressOpen, setExpressOpen] = useState(false); const [expressSubmitting, setExpressSubmitting] = useState(false); const [expressForm, setExpressForm] = useState({ name: '', phone: '', notes: '', withAppointment: false, service_id: '', doctor_id: '', date: todayLocal(), time: '', duration: '', }); const filteredAppointments = appointments.filter((apt) => { if (stateFilter && apt.state !== stateFilter) return false; if (doctorFilter && String(apt.doctor_id) !== doctorFilter) return false; return true; }); // ---- Filtros rápidos ---- const timeToFloat = (t: string): number | null => { const m = /^(\d{1,2}):(\d{2})/.exec((t || '').trim()); return m ? parseInt(m[1], 10) + parseInt(m[2], 10) / 60 : null; }; const nowDate = new Date(); const nowFloat = nowDate.getHours() + nowDate.getMinutes() / 60; const todayStr = `${nowDate.getFullYear()}-${String(nowDate.getMonth() + 1).padStart(2, '0')}-${String(nowDate.getDate()).padStart(2, '0')}`; // Fecha local en formato YYYY-MM-DD (evita el desfase UTC de toISOString) const fmtLocalDate = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; // Navegación rápida de fecha: día a día (semana completa en vista Semana) const shiftDate = (dir: number) => { const base = new Date(`${date}T12:00:00`); base.setDate(base.getDate() + dir * (viewMode === 'semana' ? 7 : 1)); setDate(fmtLocalDate(base)); }; // Semana (lunes a domingo) que contiene la fecha visible const weekDatesOf = (d: string): string[] => { const start = new Date(`${d}T12:00:00`); start.setDate(start.getDate() - ((start.getDay() + 6) % 7)); return Array.from({ length: 7 }, (_, i) => { const day = new Date(start); day.setDate(day.getDate() + i); return fmtLocalDate(day); }); }; const weekDates = weekDatesOf(date); const WEEKDAY_LABELS = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom']; // Candidata a check-in: confirmada, de hoy, ya empezó y aún no termina const isCheckinDue = (a: Appointment): boolean => { const start = timeToFloat(a.time); if (a.state !== 'confirmed' || a.date !== todayStr || start === null) return false; const end = start + (a.duration ?? 30) / 60; return start <= nowFloat && nowFloat < end; }; // Candidata a no-show: confirmada, de hoy, ya debió terminar const isNoShowDue = (a: Appointment): boolean => { const start = timeToFloat(a.time); if (a.state !== 'confirmed' || a.date !== todayStr || start === null) return false; return start + (a.duration ?? 30) / 60 <= nowFloat; }; // Sin 1ra confirmación telefónica (misma regla que pending_confirm del API) const isPendingConfirm = (a: Appointment): boolean => !a.phone_confirmed && !['cancelled', 'no_show', 'done'].includes(a.state); const visibleAppointments = filteredAppointments.filter((apt) => { if (quickFilter === 'primera' && !apt.is_first_visit) return false; if (quickFilter === 'checkin' && !isCheckinDue(apt)) return false; if (quickFilter === 'noshow' && !isNoShowDue(apt)) return false; if (quickFilter === 'porconfirmar' && !isPendingConfirm(apt)) return false; if (quickFilter === 'cancelled' && apt.state !== 'cancelled') return false; if (quickFilter === 'waitlist' && apt.state !== 'waitlist') return false; return true; }); const countPrimera = filteredAppointments.filter((a) => a.is_first_visit).length; const countCheckin = filteredAppointments.filter(isCheckinDue).length; const countNoShow = filteredAppointments.filter(isNoShowDue).length; const countPorConfirmar = filteredAppointments.filter(isPendingConfirm).length; const countCanceladas = filteredAppointments.filter((a) => a.state === 'cancelled').length; const countWaitlist = filteredAppointments.filter((a) => a.state === 'waitlist').length; // Pacientes en la lista de espera (registro aparte, no citas con estado waitlist) const countEnEspera = listaEspera.filter((e) => e.state === 'esperando').length; // Indicador de doble confirmación: sin confirmar (gris) / teléfono (azul) / en clínica (verde) const confirmIndicator = (apt: Appointment, size = 12) => { if (['arrived', 'in_progress', 'done'].includes(apt.state)) { return ( ); } if (apt.phone_confirmed) { return ( ); } return ( ); }; // Icono de cumpleaños: el paciente cumple años el día de la cita (viene del API) const birthdayIcon = (apt: Appointment, size = 11) => apt.patient_birthday ? ( ) : null; // Distintivo de origen: la cita se agendó desde el portal público de reservas. const portalIcon = (apt: Appointment, size = 11) => apt.booking_source === 'public_portal' ? ( ) : null; const confirmTitle = (apt: Appointment): string => { if (['arrived', 'in_progress', 'done'].includes(apt.state)) return '2da confirmación: llegó a la clínica'; if (apt.phone_confirmed) { const quien = apt.phone_confirmed_by ? ` por ${apt.phone_confirmed_by}` : ''; const cuando = apt.phone_confirmed_at ? ` el ${apt.phone_confirmed_at}` : ''; return `1ra confirmación (teléfono)${quien}${cuando}`; } return 'Sin confirmar por teléfono'; }; // ---- Sala de espera: citas con check-in (2da confirmación) por atender ---- // Para rol medico el servidor ya devuelve solo sus citas; para recepción/admin // se respeta el filtro de médico seleccionado (filteredAppointments). const waitSortKey = (a: Appointment) => a.arrived_at || `9999-12-31 ${a.time}`; const waitingAppointments = filteredAppointments .filter((a) => a.state === 'arrived') .sort((a, b) => waitSortKey(a).localeCompare(waitSortKey(b))); // arrived_at viene en UTC (datetime de Odoo) const waitLabel = (apt: Appointment): string | null => { if (!apt.arrived_at) return null; const arrived = new Date(`${apt.arrived_at.replace(' ', 'T')}Z`); if (Number.isNaN(arrived.getTime())) return null; const mins = Math.max(0, Math.round((Date.now() - arrived.getTime()) / 60000)); if (mins < 1) return 'Recién llegó'; if (mins < 60) return `${mins} min en espera`; return `${Math.floor(mins / 60)} h ${String(mins % 60).padStart(2, '0')} min en espera`; }; // ---- Vista por médico (grid doctores x bloques de 15 min) ---- const GRID_START = 8; // horario clínica: 8:00 const GRID_END = 19; // horario clínica: 19:00 (último bloque inicia 18:45) const SLOT_MIN = 15; const SLOTS_PER_HOUR = 60 / SLOT_MIN; // Bloque de 15 min al que pertenece una hora "HH:MM" (null si fuera de 8:00-19:00) const aptSlot = (t: string): number | null => { const m = /^(\d{1,2}):?(\d{2})?/.exec((t || '').trim()); if (!m) return null; const h = parseInt(m[1], 10); const mins = m[2] ? parseInt(m[2], 10) : 0; if (Number.isNaN(h) || Number.isNaN(mins)) return null; const f = h + mins / 60; if (f < GRID_START || f >= GRID_END) return null; return GRID_START + Math.floor((f - GRID_START) * SLOTS_PER_HOUR + 1e-6) / SLOTS_PER_HOUR; }; // Las canceladas y la lista de espera no ocupan horario: el hueco queda libre const NON_BLOCKING_STATES = ['cancelled', 'waitlist']; const gridAppointments = visibleAppointments.filter((a) => !NON_BLOCKING_STATES.includes(a.state)); const gridSlots: number[] = Array.from( { length: (GRID_END - GRID_START) * SLOTS_PER_HOUR }, (_, i) => GRID_START + i / SLOTS_PER_HOUR ); // Número de bloques de 15 min que ocupa una cita según su duración real const aptSlotSpan = (apt: Appointment) => Math.max(1, Math.round((apt.duration || DEFAULT_DURATION) / SLOT_MIN)); const slotIndex = (slot: number) => Math.round((slot - GRID_START) * SLOTS_PER_HOUR); // Filtro por tipo de staff: las pills y las columnas del grid usan esta lista const typedDoctors = doctors.filter( (d) => !staffTypeFilter || (d.staff_type ?? 'doctor') === staffTypeFilter ); const gridDoctors = doctorFilter ? typedDoctors.filter((d) => String(d.id) === doctorFilter) : typedDoctors; const hasUnassigned = !doctorFilter && gridAppointments.some((a) => !a.doctor_id); const gridColumns: { key: string; label: string; subtitle: string; doctorId: number | null }[] = [ ...gridDoctors.map((d) => ({ key: `d-${d.id}`, label: d.name, subtitle: d.job_title || '', doctorId: d.id })), ...(hasUnassigned ? [{ key: 'none', label: 'Sin asignar', subtitle: '', doctorId: null }] : []), ]; /** * Citas que tocan una celda (médico × bloque de 15 min), indicando si el * bloque es el inicio de la cita o una continuación por su duración real. */ const gridCellEntries = (doctorId: number | null, slot: number) => { const i = slotIndex(slot); const out: { apt: Appointment; isStart: boolean; isEnd: boolean }[] = []; for (const a of gridAppointments) { if (doctorId ? a.doctor_id !== doctorId : !!a.doctor_id) continue; const start = aptSlot(a.time); if (start === null) continue; const s = slotIndex(start); const span = aptSlotSpan(a); if (i >= s && i < s + span) { out.push({ apt: a, isStart: i === s, isEnd: i === s + span - 1 }); } } return out; }; const gridDoctorCount = (doctorId: number | null) => gridAppointments.filter((a) => (doctorId ? a.doctor_id === doctorId : !a.doctor_id)).length; // Bloqueo que cubre una celda (médico × bloque de 15 min) const bloqueoEnCelda = (doctorId: number | null, slot: number) => doctorId ? bloqueos.find((b) => b.doctor_id === doctorId && (b.all_day || (slot < b.time_to && slot + SLOT_MIN / 60 > b.time_from))) : undefined; // Bloqueo de día completo del médico en la fecha visible (para la cabecera) const bloqueoDia = (doctorId: number | null) => doctorId ? bloqueos.find((b) => b.doctor_id === doctorId && b.all_day) : undefined; // Horario semanal del médico: celda fuera de su semana tipo. // Devuelve null si el médico no tiene horario configurado (sin restricción). const fueraDeHorario = (doctorId: number | null, slot: number): string | null => { if (!doctorId) return null; const doc = doctors.find((d) => d.id === doctorId); if (!doc?.tiene_horario || !doc.horario?.length) return null; const dow = String((new Date(`${date}T12:00:00`).getDay() + 6) % 7); const linea = doc.horario.find((h) => h.day_of_week === dow); if (!linea) return `Fuera de horario (no atiende ese día · ${doc.horario_resumen})`; if (slot < linea.hour_from - 1e-6 || slot >= linea.hour_to - 1e-6) { return `Fuera de horario (trabaja ${horaFloatStr(linea.hour_from)}-${horaFloatStr(linea.hour_to)})`; } return null; }; // Drag & drop para mover citas (solo pendientes/confirmadas) const DRAGGABLE_STATES = ['pending', 'confirmed']; const handleDrop = async (doctorId: number | null, slot: number) => { if (!dragApt) return; if (exigePermiso('editar')) { setDragApt(null); return; } if (exigePermiso('editar')) { setDragApt(null); return; } const apt = dragApt; setDragApt(null); setDropTarget(null); // Evita que el click posterior al drop abra el modal de nueva cita justDroppedRef.current = true; // Celdas bloqueadas u ocupadas por otra cita no aceptan el drop: el // servidor también lo rechazaría (solape / bloqueo). if (bloqueoEnCelda(doctorId, slot)) { toast.error('Ese horario está bloqueado para este médico'); return; } if (gridCellEntries(doctorId, slot).some((e) => e.apt.id !== apt.id)) { toast.error('Ese horario ya tiene una cita'); return; } if ((apt.doctor_id ?? null) === doctorId && aptSlot(apt.time) === slot) return; const nombreCol = doctorId ? (doctors.find((d) => d.id === doctorId)?.name || '') : 'Sin asignar'; const horaStr = horaFloatStr(slot); if (!window.confirm(`¿Mover la cita de ${apt.patient} a ${nombreCol} ${horaStr}?`)) return; const anterior = { doctorId: apt.doctor_id ?? null, time: apt.time, date: apt.date, doctor: apt.doctor }; try { await odooApi.updateAppointment(apt.id, { doctor_id: doctorId ?? null, time: slot as unknown as string, }); toast.success('Cita movida'); await load(); // El hueco anterior quedó libre: avisar si hay pacientes en espera void avisarHuecoLibre({ doctorId: anterior.doctorId, doctorLabel: anterior.doctor, serviceId: apt.service_id, date: anterior.date, time: anterior.time, }); } catch (err) { applyAgendaError(err, 'No se pudo mover la cita'); } }; const chipDragProps = (apt: Appointment) => DRAGGABLE_STATES.includes(apt.state) && puedeEditar ? { draggable: true, onDragStart: (e: React.DragEvent) => { e.stopPropagation(); setTooltip(null); setDragApt(apt); }, onDragEnd: () => { setDragApt(null); setDropTarget(null); }, } : {}; const openBloqueos = () => { setBloqueoQuick(null); setBloqueosOpen(true); }; // Cierre rápido desde la cabecera de la columna de un médico const cerrarAgenda = async (doctorId: number, opcion: 'all_day' | 'rest_day' | 'range') => { setQuickCloseMenuFor(null); const doc = doctors.find((d) => d.id === doctorId); if (opcion === 'range') { setBloqueoQuick({ doctorIds: [doctorId], allDay: false, timeFrom: '13:00', timeTo: '14:00' }); setBloqueosOpen(true); return; } const label = opcion === 'all_day' ? 'todo el día' : 'el resto del día'; if (!window.confirm(`¿Cerrar la agenda de ${doc?.name || 'este médico'} ${label} (${date})?`)) return; const ahora = new Date(); const ahoraFloat = ahora.getHours() + ahora.getMinutes() / 60; const desde = opcion === 'rest_day' ? Math.max(GRID_START, Math.ceil(ahoraFloat * 4) / 4) : GRID_START; try { const res = await odooApi.createBloqueo({ doctor_ids: [doctorId], date_from: date, date_to: date, all_day: opcion === 'all_day', time_from: desde, time_to: GRID_END, motivo_tipo: 'otro', motivo: 'Cierre rápido desde la agenda', }); toast.success(opcion === 'all_day' ? 'Agenda cerrada todo el día' : `Agenda cerrada de ${horaFloatStr(desde)} a 19:00`); void res; await load(); } catch (err) { const data = (err as { response?: { data?: { message?: string; conflicto_citas?: boolean } } })?.response?.data; if (data?.conflicto_citas) { if (window.confirm(`${data.message}\n\n¿Cerrar la agenda de todos modos? Las citas ya agendadas se conservan.`)) { try { await odooApi.createBloqueo({ doctor_ids: [doctorId], date_from: date, date_to: date, all_day: opcion === 'all_day', time_from: desde, time_to: GRID_END, motivo_tipo: 'otro', motivo: 'Cierre rápido desde la agenda', force: true, }); toast.success('Agenda cerrada'); await load(); } catch { toast.error('No se pudo cerrar la agenda'); } } return; } toast.error(data?.message || 'No se pudo cerrar la agenda'); } }; const horaFloatStr = (h: number) => { const hh = Math.floor(h); const mm = Math.round((h - hh) * 60); return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`; }; const initials = (name: string) => name.split(' ').filter(Boolean).slice(0, 2).map((w) => w[0]).join('').toUpperCase(); // Colores por estado siguiendo la paleta de la marca (coral/mint/sky/lilac). // El color se lleva en el fondo; la barra lateral izquierda indica la categoría del servicio. const gridStateClass: Record = { pending: 'bg-amber-100 text-amber-900', confirmed: 'bg-emerald-200 text-emerald-900', arrived: 'bg-sky-200 text-sky-900', in_progress: 'bg-violet-300 text-violet-950', done: 'bg-slate-200 text-slate-600', no_show: 'bg-rose-200 text-rose-900', cancelled: 'bg-rose-100 text-rose-700 line-through', }; // Barra lateral por categoría del servicio (service_category ya viene del API) const categoryBarClass: Record = { consulta: 'border-l-sky-500', tratamiento: 'border-l-violet-500', procedimiento: 'border-l-amber-500', paquete: 'border-l-emerald-600', }; const categoryLabels: Record = { consulta: 'Consulta', tratamiento: 'Tratamiento', procedimiento: 'Procedimiento', paquete: 'Paquete', }; const aptCategoryBar = (apt: Appointment) => categoryBarClass[apt.service_category || ''] || 'border-l-theme-border-strong'; // Slot del grid que contiene la hora actual (solo si la fecha visible es hoy) const nowSlot = date === todayStr ? aptSlot(`${String(nowDate.getHours()).padStart(2, '0')}:${String(nowDate.getMinutes()).padStart(2, '0')}`) : null; // Tooltip flotante: posición fija para que no lo recorten los contenedores con overflow const showAptTooltip = (e: React.MouseEvent, apt: Appointment) => { const r = e.currentTarget.getBoundingClientRect(); const above = r.bottom > window.innerHeight - 200; setTooltip({ apt, x: Math.max(8, Math.min(r.left, window.innerWidth - 280)), y: above ? r.top : r.bottom, above, }); }; const gridStateLegend: { state: string; label: string; dot: string }[] = [ { state: 'pending', label: 'Pendiente', dot: 'bg-amber-300' }, { state: 'confirmed', label: 'Confirmada', dot: 'bg-emerald-400' }, { state: 'arrived', label: 'Llegó', dot: 'bg-sky-400' }, { state: 'in_progress', label: 'En curso', dot: 'bg-violet-500' }, { state: 'done', label: 'Completada', dot: 'bg-slate-400' }, ]; const firstName = (full: string) => (full || '').trim().split(' ')[0] || '-'; // Duración sugerida por el servicio elegido (el campo vacío = usar esa) const serviceDuracionPropuesta = services.find((s) => String(s.id) === form.service_id)?.duration_min || 0; /** * Menú de cierre rápido que va en la cabecera de la columna/fila de un médico * (punto 3): cerrar todo el día, el resto del día o un rango; y reabrir si ya * está cerrado. Los médicos solo ven el suyo (la agenda ya viene filtrada). */ const reabrirAgenda = async (doctorId: number) => { const b = bloqueoDia(doctorId); if (!b) return; if (!window.confirm(`¿Reabrir la agenda de ${doctors.find((d) => d.id === doctorId)?.name || 'este médico'} (${b.date})?`)) return; setQuickCloseMenuFor(null); try { await odooApi.deleteBloqueo(b.id); toast.success('Agenda reabierta'); await load({ silent: true }); } catch { toast.error('No se pudo reabrir la agenda'); } }; // Escape cierra el menú desplegable de cierre rápido useEffect(() => { if (quickCloseMenuFor === null) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setQuickCloseMenuFor(null); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [quickCloseMenuFor]); const menuCierreRapido = (doctorId: number | null) => { if (!doctorId) return null; const abierto = quickCloseMenuFor === doctorId; const cerrado = bloqueoDia(doctorId); return ( e.stopPropagation()}> {abierto && ( <> setQuickCloseMenuFor(null)} aria-hidden="true" />
{cerrado && ( )} {quickCloseOptions(nowFloat).map((op) => ( ))}
)}
); }; const gridLegend = (
{gridStateLegend.map((item) => ( {item.label} ))} · Las citas canceladas no bloquean horario: su hueco aparece como libre. · Barra por categoría: {Object.entries(categoryLabels).map(([key, label]) => ( {label} ))}
); // Auto-scroll del grid a ~1h antes de la hora actual. // Se llama al terminar load() (post-render) para que el Skeleton no remonte // la tabla y pierda el scroll; solo si la fecha visible es hoy y una sola // vez por (fecha, vista), para no pelear con el scroll manual del usuario. const scrollToNowIfToday = useCallback(() => { if (date !== todayStr) return; if (viewMode !== 'doctores' && viewMode !== 'horas') return; const key = `${date}-${viewMode}`; if (autoScrollKey.current === key) return; if (nowFloat < GRID_START || nowFloat >= GRID_END) return; autoScrollKey.current = key; const target = Math.max( GRID_START, Math.floor((nowFloat - 1) * SLOTS_PER_HOUR + 1e-6) / SLOTS_PER_HOUR ); setTimeout(() => { const el = document.querySelector(`[data-grid-slot="${target.toFixed(2)}"]`) as HTMLElement | null; if (!el) return; const wrap = gridScrollRef.current; if (viewMode === 'horas' && wrap) { // En "Por hora" el desplazamiento es horizontal dentro del contenedor wrap.scrollLeft = Math.max(0, el.offsetLeft - 200); } else if (wrap) { // El grid tiene su propio scroll vertical con la cabecera sticky: hay // que descontar su alto para que la fila no quede tapada. const headerH = 72; wrap.scrollTop = Math.max(0, el.getBoundingClientRect().top - wrap.getBoundingClientRect().top + wrap.scrollTop - headerH); } else { el.scrollIntoView({ block: 'start' }); } }, 80); // eslint-disable-next-line react-hooks/exhaustive-deps }, [date, viewMode]); /** * Carga citas + bloqueos + lista de espera en un solo request. * `silent` es el modo del auto refresco: no toca el loader, no limpia la * selección en lote y no reinicia el scroll, para no parpadear ni perder * el contexto del usuario. */ const load = useCallback(async (opts?: { silent?: boolean }) => { const silent = !!opts?.silent; try { if (!silent) { setLoading(true); setError(null); // La selección en lote no sobrevive cambios de fecha/vista/filtros setSelectedIds([]); } // Vista Semana: el API ya soporta date_from/date_to (una sola llamada por rango) const params: Record = viewMode === 'semana' ? { date_from: weekDatesOf(date)[0], date_to: weekDatesOf(date)[6], page_size: '200' } : date ? { date } : {}; const res = await odooApi.getAppointments(params); if (res.status === 'success') { setAppointments(res.appointments); setBloqueos(res.bloqueos || []); setListaEspera(res.lista_espera || []); } } catch (err) { // Un refresco silencioso fallido no debe romper la vista if (!silent) { setError('Error al cargar citas'); toast.error('Error al cargar citas'); } console.error(err); } finally { if (!silent) { setLoading(false); scrollToNowIfToday(); } } }, [date, viewMode, scrollToNowIfToday]); const loadReferences = useCallback(async () => { try { const [servicesRes, doctorsRes] = await Promise.all([ odooApi.getServices(), odooApi.getDoctors(), ]); if (servicesRes.status === 'success') setServices(servicesRes.services); if (doctorsRes.status === 'success') setDoctors(doctorsRes.doctors); } catch (err) { console.error(err); } }, []); useEffect(() => { load(); loadReferences(); }, [load, loadReferences]); // ---- Lista de espera: aviso cuando se libera un hueco ---- const avisarHuecoLibre = useCallback(async (opts: { doctorId?: number | null; doctorLabel?: string | null; serviceId?: number; date?: string; time?: string; }) => { try { const res = await odooApi.getListaEsperaCompatibles({ doctor_id: opts.doctorId || undefined, servicio_id: opts.serviceId || undefined, date: opts.date, }); if (res.status === 'success' && res.lista.length > 0) { setHuecoLibre({ texto: `Se liberó ${opts.time || 'un horario'}${opts.doctorLabel ? ` con ${opts.doctorLabel}` : ''}${opts.date ? ` (${opts.date})` : ''}`, lista: res.lista, }); } } catch { // Un aviso que no se pudo calcular no debe interrumpir la operación } }, []); const recargarListaEspera = useCallback(async () => { try { const res = await odooApi.getListaEspera(); if (res.status === 'success') setListaEspera(res.lista); } catch { toast.error('No se pudo cargar la lista de espera'); } }, []); // Toggle de sala en la lista de espera: Consultoría <-> Cabina const cambiarSala = async (entry: ListaEspera, sala: string) => { try { await odooApi.updateListaEspera(entry.id, { sala }); const etiqueta = sala === 'consultoria' ? 'Consultoría' : sala === 'cabina' ? term('room_singular') : 'Sin sala'; toast.success(`${entry.patient}: ${etiqueta}`); await recargarListaEspera(); } catch (err) { const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; toast.error(msg || 'No se pudo cambiar la sala'); } }; const quitarDeListaEspera = async (entry: ListaEspera) => { if (!window.confirm(`¿Quitar a ${entry.patient} de la lista de espera?`)) return; try { await odooApi.updateListaEspera(entry.id, { state: 'descartado' }); toast.success('Paciente descartado de la lista de espera'); await recargarListaEspera(); } catch { toast.error('No se pudo quitar de la lista de espera'); } }; /** * "Agendar" desde la lista de espera: abre el modal de nueva cita con el * paciente precargado. Al guardar, el registro pasa a 'agendado' vinculado a * la cita; si se cancela el modal, simplemente no se toca nada. */ const agendarDesdeLista = (entry: ListaEspera) => { if (exigePermiso('agendar')) return; setPickedPatient({ id: String(entry.patient_id), name: entry.patient }); setForm((f) => ({ ...f, patient_id: String(entry.patient_id), date: entry.date_from || date, time: '', service_id: entry.service_id ? String(entry.service_id) : f.service_id, doctor_id: entry.doctor_id ? String(entry.doctor_id) : f.doctor_id, duration: '', })); setQuickMode(false); setEditingId(null); setWaitingSourceId(entry.id); setDate(entry.date_from || date); setCreateOpen(true); }; // Vincula la espera con la cita recién creada const cerrarModalCita = (citaId?: number) => { setCreateOpen(false); setEditingId(null); setCrearNuevo(false); const wid = waitingSourceId; setWaitingSourceId(null); if (wid && citaId) { void odooApi .updateListaEspera(wid, { state: 'agendado', cita_id: citaId }) .then(recargarListaEspera) .catch(() => undefined); } }; // ---- Auto refresco (30 s): citas + bloqueos + lista de espera ---- // Se pospone si hay un modal abierto, si el usuario está escribiendo o // arrastrando una cita, o si hay una operación en curso. const autoRefresh = useAutoRefresh({ onRefresh: () => load({ silent: true }), blocked: () => createOpen || bloqueosOpen || expressOpen || printOpen || !!detailApt || !!conflict || !!huecoLibre || listaModalOpen || !!dragApt || submitting || savingEdit || bulkConfirming || listaAgendando !== null, intervalMs: 30000, storageKey: 'skeen_agenda_autorefresh', }); // Pre-carga "Agendar cita" desde la ficha del paciente: /agenda?patient_id=X // Abre el modal de nueva cita con el paciente ya seleccionado. // Se re-ejecuta cuando cambia patient_id (llegar a /agenda ya montada desde el // drawer no remonta el componente) y el parámetro se consume tras abrir el // modal para poder reabrir la misma ficha más de una vez. const preloadPatientId = searchParams.get('patient_id'); useEffect(() => { if (!preloadPatientId) return; let active = true; (async () => { try { const res = await odooApi.getPatient(parseInt(preloadPatientId, 10)); if (active && res.status === 'success') { setPreloadedPatient(res.patient); setQuickMode(false); setEditingId(null); setCrearNuevo(false); setPickedPatient({ id: String(res.patient.id), name: res.patient.name }); setForm((f) => ({ ...f, patient_id: String(res.patient.id) })); setCreateOpen(true); } } catch { console.error('No se pudo precargar el paciente'); } finally { if (active) { setSearchParams((prev) => { const next = new URLSearchParams(prev); next.delete('patient_id'); return next; }, { replace: true }); } } })(); return () => { active = false; }; }, [preloadPatientId, setSearchParams]); // Aviso de duplicado: teléfono del mini-form "Crear paciente nuevo" useEffect(() => { if (!crearNuevo) { setNuevoPacDup(null); return; } const phone = nuevoPac.phone.trim(); if (phone.replace(/\D/g, '').length < 7) { setNuevoPacDup(null); return; } let active = true; const t = setTimeout(async () => { try { const res = await odooApi.checkPatientPhone(phone); if (active) setNuevoPacDup(res.status === 'success' && res.exists && res.patient ? { id: res.patient.id, name: res.patient.name } : null); } catch { if (active) setNuevoPacDup(null); } }, 400); return () => { active = false; clearTimeout(t); }; }, [nuevoPac.phone, crearNuevo]); // Crear paciente nuevo desde el selector del modal y seleccionarlo const crearYSeleccionarPaciente = async () => { if (!nuevoPac.name.trim() || !nuevoPac.phone.trim()) { toast.error('Nombre y teléfono son obligatorios'); return; } try { setCreandoPac(true); const res = await odooApi.createPatient({ name: nuevoPac.name.trim(), phone: nuevoPac.phone.trim(), }); if (res.status === 'success') { toast.success('Paciente creado'); setPickedPatient({ id: String(res.patient.id), name: res.patient.name }); setForm((f) => ({ ...f, patient_id: String(res.patient.id) })); setCrearNuevo(false); } } catch (err) { toast.error('Error al crear el paciente'); console.error(err); } finally { setCreandoPac(false); } }; const fetchSlots = useCallback(async (serviceId: string, aptDate: string, doctorId?: string, duration?: string) => { if (!serviceId || !aptDate) { setSlots([]); return; } try { setSlotsLoading(true); const res = await odooApi.getAvailableSlots( aptDate, parseInt(serviceId, 10), doctorId ? parseInt(doctorId, 10) : undefined, duration ? parseInt(duration, 10) : undefined, ); if (res.status === 'success') setSlots(res.slots); } catch { toast.error('Error al cargar horarios disponibles'); setSlots([]); } finally { setSlotsLoading(false); } }, []); useEffect(() => { fetchSlots(form.service_id, form.date, form.doctor_id, form.duration); }, [form.service_id, form.date, form.doctor_id, form.duration, fetchSlots]); // Permiso de Agenda que exige cada acción de estado. Espejo de // AGENDA_STATUS_PERM / AGENDA_STATE_PERM en el backend: si aquí no cuadra, // la API devuelve 403 igual, así que solo evita el click inútil. const permisoParaAccion = (action: string, apt: Appointment | null, destino?: string): AgendaPerm => { if (action === 'arrive') return 'visitas'; if (action === 'cancel') return 'eliminar'; if (action === 'waitlist') return 'agendar'; if (action === 'next' && apt) { const actual = stepIndex(apt.state); const siguiente = actual >= 0 && actual < CHECKLIST_STEPS.length - 1 ? CHECKLIST_STEPS[actual + 1].state : null; // 'next' solo recorre CHECKLIST_STEPS: el único destino especial es la llegada. if (siguiente === 'arrived') return 'visitas'; } if (action === 'set_state') { if (destino === 'arrived') return 'visitas'; if (destino === 'cancelled') return 'eliminar'; } return 'editar'; }; const handleStatus = async (id: number, action: string, state?: string) => { const aptParaPermiso = appointments.find((a) => a.id === id) || (detailApt?.id === id ? detailApt : null); const permiso = permisoParaAccion(action, aptParaPermiso, state); if (!canAgenda(permiso)) { const etiqueta = AGENDA_PERM_ITEMS.find((p) => p.key === permiso)?.label ?? permiso; toast.error(`Sin permiso: ${etiqueta}`); return; } try { setUpdating(id); // Datos del hueco que se libera (para avisar a la lista de espera) const apt = appointments.find((a) => a.id === id) || (detailApt?.id === id ? detailApt : null); const res = await odooApi.updateAppointmentStatus(id, action, undefined, state); toast.success('Estado actualizado'); if (res.status === 'success' && detailApt?.id === id) { setDetailApt(res.appointment); void cargarHistorialEstado(id); // Si retrocede el checklist, la cita ya no está completada: el hueco // histórico no se anuncia, solo las transiciones que liberan agenda. } await load(); if (['cancel', 'no_show'].includes(action) && apt) { void avisarHuecoLibre({ doctorId: apt.doctor_id, doctorLabel: apt.doctor, serviceId: apt.service_id, date: apt.date, time: apt.time, }); } } catch (err) { const msg = (err as ApiErrorShape)?.response?.data?.message; toast.error(msg || 'Error al actualizar estado'); console.error(err); } finally { setUpdating(null); } }; const cargarHistorialEstado = useCallback(async (id: number) => { try { const res = await odooApi.getAppointmentStateHistory(id); if (res.status === 'success') setStateHistory(res.history); } catch { setStateHistory([]); } }, []); // Saldo a favor del paciente de la cita abierta (para verlo al llegar) const [saldoPaciente, setSaldoPaciente] = useState(null); const [recargaAptOpen, setRecargaAptOpen] = useState(false); const [reciboApt, setReciboApt] = useState(null); // Notificaciones ya registradas para la cita abierta (recordatorio/confirmación) const notifActivo = !!clinicSettings.notificaciones?.activo; const puedeEnviarNotif = can('notificaciones.enviar'); const [notifCita, setNotifCita] = useState([]); const [enviandoNotif, setEnviandoNotif] = useState(false); const cargarNotifCita = (citaId: number) => { if (!notifActivo) { setNotifCita([]); return; } odooApi.getNotificaciones({ cita_id: citaId, limit: 50 }) .then((res) => setNotifCita(res.notificaciones || [])) .catch(() => setNotifCita([])); }; const enviarRecordatorioAhora = async () => { if (!detailApt) return; setEnviandoNotif(true); try { const res = await odooApi.enviarNotificacion({ cita_id: detailApt.id }); toast.success(res.message || 'Recordatorio enviado'); cargarNotifCita(detailApt.id); } catch (err) { const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; toast.error(msg || 'No se pudo enviar el recordatorio'); } finally { setEnviandoNotif(false); } }; const openAptDetail = (apt: Appointment) => { setDetailApt(apt); setSaldoPaciente(null); setRecargaAptOpen(false); setStateHistory([]); setNotifCita([]); cargarNotifCita(apt.id); if (apt.patient_id) { odooApi.getPatientWallet(apt.patient_id) .then((w) => { if (w.status === 'success') setSaldoPaciente(w); }) .catch(() => setSaldoPaciente(null)); } void cargarHistorialEstado(apt.id); setEditMode(false); setEditForm({ date: apt.date, time: apt.time, doctor_id: apt.doctor_id ? String(apt.doctor_id) : '', notes: apt.notes || '', }); }; const saveAptEdit = async () => { if (!detailApt) return; if (exigePermiso('editar')) return; if (!editForm.date || !editForm.time) { toast.error('Fecha y hora son obligatorias'); return; } try { setSavingEdit(true); await odooApi.updateAppointment(detailApt.id, { date: editForm.date, time: parseTimeToFloat(editForm.time) as unknown as string, doctor_id: editForm.doctor_id ? parseInt(editForm.doctor_id, 10) : undefined, notes: editForm.notes, }); toast.success('Reserva actualizada'); setDetailApt(null); await load(); } catch (err) { // Mostrar el rechazo del servidor con mensaje humano si es conflicto applyAgendaError(err, 'Error al actualizar la reserva'); } finally { setSavingEdit(false); } }; const aptStateLabels = (state: string) => aptStateLabel(state, term); const handlePhoneConfirm = async (apt: Appointment, confirmed: boolean) => { try { setUpdating(apt.id); const res = await odooApi.phoneConfirm(apt.id, confirmed); toast.success(confirmed ? 'Cita confirmada por teléfono' : 'Confirmación telefónica retirada'); if (res.status === 'success' && detailApt?.id === apt.id) setDetailApt(res.appointment); await load(); } catch (err) { toast.error('Error al actualizar la confirmación telefónica'); console.error(err); } finally { setUpdating(null); } }; // ---- Confirmación telefónica en lote (solo vista Lista) ---- // Solo citas pendientes/confirmadas sin 1ra confirmación telefónica. const bulkEligible = (apt: Appointment): boolean => ['pending', 'confirmed'].includes(apt.state) && !apt.phone_confirmed; const toggleSelected = (id: number) => { setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])); }; const bulkConfirmSelected = async () => { if (selectedIds.length === 0) return; try { setBulkConfirming(true); const res = await odooApi.bulkPhoneConfirm(selectedIds, true); const n = res.confirmed_count ?? 0; const omitidas = (res.skipped_invalid_state?.length || 0) + (res.not_found?.length || 0); toast.success(`${n} ${n === 1 ? 'cita confirmada' : 'citas confirmadas'}${omitidas ? ` · ${omitidas} omitidas` : ''}`); setSelectedIds([]); await load(); } catch (err) { toast.error('Error en la confirmación en lote'); console.error(err); } finally { setBulkConfirming(false); } }; // ---- Conflicto de agenda: mensaje humano + "Ver esa cita" ---- const applyAgendaError = (err: unknown, fallback: string) => { const c = parseAgendaConflict(err); if (c) { setConflict(c); toast.error(c.message); return; } const msg = (err as ApiErrorShape)?.response?.data?.message; toast.error(msg || fallback); console.error(err); }; const verCitaEnConflicto = async () => { if (!conflict?.conflicting) return; try { const res = await odooApi.getAppointment(conflict.conflicting.id); setConflict(null); setCreateOpen(false); setEditingId(null); setCrearNuevo(false); if (res.status === 'success') { openAptDetail(res.appointment); } else { toast.error('No se pudo abrir la cita en conflicto'); } } catch (err) { toast.error('No se pudo abrir la cita en conflicto'); console.error(err); } }; const handlePackageFinished = async (id: number, finished: boolean) => { if (exigePermiso('editar')) return; try { setPkgUpdating(id); await odooApi.packageFinished(id, finished); toast.success(finished ? 'Paquete marcado como terminado' : 'Marca de paquete revertida'); await load(); } catch (err) { toast.error('Error al actualizar paquete'); console.error(err); } finally { setPkgUpdating(null); } }; const parseTimeToFloat = (t: string): number | undefined => { if (!t) return undefined; const [h, m] = t.split(':').map((x) => parseInt(x, 10)); if (Number.isNaN(h)) return undefined; return h + (Number.isNaN(m) ? 0 : m / 60); }; const resetExpressForm = () => setExpressForm({ name: '', phone: '', notes: '', withAppointment: false, service_id: '', doctor_id: '', date: todayLocal(), time: '', duration: '', }); const submitExpress = async () => { if (!expressForm.name.trim() || !expressForm.phone.trim()) { toast.error('Nombre y teléfono son obligatorios'); return; } if (expressForm.withAppointment && (!expressForm.service_id || !expressForm.date || !expressForm.time)) { toast.error('Para la cita de valoración selecciona servicio, fecha y hora'); return; } try { setExpressSubmitting(true); const patientPayload: Partial & { notes?: string } = { name: expressForm.name.trim(), phone: expressForm.phone.trim(), source: 'walkin', notes: expressForm.notes || undefined, }; const res = await odooApi.createPatient(patientPayload); const newId = res.patient?.id; if (expressForm.withAppointment && newId) { await odooApi.createAppointment({ patient_id: newId, service_id: parseInt(expressForm.service_id, 10), doctor_id: expressForm.doctor_id ? parseInt(expressForm.doctor_id, 10) : undefined, date: expressForm.date, time: parseTimeToFloat(expressForm.time) as unknown as string, notes: 'Valoración express', state: 'confirmed', }); } toast.success(expressForm.withAppointment ? 'Prospecto y cita de valoración creados' : 'Prospecto creado'); setExpressOpen(false); resetExpressForm(); await load(); } catch (err) { toast.error('Error en valoración express'); console.error(err); } finally { setExpressSubmitting(false); } }; const createAppointment = async () => { if (!form.patient_id || !form.service_id || !form.date || !form.time) { toast.error('Completa los campos obligatorios'); return; } const service = services.find((s) => String(s.id) === form.service_id); const sessions = form.sessions > 1 ? form.sessions : ((service?.package_sessions ?? 0) > 1 ? (service?.package_sessions ?? 1) : 1); try { setSubmitting(true); const res = await odooApi.createAppointment({ patient_id: parseInt(form.patient_id, 10), service_id: parseInt(form.service_id, 10), doctor_id: form.doctor_id ? parseInt(form.doctor_id, 10) : undefined, date: form.date, time: parseTimeToFloat(form.time) as unknown as string, notes: form.notes, sessions, duration: form.duration ? parseInt(form.duration, 10) : 0, branch: form.branch || defaultCode || undefined, } as never); toast.success(sessions > 1 ? `Paquete creado: ${sessions} citas` : 'Cita creada'); // Si la cita salió de la lista de espera, se marca como agendada cerrarModalCita(res.appointment?.id); setPickedPatient(null); setForm({ patient_id: '', service_id: '', doctor_id: '', date: todayLocal(), time: '', notes: '', sessions: 1, duration: '', branch: '', }); await load(); } catch (err) { applyAgendaError(err, 'Error al crear cita'); } finally { setSubmitting(false); } }; // Editar una cita existente: abre el modal de creación precargado y guarda con PUT. const openEditCita = (apt: Appointment) => { if (exigePermiso('editar')) return; setQuickMode(false); setDetailApt(null); setEditMode(false); setEditingId(apt.id); setPickedPatient(null); setForm({ patient_id: String(apt.patient_id), service_id: String(apt.service_id), doctor_id: apt.doctor_id ? String(apt.doctor_id) : '', date: apt.date, time: apt.time, notes: apt.notes || '', sessions: 1, duration: String(apt.duration || apt.service_duration || DEFAULT_DURATION), branch: apt.branch || '', }); setCreateOpen(true); }; const saveEditCita = async () => { if (!editingId) return; if (!form.patient_id || !form.service_id || !form.date || !form.time) { toast.error('Completa los campos obligatorios'); return; } try { setSubmitting(true); await odooApi.updateAppointment(editingId, { partner_id: parseInt(form.patient_id, 10), servicio_id: parseInt(form.service_id, 10), doctor_id: form.doctor_id ? parseInt(form.doctor_id, 10) : null, date: form.date, time: parseTimeToFloat(form.time) ?? 0, notes: form.notes, duration: form.duration ? parseInt(form.duration, 10) : 0, branch: form.branch || defaultCode || undefined, } as never); toast.success('Cita actualizada'); setCreateOpen(false); setEditingId(null); setDetailApt(null); setEditMode(false); await load(); } catch (err) { applyAgendaError(err, 'No se pudo actualizar la cita'); } finally { setSubmitting(false); } }; const openCreate = () => { if (exigePermiso('agendar')) return; setQuickMode(false); setEditingId(null); setPickedPatient(null); setForm((f) => ({ ...f, patient_id: '', duration: '', branch: '', ...(medicoEmployeeId ? { doctor_id: String(medicoEmployeeId) } : {}) })); setQuickForm({ name: '', phone: '', doctor_id: medicoEmployeeId ? String(medicoEmployeeId) : doctorFilter, date, time: '', notes: '', duration: '', branch: '', }); setCreateOpen(true); }; // Nueva cita precargada desde una celda libre del grid (fecha/hora/médico // del slot clicado). Se usa un solo click (el drag&drop se distingue con // justDroppedRef: tras un drop real el click posterior se ignora). const justDroppedRef = useRef(false); const openCreateAt = (opts?: { date?: string; time?: string; doctorId?: number | null }) => { if (exigePermiso('agendar')) return; setQuickMode(false); setEditingId(null); setPickedPatient(null); const d = opts?.date ?? date; const docId = medicoEmployeeId ? String(medicoEmployeeId) : (opts?.doctorId != null ? String(opts.doctorId) : doctorFilter); const t = opts?.time ?? ''; setForm((f) => ({ ...f, patient_id: '', date: d, time: t, doctor_id: docId, branch: '' })); setQuickForm({ name: '', phone: '', doctor_id: docId, date: d, time: t, notes: '', duration: '', branch: '', }); setCreateOpen(true); }; const clickCeldaLibre = (e: React.MouseEvent, dateStr: string, slot: number | null, doctorId: number | null) => { if (justDroppedRef.current) { justDroppedRef.current = false; return; } e.preventDefault(); openCreateAt({ date: dateStr, time: slot != null ? horaFloatStr(slot) : '', doctorId, }); }; const submitQuick = async () => { if (!quickForm.name.trim() || !quickForm.phone.trim()) { toast.error('Nombre y teléfono son obligatorios'); return; } if (!quickForm.date || !quickForm.time) { toast.error('Selecciona fecha y hora'); return; } try { setSubmitting(true); const res = await odooApi.createQuickAppointment({ name: quickForm.name.trim(), phone: quickForm.phone.trim(), date: quickForm.date, time: parseTimeToFloat(quickForm.time) ?? 0, doctor_id: quickForm.doctor_id ? parseInt(quickForm.doctor_id, 10) : undefined, notes: quickForm.notes || undefined, duration: quickForm.duration ? parseInt(quickForm.duration, 10) : undefined, branch: quickForm.branch || defaultCode || undefined, }); if (res.patient_created) { toast.success('Paciente y cita rápida creados'); } else { if (res.patient && String(res.patient.id) !== form.patient_id) { // El teléfono ya existía: se reutilizó la ficha de ese paciente setPickedPatient({ id: String(res.patient.id), name: res.patient.name }); } toast.success('Cita rápida creada (paciente existente)'); } setCreateOpen(false); await load(); } catch (err) { applyAgendaError(err, 'Error al crear la cita rápida'); } finally { setSubmitting(false); } }; // Nombre del paciente precargado (celda libre, query param o edición) para // que el combobox muestre el texto correcto sin pedir otra búsqueda. const selectedPatientName = useMemo(() => { if (!form.patient_id) return ''; if (pickedPatient && pickedPatient.id === form.patient_id) return pickedPatient.name; if (preloadedPatient && String(preloadedPatient.id) === form.patient_id) return preloadedPatient.name; const sel = appointments.find((a) => String(a.patient_id) === form.patient_id); if (sel) return sel.patient; if (editingId) { const edit = appointments.find((a) => a.id === editingId); if (edit && String(edit.patient_id) === form.patient_id) return edit.patient; } return ''; }, [form.patient_id, pickedPatient, preloadedPatient, appointments, editingId]); const serviceOptions = [ { value: '', label: 'Seleccionar servicio...' }, ...services.map((s) => ({ value: String(s.id), label: (s.package_price ?? 0) > 0 && (s.package_sessions ?? 0) > 0 ? `${s.name} — $${s.price} / ${s.package_sessions} sesiones $${s.package_price}` : `${s.name} — $${s.price}`, })), ]; const slotOptions = slots.length ? [ { value: '', label: 'Seleccionar hora...' }, ...slots.map((s) => ({ value: s.time_str, label: s.time_str })), ] : [ { value: '', label: 'Selecciona servicio y fecha primero' }, ]; const TableHeader = () => ( selectedIds.includes(a.id)) } onChange={(e) => { setSelectedIds(e.target.checked ? visibleAppointments.filter(bulkEligible).map((a) => a.id) : []); }} className="w-4 h-4 rounded border-theme-border-strong" /> Hora Paciente Servicio Doctor Estado Conf. Pago Acciones ); const ActionButtons = ({ apt }: { apt: Appointment }) => (
{puedeEditar && apt.state !== 'confirmed' && apt.state !== 'done' && apt.state !== 'cancelled' && ( )} {puedeVisitas && apt.state === 'confirmed' && ( )} {puedeEditar && apt.state === 'arrived' && ( )} {puedeEditar && apt.state !== 'done' && apt.state !== 'cancelled' && ( )} {puedeEditar && (apt.state === 'confirmed' || apt.state === 'arrived') && ( )} {puedeEditar && apt.service_category === 'paquete' && apt.state !== 'cancelled' && ( apt.package_finished ? ( ) : ( ) )} {puedeEliminar && apt.state !== 'cancelled' && ( )}
); return ( {puedeAgendar && ( )}
{/* Navegación rápida de fecha: día anterior/siguiente (semana en vista Semana) y Hoy */}
setDate(e.target.value)} className="w-full sm:max-w-[180px]" />
toggleSelected(apt.id)} aria-label={`Seleccionar cita de ${apt.patient} ${apt.time}`} className="w-4 h-4 rounded border-theme-border-strong" /> ) : null} {apt.time} {apt.doctor || '-'} {aptStateLabel(apt.state, term)} {apt.payment_state} ))}
{/* Mobile cards */}
{visibleAppointments.map((apt) => ( {aptStateLabel(apt.state, term)} }, { label: 'Confirmación', value: {confirmIndicator(apt, 14)} }, { label: 'Pago', value: apt.payment_state }, ]} actions={ <> {bulkEligible(apt) && ( )} } /> ))}
)}
{/* Tooltip flotante de cita (hover en tarjetas de las vistas de grid) */} {tooltip && (

{tooltip.apt.time} · {tooltip.apt.patient}

{tooltip.apt.service} {tooltip.apt.service_category ? ` · ${categoryLabels[tooltip.apt.service_category] || tooltip.apt.service_category}` : ''}

Tel: {tooltip.apt.phone || '—'}

Médico: {tooltip.apt.doctor || 'Sin asignar'}

{confirmTitle(tooltip.apt)}

{/* Mini-checklist del flujo (visual; el click abre el detalle con el checklist completo) */}
{CHECKLIST_STEPS.map((step) => { const i = stepIndex(tooltip.apt.state); const hecho = i >= 0 && stepIndex(step.state) <= i; return ( {hecho && } {step.label} ); })}
)} {/* Modal de bloqueos / cierre de agendas de los médicos */} { setBloqueosOpen(false); setBloqueoQuick(null); }} date={date} doctors={doctors} isMedico={isMedico} ownDoctorId={medicoEmployeeId} initial={bloqueoQuick} puedeGestionar={puedeAgendar} onChanged={() => { void load({ silent: true }); }} /> cerrarModalCita()} title={editingId ? `Editar ${term('appointment_singular').toLowerCase()}` : `Nueva ${term('appointment_singular').toLowerCase()}`} maxWidth="lg" footer={ <> } >
{quickMode ? ( <>

Si el teléfono ya existe, se reutiliza ese paciente; si no, se crea con datos mínimos.

setQuickForm({ ...quickForm, name: e.target.value })} /> setQuickForm({ ...quickForm, phone: e.target.value })} /> ({ value: s.code, label: s.name }))} value={quickForm.branch || defaultCode} onChange={(e) => setQuickForm({ ...quickForm, branch: e.target.value })} />
setQuickForm({ ...quickForm, date: e.target.value })} /> setQuickForm({ ...quickForm, duration: e.target.value })} />