Oleada UX 2: Agenda (mensajes de conflicto humanos, imprimir agenda del dia, confirmacion en lote, prefs persistentes) y Pacientes (acciones en ficha, timeline unificada, aviso de telefono duplicado) + fixes de precarga e impresion

This commit is contained in:
2026-09-17 18:47:35 +00:00
parent afc208a7b7
commit b4f91527f1
8 changed files with 1048 additions and 116 deletions

View File

@@ -1,8 +1,9 @@
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, Trash2, Phone, Cake, CreditCard, Pencil } from 'lucide-react';
import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, PackageCheck, Sparkles, RotateCcw, LayoutGrid, List, Rows3, CalendarRange, CalendarClock, ChevronLeft, ChevronRight, Ban, Trash2, Phone, Cake, CreditCard, Pencil, AlertTriangle, Printer, PhoneCall } from 'lucide-react';
import Layout from '../components/Layout';
import { usePatientDrawer } from '../components/PatientDrawer';
import AgendaPrint from '../components/AgendaPrint';
import {
Card,
Button,
@@ -30,6 +31,63 @@ const stateOptions = [
{ value: 'cancelled', label: 'Cancelada' },
];
// ---- 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...' }];
@@ -43,7 +101,7 @@ const QUARTER_TIME_OPTIONS = (() => {
})();
const Agenda: FC = () => {
const [searchParams] = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const { user } = useAuth();
const { openPatient } = usePatientDrawer();
@@ -60,10 +118,41 @@ const Agenda: FC = () => {
const [error, setError] = useState<string | null>(null);
const [date, setDate] = useState<string>(() => searchParams.get('date') || new Date().toISOString().split('T')[0]);
const [stateFilter, setStateFilter] = useState('');
const [doctorFilter, setDoctorFilter] = useState(() => (medicoEmployeeId ? String(medicoEmployeeId) : ''));
const [staffTypeFilter, setStaffTypeFilter] = useState<'' | 'doctor' | 'cosmetologa'>('');
const [viewMode, setViewMode] = useState<'lista' | 'doctores' | 'horas' | 'semana'>('doctores');
const [doctorFilter, setDoctorFilter] = useState(() => {
if (medicoEmployeeId) return String(medicoEmployeeId);
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'>('');
// 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);
const [detailApt, setDetailApt] = useState<Appointment | null>(null);
const [editMode, setEditMode] = useState(false);
const [editForm, setEditForm] = useState({ date: '', time: '', doctor_id: '', notes: '' });
@@ -99,6 +188,8 @@ const Agenda: FC = () => {
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({
@@ -322,9 +413,7 @@ const Agenda: FC = () => {
toast.success('Cita movida');
await load();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo mover la cita');
console.error(err);
applyAgendaError(err, 'No se pudo mover la cita');
}
};
@@ -498,6 +587,8 @@ const Agenda: FC = () => {
try {
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' }
@@ -535,6 +626,43 @@ const Agenda: FC = () => {
loadReferences();
}, [load, loadReferences]);
// 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);
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]);
// Cargar pacientes para el selector (búsqueda server-side)
useEffect(() => {
if (!createOpen) return;
@@ -549,6 +677,32 @@ const Agenda: FC = () => {
return () => clearTimeout(t);
}, [createOpen, patientSearch]);
// 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()) {
@@ -644,10 +798,8 @@ const Agenda: FC = () => {
setDetailApt(null);
await load();
} catch (err) {
// Mostrar el rechazo del servidor (horario 8-19h, solapamiento por médico, bloqueos)
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al actualizar la reserva');
console.error(err);
// Mostrar el rechazo del servidor con mensaje humano si es conflicto
applyAgendaError(err, 'Error al actualizar la reserva');
} finally {
setSavingEdit(false);
}
@@ -673,6 +825,65 @@ const Agenda: FC = () => {
}
};
// ---- 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) => {
try {
setPkgUpdating(id);
@@ -756,7 +967,7 @@ const Agenda: FC = () => {
service_id: parseInt(form.service_id, 10),
doctor_id: form.doctor_id ? parseInt(form.doctor_id, 10) : undefined,
date: form.date,
time: form.time,
time: parseTimeToFloat(form.time) as unknown as string,
notes: form.notes,
sessions,
});
@@ -773,8 +984,7 @@ const Agenda: FC = () => {
});
await load();
} catch (err) {
toast.error('Error al crear cita');
console.error(err);
applyAgendaError(err, 'Error al crear cita');
} finally {
setSubmitting(false);
}
@@ -822,9 +1032,7 @@ const Agenda: FC = () => {
setEditMode(false);
await load();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo actualizar la cita');
console.error(err);
applyAgendaError(err, 'No se pudo actualizar la cita');
} finally {
setSubmitting(false);
}
@@ -907,9 +1115,7 @@ const Agenda: FC = () => {
setCreateOpen(false);
await load();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al crear la cita rápida');
console.error(err);
applyAgendaError(err, 'Error al crear la cita rápida');
} finally {
setSubmitting(false);
}
@@ -921,17 +1127,25 @@ const Agenda: FC = () => {
value: String(p.id),
label: `${p.name} — ${p.phone || 'sin teléfono'}${p.primary_doctor ? ` · ${p.primary_doctor}` : ''}`,
}));
// Garantiza que el paciente precargado (desde celda o edición) aparezca
// Garantiza que el paciente precargado (desde celda, query param o edición) aparezca
if (form.patient_id && !list.some((o) => o.value === form.patient_id)) {
const pre = preloadedPatient && String(preloadedPatient.id) === form.patient_id ? preloadedPatient : null;
const sel = appointments.find((a) => String(a.patient_id) === form.patient_id) as Appointment | undefined;
opts.push({ value: form.patient_id, label: sel ? `${sel.patient} — (cita seleccionada)` : `Paciente #${form.patient_id}` });
opts.push({
value: form.patient_id,
label: pre
? `${pre.name} — ${pre.phone || 'sin teléfono'}`
: sel
? `${sel.patient} — (cita seleccionada)`
: `Paciente #${form.patient_id}`,
});
}
opts.push(...list);
if (patientSearch.trim()) {
opts.push({ value: '__crear__', label: `Crear paciente nuevo: "${patientSearch.trim()}"` });
}
return opts;
}, [patients, patientSearch, form.patient_id, appointments]);
}, [patients, patientSearch, form.patient_id, appointments, preloadedPatient]);
const serviceOptions = [
{ value: '', label: 'Seleccionar servicio...' },
@@ -955,6 +1169,20 @@ const Agenda: FC = () => {
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>
@@ -1107,6 +1335,16 @@ const Agenda: FC = () => {
>
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"
@@ -1668,6 +1906,29 @@ const Agenda: FC = () => {
/>
) : (
<>
{/* 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">
@@ -1675,6 +1936,17 @@ const Agenda: FC = () => {
<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}
@@ -1734,7 +2006,22 @@ const Agenda: FC = () => {
{ label: 'Confirmación', value: <span title={confirmTitle(apt)}>{confirmIndicator(apt, 14)}</span> },
{ label: 'Pago', value: apt.payment_state },
]}
actions={<ActionButtons apt={apt} />}
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>
@@ -1948,6 +2235,21 @@ const Agenda: FC = () => {
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
@@ -2298,6 +2600,58 @@ const Agenda: FC = () => {
</div>
)}
</Modal>
{/* 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>
);
};