Agenda: cierre rápido de agendas por médico, lista de espera, duraciones 30/45/60, checklist de estados con bitácora y auto-refresh 30s

- BloqueosModal: alta multi-médico, rango repetible por día de la semana,
  motivos predefinidos, edición/borrado y cierre rápido desde la cabecera
  de cada columna (todo el día / resto del día / rango).
- skeen.lista.espera + panel y modal en la agenda: prioridad, franja, rango
  de fechas, agendar precargado, avisar por WhatsApp y aviso cuando se
  libera un hueco compatible.
- duration en skeen.cita (0 = la del servicio): la cita ocupa sus bloques de
  15 min reales en las 4 vistas, con celdas de continuación.
- Checklist de estados (Agendada → Confirmada → En cabina → En consulta →
  Completada) con avance/retroceso, cancelación y no-show con confirmación,
  y bitácora de quién/cuándo en skeen.cita.state.log.
- Etiquetas del sistema legacy alineadas (Canceló / Lista de espera / En
  cabina) en badges de agenda y dashboard.
- Auto refresco silencioso cada 30 s (pausable, se pospone con modales,
  escritura o drag) en citas + bloqueos + lista de espera en un solo request.
This commit is contained in:
Kimi
2026-09-24 15:46:38 +00:00
parent e32a79084a
commit d0eac00a40
19 changed files with 2964 additions and 328 deletions

View File

@@ -0,0 +1,522 @@
import type { FC } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Pencil, Plus, Trash2, X } from 'lucide-react';
import {
Badge, Button, Input, Modal, TextArea, toast,
} from '../ui';
import { odooApi, type Bloqueo, type BloqueoMotivo, type Doctor } from '../../services/odoo';
const MOTIVOS: { value: BloqueoMotivo; label: string }[] = [
{ value: 'vacaciones', label: 'Vacaciones' },
{ value: 'junta', label: 'Junta' },
{ value: 'comida', label: 'Comida' },
{ value: 'personal', label: 'Personal' },
{ value: 'otro', label: 'Otro' },
];
const DAYS = [
{ value: 0, label: 'Lun' },
{ value: 1, label: 'Mar' },
{ value: 2, label: 'Mié' },
{ value: 3, label: 'Jue' },
{ value: 4, label: 'Vie' },
{ value: 5, label: 'Sáb' },
{ value: 6, label: 'Dom' },
];
export 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 parseTimeToFloat = (t: string): number => {
const [h, m] = (t || '').split(':').map((x) => parseInt(x, 10));
return (Number.isNaN(h) ? 0 : h) + (Number.isNaN(m) ? 0 : m / 60);
};
export interface BloqueoQuickClose {
doctorIds: number[];
allDay: boolean;
timeFrom?: string;
timeTo?: string;
}
interface Props {
isOpen: boolean;
onClose: () => void;
/** Fecha visible en la agenda (referencia para la lista y el alta) */
date: string;
doctors: Doctor[];
/** Rol médico: solo puede bloquear su propia agenda */
isMedico?: boolean;
ownDoctorId?: number | null;
/** Precarga del cierre rápido desde la cabecera de una columna */
initial?: BloqueoQuickClose | null;
/** Avisa a la agenda para recargar sus bloqueos */
onChanged: () => void;
}
const BloqueosModal: FC<Props> = ({
isOpen, onClose, date, doctors, isMedico, ownDoctorId, initial, onChanged,
}) => {
const [doctorIds, setDoctorIds] = useState<number[]>([]);
const [dateFrom, setDateFrom] = useState(date);
const [dateTo, setDateTo] = useState(date);
const [repite, setRepite] = useState(false);
const [days, setDays] = useState<number[]>([]);
const [allDay, setAllDay] = useState(false);
const [timeFrom, setTimeFrom] = useState('13:00');
const [timeTo, setTimeTo] = useState('14:00');
const [motivoTipo, setMotivoTipo] = useState<BloqueoMotivo>('otro');
const [motivo, setMotivo] = useState('');
const [saving, setSaving] = useState(false);
const [editing, setEditing] = useState<Bloqueo | null>(null);
const [bloqueos, setBloqueos] = useState<Bloqueo[]>([]);
const [loading, setLoading] = useState(false);
const resetForm = useCallback((fecha?: string) => {
setEditing(null);
setDoctorIds(isMedico && ownDoctorId ? [ownDoctorId] : []);
setDateFrom(fecha || date);
setDateTo(fecha || date);
setRepite(false);
setDays([]);
setAllDay(false);
setTimeFrom('13:00');
setTimeTo('14:00');
setMotivoTipo('otro');
setMotivo('');
}, [date, isMedico, ownDoctorId]);
// Rango consultado para la lista: el del formulario (o la fecha visible)
const rangeStart = repite && dateFrom && dateTo && dateTo < dateFrom ? dateTo : dateFrom;
const rangeEnd = repite && dateFrom && dateTo && dateTo < dateFrom ? dateFrom : (dateTo || dateFrom);
const fetchBloqueos = useCallback(async (desde: string, hasta: string) => {
try {
setLoading(true);
const res = await odooApi.getBloqueos({ date_from: desde, date_to: hasta });
if (res.status === 'success') setBloqueos(res.bloqueos);
} catch {
toast.error('No se pudieron cargar los bloqueos');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!isOpen) return;
if (initial) {
setEditing(null);
setDoctorIds(initial.doctorIds);
setDateFrom(date);
setDateTo(date);
setRepite(false);
setDays([]);
setAllDay(initial.allDay);
if (initial.timeFrom) setTimeFrom(initial.timeFrom);
if (initial.timeTo) setTimeTo(initial.timeTo);
setMotivo('');
} else {
resetForm(date);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, initial, date]);
useEffect(() => {
if (!isOpen) return;
void fetchBloqueos(rangeStart || date, rangeEnd || date);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, rangeStart, rangeEnd, date]);
const toggleDoctor = (id: number) => {
setDoctorIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
};
const toggleDay = (d: number) => {
setDays((prev) => (prev.includes(d) ? prev.filter((x) => x !== d) : [...prev, d]));
};
const totalFechas = useMemo(() => {
if (!repite) return 1;
if (!dateFrom || !rangeEnd || rangeEnd < dateFrom) return 1;
let n = 0;
const cur = new Date(`${rangeEnd}T12:00:00`);
const inicio = new Date(`${dateFrom}T12:00:00`);
while (inicio <= cur) {
if (!days.length || days.includes((inicio.getDay() + 6) % 7)) n += 1;
inicio.setDate(inicio.getDate() + 1);
}
return n;
}, [repite, dateFrom, rangeEnd, days]);
const submit = async (force = false) => {
if (doctorIds.length === 0) {
toast.error('Selecciona al menos un médico');
return;
}
if (!dateFrom) {
toast.error('La fecha es obligatoria');
return;
}
if (repite && rangeEnd < dateFrom) {
toast.error('El rango de fechas está invertido');
return;
}
if (!allDay && parseTimeToFloat(timeTo) <= parseTimeToFloat(timeFrom)) {
toast.error('La hora final debe ser mayor a la inicial');
return;
}
try {
setSaving(true);
if (editing) {
await odooApi.updateBloqueo(editing.id, {
doctor_id: doctorIds[0],
date: dateFrom,
all_day: allDay,
time_from: parseTimeToFloat(timeFrom),
time_to: parseTimeToFloat(timeTo),
motivo_tipo: motivoTipo,
motivo,
force,
});
toast.success('Bloqueo actualizado');
} else {
const res = await odooApi.createBloqueo({
doctor_ids: doctorIds,
date_from: dateFrom,
date_to: repite ? rangeEnd : dateFrom,
days: repite ? days : [],
all_day: allDay,
time_from: parseTimeToFloat(timeFrom),
time_to: parseTimeToFloat(timeTo),
motivo_tipo: motivoTipo,
motivo,
force,
});
toast.success(res.creados === 1 ? 'Bloqueo creado' : `${res.creados} bloqueos creados`);
}
resetForm(date);
await fetchBloqueos(rangeStart || date, rangeEnd || date);
onChanged();
} catch (err) {
const data = (err as { response?: { data?: { message?: string; conflicto_citas?: boolean } } })?.response?.data;
const msg = data?.message || 'No se pudo guardar el bloqueo';
if (data?.conflicto_citas) {
if (window.confirm(`${msg}\n\n¿Bloquear de todos modos?`)) {
setSaving(false);
await submit(true);
return;
}
toast.error(msg);
} else {
toast.error(msg);
}
console.error(err);
} finally {
setSaving(false);
}
};
const remove = async (b: Bloqueo, serie = false) => {
const etiqueta = serie
? `toda la serie (${b.grupo})`
: `${b.doctor} · ${b.date}`;
if (!window.confirm(`¿Eliminar el bloqueo de ${etiqueta}?`)) return;
try {
if (serie && b.grupo) {
const res = await odooApi.deleteBloqueoGrupo(b.grupo);
toast.success(`${res.eliminados} bloqueos eliminados`);
} else {
await odooApi.deleteBloqueo(b.id);
toast.success('Bloqueo eliminado');
}
await fetchBloqueos(rangeStart || date, rangeEnd || date);
onChanged();
} catch {
toast.error('No se pudo eliminar el bloqueo');
}
};
const startEdit = (b: Bloqueo) => {
setEditing(b);
setDoctorIds([b.doctor_id]);
setDateFrom(b.date);
setDateTo(b.date);
setRepite(false);
setAllDay(b.all_day);
setTimeFrom(horaFloatStr(b.time_from));
setTimeTo(horaFloatStr(b.time_to));
setMotivoTipo((b.motivo_tipo || 'otro') as BloqueoMotivo);
setMotivo(b.motivo || '');
};
const porFecha = useMemo(() => {
const map = new Map<string, Bloqueo[]>();
[...bloqueos]
.sort((a, b) => (a.date === b.date ? a.time_from - b.time_from : a.date.localeCompare(b.date)))
.forEach((b) => map.set(b.date, [...(map.get(b.date) || []), b]));
return [...map.entries()];
}, [bloqueos]);
const daysOfWeek = (b: Bloqueo) => DAYS[(new Date(`${b.date}T12:00:00`).getDay() + 6) % 7].label;
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Bloqueos de agenda"
maxWidth="2xl"
footer={<Button variant="outline" onClick={onClose}>Cerrar</Button>}
>
<div className="space-y-5">
{/* Alta / edición */}
<div className="p-3 bg-theme-bg rounded-xl space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-theme-muted uppercase">
{editing ? `Editando bloqueo #${editing.id}` : 'Nuevo bloqueo'}
</p>
{editing && (
<button
type="button"
onClick={() => resetForm(date)}
className="inline-flex items-center gap-1 text-xs text-theme-muted underline underline-offset-2"
>
<X size={12} /> Cancelar edición
</button>
)}
</div>
{/* Médicos (multi-selección) */}
<div>
<p className="text-xs font-medium text-theme-muted uppercase mb-1.5">Médicos *</p>
{isMedico ? (
<p className="text-sm text-theme-heading">
{doctors.find((d) => d.id === ownDoctorId)?.name || 'Mi agenda'}
</p>
) : (
<>
<div className="flex flex-wrap gap-1.5">
{doctors.map((d) => {
const active = doctorIds.includes(d.id);
return (
<button
key={d.id}
type="button"
onClick={() => toggleDoctor(d.id)}
className={`px-2.5 py-1 rounded-full text-xs border transition-all ${
active
? 'bg-theme-accent text-theme-inverse border-theme-accent'
: 'bg-theme-surface text-theme-muted border-theme-border hover:border-theme-border-strong'
}`}
>
{d.name}
</button>
);
})}
</div>
<button
type="button"
onClick={() => setDoctorIds(doctorIds.length === doctors.length ? [] : doctors.map((d) => d.id))}
className="mt-1.5 text-xs text-theme-muted underline underline-offset-2"
>
{doctorIds.length === doctors.length ? 'Quitar todos' : 'Seleccionar todos'}
</button>
</>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Input
label={repite ? 'Desde *' : 'Fecha *'}
type="date"
value={dateFrom}
onChange={(e) => { setDateFrom(e.target.value); if (!repite) setDateTo(e.target.value); }}
/>
{repite ? (
<Input
label="Hasta *"
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
/>
) : (
<div className="flex items-end pb-2">
<p className="text-xs text-theme-muted">
Para bloquear varios días (p. ej. todos los lunes de octubre) activa el rango.
</p>
</div>
)}
</div>
<label className="inline-flex items-center gap-2 text-sm text-theme-heading cursor-pointer">
<input
type="checkbox"
checked={repite}
onChange={(e) => { setRepite(e.target.checked); setDays([]); if (!e.target.checked) setDateTo(dateFrom); }}
className="w-4 h-4 rounded border-theme-border-strong"
/>
Rango de fechas (repetir varios días)
</label>
{repite && (
<div>
<p className="text-xs font-medium text-theme-muted uppercase mb-1.5">
Días de la semana (vacío = todos)
</p>
<div className="flex flex-wrap gap-1.5">
{DAYS.map((d) => {
const active = days.includes(d.value);
return (
<button
key={d.value}
type="button"
onClick={() => toggleDay(d.value)}
className={`w-11 py-1 rounded-full text-xs border transition-all ${
active
? 'bg-brand-mint text-theme-heading border-emerald-700 font-semibold'
: 'bg-theme-surface text-theme-muted border-theme-border hover:border-theme-border-strong'
}`}
>
{d.label}
</button>
);
})}
</div>
<p className="mt-1.5 text-xs text-theme-muted">
Se crearán bloqueos en {totalFechas} {totalFechas === 1 ? 'fecha' : 'fechas'}
{doctorIds.length > 1 ? ` × ${doctorIds.length} médicos = ${totalFechas * doctorIds.length} registros` : ''}.
</p>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<label className="flex items-center gap-2 self-end pb-2 cursor-pointer">
<input
type="checkbox"
checked={allDay}
onChange={(e) => setAllDay(e.target.checked)}
className="w-4 h-4 rounded border-theme-border-strong"
/>
<span className="text-sm text-theme-heading">Todo el día</span>
</label>
{!allDay && (
<>
<Input label="Desde" type="time" value={timeFrom} onChange={(e) => setTimeFrom(e.target.value)} />
<Input label="Hasta" type="time" value={timeTo} onChange={(e) => setTimeTo(e.target.value)} />
</>
)}
</div>
<div>
<p className="text-xs font-medium text-theme-muted uppercase mb-1.5">Motivo</p>
<div className="flex flex-wrap gap-1.5 mb-2">
{MOTIVOS.map((m) => (
<button
key={m.value}
type="button"
onClick={() => setMotivoTipo(m.value)}
className={`px-3 py-1 rounded-full text-xs border transition-all ${
motivoTipo === m.value
? 'bg-theme-accent text-theme-inverse border-theme-accent'
: 'bg-theme-surface text-theme-muted border-theme-border hover:border-theme-border-strong'
}`}
>
{m.label}
</button>
))}
</div>
<TextArea
label="Detalle (opcional)"
placeholder="Ej. comida con el equipo, vacaciones en la playa..."
value={motivo}
onChange={(e) => setMotivo(e.target.value)}
/>
</div>
<div className="flex justify-end">
<Button size="sm" onClick={() => void submit()} loading={saving}>
{editing ? <Pencil size={14} className="mr-1" /> : <Plus size={14} className="mr-1" />}
{editing ? 'Guardar cambios' : 'Crear bloqueo'}
</Button>
</div>
</div>
{/* Lista */}
<div>
<p className="text-xs font-medium text-theme-muted uppercase mb-2">
Bloqueos del {rangeStart === rangeEnd ? rangeStart : `${rangeStart} al ${rangeEnd}`}
{loading ? ' · cargando...' : ` · ${bloqueos.length}`}
</p>
{bloqueos.length === 0 ? (
<p className="text-sm text-theme-muted py-4 text-center">Sin bloqueos en ese periodo.</p>
) : (
<div className="space-y-3 max-h-72 overflow-y-auto">
{porFecha.map(([fecha, items]) => (
<div key={fecha}>
<p className="text-[11px] font-semibold text-theme-heading mb-1">
{fecha} · {daysOfWeek(items[0])}
</p>
<ul className="space-y-1">
{items.map((b) => (
<li
key={b.id}
className="flex items-center justify-between gap-2 p-2 bg-theme-bg rounded-lg text-sm"
>
<span className="min-w-0 text-theme-heading truncate">
<span className="font-medium">{b.doctor}</span>
<span className="text-theme-muted">
{' · '}{b.all_day ? 'Todo el día' : `${horaFloatStr(b.time_from)}–${horaFloatStr(b.time_to)}`}
</span>
<Badge variant="default" className="ml-2">{b.motivo_tipo_label || 'Otro'}</Badge>
{b.motivo && <span className="text-theme-muted"> {b.motivo}</span>}
</span>
<span className="flex items-center gap-1 shrink-0">
{b.grupo && (
<button
type="button"
onClick={() => void remove(b, true)}
className="text-[11px] text-theme-muted underline underline-offset-2"
title={`Eliminar la serie ${b.grupo}`}
>
serie
</button>
)}
<Button variant="ghost" size="sm" onClick={() => startEdit(b)} title="Editar">
<Pencil size={14} className="text-theme-heading" />
</Button>
<Button variant="ghost" size="sm" onClick={() => void remove(b)} title="Eliminar">
<Trash2 size={14} className="text-rose-500" />
</Button>
</span>
</li>
))}
</ul>
</div>
))}
</div>
)}
</div>
</div>
</Modal>
);
};
/** Menú de cierre rápido que se muestra en la cabecera de una columna de médico */
export interface QuickCloseOption {
key: 'all_day' | 'rest_day' | 'range';
label: string;
hint: string;
}
export const quickCloseOptions = (nowFloat: number, endOfDay = 19): QuickCloseOption[] => [
{ key: 'all_day', label: 'Todo el día', hint: 'Bloquea la agenda completa de esa fecha' },
{
key: 'rest_day',
label: 'Resto del día',
hint: `De ${horaFloatStr(Math.min(nowFloat, endOfDay))} a ${horaFloatStr(endOfDay)}`,
},
{ key: 'range', label: 'Rango de horas', hint: 'Elegir desde y hasta' },
];
export default BloqueosModal;

View File

@@ -0,0 +1,210 @@
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { Check, ChevronLeft, ChevronRight, History, UserCheck, UserX, XCircle } from 'lucide-react';
import type { Appointment, AppointmentStateLog } from '../../services/odoo';
/** Pasos del checklist de atención (espejo de STATE_FLOW en skeen.cita) */
export const CHECKLIST_STEPS = [
{ state: 'pending', label: 'Agendada', hint: 'Cita creada, sin confirmar' },
{ state: 'confirmed', label: 'Confirmada', hint: '1ra confirmación: teléfono' },
{ state: 'arrived', label: 'En cabina', hint: '2da confirmación: llegó a la clínica' },
{ state: 'in_progress', label: 'En consulta', hint: 'El médico la está atendiendo' },
{ state: 'done', label: 'Completada', hint: 'Cita terminada' },
] as const;
export type ChecklistState = typeof CHECKLIST_STEPS[number]['state'];
export const stepIndex = (state: string) => CHECKLIST_STEPS.findIndex((s) => s.state === state);
interface Props {
apt: Appointment;
updating?: boolean;
history?: AppointmentStateLog[];
/** action = next | back | set_state | cancel | no_show ; state = destino en set_state */
onChange: (action: string, state?: string) => void;
/** Oculta la bitácora (vista compacta) */
compact?: boolean;
}
/**
* Checklist de estados de la cita: avanza y retrocede paso a paso.
*
* Los estados alternativos (Canceló / No se presentó) quedan fuera del
* checklist y piden confirmación. La confirmación telefónica y la llegada
* siguen sincronizadas con phone_confirmed / arrived_at desde el backend.
*/
export const EstadoChecklist: FC<Props> = ({ apt, updating, history, onChange, compact }) => {
const [confirmarSalida, setConfirmarSalida] = useState<'cancel' | 'no_show' | null>(null);
const idx = stepIndex(apt.state);
const fueraDelFlujo = idx < 0;
useEffect(() => { setConfirmarSalida(null); }, [apt.id, apt.state]);
const irA = (state: string) => {
if (state === apt.state) return;
const destino = stepIndex(state);
if (destino >= 0 && idx >= 0 && destino < idx) {
if (!window.confirm(`¿Regresar la cita de "${CHECKLIST_STEPS[idx].label}" a "${CHECKLIST_STEPS[destino].label}"?`)) return;
}
onChange('set_state', state);
};
return (
<div className="p-3 bg-theme-bg rounded-xl space-y-3">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-medium text-theme-muted uppercase">Flujo de la cita</p>
{fueraDelFlujo && (
<span className="text-[11px] font-medium text-rose-600">
Fuera del flujo: {apt.state_label || apt.state}
</span>
)}
</div>
{/* Pasos */}
<ol className="flex flex-wrap items-center gap-x-1 gap-y-2">
{CHECKLIST_STEPS.map((step, i) => {
const activo = i === idx;
const hecho = !fueraDelFlujo && i < idx;
return (
<li key={step.state} className="flex items-center">
{i > 0 && (
<span
className={`mx-1 h-0.5 w-4 sm:w-6 rounded ${hecho || activo ? 'bg-emerald-600' : 'bg-theme-border-strong'}`}
aria-hidden="true"
/>
)}
<button
type="button"
onClick={() => irA(step.state)}
disabled={updating}
title={`${step.hint}${activo ? ' (estado actual)' : ' — click para mover la cita aquí'}`}
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all disabled:opacity-60 ${
activo
? 'bg-theme-accent text-theme-inverse border-theme-accent'
: hecho
? 'bg-brand-mint text-theme-heading border-emerald-700'
: 'bg-theme-surface text-theme-muted border-theme-border hover:border-theme-border-strong'
}`}
>
{hecho ? <Check size={12} /> : <span className="text-[10px]">{i + 1}</span>}
{step.label}
</button>
</li>
);
})}
</ol>
{/* Avanzar / retroceder */}
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => onChange('back')}
disabled={updating || idx <= 0}
className="inline-flex items-center gap-1 rounded-xl border border-theme-border px-2.5 py-1.5 text-xs font-medium text-theme-heading hover:border-theme-border-strong disabled:opacity-40"
title="Regresar un paso"
>
<ChevronLeft size={14} />
Retroceder
</button>
<button
type="button"
onClick={() => onChange('next')}
disabled={updating || idx < 0 || idx >= CHECKLIST_STEPS.length - 1}
className="inline-flex items-center gap-1 rounded-xl bg-theme-accent px-3 py-1.5 text-xs font-medium text-theme-inverse disabled:opacity-40"
title="Avanzar un paso"
>
{idx >= 0 && idx < CHECKLIST_STEPS.length - 1 ? `Marcar ${CHECKLIST_STEPS[idx + 1].label}` : 'Siguiente'}
<ChevronRight size={14} />
</button>
<span className="ml-auto flex items-center gap-2">
<button
type="button"
onClick={() => setConfirmarSalida('no_show')}
disabled={updating}
className="inline-flex items-center gap-1 rounded-xl border border-rose-300 px-2.5 py-1.5 text-xs font-medium text-rose-700 hover:bg-rose-50 disabled:opacity-40"
title="El paciente no se presentó"
>
<UserX size={13} />
No se presentó
</button>
<button
type="button"
onClick={() => setConfirmarSalida('cancel')}
disabled={updating}
className="inline-flex items-center gap-1 rounded-xl border border-rose-300 px-2.5 py-1.5 text-xs font-medium text-rose-700 hover:bg-rose-50 disabled:opacity-40"
title="Cancelar la cita"
>
<XCircle size={13} />
Cancelar
</button>
</span>
</div>
{/* Confirmación explícita para los estados alternativos */}
{confirmarSalida && (
<div className="rounded-xl border border-rose-300 bg-rose-50 p-2.5 text-xs text-rose-800">
<p className="font-medium">
{confirmarSalida === 'cancel'
? '¿Marcar la cita como Canceló? El hueco queda libre.'
: '¿Marcar que el paciente no se presentó?'}
</p>
<div className="mt-2 flex gap-2">
<button
type="button"
onClick={() => { onChange(confirmarSalida); setConfirmarSalida(null); }}
className="rounded-lg bg-rose-600 px-2.5 py-1 font-medium text-white"
>
{confirmarSalida === 'cancel' ? 'Sí, cancelar la cita' : 'Sí, marcar no se presentó'}
</button>
<button
type="button"
onClick={() => setConfirmarSalida(null)}
className="rounded-lg border border-rose-300 px-2.5 py-1 font-medium"
>
Volver
</button>
</div>
</div>
)}
{/* Doble confirmación: resumen de los campos propios */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-[11px]">
<span className="inline-flex items-center gap-1 text-theme-muted">
<UserCheck size={12} className={apt.phone_confirmed ? 'text-blue-600' : 'opacity-40'} />
1ra confirmación (teléfono): {apt.phone_confirmed
? `sí${apt.phone_confirmed_by ? ` · ${apt.phone_confirmed_by}` : ''}`
: 'pendiente'}
</span>
<span className="inline-flex items-center gap-1 text-theme-muted">
<UserCheck size={12} className={apt.arrived_at ? 'text-emerald-600' : 'opacity-40'} />
Llegada: {apt.arrived_at ? apt.arrived_at : 'pendiente'}
</span>
</div>
{/* Bitácora */}
{!compact && (
<div className="rounded-xl border border-theme-border bg-theme-surface p-2.5">
<p className="mb-1 inline-flex items-center gap-1 text-[11px] font-medium text-theme-muted uppercase">
<History size={11} /> Bitácora de cambios
</p>
{!history?.length ? (
<p className="text-[11px] text-theme-muted">Sin cambios registrados todavía.</p>
) : (
<ul className="space-y-0.5 max-h-32 overflow-y-auto">
{history.map((h) => (
<li key={h.id} className="text-[11px] text-theme-muted">
<span className="text-theme-heading">{h.date}</span>
{' · '}{h.from_label || '—'} → <span className="font-medium text-theme-heading">{h.to_label}</span>
{h.user ? ` · ${h.user}` : ''}
{h.note ? ` · ${h.note}` : ''}
</li>
))}
</ul>
)}
</div>
)}
</div>
);
};
export default EstadoChecklist;

View File

@@ -0,0 +1,295 @@
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { CalendarPlus, Clock3, MessageCircle, Plus, Trash2, Users } from 'lucide-react';
import { Badge, Button, Input, Modal, Select, TextArea, toast } from '../ui';
import PatientCombobox from '../PatientCombobox';
import { odooApi, type Doctor, type ListaEspera, type Patient, type Service } from '../../services/odoo';
const PRIORIDADES = [
{ value: 'alta', label: 'Alta' },
{ value: 'normal', label: 'Normal' },
{ value: 'baja', label: 'Baja' },
];
const prioridadVariant = (p: ListaEspera['priority']) =>
p === 'alta' ? 'danger' : p === 'baja' ? 'default' : 'warning';
/** Enlace de WhatsApp con mensaje prellenado (wa.me usa solo dígitos) */
export const whatsappLink = (phone: string, text: string) => {
const digits = (phone || '').replace(/\D/g, '');
if (digits.length < 7) return null;
return `https://wa.me/${digits}?text=${encodeURIComponent(text)}`;
};
interface PanelProps {
entries: ListaEspera[];
loading?: boolean;
loadingAgendaId?: number | null;
onAgendar: (entry: ListaEspera) => void;
onQuitar: (entry: ListaEspera) => void;
onAdd: () => void;
onRefresh: () => void;
}
/**
* Panel de lista de espera de la agenda: pacientes sin hueco, ordenados por
* prioridad y antigüedad, con acciones de agendar / avisar / quitar.
*/
export const ListaEsperaPanel: FC<PanelProps> = ({
entries, loading, loadingAgendaId, onAgendar, onQuitar, onAdd, onRefresh,
}) => {
const [abierto, setAbierto] = useState(true);
return (
<div className="mb-4 sm:mb-5 rounded-2xl border-2 border-violet-300 bg-violet-50/50 p-3">
<div className="flex flex-wrap items-center gap-2 mb-2">
<button
type="button"
onClick={() => setAbierto((v) => !v)}
className="inline-flex items-center gap-2 text-xs font-semibold text-theme-heading uppercase"
title={abierto ? 'Plegar la lista de espera' : 'Desplegar la lista de espera'}
>
<Clock3 size={16} className="text-violet-700" />
Lista de espera
<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">
{entries.length}
</span>
<span className="text-theme-muted normal-case font-normal">{abierto ? '· ocultar' : '· ver'}</span>
</button>
<div className="ml-auto flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={onRefresh} title="Recargar la lista de espera">
Actualizar
</Button>
<Button size="sm" variant="outline" onClick={onAdd}>
<Plus size={14} className="mr-1" />
Agregar paciente
</Button>
</div>
</div>
{!abierto ? null : entries.length === 0 ? (
<p className="text-xs text-theme-muted">
{loading ? 'Cargando...' : 'Sin pacientes en espera. Cuando no haya hueco, agrégalos aquí para avisarles al liberarse una cita.'}
</p>
) : (
<div className="flex flex-wrap gap-2">
{entries.map((e) => {
const wa = whatsappLink(
e.phone,
`Hola ${e.patient}, se liberó un espacio en la clínica${e.doctor ? ` con ${e.doctor}` : ''}. ¿Te gustaría tomarlo?`,
);
return (
<div
key={e.id}
className="w-full sm:w-[320px] rounded-xl border border-violet-300 bg-theme-surface px-3 py-2 shadow-card"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-semibold text-theme-heading truncate">{e.patient}</p>
<p className="text-xs text-theme-muted truncate">
{e.service || 'Cualquier servicio'}
{e.doctor ? ` · ${e.doctor}` : ' · sin médico preferido'}
</p>
</div>
<Badge variant={prioridadVariant(e.priority)}>{e.priority_label || e.priority}</Badge>
</div>
<p className="mt-1 text-[11px] text-theme-muted">
{e.date_from || e.date_to
? `Desea: ${e.date_from || '...'} → ${e.date_to || '...'}`
: 'Sin rango de fechas definido'}
{e.franja ? ` · ${e.franja === 'manana' ? 'mañana' : 'tarde'}` : ''}
{' · '}
{e.espera_dias === 0 ? 'hoy' : `hace ${e.espera_dias} d`}
</p>
{e.notes && <p className="text-[11px] text-theme-muted italic truncate">{e.notes}</p>}
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<Button
size="sm"
onClick={() => onAgendar(e)}
loading={loadingAgendaId === e.id}
title="Agendar con el paciente precargado"
>
<CalendarPlus size={14} className="mr-1" />
Agendar
</Button>
{wa ? (
<a
href={wa}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-xl border border-emerald-600 px-2.5 py-1.5 text-xs font-medium text-emerald-700 hover:bg-emerald-50"
title="Avisar por WhatsApp"
>
<MessageCircle size={14} />
Avisar
</a>
) : (
<span className="text-[11px] text-theme-muted">Sin teléfono</span>
)}
<button
type="button"
onClick={() => onQuitar(e)}
className="ml-auto inline-flex items-center gap-1 text-xs text-rose-600 hover:underline"
title="Quitar de la lista de espera"
>
<Trash2 size={13} />
Quitar
</button>
</div>
</div>
);
})}
</div>
)}
</div>
);
};
interface ModalProps {
isOpen: boolean;
onClose: () => void;
doctors: Doctor[];
services: Service[];
/** Paciente ya elegido (desde el modal de cita o el detalle) */
patient?: Patient | { id: number; name: string } | null;
defaultDoctorId?: string;
defaultServiceId?: string;
isMedico?: boolean;
onCreated?: (registro: ListaEspera) => void;
}
/** Alta de un paciente en la lista de espera (paciente, médico/servicio, prioridad, rango) */
export const ListaEsperaModal: FC<ModalProps> = ({
isOpen, onClose, doctors, services, patient, defaultDoctorId, defaultServiceId, isMedico, onCreated,
}) => {
const [patientId, setPatientId] = useState('');
const [patientName, setPatientName] = useState('');
const [doctorId, setDoctorId] = useState(defaultDoctorId || '');
const [serviceId, setServiceId] = useState(defaultServiceId || '');
const [priority, setPriority] = useState('normal');
const [franja, setFranja] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [notes, setNotes] = useState('');
const [saving, setSaving] = useState(false);
// Al abrir con paciente precargado se rellenan los campos del formulario
const patientIdProp = patient?.id ?? null;
const patientNameProp = patient?.name ?? '';
useEffect(() => {
if (!isOpen) return;
setPatientId(patientIdProp ? String(patientIdProp) : '');
setPatientName(patientNameProp || '');
setDoctorId(defaultDoctorId || '');
setServiceId(defaultServiceId || '');
setPriority('normal');
setFranja('');
setDateFrom('');
setDateTo('');
setNotes('');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, patientIdProp, patientNameProp, defaultDoctorId, defaultServiceId]);
const submit = async () => {
if (!patientId) {
toast.error('Selecciona al paciente');
return;
}
try {
setSaving(true);
const res = await odooApi.createListaEspera({
partner_id: parseInt(patientId, 10),
doctor_id: doctorId ? parseInt(doctorId, 10) : null,
service_id: serviceId ? parseInt(serviceId, 10) : null,
priority,
franja,
date_from: dateFrom || undefined,
date_to: dateTo || undefined,
notes,
});
toast.success('Paciente agregado a la lista de espera');
onCreated?.(res.registro);
onClose();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo agregar a la lista de espera');
} finally {
setSaving(false);
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Agregar a lista de espera"
maxWidth="md"
footer={
<>
<Button variant="outline" onClick={onClose}>Cancelar</Button>
<Button onClick={submit} loading={saving}>
<Users size={14} className="mr-1.5" />
Agregar
</Button>
</>
}
>
<div className="space-y-4">
<p className="text-sm text-theme-muted">
Úsalo cuando no haya disponibilidad: el paciente queda registrado y se le avisa
en cuanto se libere una cita compatible.
</p>
<PatientCombobox
label="Paciente *"
value={patientId}
initialName={patientName}
placeholder="Escribe nombre, apellidos o teléfono..."
onChange={(id, p) => {
setPatientId(id);
setPatientName(p?.name || '');
}}
/>
<Select
label="Médico deseado"
options={[{ value: '', label: 'Cualquiera' }, ...doctors.map((d) => ({ value: String(d.id), label: d.name }))]}
value={doctorId}
onChange={(e) => setDoctorId(e.target.value)}
disabled={isMedico}
/>
<Select
label="Servicio deseado"
options={[{ value: '', label: 'Cualquiera' }, ...services.map((s) => ({ value: String(s.id), label: s.name }))]}
value={serviceId}
onChange={(e) => setServiceId(e.target.value)}
/>
<div className="grid grid-cols-2 gap-4">
<Select
label="Prioridad"
options={PRIORIDADES}
value={priority}
onChange={(e) => setPriority(e.target.value)}
/>
<Select
label="Preferencia"
options={[
{ value: '', label: 'Cualquiera' },
{ value: 'manana', label: 'Mañana' },
{ value: 'tarde', label: 'Tarde' },
]}
value={franja}
onChange={(e) => setFranja(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Input label="Fecha desde" type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)} />
<Input label="Fecha hasta" type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)} />
</div>
<TextArea
label="Nota"
placeholder="Ej. disponible solo por las tardes, viene de WACRM..."
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</div>
</Modal>
);
};

View File

@@ -0,0 +1,180 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Pause, Play, RefreshCw } from 'lucide-react';
/**
* Auto refresco por polling inteligente (cada 30 s).
*
* Decisión de diseño: se usa polling, no SSE.
* - El API es Odoo + WSGI con hilos; una conexión SSE por usuario mantendría
* un worker ocupado y el proxy (nginx) corta las respuestas sin tráfico. El
* bus nativo de Odoo (longpolling) tampoco está montado para el frontend.
* - El polling aquí es de un solo request por refresco (la agenda trae citas y
* bloqueos en la misma llamada; la lista de espera en otra) y solo se dispara
* con la pestaña visible.
* - Se pospone si hay un modal abierto, si el usuario está escribiendo o
* arrastrando, y no toca el estado de carga: el refresco es "silencioso",
* por lo que no parpadea ni pierde scroll, filtros o vista activa.
*/
export interface AutoRefreshOptions {
/** Acción de recarga (debe ser silenciosa: sin loaders ni resets) */
onRefresh: () => void | Promise<void>;
/** Si devuelve true, este ciclo se pospone (modal, drag, escritura...) */
blocked?: () => boolean;
/** Intervalo en ms (por defecto 30 s) */
intervalMs?: number;
/** Permite desactivar el ciclo (p. ej. una vista sin datos en vivo) */
enabled?: boolean;
/** Clave de localStorage para recordar la pausa entre navegaciones */
storageKey?: string;
}
export interface AutoRefreshState {
/** Momento del último refresco completado */
lastUpdated: Date;
/** Segundos transcurridos desde el último refresco */
secondsAgo: number;
paused: boolean;
setPaused: (value: boolean) => void;
/** Fuerza un refresco inmediato (ignora la pausa, respeta el bloqueo) */
refreshNow: () => Promise<void>;
/** Marca "actualizado" cuando la vista recarga por su cuenta */
markUpdated: () => void;
refreshing: boolean;
}
export function useAutoRefresh({
onRefresh,
blocked,
intervalMs = 30000,
enabled = true,
storageKey,
}: AutoRefreshOptions): AutoRefreshState {
const [lastUpdated, setLastUpdated] = useState<Date>(() => new Date());
const [secondsAgo, setSecondsAgo] = useState(0);
const [refreshing, setRefreshing] = useState(false);
const [paused, setPausedState] = useState<boolean>(() => {
if (!storageKey) return false;
try {
return localStorage.getItem(storageKey) === '1';
} catch {
return false;
}
});
// Callbacks en refs: el intervalo no se re-crea en cada render ni reinicia
// el contador cada vez que la vista cambia de filtro.
const onRefreshRef = useRef(onRefresh);
onRefreshRef.current = onRefresh;
const blockedRef = useRef(blocked);
blockedRef.current = blocked;
const pausedRef = useRef(paused);
pausedRef.current = paused;
const inFlight = useRef(false);
const setPaused = useCallback((value: boolean) => {
setPausedState(value);
if (!storageKey) return;
try {
localStorage.setItem(storageKey, value ? '1' : '0');
} catch {
// localStorage no disponible: la pausa solo dura la sesión
}
}, [storageKey]);
const runRefresh = useCallback(async (force = false) => {
if (inFlight.current) return;
if (!force && pausedRef.current) return;
if (document.hidden) return;
if (blockedRef.current?.()) return;
inFlight.current = true;
setRefreshing(true);
try {
await onRefreshRef.current();
setLastUpdated(new Date());
} catch {
// Un refresco fallido no debe romper la vista: se reintenta al siguiente ciclo
} finally {
inFlight.current = false;
setRefreshing(false);
}
}, []);
const markUpdated = useCallback(() => {
setLastUpdated(new Date());
}, []);
const refreshNow = useCallback(async () => {
await runRefresh(true);
}, [runRefresh]);
// Ciclo de refresco
useEffect(() => {
if (!enabled) return;
const id = window.setInterval(() => { void runRefresh(); }, intervalMs);
return () => window.clearInterval(id);
}, [enabled, intervalMs, runRefresh]);
// Al volver a la pestaña, si el último refresco quedó viejo, se pone al día
useEffect(() => {
if (!enabled) return;
const onVisible = () => {
if (document.hidden) return;
if (Date.now() - lastUpdated.getTime() >= intervalMs) void runRefresh();
};
document.addEventListener('visibilitychange', onVisible);
return () => document.removeEventListener('visibilitychange', onVisible);
}, [enabled, intervalMs, lastUpdated, runRefresh]);
// Etiqueta "hace Xs" (se recalcula cada 5 s para no re-renderizar de más)
useEffect(() => {
const id = window.setInterval(() => {
setSecondsAgo(Math.max(0, Math.round((Date.now() - lastUpdated.getTime()) / 1000)));
}, 5000);
return () => window.clearInterval(id);
}, [lastUpdated]);
return { lastUpdated, secondsAgo, paused, setPaused, refreshNow, markUpdated, refreshing };
}
interface BadgeProps {
state: AutoRefreshState;
/** Texto del tooltip que explica qué se refresca */
hint?: string;
className?: string;
}
/** Indicador "Actualizado hace Xs" con pausa/reanudar y refresco manual. */
export function AutoRefreshBadge({ state, hint, className = '' }: BadgeProps) {
const { secondsAgo, paused, setPaused, refreshNow, refreshing } = state;
const texto = secondsAgo < 5
? 'Actualizado ahora'
: `Actualizado hace ${secondsAgo < 60 ? `${secondsAgo}s` : `${Math.floor(secondsAgo / 60)} min`}`;
return (
<span className={`inline-flex items-center gap-1.5 text-xs text-theme-muted ${className}`}>
<span
className={`inline-block h-1.5 w-1.5 rounded-full ${paused ? 'bg-theme-border-strong' : 'bg-emerald-500'}`}
aria-hidden="true"
/>
<span title={hint || 'La agenda se actualiza sola cada 30 segundos'}>{texto}</span>
<button
type="button"
onClick={() => void refreshNow()}
disabled={refreshing}
className="inline-flex items-center rounded-full border border-theme-border p-1 hover:border-theme-border-strong disabled:opacity-50"
title="Actualizar ahora"
aria-label="Actualizar ahora"
>
<RefreshCw size={11} className={refreshing ? 'animate-spin' : ''} />
</button>
<button
type="button"
onClick={() => setPaused(!paused)}
className="inline-flex items-center gap-1 rounded-full border border-theme-border px-2 py-0.5 hover:border-theme-border-strong"
title={paused ? 'Reanudar la actualización automática' : 'Pausar la actualización automática'}
>
{paused ? <Play size={10} /> : <Pause size={10} />}
{paused ? 'Reanudar' : 'Pausar'}
</button>
</span>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -52,6 +52,7 @@ import { usePatientDrawer } from '../components/PatientDrawer';
import { useTheme } from '../lib/theme';
import { useAuth } from '../lib/auth';
import { waLink } from '../lib/whatsapp';
import { APT_STATE_LABELS } from './Agenda';
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
@@ -705,7 +706,7 @@ const Dashboard: FC = () => {
<p className="text-sm font-medium text-theme-heading truncate">{apt.patient}</p>
<p className="text-xs text-theme-muted truncate">{apt.service} • {apt.time}</p>
</div>
<Badge variant={badgeForAppointmentState(apt.state)}>{apt.state}</Badge>
<Badge variant={badgeForAppointmentState(apt.state)}>{APT_STATE_LABELS[apt.state] || apt.state}</Badge>
</div>
))}
</div>

View File

@@ -62,7 +62,11 @@ export interface Appointment {
payment_state: string;
price: number;
amount_paid?: number;
/** Duración efectiva en minutos (propia de la cita o la del servicio) */
duration?: number;
/** Duración propia de la cita (0 = la del servicio) */
duration_override?: number;
service_duration?: number;
is_first_visit?: boolean;
doctor_id?: number | null;
doctor?: string;
@@ -79,8 +83,15 @@ export interface Appointment {
arrived_at?: string | null;
// El paciente cumple años el día de la cita (badge pastel en agenda)
patient_birthday?: boolean;
/** Hora en float (p. ej. 9.5) tal como la guarda Odoo */
time_float?: number;
/** Etiqueta del estado con el vocabulario de la clínica */
state_label?: string;
}
/** Motivos predefinidos de un bloqueo de agenda */
export type BloqueoMotivo = 'vacaciones' | 'junta' | 'comida' | 'personal' | 'otro';
export interface Bloqueo {
id: number;
doctor_id: number;
@@ -90,6 +101,50 @@ export interface Bloqueo {
time_from: number;
time_to: number;
motivo: string;
motivo_tipo?: BloqueoMotivo;
motivo_tipo_label?: string;
motivo_label?: string;
grupo?: string;
created_by?: string;
}
/** Entrada en la lista de espera de pacientes */
export interface ListaEspera {
id: number;
reference: string;
patient_id: number;
patient: string;
phone: string;
doctor_id: number | null;
doctor: string | null;
service_id: number | null;
service: string | null;
priority: 'alta' | 'normal' | 'baja';
priority_label: string;
franja: '' | 'manana' | 'tarde';
date_from: string | null;
date_to: string | null;
notes: string;
state: 'esperando' | 'agendado' | 'descartado';
cita_id: number | null;
created_by: string;
create_date: string | null;
resolved_at: string | null;
espera_dias: number;
match_servicio?: boolean;
match_doctor?: boolean;
}
/** Cambio de estado de una cita (bitácora del checklist) */
export interface AppointmentStateLog {
id: number;
from_state: string;
from_label: string;
to_state: string;
to_label: string;
user: string;
note: string;
date: string;
}
export type VisitaState = 'en_curso' | 'completada' | 'cancelada';
@@ -1182,7 +1237,7 @@ export const odooApi = {
},
// Citas
async getAppointments(params?: Record<string, string | number>): Promise<{ status: string; appointments: Appointment[]; bloqueos?: Bloqueo[]; total?: number; page?: number; page_size?: number; total_pages?: number }> {
async getAppointments(params?: Record<string, string | number>): Promise<{ status: string; appointments: Appointment[]; bloqueos?: Bloqueo[]; lista_espera?: ListaEspera[]; total?: number; page?: number; page_size?: number; total_pages?: number; medico_sin_employee?: boolean }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/appointments`, { params });
return data;
},
@@ -1192,7 +1247,7 @@ export const odooApi = {
return data;
},
async createQuickAppointment(payload: { name: string; phone: string; date: string; time: number; doctor_id?: number; notes?: string }): Promise<{ status: string; appointment: Appointment; patient: Patient; patient_created: boolean; message?: string }> {
async createQuickAppointment(payload: { name: string; phone: string; date: string; time: number; doctor_id?: number; notes?: string; duration?: number }): Promise<{ status: string; appointment: Appointment; patient: Patient; patient_created: boolean; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/appointments/quick`, payload);
return data;
},
@@ -1222,8 +1277,14 @@ export const odooApi = {
return data;
},
async updateAppointmentStatus(id: number, action: string, reason?: string): Promise<{ status: string; appointment: Appointment }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/appointments/${id}/status`, { action, reason });
async updateAppointmentStatus(id: number, action: string, reason?: string, state?: string): Promise<{ status: string; appointment: Appointment }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/appointments/${id}/status`, { action, reason, state });
return data;
},
/** Bitácora de cambios de estado de la cita (checklist con retroceso) */
async getAppointmentStateHistory(id: number): Promise<{ status: string; history: AppointmentStateLog[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/appointments/${id}/state_history`);
return data;
},
@@ -1274,8 +1335,11 @@ export const odooApi = {
return data;
},
async getAvailableSlots(date: string, servicio_id: number): Promise<{ status: string; slots: { time: number; time_str: string }[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/appointments/slots`, { params: { date, servicio_id } });
async getAvailableSlots(date: string, servicio_id: number, doctor_id?: number, duration?: number): Promise<{ status: string; slots: { time: number; time_str: string }[] }> {
const params: Record<string, string | number> = { date, servicio_id };
if (doctor_id) params.doctor_id = doctor_id;
if (duration) params.duration = duration;
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/appointments/slots`, { params });
return data;
},
@@ -1285,16 +1349,75 @@ export const odooApi = {
return data;
},
async createBloqueo(payload: { doctor_id: number; date: string; all_day?: boolean; time_from?: number; time_to?: number; motivo?: string }): Promise<{ status: string; bloqueo: Bloqueo }> {
async createBloqueo(payload: {
doctor_ids: number[];
date_from: string;
date_to?: string;
days?: number[];
all_day?: boolean;
time_from?: number;
time_to?: number;
motivo_tipo?: BloqueoMotivo;
motivo?: string;
force?: boolean;
}): Promise<{ status: string; grupo: string; creados: number; bloqueos: Bloqueo[] }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/bloqueos`, payload);
return data;
},
async updateBloqueo(id: number, payload: Partial<{
doctor_id: number; date: string; all_day: boolean; time_from: number; time_to: number;
motivo_tipo: BloqueoMotivo; motivo: string; force: boolean;
}>): Promise<{ status: string; bloqueo: Bloqueo }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/bloqueos/${id}`, payload);
return data;
},
async deleteBloqueo(id: number): Promise<{ status: string; message: string }> {
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/bloqueos/${id}`);
return data;
},
/** Elimina la serie completa de bloqueos creados juntos (mismo grupo) */
async deleteBloqueoGrupo(grupo: string): Promise<{ status: string; eliminados: number }> {
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/bloqueos/grupo/${grupo}`);
return data;
},
// Lista de espera de pacientes
async getListaEspera(params?: Record<string, string | number>): Promise<{ status: string; lista: ListaEspera[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/lista_espera`, { params });
return data;
},
async createListaEspera(payload: {
partner_id: number; doctor_id?: number | null; service_id?: number | null;
priority?: string; franja?: string; date_from?: string; date_to?: string; notes?: string;
}): Promise<{ status: string; registro: ListaEspera }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/lista_espera`, payload);
return data;
},
async updateListaEspera(id: number, payload: Partial<{
state: 'esperando' | 'agendado' | 'descartado'; cita_id: number; priority: string;
franja: string; date_from: string; date_to: string; notes: string; doctor_id: number;
service_id: number;
}>): Promise<{ status: string; registro: ListaEspera }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/lista_espera/${id}`, payload);
return data;
},
async deleteListaEspera(id: number): Promise<{ status: string; message: string }> {
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/lista_espera/${id}`);
return data;
},
/** Pacientes en espera compatibles con un hueco que se liberó */
async getListaEsperaCompatibles(params: { doctor_id?: number; servicio_id?: number; date?: string }): Promise<{ status: string; lista: ListaEspera[]; total: number }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/lista_espera/compatibles`, { params });
return data;
},
// Visitas clínicas
async getVisitas(params?: Record<string, string | number>): Promise<{ status: string; visitas: Visita[]; total?: number; page?: number; page_size?: number; total_pages?: number }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/visitas`, { params });