Files
SKEEN-Proyecto/frontend/src/pages/Agenda.tsx

3349 lines
149 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string> = {
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<Appointment[]>([]);
const [services, setServices] = useState<Service[]>([]);
const [doctors, setDoctors] = useState<Doctor[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [updating, setUpdating] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [date, setDate] = useState<string>(() => 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<AgendaViewMode>(() => {
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<number[]>([]);
const [bulkConfirming, setBulkConfirming] = useState(false);
// Conflicto de agenda (solape / bloqueo) con acción "Ver esa cita"
const [conflict, setConflict] = useState<AgendaConflict | null>(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<Patient | null>(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<Appointment | null>(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<number | null>(null);
const [slots, setSlots] = useState<{ time: number; time_str: string }[]>([]);
const [slotsLoading, setSlotsLoading] = useState(false);
const [bloqueos, setBloqueos] = useState<Bloqueo[]>([]);
const [bloqueosOpen, setBloqueosOpen] = useState(false);
// Precarga del modal de bloqueos ("Cerrar agenda" desde la columna del médico)
const [bloqueoQuick, setBloqueoQuick] = useState<BloqueoQuickClose | null>(null);
// Menú rápido abierto en la cabecera de una columna (doctorId)
const [quickCloseMenuFor, setQuickCloseMenuFor] = useState<number | null>(null);
// Lista de espera de pacientes (llega en el mismo payload que las citas)
const [listaEspera, setListaEspera] = useState<ListaEspera[]>([]);
const [listaModalOpen, setListaModalOpen] = useState(false);
const [listaPrefill, setListaPrefill] = useState<{ patient?: Patient | { id: number; name: string } | null; doctorId?: string; serviceId?: string }>({});
const [listaAgendando] = useState<number | null>(null);
// Registro de lista de espera del que se abrió el modal de nueva cita
const [waitingSourceId, setWaitingSourceId] = useState<number | null>(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<AppointmentStateLog[]>([]);
const [dragApt, setDragApt] = useState<Appointment | null>(null);
const [dropTarget, setDropTarget] = useState<string | null>(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<HTMLDivElement | null>(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<number | null>(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 (
<UserCheck size={size} className="inline shrink-0 text-emerald-600"
aria-label="2da confirmación: en clínica" />
);
}
if (apt.phone_confirmed) {
return (
<Phone size={size} className="inline shrink-0 text-blue-600"
aria-label={`Confirmada por teléfono${apt.phone_confirmed_by ? ` por ${apt.phone_confirmed_by}` : ''}`} />
);
}
return (
<Phone size={size} className="inline shrink-0 text-theme-muted opacity-50"
aria-label="Sin confirmar por teléfono" />
);
};
// 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 ? (
<Cake size={size} className="inline shrink-0 text-pink-500" aria-label="Cumpleaños del paciente" />
) : 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' ? (
<Globe size={size} className="inline shrink-0 text-sky-600" aria-label="Reservada en línea por el cliente" />
) : 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<string, string> = {
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<string, string> = {
consulta: 'border-l-sky-500',
tratamiento: 'border-l-violet-500',
procedimiento: 'border-l-amber-500',
paquete: 'border-l-emerald-600',
};
const categoryLabels: Record<string, string> = {
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<HTMLElement>, 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 (
<span className="relative inline-flex shrink-0" onClick={(e) => e.stopPropagation()}>
<button
type="button"
onClick={() => setQuickCloseMenuFor(abierto ? null : doctorId)}
title={cerrado ? 'Agenda cerrada — click para reabrir o cambiar' : 'Cerrar la agenda de este médico'}
className={`inline-flex items-center gap-0.5 rounded-full border px-1.5 py-1 text-[10px] font-medium transition-colors ${
cerrado
? 'border-rose-300 bg-rose-50 text-rose-700'
: 'border-theme-border text-theme-muted hover:border-theme-border-strong hover:text-theme-heading'
}`}
>
<CalendarOff size={12} />
<ChevronDown size={10} />
</button>
{abierto && (
<>
<span
className="fixed inset-0 z-40"
onClick={() => setQuickCloseMenuFor(null)}
aria-hidden="true"
/>
<div className="absolute right-0 top-full z-50 mt-1 w-60 rounded-xl border border-theme-border bg-theme-surface p-1.5 shadow-soft">
{cerrado && (
<button
type="button"
onClick={() => void reabrirAgenda(doctorId)}
className="mb-1 w-full rounded-lg border border-emerald-300 bg-brand-mint px-2.5 py-1.5 text-left text-xs font-medium text-emerald-800 hover:brightness-95"
>
Reabrir agenda ({date})
</button>
)}
{quickCloseOptions(nowFloat).map((op) => (
<button
key={op.key}
type="button"
onClick={() => void cerrarAgenda(doctorId, op.key)}
className="w-full rounded-lg px-2.5 py-1.5 text-left text-xs hover:bg-theme-bg"
>
<span className="block font-medium text-theme-heading">{op.label}</span>
<span className="block text-[10px] text-theme-muted">{op.hint}</span>
</button>
))}
<button
type="button"
onClick={() => {
setQuickCloseMenuFor(null);
setBloqueoQuick({ doctorIds: [doctorId], allDay: false });
setBloqueosOpen(true);
}}
className="mt-1 w-full rounded-lg border-t border-theme-border px-2.5 py-1.5 text-left text-xs text-theme-muted hover:bg-theme-bg hover:text-theme-heading"
>
Varias fechas / ver bloqueos…
</button>
</div>
</>
)}
</span>
);
};
const gridLegend = (
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2">
{gridStateLegend.map((item) => (
<span key={item.state} className="inline-flex items-center gap-1.5 text-xs text-theme-muted">
<span className={`inline-block h-2.5 w-2.5 rounded-full ${item.dot}`} />
{item.label}
</span>
))}
<span className="text-xs text-theme-muted">
· Las citas canceladas no bloquean horario: su hueco aparece como libre.
</span>
<span className="inline-flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-theme-muted">
· Barra por categoría:
{Object.entries(categoryLabels).map(([key, label]) => (
<span key={key} className="inline-flex items-center gap-1">
<span className={`inline-block h-3 w-1 rounded-full border-l-2 ${categoryBarClass[key]}`} />
{label}
</span>
))}
</span>
</div>
);
// 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<string, string> = 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<PatientWallet | null>(null);
const [recargaAptOpen, setRecargaAptOpen] = useState(false);
const [reciboApt, setReciboApt] = useState<ReceiptData | null>(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<Notificacion[]>([]);
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<Patient> & { 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 = () => (
<thead className="bg-theme-bg">
<tr>
<th className="text-center p-3 text-xs font-medium text-theme-muted uppercase" title="Seleccionar citas por confirmar">
<input
type="checkbox"
aria-label="Seleccionar todas las citas por confirmar"
checked={
visibleAppointments.some(bulkEligible) &&
visibleAppointments.filter(bulkEligible).every((a) => 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"
/>
</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Hora</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Paciente</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden sm:table-cell">Servicio</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Doctor</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Estado</th>
<th className="text-center p-3 text-xs font-medium text-theme-muted uppercase" title="Doble confirmación: teléfono / llegada">Conf.</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden sm:table-cell">Pago</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th>
</tr>
</thead>
);
const ActionButtons = ({ apt }: { apt: Appointment }) => (
<div className="flex items-center gap-1">
{puedeEditar && apt.state !== 'confirmed' && apt.state !== 'done' && apt.state !== 'cancelled' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleStatus(apt.id, 'confirm')}
disabled={updating === apt.id}
title="Confirmar"
>
<CheckCircle size={16} className="text-theme-heading" />
</Button>
)}
{puedeVisitas && apt.state === 'confirmed' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleStatus(apt.id, 'arrive')}
disabled={updating === apt.id}
title="Llegada (genera la visita del día)"
>
<UserCheck size={16} className="text-blue-600" />
</Button>
)}
{puedeEditar && apt.state === 'arrived' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleStatus(apt.id, 'start')}
disabled={updating === apt.id}
title="Marcar en curso"
>
<Play size={16} className="text-violet-600" />
</Button>
)}
{puedeEditar && apt.state !== 'done' && apt.state !== 'cancelled' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleStatus(apt.id, 'done')}
disabled={updating === apt.id}
title="Completar"
>
<CheckCheck size={16} className="text-blue-600" />
</Button>
)}
{puedeEditar && (apt.state === 'confirmed' || apt.state === 'arrived') && (
<Button
variant="ghost"
size="sm"
onClick={() => handleStatus(apt.id, 'no_show')}
disabled={updating === apt.id}
title="No se presentó (no show)"
>
<UserX size={16} className="text-rose-500" />
</Button>
)}
{puedeEditar && apt.service_category === 'paquete' && apt.state !== 'cancelled' && (
apt.package_finished ? (
<Button
variant="ghost"
size="sm"
onClick={() => handlePackageFinished(apt.id, false)}
disabled={pkgUpdating === apt.id}
title="Paquete terminado — click para revertir"
>
<RotateCcw size={16} className="text-amber-600" />
</Button>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => handlePackageFinished(apt.id, true)}
disabled={pkgUpdating === apt.id}
title="Marcar paquete terminado"
>
<PackageCheck size={16} className="text-violet-600" />
</Button>
)
)}
{puedeEliminar && apt.state !== 'cancelled' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleStatus(apt.id, 'cancel')}
disabled={updating === apt.id}
title="Cancelar"
>
<XCircle size={16} className="text-rose-600" />
</Button>
)}
</div>
);
return (
<Layout title="Agenda" subtitle="Gestión de citas">
<PageHeader title="Agenda" subtitle="Filtra por fecha, estado o médico">
<AutoRefreshBadge state={autoRefresh} />
<Button variant="outline" onClick={() => setListaModalOpen(true)}>
<Users size={16} className="mr-2" />
Lista de espera
{countEnEspera > 0 && (
<span className="ml-1.5 inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-brand-lilac text-[10px] font-bold text-theme-heading">
{countEnEspera}
</span>
)}
</Button>
<Button variant="outline" onClick={openBloqueos}>
<Ban size={16} className="mr-2" />
Cerrar agendas
</Button>
<Button variant="outline" onClick={() => setExpressOpen(true)}>
<Sparkles size={16} className="mr-2" />
Valoración express
</Button>
{puedeAgendar && (
<Button onClick={openCreate}>
<Plus size={16} className="mr-2" />
Nueva {term('appointment_singular').toLowerCase()}
</Button>
)}
</PageHeader>
<Card>
<Card.Body>
<div className="flex flex-col sm:flex-row sm:items-end gap-3 sm:gap-4 mb-4 sm:mb-6">
{/* Navegación rápida de fecha: día anterior/siguiente (semana en vista Semana) y Hoy */}
<div className="flex flex-wrap items-end gap-1.5">
<Button
variant="outline"
size="sm"
className="mb-[1px]"
onClick={() => shiftDate(-1)}
title={viewMode === 'semana' ? 'Semana anterior' : 'Día anterior'}
>
<ChevronLeft size={16} />
</Button>
<Input
label="Fecha"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="w-full sm:max-w-[180px]"
/>
<Button
variant="outline"
size="sm"
className="mb-[1px]"
onClick={() => shiftDate(1)}
title={viewMode === 'semana' ? 'Semana siguiente' : 'Día siguiente'}
>
<ChevronRight size={16} />
</Button>
<Button
variant="outline"
size="sm"
className="mb-[1px]"
onClick={() => setDate(todayStr)}
disabled={date === todayStr}
title="Ir a hoy"
>
Hoy
</Button>
<Button
variant="outline"
size="sm"
className="mb-[1px]"
onClick={() => setPrintOpen(true)}
title="Imprimir agenda del día"
>
<Printer size={16} className="mr-1" />
Imprimir
</Button>
</div>
<Select
label="Estado"
options={stateOptions.map((o) => (o.value === 'arrived' ? { ...o, label: `En ${term('room_singular').toLowerCase()}` } : o))}
value={stateFilter}
onChange={(e) => setStateFilter(e.target.value)}
className="sm:max-w-[180px]"
/>
<div className="flex gap-1 sm:ml-auto">
<Button
variant={viewMode === 'lista' ? 'primary' : 'outline'}
size="sm"
onClick={() => setViewMode('lista')}
title="Vista de lista"
>
<List size={16} className="mr-1" />
Lista
</Button>
<Button
variant={viewMode === 'doctores' ? 'primary' : 'outline'}
size="sm"
onClick={() => setViewMode('doctores')}
title="Médicos en columnas, horas en filas"
>
<LayoutGrid size={16} className="mr-1" />
Por médico
</Button>
<Button
variant={viewMode === 'horas' ? 'primary' : 'outline'}
size="sm"
onClick={() => setViewMode('horas')}
title="Médicos en filas, horas en columnas"
>
<Rows3 size={16} className="mr-1" />
Por hora
</Button>
<Button
variant={viewMode === 'semana' ? 'primary' : 'outline'}
size="sm"
onClick={() => setViewMode('semana')}
title="Semana completa: 7 días por médico"
>
<CalendarRange size={16} className="mr-1" />
Semana
</Button>
</div>
</div>
{/* Filtro de médicos: pill por médico con chip de pacientes asignados */}
<div className="mb-4 sm:mb-5">
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Médico</p>
{isMedico ? (
medicoEmployeeId ? (
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border bg-theme-accent text-theme-inverse border-theme-accent"
title="Tu agenda: solo ves tus propias citas">
{user?.employee_name || 'Mi agenda'}
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full text-[10px] bg-theme-surface/30 text-theme-inverse">
{doctors.find((d) => d.id === medicoEmployeeId)?.patient_count ?? 0}
</span>
</span>
) : (
<p className="text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded-xl px-3 py-2 inline-block">
Tu usuario no está vinculado a un médico (hr.employee). Pide a un administrador que vincule tu usuario.
</p>
)
) : (
<div className="flex flex-wrap items-center gap-2">
{/* Selector de tipo de staff */}
<div className="flex items-center gap-1 mr-2 border-r border-theme-border pr-3">
{([
{ key: '', label: 'Todos' },
{ key: 'doctor', label: 'Doctores' },
{ key: 'cosmetologa', label: 'Cosmetólogas' },
] as const).map((t) => (
<button
key={t.key}
type="button"
onClick={() => setStaffTypeFilter(t.key)}
className={`px-2.5 py-1 rounded-full text-[11px] font-medium border transition-all ${
staffTypeFilter === t.key
? 'bg-theme-accent text-theme-inverse border-theme-accent'
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
}`}
>
{t.label}
</button>
))}
</div>
<button
type="button"
onClick={() => setDoctorFilter('')}
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-all ${
!doctorFilter
? 'bg-theme-accent text-theme-inverse border-theme-accent'
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
}`}
>
Todos
</button>
{typedDoctors.map((d) => {
const active = doctorFilter === String(d.id);
return (
<button
key={d.id}
type="button"
onClick={() => setDoctorFilter(active ? '' : String(d.id))}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border transition-all ${
active
? 'bg-theme-accent text-theme-inverse border-theme-accent'
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
}`}
>
{d.name}
<span
title="Pacientes asignados"
className={`inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full text-[10px] ${
active ? 'bg-theme-surface/30 text-theme-inverse' : 'bg-theme-surface text-theme-heading border border-theme-border'
}`}
>
{d.patient_count ?? 0}
</span>
</button>
);
})}
</div>
)}
</div>
{/* Filtros rápidos */}
<div className="flex flex-wrap items-center gap-2 mb-4 sm:mb-5">
{(viewMode === 'doctores' || viewMode === 'horas') && (
<button
type="button"
onClick={() => setQuickFilter(quickFilter === 'libres' ? '' : 'libres')}
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-all ${
quickFilter === 'libres'
? 'bg-brand-mint text-theme-heading border-emerald-700'
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
}`}
>
Espacios libres
</button>
)}
{([
{ key: 'primera', label: 'Primera vez', count: countPrimera },
{ key: 'checkin', label: 'Check-in', count: countCheckin },
{ key: 'noshow', label: 'No show', count: countNoShow },
{ key: 'porconfirmar', label: 'Por confirmar', count: countPorConfirmar },
{ key: 'cancelled', label: 'Canceló', count: countCanceladas },
{ key: 'waitlist', label: 'En lista', count: countWaitlist },
] as const).map((chip) => (
<button
key={chip.key}
type="button"
onClick={() => setQuickFilter(quickFilter === chip.key ? '' : chip.key)}
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-all ${
quickFilter === chip.key
? 'bg-theme-accent text-theme-inverse border-theme-accent'
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
}`}
>
{chip.label}
<span className={`ml-1.5 inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full text-[10px] ${
quickFilter === chip.key ? 'bg-theme-surface/30 text-theme-inverse' : 'bg-theme-surface text-theme-heading border border-theme-border'
}`}>
{chip.count}
</span>
</button>
))}
{quickFilter && (
<button
type="button"
onClick={() => setQuickFilter('')}
className="text-xs text-theme-muted underline underline-offset-2 hover:text-theme-heading"
>
Quitar filtro
</button>
)}
</div>
{/* Aviso: se liberó un hueco y hay pacientes en espera compatibles */}
{huecoLibre && (
<div className="mb-4 sm:mb-5 rounded-2xl border-2 border-amber-400 bg-amber-50 p-3">
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs font-semibold text-amber-900 uppercase">
{huecoLibre.texto} · {huecoLibre.lista.length} en espera compatible
</p>
<button
type="button"
onClick={() => setHuecoLibre(null)}
className="ml-auto text-xs text-amber-900 underline underline-offset-2"
>
Ocultar aviso
</button>
</div>
<div className="mt-2 flex flex-wrap gap-2">
{huecoLibre.lista.slice(0, 6).map((e) => {
const wa = whatsappLink(e.phone, `Hola ${e.patient}, se liberó un espacio. ¿Te gustaría tomarlo?`);
return (
<div key={e.id} className="rounded-xl border border-amber-300 bg-theme-surface px-3 py-2 text-xs">
<p className="font-semibold text-theme-heading">{e.patient}</p>
<p className="text-theme-muted">{e.service || 'Cualquier servicio'} · {e.priority_label}</p>
<div className="mt-1.5 flex items-center gap-2">
<Button size="sm" onClick={() => { setHuecoLibre(null); agendarDesdeLista(e); }}>
Agendar
</Button>
{wa && (
<a href={wa} target="_blank" rel="noreferrer" className="text-emerald-700 hover:underline">
Avisar
</a>
)}
</div>
</div>
);
})}
</div>
</div>
)}
{/* Lista de espera de pacientes (pacientes sin hueco reservado) */}
<ListaEsperaPanel
entries={listaEspera.filter((e) => e.state === 'esperando')}
loading={loading}
loadingAgendaId={listaAgendando}
puedeGestionar={puedeAgendar}
onChangeSala={(e, sala) => void cambiarSala(e, sala)}
onAgendar={agendarDesdeLista}
onQuitar={(e) => void quitarDeListaEspera(e)}
onAdd={() => { setListaPrefill({ doctorId: doctorFilter || undefined }); setListaModalOpen(true); }}
onRefresh={() => void recargarListaEspera()}
/>
{/* Sala de espera: pacientes con check-in (2da confirmación) por atender */}
<div className="mb-4 sm:mb-5 rounded-2xl border-2 border-sky-700 bg-brand-sky/40 p-3">
<div className="flex items-center gap-2 mb-2">
<UserCheck size={16} className="text-sky-700" />
<p className="text-xs font-semibold text-theme-heading uppercase">Por atender</p>
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full text-[10px] bg-theme-surface text-theme-heading border border-theme-border">
{waitingAppointments.length}
</span>
</div>
{waitingAppointments.length === 0 ? (
<p className="text-xs text-theme-muted">Sin pacientes en espera.</p>
) : (
<div className="flex flex-wrap gap-2">
{waitingAppointments.map((apt) => (
<button
key={apt.id}
type="button"
onClick={() => openAptDetail(apt)}
className="rounded-xl border border-sky-700 bg-theme-surface px-3 py-2 text-left shadow-card transition-shadow hover:shadow-soft"
>
<p className="text-sm font-semibold text-theme-heading">{portalIcon(apt)} {apt.time} · {apt.patient}</p>
<p className="text-xs text-theme-muted truncate max-w-[240px]">
{apt.service}{!isMedico && apt.doctor ? ` · ${apt.doctor}` : ''}
</p>
<p className="text-xs font-medium text-sky-700">{waitLabel(apt) || 'En espera'}</p>
</button>
))}
</div>
)}
</div>
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
{loading ? (
viewMode === 'lista' ? (
<Skeleton count={6} className="h-12 w-full" />
) : (
/* Skeleton con forma de grid (columnas de médicos × filas) */
<div className="rounded-2xl border border-theme-border p-3 animate-pulse">
<div className="flex gap-2 mb-2">
<div className="h-9 w-20 shrink-0 rounded-xl bg-theme-border-strong" />
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-9 flex-1 rounded-xl bg-theme-border-strong" />
))}
</div>
<div className="space-y-1.5">
{Array.from({ length: 6 }).map((_, r) => (
<div key={r} className="flex gap-2">
<div className="h-10 w-20 shrink-0 rounded-xl bg-theme-border-strong" />
{Array.from({ length: 5 }).map((_, c) => (
<div key={c} className="h-10 flex-1 rounded-xl bg-theme-border-strong" />
))}
</div>
))}
</div>
</div>
)
) : viewMode === 'doctores' ? (
gridColumns.length === 0 ? (
<EmptyState
title="Sin médicos"
subtitle="No hay médicos dados de alta para mostrar en la vista por médico."
/>
) : (
<div>
<div ref={gridScrollRef} className="overflow-auto max-h-[70vh] rounded-2xl border border-theme-border shadow-card">
<table className="w-full border-collapse bg-theme-surface">
<thead>
<tr>
<th className="sticky left-0 top-0 z-30 w-20 bg-theme-surface p-3 text-left text-xs font-medium text-theme-muted uppercase border-b border-theme-border">
Hora
</th>
{gridColumns.map((col) => (
<th key={col.key} className="sticky top-0 z-20 min-w-[180px] bg-theme-surface p-3 border-b border-l border-theme-border">
<div className="flex items-center gap-2.5">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-lilac text-xs font-bold text-theme-heading">
{initials(col.label)}
</span>
<div className="min-w-0 text-left">
<p className="truncate text-sm font-semibold text-theme-heading normal-case">
{col.label}
{bloqueoDia(col.doctorId) && (
<span className="ml-1 rounded bg-rose-100 px-1 text-[9px] font-medium text-rose-700 align-middle">
cerrada
</span>
)}
</p>
<p className="truncate text-[11px] font-normal text-theme-muted normal-case">
{col.subtitle ? `${col.subtitle} · ` : ''}
{gridDoctorCount(col.doctorId)} {gridDoctorCount(col.doctorId) === 1 ? 'cita' : 'citas'}
</p>
</div>
{menuCierreRapido(col.doctorId)}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{gridSlots.map((slot) => {
const isNowSlot = nowSlot !== null && slot === nowSlot;
return (
<tr key={slot} data-grid-slot={slot.toFixed(2)}>
<td className={`sticky left-0 z-10 bg-theme-surface px-2 py-1 text-[11px] font-semibold align-top whitespace-nowrap border-b border-theme-border ${isNowSlot ? 'text-theme-accent border-t-2 border-t-theme-accent' : 'text-theme-muted'}`}>
{horaFloatStr(slot)}
{isNowSlot && (
<span className="ml-1 rounded bg-theme-accent px-1 text-[9px] font-medium text-theme-inverse">ahora</span>
)}
</td>
{gridColumns.map((col) => {
const cell = gridCellEntries(col.doctorId, slot);
const bloqueo = bloqueoEnCelda(col.doctorId, slot);
const fuera = fueraDeHorario(col.doctorId, slot);
const cellKey = `${col.key}-${slot}`;
return (
<td
key={col.key}
onDragOver={(e) => { if (dragApt && !bloqueo) { e.preventDefault(); setDropTarget(cellKey); } }}
onDragLeave={() => setDropTarget((t) => (t === cellKey ? null : t))}
onDrop={(e) => { e.preventDefault(); handleDrop(col.doctorId, slot); }}
className={`p-1 align-top border-b border-l border-theme-border transition-colors ${dropTarget === cellKey ? 'bg-theme-accent-bg' : ''} ${isNowSlot ? 'border-t-2 border-t-theme-accent' : ''} ${fuera ? 'opacity-50' : ''}`}
title={fuera || undefined}
>
{bloqueo ? (
<div
className="rounded-lg border border-theme-border py-1.5 text-center text-[10px] font-medium text-theme-muted"
style={{ backgroundImage: 'repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(0,0,0,0.05) 6px, rgba(0,0,0,0.05) 12px)' }}
title={`Bloqueado — ${bloqueo.motivo || 'sin motivo'}`}
>
<Ban size={11} className="inline mr-1" />
Bloqueado{bloqueo.motivo ? ` — ${bloqueo.motivo}` : ''}
</div>
) : cell.length === 0 ? (
<div
onClick={(e) => { if (fuera) return; clickCeldaLibre(e, date, slot, col.doctorId); }}
className={`rounded-lg border py-1.5 text-center text-[10px] font-medium transition-colors ${
fuera
? 'border-theme-border bg-theme-bg text-theme-muted cursor-not-allowed'
: quickFilter === 'libres'
? 'cursor-pointer bg-brand-mint border-emerald-700 text-theme-heading font-semibold'
: 'cursor-pointer border-dashed border-theme-border-strong text-theme-muted hover:bg-theme-accent-bg'
}`}
title={fuera || `Nueva cita aquí — ${horaFloatStr(slot)}`}
>
{fuera ? 'Fuera de horario' : 'Libre'}
</div>
) : (
cell.map(({ apt, isStart }) => (
isStart ? (
<div
key={apt.id}
onClick={() => { setTooltip(null); openAptDetail(apt); }}
onMouseEnter={(e) => showAptTooltip(e, apt)}
onMouseLeave={() => setTooltip(null)}
{...chipDragProps(apt)}
className={`mb-1 rounded-lg border-l-4 px-2 py-1 text-[11px] leading-tight shadow-card transition-shadow hover:shadow-soft ${
DRAGGABLE_STATES.includes(apt.state) ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer'
} ${aptCategoryBar(apt)} ${gridStateClass[apt.state] || 'bg-theme-bg text-theme-heading'} ${quickFilter === 'libres' ? 'opacity-40' : ''}`}
>
<div className="font-semibold truncate">
<span title={confirmTitle(apt)} className="mr-1 align-middle">{confirmIndicator(apt)}</span>
{apt.patient_birthday && <span title="Cumpleaños del paciente" className="mr-0.5 align-middle">{birthdayIcon(apt)}</span>}
{portalIcon(apt) && <span title="Reservada en línea" className="mr-0.5 align-middle">{portalIcon(apt)}</span>}
{apt.time} · {apt.patient}
</div>
<div className="truncate opacity-75">
{apt.service}
{(apt.duration || DEFAULT_DURATION) > DEFAULT_DURATION && (
<span className="ml-1 opacity-90">· {apt.duration} min</span>
)}
</div>
</div>
) : (
// Continuación de una cita más larga que el bloque de 15 min
<div
key={`${apt.id}-cont`}
onClick={() => { setTooltip(null); openAptDetail(apt); }}
title={`${apt.time} · ${apt.patient} (${apt.duration} min)`}
className={`mb-1 h-5 rounded-lg border-l-4 px-2 opacity-80 ${
aptCategoryBar(apt)
} ${gridStateClass[apt.state] || 'bg-theme-bg'} ${quickFilter === 'libres' ? 'opacity-40' : ''}`}
/>
)
))
)}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
{gridLegend}
</div>
)
) : viewMode === 'horas' ? (
gridColumns.length === 0 ? (
<EmptyState
title="Sin médicos"
subtitle="No hay médicos dados de alta para mostrar en la vista por hora."
/>
) : (
<div>
<div ref={gridScrollRef} className="overflow-auto max-h-[70vh] rounded-2xl border border-theme-border shadow-card">
<table className="w-full border-collapse bg-theme-surface">
<thead>
<tr>
<th className="sticky left-0 top-0 z-30 min-w-[190px] bg-theme-surface p-2.5 text-left text-xs font-medium text-theme-muted uppercase border-b border-theme-border">
Médico
</th>
{gridSlots.map((slot) => {
const isNowSlot = nowSlot !== null && slot === nowSlot;
return (
<th key={slot} data-grid-slot={slot.toFixed(2)} className={`sticky top-0 z-20 min-w-[60px] bg-theme-surface p-1 text-center text-[10px] font-medium border-b border-l border-theme-border ${isNowSlot ? 'text-theme-accent border-b-2 border-b-theme-accent' : 'text-theme-muted'}`}>
{horaFloatStr(slot)}
{isNowSlot && <span className="block text-[8px] font-semibold uppercase">ahora</span>}
</th>
);
})}
</tr>
</thead>
<tbody>
{gridColumns.map((col) => (
<tr key={col.key}>
<td className="sticky left-0 z-10 bg-theme-surface p-2.5 border-b border-theme-border">
<div className="flex items-center gap-2">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-brand-lilac text-[10px] font-bold text-theme-heading">
{initials(col.label)}
</span>
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-theme-heading">
{col.label}
{bloqueoDia(col.doctorId) && (
<span className="ml-1 rounded bg-rose-100 px-1 text-[9px] font-medium text-rose-700">
cerrada
</span>
)}
</p>
<p className="text-[10px] text-theme-muted">
{gridDoctorCount(col.doctorId)} {gridDoctorCount(col.doctorId) === 1 ? 'cita' : 'citas'}
</p>
</div>
{menuCierreRapido(col.doctorId)}
</div>
</td>
{gridSlots.map((slot) => {
const cell = gridCellEntries(col.doctorId, slot);
const bloqueo = bloqueoEnCelda(col.doctorId, slot);
const fuera = fueraDeHorario(col.doctorId, slot);
const cellKey = `${col.key}-${slot}`;
const isNowSlot = nowSlot !== null && slot === nowSlot;
return (
<td
key={slot}
onDragOver={(e) => { if (dragApt && !bloqueo) { e.preventDefault(); setDropTarget(cellKey); } }}
onDragLeave={() => setDropTarget((t) => (t === cellKey ? null : t))}
onDrop={(e) => { e.preventDefault(); handleDrop(col.doctorId, slot); }}
className={`p-0.5 align-top border-b border-theme-border transition-colors ${isNowSlot ? 'border-l-2 border-l-theme-accent bg-theme-accent-bg/40' : 'border-l'} ${dropTarget === cellKey ? 'bg-theme-accent-bg' : ''} ${fuera ? 'opacity-50' : ''}`}
title={fuera || undefined}
>
{bloqueo ? (
<div
className="rounded border border-theme-border py-1.5 text-center text-[10px] font-medium text-theme-muted"
style={{ backgroundImage: 'repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(0,0,0,0.05) 6px, rgba(0,0,0,0.05) 12px)' }}
title={`Bloqueado — ${bloqueo.motivo || 'sin motivo'}`}
>
<Ban size={10} className="inline" />
</div>
) : cell.length === 0 ? (
<div
onClick={(e) => { if (fuera) return; clickCeldaLibre(e, date, slot, col.doctorId); }}
className={`rounded border py-1.5 transition-colors ${
fuera
? 'cursor-not-allowed border-theme-border bg-theme-bg'
: quickFilter === 'libres'
? 'cursor-pointer bg-brand-mint border-emerald-700'
: 'cursor-pointer border-dashed border-theme-border hover:bg-theme-accent-bg'
}`}
title={fuera || `Libre ${horaFloatStr(slot)}`}
/>
) : (
cell.map(({ apt, isStart }) => (
isStart ? (
<div
key={apt.id}
onClick={() => { setTooltip(null); openAptDetail(apt); }}
onMouseEnter={(e) => showAptTooltip(e, apt)}
onMouseLeave={() => setTooltip(null)}
{...chipDragProps(apt)}
className={`mb-0.5 rounded border-l-2 px-1 py-0.5 text-[10px] leading-tight shadow-card transition-shadow hover:shadow-soft ${
DRAGGABLE_STATES.includes(apt.state) ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer'
} ${aptCategoryBar(apt)} ${gridStateClass[apt.state] || 'bg-theme-bg text-theme-heading'} ${quickFilter === 'libres' ? 'opacity-40' : ''}`}
>
<div className="font-semibold truncate">
<span title={confirmTitle(apt)} className="mr-0.5 align-middle">{confirmIndicator(apt, 10)}</span>
{apt.patient_birthday && <span title="Cumpleaños del paciente" className="mr-0.5 align-middle">{birthdayIcon(apt, 10)}</span>}
{portalIcon(apt, 10) && <span title="Reservada en línea" className="mr-0.5 align-middle">{portalIcon(apt, 10)}</span>}
{apt.time} · {firstName(apt.patient)}
</div>
</div>
) : (
<div
key={`${apt.id}-cont`}
onClick={() => { setTooltip(null); openAptDetail(apt); }}
title={`${apt.time} · ${apt.patient} (${apt.duration} min)`}
className={`mb-0.5 h-4 rounded border-l-2 opacity-80 ${
aptCategoryBar(apt)
} ${gridStateClass[apt.state] || 'bg-theme-bg'} ${quickFilter === 'libres' ? 'opacity-40' : ''}`}
/>
)
))
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
{gridLegend}
</div>
)
) : viewMode === 'semana' ? (
gridColumns.length === 0 ? (
<EmptyState
title="Sin médicos"
subtitle="No hay médicos dados de alta para mostrar en la vista semanal."
/>
) : (
<div>
<p className="text-sm text-theme-muted mb-2">
Semana del {weekDates[0]} al {weekDates[6]} · usa ‹ › para moverte por semana
</p>
<div className="overflow-auto max-h-[70vh] rounded-2xl border border-theme-border shadow-card">
<table className="w-full border-collapse bg-theme-surface">
<thead>
<tr>
<th className="sticky left-0 top-0 z-30 min-w-[150px] bg-theme-surface p-2.5 text-left text-xs font-medium text-theme-muted uppercase border-b border-theme-border">
Médico
</th>
{weekDates.map((d, i) => (
<th
key={d}
className={`sticky top-0 z-20 min-w-[132px] bg-theme-surface p-2 text-center text-[11px] font-medium border-b border-l border-theme-border ${
d === todayStr ? 'bg-theme-accent-bg text-theme-accent' : 'text-theme-muted'
}`}
>
{WEEKDAY_LABELS[i]} {d.slice(8, 10)}/{d.slice(5, 7)}
{d === todayStr && <span className="block text-[9px] font-semibold uppercase">hoy</span>}
</th>
))}
</tr>
</thead>
<tbody>
{gridColumns.map((col) => (
<tr key={col.key}>
<td className="sticky left-0 z-10 bg-theme-surface p-2.5 border-b border-theme-border">
<div className="flex items-center gap-2">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-brand-lilac text-[10px] font-bold text-theme-heading">
{initials(col.label)}
</span>
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-theme-heading">
{col.label}
{bloqueoDia(col.doctorId) && (
<span className="ml-1 rounded bg-rose-100 px-1 text-[9px] font-medium text-rose-700">
cerrada
</span>
)}
</p>
<p className="text-[10px] text-theme-muted">
{gridDoctorCount(col.doctorId)} {gridDoctorCount(col.doctorId) === 1 ? 'cita' : 'citas'}
</p>
</div>
{menuCierreRapido(col.doctorId)}
</div>
</td>
{weekDates.map((d) => {
const dayApts = gridAppointments
.filter((a) => a.date === d && (col.doctorId ? a.doctor_id === col.doctorId : !a.doctor_id))
.sort((a, b) => (timeToFloat(a.time) ?? 0) - (timeToFloat(b.time) ?? 0));
const dayBloqueo = col.doctorId
? bloqueos.find((b) => b.doctor_id === col.doctorId && b.date === d && b.all_day)
: undefined;
return (
<td key={d} className={`p-1 align-top border-b border-l border-theme-border ${d === todayStr ? 'bg-theme-accent-bg/30' : ''}`}>
{dayBloqueo ? (
<div
className="rounded border border-theme-border py-1.5 text-center text-[10px] font-medium text-theme-muted"
style={{ backgroundImage: 'repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(0,0,0,0.05) 6px, rgba(0,0,0,0.05) 12px)' }}
title={`Bloqueado — ${dayBloqueo.motivo || 'sin motivo'}`}
>
<Ban size={10} className="inline mr-0.5" />
{dayBloqueo.motivo || 'Bloqueado'}
</div>
) : dayApts.length === 0 ? (
<div
onClick={(e) => clickCeldaLibre(e, d, null, col.doctorId)}
className="cursor-pointer rounded border border-dashed border-theme-border py-1.5 text-center text-[10px] text-theme-muted hover:bg-theme-accent-bg"
title="Nueva cita este día"
>
Libre
</div>
) : (
dayApts.map((apt) => (
<div
key={apt.id}
onClick={() => { setTooltip(null); openAptDetail(apt); }}
onMouseEnter={(e) => showAptTooltip(e, apt)}
onMouseLeave={() => setTooltip(null)}
className={`mb-0.5 rounded border-l-2 px-1 py-0.5 text-[10px] leading-tight shadow-card cursor-pointer transition-shadow hover:shadow-soft ${aptCategoryBar(apt)} ${gridStateClass[apt.state] || 'bg-theme-bg text-theme-heading'}`}
>
<span className="mr-0.5 align-middle">{confirmIndicator(apt, 10)}</span>
{apt.patient_birthday && <span title="Cumpleaños del paciente" className="mr-0.5 align-middle">{birthdayIcon(apt, 10)}</span>}
{portalIcon(apt, 10) && <span title="Reservada en línea" className="mr-0.5 align-middle">{portalIcon(apt, 10)}</span>}
<span className="font-semibold">{apt.time}</span> {firstName(apt.patient)}
</div>
))
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
{gridLegend}
</div>
)
) : visibleAppointments.length === 0 ? (
<EmptyState
title="Sin citas"
subtitle="No hay citas para los filtros seleccionados."
actionLabel={`Nueva ${term('appointment_singular').toLowerCase()}`}
onAction={openCreate}
/>
) : (
<>
{/* Barra de confirmación telefónica en lote (solo vista Lista) */}
{selectedIds.length > 0 && (
<div className="flex flex-wrap items-center gap-3 mb-3 p-3 rounded-xl border border-blue-200 bg-blue-50">
<PhoneCall size={16} className="text-blue-700 shrink-0" />
<p className="text-sm font-medium text-theme-heading">
{selectedIds.length} {selectedIds.length === 1 ? 'cita seleccionada' : 'citas seleccionadas'}
</p>
<div className="ml-auto flex gap-2">
<Button size="sm" variant="outline" onClick={() => setSelectedIds([])}>
Quitar selección
</Button>
<Button
size="sm"
onClick={bulkConfirmSelected}
loading={bulkConfirming}
title="Marcar la 1ra confirmación (teléfono) de las citas seleccionadas"
>
<PhoneCall size={14} className="mr-1.5" />
Confirmar seleccionadas
</Button>
</div>
</div>
)}
{/* Desktop table */}
<div className="hidden sm:block overflow-x-auto">
<table className="w-full">
<TableHeader />
<tbody className="divide-y">
{visibleAppointments.map((apt) => (
<tr key={apt.id} className="hover:bg-theme-bg">
<td className="p-3 text-center">
{bulkEligible(apt) ? (
<input
type="checkbox"
checked={selectedIds.includes(apt.id)}
onChange={() => toggleSelected(apt.id)}
aria-label={`Seleccionar cita de ${apt.patient} ${apt.time}`}
className="w-4 h-4 rounded border-theme-border-strong"
/>
) : null}
</td>
<td className="p-3 text-sm text-theme-heading">
<Clock size={14} className="inline mr-1 text-theme-muted" />
{apt.time}
</td>
<td className="p-3 text-sm font-medium text-theme-heading">
<button
type="button"
onClick={() => openPatient(apt.patient_id)}
className="hover:underline hover:text-theme-accent text-left"
title="Abrir ficha del paciente"
>
{apt.patient_birthday && <span title="Cumpleaños del paciente" className="mr-1 align-middle">{birthdayIcon(apt, 12)}</span>}
{portalIcon(apt, 12) && <span title="Reservada en línea" className="mr-1 align-middle">{portalIcon(apt, 12)}</span>}
{apt.patient}
</button>
</td>
<td className="p-3 text-sm text-theme-muted hidden sm:table-cell">
<button type="button" onClick={() => openAptDetail(apt)} className="hover:underline hover:text-theme-heading text-left">
{apt.service}
</button>
</td>
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{apt.doctor || '-'}</td>
<td className="p-3">
<Badge variant={badgeForAppointmentState(apt.state)}>{aptStateLabel(apt.state, term)}</Badge>
</td>
<td className="p-3 text-center">
<button
type="button"
onClick={() => handlePhoneConfirm(apt, !apt.phone_confirmed)}
disabled={updating === apt.id || ['arrived', 'in_progress', 'done', 'cancelled', 'no_show'].includes(apt.state)}
title={confirmTitle(apt)}
className="inline-flex disabled:cursor-default"
>
{confirmIndicator(apt, 16)}
</button>
</td>
<td className="p-3 text-sm text-theme-muted hidden sm:table-cell">{apt.payment_state}</td>
<td className="p-3">
<ActionButtons apt={apt} />
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="sm:hidden space-y-3">
{visibleAppointments.map((apt) => (
<MobileCard
key={apt.id}
title={apt.patient}
subtitle={apt.service}
rows={[
{ label: 'Hora', value: apt.time },
{ label: 'Doctor', value: apt.doctor || '-' },
{ label: 'Estado', value: <Badge variant={badgeForAppointmentState(apt.state)}>{aptStateLabel(apt.state, term)}</Badge> },
{ label: 'Confirmación', value: <span title={confirmTitle(apt)}>{confirmIndicator(apt, 14)}</span> },
{ label: 'Pago', value: apt.payment_state },
]}
actions={
<>
{bulkEligible(apt) && (
<label className="inline-flex items-center gap-1.5 text-xs text-theme-muted cursor-pointer">
<input
type="checkbox"
checked={selectedIds.includes(apt.id)}
onChange={() => toggleSelected(apt.id)}
className="w-4 h-4 rounded border-theme-border-strong"
/>
Sel.
</label>
)}
<ActionButtons apt={apt} />
</>
}
/>
))}
</div>
</>
)}
</Card.Body>
</Card>
{/* Tooltip flotante de cita (hover en tarjetas de las vistas de grid) */}
{tooltip && (
<div
className="pointer-events-none fixed z-50 w-64 rounded-xl border border-theme-border bg-theme-surface p-3 text-xs shadow-soft"
style={tooltip.above
? { left: tooltip.x, top: tooltip.y, transform: 'translateY(calc(-100% - 6px))' }
: { left: tooltip.x, top: tooltip.y + 6 }}
>
<p className="font-semibold text-theme-heading">{tooltip.apt.time} · {tooltip.apt.patient}</p>
<p className="mt-1 text-theme-muted">
{tooltip.apt.service}
{tooltip.apt.service_category ? ` · ${categoryLabels[tooltip.apt.service_category] || tooltip.apt.service_category}` : ''}
</p>
<p className="text-theme-muted">Tel: {tooltip.apt.phone || '—'}</p>
<p className="text-theme-muted">Médico: {tooltip.apt.doctor || 'Sin asignar'}</p>
<p className="mt-1 text-theme-muted">{confirmTitle(tooltip.apt)}</p>
{/* Mini-checklist del flujo (visual; el click abre el detalle con el checklist completo) */}
<div className="mt-2 pt-2 border-t border-theme-border flex items-center gap-1">
{CHECKLIST_STEPS.map((step) => {
const i = stepIndex(tooltip.apt.state);
const hecho = i >= 0 && stepIndex(step.state) <= i;
return (
<span
key={step.state}
title={step.label}
className={`inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[9px] font-medium ${
stepIndex(tooltip.apt.state) === stepIndex(step.state)
? 'bg-violet-300 text-violet-950'
: hecho
? 'bg-emerald-200 text-emerald-900'
: 'bg-theme-bg text-theme-muted'
}`}
>
{hecho && <Check size={9} />}
{step.label}
</span>
);
})}
</div>
</div>
)}
{/* Modal de bloqueos / cierre de agendas de los médicos */}
<BloqueosModal
isOpen={bloqueosOpen}
onClose={() => { setBloqueosOpen(false); setBloqueoQuick(null); }}
date={date}
doctors={doctors}
isMedico={isMedico}
ownDoctorId={medicoEmployeeId}
initial={bloqueoQuick}
puedeGestionar={puedeAgendar}
onChanged={() => { void load({ silent: true }); }}
/>
<Modal
isOpen={createOpen}
onClose={() => cerrarModalCita()}
title={editingId ? `Editar ${term('appointment_singular').toLowerCase()}` : `Nueva ${term('appointment_singular').toLowerCase()}`}
maxWidth="lg"
footer={
<>
<Button variant="outline" onClick={() => cerrarModalCita()}>Cancelar</Button>
<Button onClick={editingId ? saveEditCita : quickMode ? submitQuick : createAppointment} loading={submitting}>
{editingId ? 'Guardar cambios' : quickMode ? 'Guardar cita rápida' : 'Guardar cita'}
</Button>
</>
}
>
<div className="space-y-4">
<label className={quickToggleClass}>
<input
type="checkbox"
checked={quickMode}
onChange={(e) => setQuickMode(e.target.checked)}
className="rounded border-theme-border-strong"
/>
Cita rápida (solo nombre y teléfono)
</label>
{quickMode ? (
<>
<p className="text-xs text-theme-muted">
Si el teléfono ya existe, se reutiliza ese paciente; si no, se crea con datos mínimos.
</p>
<Input
label="Nombre *"
placeholder="Nombre del paciente"
value={quickForm.name}
onChange={(e) => setQuickForm({ ...quickForm, name: e.target.value })}
/>
<Input
label="Teléfono *"
placeholder="+52 ..."
value={quickForm.phone}
onChange={(e) => setQuickForm({ ...quickForm, phone: e.target.value })}
/>
<Select
label="Médico"
options={[{ value: '', label: 'Sin preferencia' }, ...doctors.map((d) => ({ value: String(d.id), label: d.name }))]}
value={quickForm.doctor_id}
onChange={(e) => setQuickForm({ ...quickForm, doctor_id: e.target.value })}
disabled={isMedico}
/>
<Select
label={term('branch_singular')}
options={sucursales.map((s) => ({ value: s.code, label: s.name }))}
value={quickForm.branch || defaultCode}
onChange={(e) => setQuickForm({ ...quickForm, branch: e.target.value })}
/>
<div className="grid grid-cols-2 gap-4">
<Input
label="Fecha *"
type="date"
value={quickForm.date}
onChange={(e) => setQuickForm({ ...quickForm, date: e.target.value })}
/>
<Select
label="Hora *"
options={QUARTER_TIME_OPTIONS}
value={quickForm.time}
onChange={(e) => setQuickForm({ ...quickForm, time: e.target.value })}
/>
</div>
<Select
label="Duración"
options={[{ value: '', label: 'La del servicio (30 min)' }, ...DURATION_OPTIONS]}
value={quickForm.duration}
onChange={(e) => setQuickForm({ ...quickForm, duration: e.target.value })}
/>
<TextArea
label="Notas"
value={quickForm.notes}
onChange={(e) => setQuickForm({ ...quickForm, notes: e.target.value })}
/>
</>
) : (
<>
<PatientCombobox
label="Paciente *"
value={form.patient_id}
initialName={selectedPatientName}
placeholder="Escribe nombre, apellidos o teléfono..."
emptyHint="Sin resultados. Usa «Crear paciente nuevo» para darlo de alta."
onCreateNew={(q) => {
setNuevoPac((p) => ({ ...p, name: p.name || q }));
setCrearNuevo(true);
}}
onChange={(id, patient) => {
setCrearNuevo(false);
setPickedPatient(patient ? { id: String(patient.id), name: patient.name } : null);
setForm((f) => ({ ...f, patient_id: id }));
}}
/>
{crearNuevo && (
<div className="rounded-xl border border-theme-border p-3 space-y-3 bg-theme-bg">
<p className="text-xs text-theme-muted">Nuevo paciente: captura nombre y teléfono para crearlo y agendarle.</p>
<div className="grid grid-cols-2 gap-3">
<Input
label="Nombre *"
placeholder="Nombre del paciente"
value={nuevoPac.name}
onChange={(e) => setNuevoPac({ ...nuevoPac, name: e.target.value })}
/>
<Input
label="Teléfono *"
placeholder="+52 ..."
value={nuevoPac.phone}
onChange={(e) => setNuevoPac({ ...nuevoPac, phone: e.target.value })}
/>
</div>
{nuevoPacDup && (
<div className="rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-800">
<p className="font-medium">
Este teléfono ya está registrado: {nuevoPacDup.name}. ¿Es la misma persona?
</p>
<div className="flex gap-2 mt-1.5">
<Button size="sm" variant="outline" onClick={() => openPatient(nuevoPacDup.id)}>
Abrir su ficha
</Button>
<Button size="sm" variant="ghost" onClick={() => setNuevoPacDup(null)}>
Continuar de todos modos
</Button>
</div>
</div>
)}
<div className="flex gap-2">
<Button size="sm" onClick={crearYSeleccionarPaciente} loading={creandoPac}>
Crear y seleccionar
</Button>
<Button size="sm" variant="ghost" onClick={() => setCrearNuevo(false)}>
Cancelar
</Button>
</div>
</div>
)}
<Select
label="Servicio *"
options={serviceOptions}
value={form.service_id}
onChange={(e) => setForm({ ...form, service_id: e.target.value })}
/>
<Select
label="Médico"
options={[{ value: '', label: 'Sin preferencia' }, ...doctors.map((d) => ({ value: String(d.id), label: d.name }))]}
value={form.doctor_id}
onChange={(e) => setForm({ ...form, doctor_id: e.target.value })}
disabled={isMedico}
/>
<Select
label={term('branch_singular')}
options={sucursales.map((s) => ({ value: s.code, label: s.name }))}
value={form.branch || defaultCode}
onChange={(e) => setForm({ ...form, branch: e.target.value })}
/>
<div className="grid grid-cols-2 gap-4">
<Input
label="Fecha *"
type="date"
value={form.date}
onChange={(e) => setForm({ ...form, date: e.target.value })}
/>
<Select
label="Hora *"
options={slotOptions}
value={form.time}
onChange={(e) => setForm({ ...form, time: e.target.value })}
disabled={slotsLoading || !form.service_id || !form.date}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Select
label="Duración"
options={[
{ value: '', label: serviceDuracionPropuesta ? `La del servicio (${serviceDuracionPropuesta} min)` : 'La del servicio' },
...DURATION_OPTIONS,
]}
value={form.duration}
onChange={(e) => setForm({ ...form, duration: e.target.value, time: '' })}
/>
<div className="flex items-end">
<p className="text-xs text-theme-muted pb-2">
Cambiar la duración recalcula los huecos disponibles de la agenda.
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<Select
label="Sesiones"
options={[
{ value: '1', label: '1 sesión' },
{ value: '2', label: '2 sesiones' },
{ value: '3', label: '3 sesiones' },
{ value: '4', label: '4 sesiones' },
{ value: '5', label: '5 sesiones' },
{ value: '6', label: '6 sesiones' },
]}
value={String(form.sessions)}
onChange={(e) => setForm({ ...form, sessions: parseInt(e.target.value, 10) })}
/>
<div className="flex items-end">
<p className="text-xs text-theme-muted pb-2">
Si el servicio tiene paquete, se sugerirá el número de sesiones del paquete.
</p>
</div>
</div>
{slotsLoading && <p className="text-xs text-theme-muted">Cargando horarios disponibles...</p>}
<TextArea
label="Notas"
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
/>
</>
)}
</div>
</Modal>
<Modal
isOpen={expressOpen}
onClose={() => setExpressOpen(false)}
title="Valoración express"
maxWidth="md"
footer={
<>
<Button variant="outline" onClick={() => setExpressOpen(false)}>Cancelar</Button>
<Button onClick={submitExpress} loading={expressSubmitting}>Guardar</Button>
</>
}
>
<div className="space-y-4">
<p className="text-sm text-theme-muted">
Captura rápida de prospecto. Opcionalmente agenda su cita de valoración en el mismo paso.
</p>
<Input
label="Nombre *"
placeholder="Nombre del prospecto"
value={expressForm.name}
onChange={(e) => setExpressForm({ ...expressForm, name: e.target.value })}
/>
<Input
label="Teléfono *"
placeholder="+52 ..."
value={expressForm.phone}
onChange={(e) => setExpressForm({ ...expressForm, phone: e.target.value })}
/>
<TextArea
label="Notas"
placeholder="Motivo de consulta, zona a tratar, etc."
value={expressForm.notes}
onChange={(e) => setExpressForm({ ...expressForm, notes: e.target.value })}
/>
<label className="inline-flex items-center gap-2 text-sm text-theme-heading cursor-pointer">
<input
type="checkbox"
checked={expressForm.withAppointment}
onChange={(e) => setExpressForm({ ...expressForm, withAppointment: e.target.checked })}
className="rounded border-theme-border-strong"
/>
Crear cita de valoración ahora
</label>
{expressForm.withAppointment && (
<div className="space-y-4 rounded-xl border border-theme-border p-4 bg-theme-bg">
<Select
label="Servicio *"
options={serviceOptions}
value={expressForm.service_id}
onChange={(e) => setExpressForm({ ...expressForm, service_id: e.target.value })}
/>
<Select
label="Médico"
options={[{ value: '', label: 'Sin preferencia' }, ...doctors.map((d) => ({ value: String(d.id), label: d.name }))]}
value={expressForm.doctor_id}
onChange={(e) => setExpressForm({ ...expressForm, doctor_id: e.target.value })}
disabled={isMedico}
/>
<div className="grid grid-cols-2 gap-4">
<Input
label="Fecha *"
type="date"
value={expressForm.date}
onChange={(e) => setExpressForm({ ...expressForm, date: e.target.value })}
/>
<Input
label="Hora *"
type="time"
min="08:00"
max="19:00"
step={900}
value={expressForm.time}
onChange={(e) => setExpressForm({ ...expressForm, time: e.target.value })}
/>
</div>
</div>
)}
</div>
</Modal>
{/* Modal detalle de cita: confirmar / no show / editar reserva */}
<Modal
isOpen={!!detailApt}
onClose={() => setDetailApt(null)}
title={editMode ? 'Reagendar cita' : 'Detalle de la cita'}
maxWidth="md"
footer={
editMode ? (
<>
<Button variant="outline" onClick={() => setEditMode(false)}>Volver</Button>
<Button onClick={saveAptEdit} loading={savingEdit}>Guardar cambios</Button>
</>
) : undefined
}
>
{detailApt && !editMode && (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={badgeForAppointmentState(detailApt.state)}>
{aptStateLabels(detailApt.state)}
</Badge>
{detailApt.is_first_visit && (
<Badge variant="primary">Primera vez</Badge>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Paciente</p>
<button
type="button"
onClick={() => openPatient(detailApt.patient_id)}
className="text-sm font-medium text-theme-heading underline underline-offset-2 hover:text-theme-accent text-left"
title="Abrir ficha del paciente"
>
{detailApt.patient}
</button>
{detailApt.patient_birthday && (
<span className="ml-1.5 inline-flex items-center gap-1 rounded-full bg-pink-50 border border-pink-300 px-2 py-0.5 text-[10px] font-medium text-pink-600 align-middle">
{birthdayIcon(detailApt, 10)} Cumpleaños hoy
</span>
)}
</div>
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Servicio</p>
<p className="text-sm font-medium text-theme-heading">{detailApt.service}</p>
</div>
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Fecha y hora</p>
<p className="text-sm font-medium text-theme-heading">{detailApt.date} · {detailApt.time}</p>
</div>
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Médico</p>
<p className="text-sm font-medium text-theme-heading">{detailApt.doctor || 'Sin asignar'}</p>
</div>
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Duración</p>
<p className="text-sm font-medium text-theme-heading">{detailApt.duration ? `${detailApt.duration} min` : '-'}</p>
</div>
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Precio</p>
<p className="text-sm font-medium text-theme-heading">${detailApt.price}</p>
</div>
<div className="p-3 rounded-xl border border-emerald-200 bg-emerald-50/60 col-span-2 flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-xs text-theme-muted mb-1 flex items-center gap-1">
<Wallet size={11} /> Saldo a favor del paciente
</p>
<p className="text-sm font-semibold text-theme-heading">
${(saldoPaciente?.equivalent_mxn ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
{saldoPaciente?.equivalent_usd != null && (
<span className="ml-1.5 text-xs font-normal text-theme-muted">
≈ {saldoPaciente.equivalent_usd.toLocaleString('en-US', { style: 'currency', currency: 'USD' })} USD
</span>
)}
</p>
</div>
<Button size="sm" variant="outline" onClick={() => setRecargaAptOpen(true)}>
<Wallet size={14} className="mr-1.5" /> Recargar saldo
</Button>
</div>
</div>
{detailApt.notes && (
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Notas</p>
<p className="text-sm text-theme-heading whitespace-pre-wrap">{detailApt.notes}</p>
</div>
)}
{/* Checklist de estados (avanzar / retroceder) con doble confirmación */}
<EstadoChecklist
apt={detailApt}
updating={updating === detailApt.id}
history={stateHistory}
puedeCancelar={puedeEliminar}
puedeNoShow={puedeEditar}
onChange={(action, state) => handleStatus(detailApt.id, action, state)}
/>
<div className="p-3 bg-theme-bg rounded-xl">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm">
<p className="font-medium text-theme-heading">1ra confirmación (teléfono)</p>
<p className="text-xs text-theme-muted">
{detailApt.phone_confirmed
? `Confirmada${detailApt.phone_confirmed_by ? ` por ${detailApt.phone_confirmed_by}` : ''}${detailApt.phone_confirmed_at ? ` · ${detailApt.phone_confirmed_at}` : ''}`
: 'Sin confirmar'}
</p>
</div>
{!['cancelled', 'no_show', 'done'].includes(detailApt.state) && (
<Button
size="sm"
variant={detailApt.phone_confirmed ? 'outline' : 'primary'}
onClick={() => handlePhoneConfirm(detailApt, !detailApt.phone_confirmed)}
disabled={updating === detailApt.id}
>
<Phone size={16} className="mr-1.5" />
{detailApt.phone_confirmed ? 'Quitar confirmación' : 'Confirmar por teléfono'}
</Button>
)}
</div>
</div>
{notifActivo && (
<div className="p-3 bg-theme-bg rounded-xl">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0 text-sm">
<p className="font-medium text-theme-heading">Recordatorios automáticos</p>
{notifCita.length === 0 ? (
<p className="text-xs text-theme-muted">Sin notificaciones registradas para esta cita.</p>
) : (
<>
<p className="text-xs text-theme-muted">
{notifCita.length} notificación(es) · última: {notifCita[0]?.estado_label}
</p>
<div className="mt-1 flex flex-wrap gap-1">
{notifCita.slice(0, 4).map((n) => (
<Badge key={n.id} variant={notifBadge(n.estado)}>
{n.tipo_label}: {n.estado_label}
</Badge>
))}
</div>
</>
)}
</div>
{puedeEnviarNotif && !['cancelled', 'no_show', 'done'].includes(detailApt.state) && (
<Button size="sm" variant="outline" loading={enviandoNotif} onClick={enviarRecordatorioAhora}>
<Bell size={16} className="mr-1.5" /> Enviar recordatorio ahora
</Button>
)}
</div>
</div>
)}
<div className="flex flex-wrap gap-2 pt-2 border-t border-theme-border">
{puedeEditar && (
<Button size="sm" variant="outline" onClick={() => setEditMode(true)}>
<CalendarClock size={16} className="mr-1.5" /> Reagendar
</Button>
)}
{puedeEditar && (
<Button size="sm" variant="outline" onClick={() => openEditCita(detailApt)}>
<Pencil size={16} className="mr-1.5" /> Editar
</Button>
)}
{/* Cobrar a la cita: abre el POS con paciente/servicio/médico precargados.
Solo en estados cobrables (oculto en cancelada/no show). */}
{!['cancelled', 'no_show'].includes(detailApt.state) && (
<Button
size="sm"
variant="outline"
onClick={() => {
const params = new URLSearchParams({
patient_id: String(detailApt.patient_id),
service_id: String(detailApt.service_id),
appointment_id: String(detailApt.id),
});
if (detailApt.doctor_id) params.set('doctor_id', String(detailApt.doctor_id));
navigate(`/pos?${params.toString()}`);
}}
>
<CreditCard size={16} className="mr-1.5" /> Cobrar
</Button>
)}
</div>
</div>
)}
{detailApt && editMode && (
<div className="space-y-4">
<p className="text-sm text-theme-muted">
{detailApt.patient} · {detailApt.service} — cambia fecha, hora y (opcional) médico sin borrar la cita.
Se aplican las validaciones de horario (8:00-19:00) y solapamiento por médico.
</p>
<div className="grid grid-cols-2 gap-4">
<Input
label="Fecha *"
type="date"
value={editForm.date}
onChange={(e) => setEditForm({ ...editForm, date: e.target.value })}
/>
<Select
label="Hora *"
options={QUARTER_TIME_OPTIONS}
value={editForm.time}
onChange={(e) => setEditForm({ ...editForm, time: e.target.value })}
/>
</div>
<Select
label="Médico"
options={[{ value: '', label: 'Sin cambios' }, ...doctors.map((d) => ({ value: String(d.id), label: d.name }))]}
value={editForm.doctor_id}
onChange={(e) => setEditForm({ ...editForm, doctor_id: e.target.value })}
disabled={isMedico}
/>
<TextArea
label="Notas"
value={editForm.notes}
onChange={(e) => setEditForm({ ...editForm, notes: e.target.value })}
/>
</div>
)}
</Modal>
{/* Recarga de saldo a favor del paciente de la cita: se monta después del
modal de detalle para quedar por encima (mismo z-index). */}
<RecargarSaldoModal
isOpen={recargaAptOpen}
onClose={() => setRecargaAptOpen(false)}
patientId={detailApt?.patient_id ?? null}
patientName={detailApt?.patient}
citaId={detailApt?.id ?? null}
saldoActual={saldoPaciente?.equivalent_mxn ?? 0}
onDone={(receipt) => {
setReciboApt(receipt);
if (detailApt?.patient_id) {
odooApi.getPatientWallet(detailApt.patient_id)
.then((w) => { if (w.status === 'success') setSaldoPaciente(w); })
.catch(() => {});
}
}}
/>
<ReciboPrint receipt={reciboApt} onClose={() => setReciboApt(null)} />
{/* Alta de paciente en la lista de espera */}
<ListaEsperaModal
isOpen={listaModalOpen}
onClose={() => { setListaModalOpen(false); setListaPrefill({}); }}
doctors={doctors}
services={services}
patient={listaPrefill.patient ?? null}
defaultDoctorId={listaPrefill.doctorId}
defaultServiceId={listaPrefill.serviceId}
isMedico={isMedico}
onCreated={() => { void recargarListaEspera(); }}
/>
{/* Modal de conflicto de agenda: mensaje humano + ver la cita que choca */}
<Modal
isOpen={!!conflict}
onClose={() => setConflict(null)}
title="Conflicto de horario"
maxWidth="sm"
footer={
<>
<Button variant="outline" onClick={() => setConflict(null)}>Entendido</Button>
{conflict?.conflicting && (
<Button onClick={verCitaEnConflicto}>
<CalendarClock size={16} className="mr-1.5" />
Ver esa cita
</Button>
)}
</>
}
>
<div className="flex items-start gap-3">
<AlertTriangle size={22} className="text-amber-600 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-theme-heading">{conflict?.message}</p>
{conflict?.conflicting && (
<p className="mt-2 text-sm text-theme-muted">
Cita existente: <span className="font-medium text-theme-heading">{conflict.conflicting.time} · {conflict.conflicting.patient}</span>
{conflict.conflicting.doctor ? ` · ${conflict.conflicting.doctor}` : ''}
</p>
)}
{conflict?.kind === 'overlap' && !conflict.conflicting && (
<p className="mt-2 text-xs text-theme-muted">Ajusta la hora o elige otro médico.</p>
)}
</div>
</div>
</Modal>
{/* Vista de impresión de la agenda del día (bloque por doctor) */}
<AgendaPrint
isOpen={printOpen}
onClose={() => setPrintOpen(false)}
date={date}
dateLabel={new Date(`${date}T12:00:00`).toLocaleDateString('es-MX', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
})}
appointments={appointments}
bloqueos={bloqueos}
doctors={typedDoctors}
filterDoctorName={doctorFilter ? (doctors.find((d) => String(d.id) === doctorFilter)?.name || null) : null}
/>
</Layout>
);
};
export default Agenda;