3349 lines
149 KiB
TypeScript
3349 lines
149 KiB
TypeScript
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;
|