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

View File

@@ -62,7 +62,11 @@ export interface Appointment {
payment_state: string; payment_state: string;
price: number; price: number;
amount_paid?: number; amount_paid?: number;
/** Duración efectiva en minutos (propia de la cita o la del servicio) */
duration?: number; duration?: number;
/** Duración propia de la cita (0 = la del servicio) */
duration_override?: number;
service_duration?: number;
is_first_visit?: boolean; is_first_visit?: boolean;
doctor_id?: number | null; doctor_id?: number | null;
doctor?: string; doctor?: string;
@@ -79,8 +83,15 @@ export interface Appointment {
arrived_at?: string | null; arrived_at?: string | null;
// El paciente cumple años el día de la cita (badge pastel en agenda) // El paciente cumple años el día de la cita (badge pastel en agenda)
patient_birthday?: boolean; 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 { export interface Bloqueo {
id: number; id: number;
doctor_id: number; doctor_id: number;
@@ -90,6 +101,50 @@ export interface Bloqueo {
time_from: number; time_from: number;
time_to: number; time_to: number;
motivo: string; 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'; export type VisitaState = 'en_curso' | 'completada' | 'cancelada';
@@ -1182,7 +1237,7 @@ export const odooApi = {
}, },
// Citas // 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 }); const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/appointments`, { params });
return data; return data;
}, },
@@ -1192,7 +1247,7 @@ export const odooApi = {
return data; 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); const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/appointments/quick`, payload);
return data; return data;
}, },
@@ -1222,8 +1277,14 @@ export const odooApi = {
return data; return data;
}, },
async updateAppointmentStatus(id: number, action: string, reason?: string): Promise<{ status: string; appointment: Appointment }> { 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 }); 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; return data;
}, },
@@ -1274,8 +1335,11 @@ export const odooApi = {
return data; return data;
}, },
async getAvailableSlots(date: string, servicio_id: number): Promise<{ status: string; slots: { time: number; time_str: string }[] }> { async getAvailableSlots(date: string, servicio_id: number, doctor_id?: number, duration?: 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 } }); 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; return data;
}, },
@@ -1285,16 +1349,75 @@ export const odooApi = {
return data; 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); const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/bloqueos`, payload);
return data; 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 }> { async deleteBloqueo(id: number): Promise<{ status: string; message: string }> {
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/bloqueos/${id}`); const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/bloqueos/${id}`);
return data; 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 // 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 }> { 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 }); const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/visitas`, { params });

View File

@@ -16,6 +16,7 @@
'data/sequences.xml', 'data/sequences.xml',
'views/cita_views.xml', 'views/cita_views.xml',
'views/bloqueo_views.xml', 'views/bloqueo_views.xml',
'views/lista_espera_views.xml',
'views/menu_views.xml', 'views/menu_views.xml',
], ],
'installable': True, 'installable': True,

View File

@@ -20,5 +20,15 @@
<field name="number_next">1</field> <field name="number_next">1</field>
<field name="number_increment">1</field> <field name="number_increment">1</field>
</record> </record>
<!-- Secuencia para la lista de espera -->
<record id="seq_skeen_lista_espera" model="ir.sequence">
<field name="name">Lista de espera SKEEN</field>
<field name="code">skeen.lista.espera</field>
<field name="prefix">LE-</field>
<field name="padding">4</field>
<field name="number_next">1</field>
<field name="number_increment">1</field>
</record>
</data> </data>
</odoo> </odoo>

View File

@@ -2,3 +2,4 @@
from . import cita from . import cita
from . import bloqueo from . import bloqueo
from . import lista_espera

View File

@@ -3,21 +3,81 @@
from odoo import models, fields, api, _ from odoo import models, fields, api, _
from odoo.exceptions import ValidationError from odoo.exceptions import ValidationError
# Motivos predefinidos (el legacy usaba 7 = Vacaciones y 8 = Horario de comida)
MOTIVO_SELECTION = [
('vacaciones', 'Vacaciones'),
('junta', 'Junta'),
('comida', 'Comida'),
('personal', 'Personal'),
('otro', 'Otro'),
]
class SkeenCitaBloqueo(models.Model): class SkeenCitaBloqueo(models.Model):
"""Bloqueo de agenda de un médico para una fecha concreta.
Un registro = un médico × una fecha. Los bloqueos creados juntos (varios
médicos y/o un rango de fechas con repetición semanal) comparten el campo
`grupo`, lo que permite editarlos o eliminarlos como serie.
"""
_name = 'skeen.cita.bloqueo' _name = 'skeen.cita.bloqueo'
_description = 'Bloqueo de Agenda por Médico' _description = 'Bloqueo de Agenda por Médico'
_order = 'date desc' _order = 'date desc, time_from'
doctor_id = fields.Many2one('hr.employee', string='Médico', required=True) doctor_id = fields.Many2one('hr.employee', string='Médico', required=True)
date = fields.Date(string='Fecha', required=True, index=True) date = fields.Date(string='Fecha', required=True, index=True)
all_day = fields.Boolean(string='Todo el día', default=False) all_day = fields.Boolean(string='Todo el día', default=False)
time_from = fields.Float(string='Desde', default=9.0) time_from = fields.Float(string='Desde', default=9.0)
time_to = fields.Float(string='Hasta', default=10.0) time_to = fields.Float(string='Hasta', default=10.0)
motivo = fields.Char(string='Motivo') motivo_tipo = fields.Selection(MOTIVO_SELECTION, string='Motivo', default='otro')
motivo = fields.Char(string='Detalle del motivo')
grupo = fields.Char(string='Grupo', index=True, copy=False,
help='Identificador de la serie (médicos y fechas creados juntos).')
created_by = fields.Char(string='Creado por', readonly=True, copy=False)
@api.constrains('time_from', 'time_to', 'all_day') @api.constrains('time_from', 'time_to', 'all_day')
def _check_horas(self): def _check_horas(self):
for b in self: for b in self:
if not b.all_day and b.time_to <= b.time_from: if not b.all_day:
if b.time_to <= b.time_from:
raise ValidationError(_('La hora final debe ser mayor a la inicial')) raise ValidationError(_('La hora final debe ser mayor a la inicial'))
if b.time_from < 0 or b.time_to > 24:
raise ValidationError(_('Las horas deben estar dentro del día'))
@api.constrains('doctor_id', 'date', 'all_day', 'time_from', 'time_to')
def _check_sin_citas(self):
"""No se puede bloquear un rango que ya tiene citas activas.
Evita "cerrar la agenda" dejando citas dentro del bloqueo. Al editar un
bloqueo se ignora a sí mismo (no tiene citas asociadas, pero el dominio
es el mismo que usa la validación de citas).
"""
Citas = self.env['skeen.cita']
if self.env.context.get('skeen_skip_bloqueo_citas'):
return
for b in self:
if not b.doctor_id or not b.date:
continue
domain = [
('doctor_id', '=', b.doctor_id.id),
('date', '=', b.date),
('state', 'not in', ('cancelled', 'no_show', 'waitlist')),
]
if not b.all_day:
domain += [('time', '<', b.time_to)]
citas = Citas.search(domain).filtered(
lambda c: b.all_day or (c.time + c._duracion() / 60.0) > b.time_from
)
if citas:
raise ValidationError(_(
'Hay %(n)s cita(s) agendadas en ese horario con %(doc)s. '
'Reprograma o cancela esas citas antes de bloquear.'
) % {'n': len(citas), 'doc': b.doctor_id.name})
def nombre_motivo(self):
"""Texto legible del motivo para la UI y los mensajes de validación."""
self.ensure_one()
etiqueta = dict(MOTIVO_SELECTION).get(self.motivo_tipo or '', '')
if self.motivo and etiqueta:
return '%s — %s' % (etiqueta, self.motivo)
return self.motivo or etiqueta or 'agenda bloqueada'

View File

@@ -5,6 +5,17 @@ from odoo.exceptions import ValidationError
from datetime import datetime, timedelta from datetime import datetime, timedelta
# Estados que liberan el horario: no ocupan agenda ni bloquean solapes.
# 'waitlist' (Lista de espera) es una cita tentativa sin hueco reservado.
INACTIVE_STATES = ('cancelled', 'no_show', 'waitlist')
# Orden del checklist de atención (permite avanzar y retroceder paso a paso).
STATE_FLOW = ['pending', 'confirmed', 'arrived', 'in_progress', 'done']
# Duración de respaldo cuando ni la cita ni el servicio la definen
DEFAULT_DURATION = 30
class SkeenServicio(models.Model): class SkeenServicio(models.Model):
_name = 'skeen.servicio' _name = 'skeen.servicio'
_description = 'Servicio Dermatológico' _description = 'Servicio Dermatológico'
@@ -107,17 +118,29 @@ class SkeenCita(models.Model):
('scheduled', 'Agendar'), ('scheduled', 'Agendar'),
], string='Estado de Flujo', default='scheduled') ], string='Estado de Flujo', default='scheduled')
# Estado # Estado (rubros alineados al vocabulario de la clínica; 'waitlist' viene
# de la leyenda del sistema legacy: 4/5 = Lista de espera)
state = fields.Selection([ state = fields.Selection([
('pending', 'Pendiente'), ('pending', 'Agendada'),
('confirmed', 'Confirmada'), ('confirmed', 'Confirmada'),
('arrived', 'Llegó'), ('arrived', 'En cabina'),
('in_progress', 'En Progreso'), ('in_progress', 'En consulta'),
('done', 'Completada'), ('done', 'Completada'),
('cancelled', 'Cancelada'), ('cancelled', 'Canceló'),
('no_show', 'No Show'), ('no_show', 'No se presentó'),
('waitlist', 'Lista de espera'),
], string='Estado', default='pending', tracking=True) ], string='Estado', default='pending', tracking=True)
# Duración propia de la cita en minutos. 0 = usar la del servicio.
# Permite que una cita de 60 min ocupe 4 bloques de 15 min en la agenda.
duration = fields.Integer(
string='Duración (min)', default=0,
help='Duración de esta cita. 0 = usar la duración del servicio.')
# Bitácora de cambios de estado (avance y retroceso del checklist)
state_log_ids = fields.One2many(
'skeen.cita.state.log', 'cita_id', string='Bitácora de estados')
# Notas # Notas
notes = fields.Text(string='Notas') notes = fields.Text(string='Notas')
cancellation_reason = fields.Text(string='Motivo Cancelación') cancellation_reason = fields.Text(string='Motivo Cancelación')
@@ -151,17 +174,14 @@ class SkeenCita(models.Model):
package_finished = fields.Boolean(string='Paquete Terminado', default=False) package_finished = fields.Boolean(string='Paquete Terminado', default=False)
package_finished_date = fields.Datetime(string='Fecha Término Paquete') package_finished_date = fields.Datetime(string='Fecha Término Paquete')
@api.depends('date', 'time', 'servicio_id') @api.depends('date', 'time', 'servicio_id', 'duration')
def _compute_datetime(self): def _compute_datetime(self):
for cita in self: for cita in self:
if cita.date and cita.time is not False: if cita.date and cita.time is not False:
hours = int(cita.time) hours = int(cita.time)
minutes = int((cita.time - hours) * 60) minutes = int((cita.time - hours) * 60)
cita.datetime_start = datetime.combine(cita.date, datetime.min.time().replace(hour=hours, minute=minutes)) cita.datetime_start = datetime.combine(cita.date, datetime.min.time().replace(hour=hours, minute=minutes))
if cita.servicio_id: cita.datetime_end = cita.datetime_start + timedelta(minutes=cita._duracion())
cita.datetime_end = cita.datetime_start + timedelta(minutes=cita.servicio_id.duration_min)
else:
cita.datetime_end = cita.datetime_start + timedelta(minutes=30)
@api.depends('time') @api.depends('time')
def _compute_time_str(self): def _compute_time_str(self):
@@ -180,19 +200,96 @@ class SkeenCita(models.Model):
vals['name'] = self.env['ir.sequence'].next_by_code('skeen.cita') or 'CITA-001' vals['name'] = self.env['ir.sequence'].next_by_code('skeen.cita') or 'CITA-001'
return super(SkeenCita, self).create(vals_list) return super(SkeenCita, self).create(vals_list)
def _duracion(self):
"""Duración efectiva de la cita en minutos.
Prioridad: `duration` propio de la cita -> `duration_min` del servicio
-> 30 min. Es la base de los solapes, los bloqueos, el horario del
médico y el pintado del grid de agenda.
"""
self.ensure_one()
return int(self.duration or (self.servicio_id.duration_min if self.servicio_id else 0) or DEFAULT_DURATION)
def _log_state(self, from_state, to_state, user=None, note=''):
"""Registra el cambio de estado en la bitácora. Nunca tumba la operación."""
try:
self.env['skeen.cita.state.log'].sudo().create({
'cita_id': self.id,
'from_state': from_state or '',
'to_state': to_state or '',
'user_name': user or '',
'note': note or '',
})
except Exception:
import logging
logging.getLogger(__name__).exception('No se pudo registrar la bitácora de estado')
def set_state(self, new_state, user=None, note=''):
"""Cambia el estado de la cita y registra quién/cuándo en la bitácora.
A diferencia de las acciones de flujo (que solo avanzan), este método
permite retroceder de un paso a otro. Mantiene sincronizados los campos
de doble confirmación:
- al volver a 'pending' se retira la confirmación telefónica;
- al confirmar se sella phone_confirmed/at/by si aún no estaba;
- al llegar (o pasar a en consulta/completada) se sella arrived_at.
"""
if not new_state:
raise ValidationError(_('Falta el estado destino'))
if new_state not in dict(self._fields['state'].selection):
raise ValidationError(_('Estado no válido: %s') % new_state)
for cita in self:
old_state = cita.state
if old_state == new_state:
continue
vals = {'state': new_state}
if new_state == 'pending':
vals.update({
'phone_confirmed': False,
'phone_confirmed_at': False,
'phone_confirmed_by': False,
})
elif new_state == 'confirmed' and not cita.phone_confirmed:
vals.update({
'phone_confirmed': True,
'phone_confirmed_at': fields.Datetime.now(),
'phone_confirmed_by': user or '',
})
if new_state in ('arrived', 'in_progress', 'done') and not cita.arrived_at:
vals['arrived_at'] = fields.Datetime.now()
cita.write(vals)
if new_state == 'done' and cita.partner_id:
cita.partner_id.write({'last_visit': fields.Date.today()})
cita._log_state(old_state, new_state, user=user, note=note)
return True
def action_next(self):
"""Avanza un paso en el checklist (Agendada -> ... -> Completada)."""
for cita in self:
idx = STATE_FLOW.index(cita.state) if cita.state in STATE_FLOW else -1
if idx < 0 or idx >= len(STATE_FLOW) - 1:
raise ValidationError(_('No hay un paso siguiente desde el estado actual'))
cita.set_state(STATE_FLOW[idx + 1])
def action_back(self):
"""Retrocede un paso en el checklist (p. ej. Completada -> En consulta)."""
for cita in self:
idx = STATE_FLOW.index(cita.state) if cita.state in STATE_FLOW else -1
if idx <= 0:
raise ValidationError(_('No hay un paso anterior desde el estado actual'))
cita.set_state(STATE_FLOW[idx - 1])
def action_confirm(self): def action_confirm(self):
self.write({'state': 'confirmed'}) self.set_state('confirmed')
def action_arrive(self): def action_arrive(self):
self.write({'state': 'arrived', 'arrived_at': fields.Datetime.now()}) self.set_state('arrived')
def action_start(self): def action_start(self):
self.write({'state': 'in_progress'}) self.set_state('in_progress')
def action_done(self): def action_done(self):
self.write({'state': 'done'}) self.set_state('done')
# Actualizar última visita del paciente
self.partner_id.write({'last_visit': fields.Date.today()})
def action_cancel(self): def action_cancel(self):
return { return {
@@ -205,7 +302,7 @@ class SkeenCita(models.Model):
} }
def action_no_show(self): def action_no_show(self):
self.write({'state': 'no_show'}) self.set_state('no_show')
def action_package_finished(self): def action_package_finished(self):
"""Marca la cita como paquete terminado (servicios de categoría 'paquete'). """Marca la cita como paquete terminado (servicios de categoría 'paquete').
@@ -221,6 +318,8 @@ class SkeenCita(models.Model):
if cita.state not in ('done', 'cancelled', 'no_show'): if cita.state not in ('done', 'cancelled', 'no_show'):
vals['state'] = 'done' vals['state'] = 'done'
cita.write(vals) cita.write(vals)
if vals.get('state'):
cita._log_state('', 'done', note='Paquete terminado')
if cita.partner_id: if cita.partner_id:
cita.partner_id.write({'last_visit': fields.Date.today()}) cita.partner_id.write({'last_visit': fields.Date.today()})
return True return True
@@ -228,18 +327,19 @@ class SkeenCita(models.Model):
def action_send_reminder(self): def action_send_reminder(self):
self.write({'reminder_sent': True, 'reminder_date': fields.Datetime.now()}) self.write({'reminder_sent': True, 'reminder_date': fields.Datetime.now()})
@api.constrains('date', 'time', 'doctor_id', 'servicio_id') @api.constrains('date', 'time', 'doctor_id', 'servicio_id', 'duration')
def _check_availability(self): def _check_availability(self):
for cita in self: for cita in self:
if cita.state in ('cancelled', 'no_show'): if cita.state in INACTIVE_STATES:
continue continue
# Verificar solapamiento SOLO dentro del mismo médico: # Verificar solapamiento SOLO dentro del mismo médico:
# la clínica permite citas simultáneas con doctores distintos. # la clínica permite citas simultáneas con doctores distintos.
# Criterio para citas sin médico: solo traslapan con otras sin médico. # Criterio para citas sin médico: solo traslapan con otras sin médico.
# datetime_end ya incluye la duración real de la cita.
domain = [ domain = [
('id', '!=', cita.id), ('id', '!=', cita.id),
('date', '=', cita.date), ('date', '=', cita.date),
('state', 'not in', ('cancelled', 'no_show')), ('state', 'not in', INACTIVE_STATES),
('doctor_id', '=', cita.doctor_id.id if cita.doctor_id else False), ('doctor_id', '=', cita.doctor_id.id if cita.doctor_id else False),
('datetime_start', '<', cita.datetime_end), ('datetime_start', '<', cita.datetime_end),
('datetime_end', '>', cita.datetime_start), ('datetime_end', '>', cita.datetime_start),
@@ -248,47 +348,82 @@ class SkeenCita(models.Model):
if overlapping: if overlapping:
raise ValidationError(_('Ya existe una cita en ese horario para ese médico!')) raise ValidationError(_('Ya existe una cita en ese horario para ese médico!'))
@api.constrains('date', 'time', 'doctor_id', 'servicio_id') @api.constrains('date', 'time', 'doctor_id', 'servicio_id', 'duration')
def _check_bloqueos(self): def _check_bloqueos(self):
"""Rechaza citas que se solapan con un bloqueo de agenda del médico""" """Rechaza citas que se solapan con un bloqueo de agenda del médico.
Sin médico asignado (citas que entran por WACRM/Hermes) solo se rechaza
si el horario está bloqueado para TODOS los médicos: es decir, la
clínica está cerrada a esa hora.
"""
Bloqueo = self.env['skeen.cita.bloqueo']
for cita in self: for cita in self:
if cita.state in ('cancelled', 'no_show') or not cita.doctor_id or not cita.date: if cita.state in INACTIVE_STATES or not cita.date:
continue continue
dur = ((cita.servicio_id.duration_min if cita.servicio_id else 30) or 30) / 60.0 dur = cita._duracion() / 60.0
ini = cita.time ini = cita.time
fin = ini + dur fin = ini + dur
bloqueos = self.env['skeen.cita.bloqueo'].search([ if not cita.doctor_id:
doctores = self.env['hr.employee'].search([
('staff_type', 'in', ('doctor', 'cosmetologa')),
])
if not doctores:
continue
bloqueos_dia = Bloqueo.search([('date', '=', cita.date)])
bloqueados = bloqueos_dia.filtered(
lambda b: b.all_day or (ini < b.time_to and fin > b.time_from)).mapped('doctor_id')
if set(doctores.ids).issubset(set(bloqueados.ids)):
raise ValidationError(_('La clínica tiene la agenda bloqueada (%s)') % (
bloqueos_dia[0].nombre_motivo() if bloqueos_dia else 'agenda bloqueada'))
continue
bloqueos = Bloqueo.search([
('doctor_id', '=', cita.doctor_id.id), ('doctor_id', '=', cita.doctor_id.id),
('date', '=', cita.date), ('date', '=', cita.date),
]) ])
for b in bloqueos: for b in bloqueos:
if b.all_day or (ini < b.time_to and fin > b.time_from): if b.all_day or (ini < b.time_to and fin > b.time_from):
raise ValidationError(_('El médico tiene un bloqueo (%s)') % (b.motivo or 'agenda bloqueada')) raise ValidationError(_('El médico tiene un bloqueo (%s)') % b.nombre_motivo())
@api.constrains('date', 'time', 'doctor_id') @api.constrains('date', 'time', 'doctor_id', 'servicio_id', 'duration')
def _check_horario_medico(self): def _check_horario_medico(self):
"""Rechaza citas fuera del horario semanal del médico. """Rechaza citas fuera del horario semanal del médico.
Sólo aplica a médicos CON horario configurado y sólo al crear/editar la Sólo aplica a médicos CON horario configurado y sólo al crear/editar la
fecha, la hora o el médico: las citas históricas fuera de horario se fecha, la hora, el médico o la duración: las citas históricas fuera de
conservan intactas (cambios de estado u otros campos no las revalidan). horario se conservan intactas (cambios de estado u otros campos no las
revalidan).
""" """
for cita in self: for cita in self:
if cita.state in ('cancelled', 'no_show') or not cita.doctor_id or not cita.date: if cita.state in INACTIVE_STATES or not cita.doctor_id or not cita.date:
continue continue
dur = (cita.servicio_id.duration_min if cita.servicio_id else 30) or 30 ok, mensaje = cita.doctor_id._valida_cita_en_horario(cita.date, cita.time, cita._duracion())
ok, mensaje = cita.doctor_id._valida_cita_en_horario(cita.date, cita.time, dur)
if not ok: if not ok:
raise ValidationError(_(mensaje)) raise ValidationError(_(mensaje))
def get_available_slots(self, date, servicio_id, doctor_id=None): @api.constrains('duration')
def _check_duration(self):
for cita in self:
if cita.duration and (cita.duration < 5 or cita.duration > 480):
raise ValidationError(_('La duración debe estar entre 5 y 480 minutos'))
def get_available_slots(self, date, servicio_id, doctor_id=None, duration=None):
"""Retorna slots disponibles para una fecha y servicio. """Retorna slots disponibles para una fecha y servicio.
Con doctor_id, además excluye los horarios bloqueados de ese médico Con doctor_id, además excluye los horarios bloqueados de ese médico
(el usuario no debe poder elegir una hora bloqueada) y respeta su (el usuario no debe poder elegir una hora bloqueada) y respeta su
horario semanal cuando lo tiene configurado (sin horario configurado horario semanal cuando lo tiene configurado (sin horario configurado
el comportamiento es el de siempre: 8:00 a 19:00).""" el comportamiento es el de siempre: 8:00 a 19:00).
servicio = self.env['skeen.servicio'].browse(servicio_id)
duration = servicio.duration_min if servicio else 30 `duration` (min) permite simular una duración distinta a la del
servicio, p. ej. cuando recepción la ajusta en el modal de la cita.
El cálculo de ocupación usa inicio + duración real de cada cita.
"""
servicio = self.env['skeen.servicio'].browse(int(servicio_id)) if servicio_id else self.env['skeen.servicio']
try:
duration = int(duration or 0) or (servicio.duration_min if servicio else DEFAULT_DURATION)
except (TypeError, ValueError):
duration = servicio.duration_min if servicio else DEFAULT_DURATION
duration = duration or DEFAULT_DURATION
# Bloqueos del médico para la fecha (día completo y por rango) # Bloqueos del médico para la fecha (día completo y por rango)
bloqueos = self.env['skeen.cita.bloqueo'].search([ bloqueos = self.env['skeen.cita.bloqueo'].search([
@@ -320,7 +455,13 @@ class SkeenCita(models.Model):
domain = [ domain = [
('date', '=', date), ('date', '=', date),
('state', 'not in', ('cancelled', 'no_show')), ('state', 'not in', INACTIVE_STATES),
# Con médico explícito el solape es por médico (la clínica
# permite citas simultáneas de médicos distintos) y una cita
# de 60 min descarta los 4 bloques de 15 min que cubre. Sin
# médico (flujo externo WACRM/Hermes) se conserva el criterio
# conservador: cualquier cita del día ocupa el hueco.
] + ([('doctor_id', '=', doctor_id)] if doctor_id else []) + [
('datetime_start', '<', slot_end), ('datetime_start', '<', slot_end),
('datetime_end', '>', slot_start), ('datetime_end', '>', slot_start),
] ]
@@ -336,3 +477,27 @@ class SkeenCita(models.Model):
current += 0.25 # Slots cada 15 min current += 0.25 # Slots cada 15 min
return slots return slots
class SkeenCitaStateLog(models.Model):
"""Bitácora de cambios de estado de una cita (avance y retroceso).
Registra el estado anterior, el nuevo, quién lo cambió (login del usuario
del frontend o del usuario de Odoo) y cuándo. Es la evidencia de auditoría
del checklist de atención.
"""
_name = 'skeen.cita.state.log'
_description = 'Bitácora de estados de la cita'
_order = 'create_date desc, id desc'
cita_id = fields.Many2one('skeen.cita', string='Cita', required=True,
ondelete='cascade', index=True)
from_state = fields.Char(string='Estado anterior')
to_state = fields.Char(string='Estado nuevo')
user_name = fields.Char(string='Usuario')
note = fields.Char(string='Nota')
date_change = fields.Datetime(string='Fecha', default=fields.Datetime.now)
def _state_label(self, state):
selection = dict(self.env['skeen.cita']._fields['state'].selection)
return selection.get(state, state or '')

View File

@@ -0,0 +1,121 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
# Rango de prioridad (menor = se atiende primero). El orden del _order usa este
# rango entero porque ordenar por el Selection lo haría alfabéticamente.
PRIORITY_RANK = {'alta': 0, 'normal': 1, 'baja': 2}
class SkeenListaEspera(models.Model):
"""Paciente en lista de espera cuando no hay disponibilidad.
Es un registro liviano: no reserva horario en la agenda. Recepción lo crea
desde el modal de nueva cita o desde el detalle de una cita sin hueco, y lo
va resolviendo cuando se libera un espacio (se agenda) o cuando el paciente
ya no lo necesita (se descarta).
Auditoría básica: `historial` guarda cada cambio con fecha y usuario, y los
campos create_uid/write_uid/date de Odoo quedan como respaldo.
"""
_name = 'skeen.lista.espera'
_description = 'Lista de espera de pacientes'
_order = 'priority_rank, create_date, id'
name = fields.Char(string='Referencia', readonly=True, copy=False, default='Nuevo')
partner_id = fields.Many2one('res.partner', string='Paciente', required=True,
domain=[('is_patient', '=', True)])
partner_phone = fields.Char(related='partner_id.phone', string='Teléfono', readonly=True)
doctor_id = fields.Many2one('hr.employee', string='Médico deseado')
servicio_id = fields.Many2one('skeen.servicio', string='Servicio deseado')
priority = fields.Selection([
('alta', 'Alta'),
('normal', 'Normal'),
('baja', 'Baja'),
], string='Prioridad', default='normal', required=True)
priority_rank = fields.Integer(string='Rango prioridad',
compute='_compute_priority_rank', store=True)
franja = fields.Selection([
('', 'Cualquiera'),
('manana', 'Mañana'),
('tarde', 'Tarde'),
], string='Preferencia', default='')
date_from = fields.Date(string='Desde')
date_to = fields.Date(string='Hasta')
notes = fields.Text(string='Nota')
state = fields.Selection([
('esperando', 'Esperando'),
('agendado', 'Agendado'),
('descartado', 'Descartado'),
], string='Estado', default='esperando', required=True, index=True)
cita_id = fields.Many2one('skeen.cita', string='Cita asignada', ondelete='set null')
resolved_at = fields.Datetime(string='Resuelto el')
created_by = fields.Char(string='Creado por', readonly=True, copy=False)
historial = fields.Text(string='Historial', readonly=True, copy=False)
_sql_constraints = []
@api.constrains('partner_id', 'state', 'doctor_id')
def _check_unico_esperando(self):
"""Un paciente no puede estar dos veces esperando al mismo médico."""
for rec in self:
if rec.state != 'esperando':
continue
duplicado = self.search([
('id', '!=', rec.id),
('partner_id', '=', rec.partner_id.id),
('state', '=', 'esperando'),
('doctor_id', '=', rec.doctor_id.id),
], limit=1)
if duplicado:
raise ValidationError(_(
'%(nombre)s ya está en la lista de espera para %(doc)s.'
) % {'nombre': rec.partner_id.name,
'doc': rec.doctor_id.name or 'sin médico asignado'})
@api.depends('priority')
def _compute_priority_rank(self):
for rec in self:
rec.priority_rank = PRIORITY_RANK.get(rec.priority or 'normal', 1)
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('name') or vals.get('name') == 'Nuevo':
vals['name'] = self.env['ir.sequence'].next_by_code('skeen.lista.espera') or 'LE-001'
records = super().create(vals_list)
for rec in records:
rec._log('Alta en lista de espera')
return records
def _log(self, mensaje, user=None):
"""Añade una línea al historial (fecha · usuario · mensaje)."""
for rec in self:
marca = fields.Datetime.now().strftime('%Y-%m-%d %H:%M')
autor = user or rec.created_by or rec.env.user.name or ''
linea = '%s · %s%s' % (marca, ('%s: ' % autor) if autor else '', mensaje)
rec.sudo().write({'historial': ((rec.historial or '').strip() + '\n' + linea).strip()})
return True
def action_agendar(self, cita=None):
for rec in self:
rec.write({
'state': 'agendado',
'cita_id': cita.id if cita else rec.cita_id.id,
'resolved_at': fields.Datetime.now(),
})
rec._log('Agendado%s' % ((' · cita %s' % cita.name) if cita else ''))
return True
def action_descartar(self):
for rec in self:
rec.write({'state': 'descartado', 'resolved_at': fields.Datetime.now()})
rec._log('Descartado')
return True

View File

@@ -3,3 +3,5 @@ access_skeen_servicio_user,Acceso a Servicios SKEEN,model_skeen_servicio,base.gr
access_skeen_service_tag_user,Acceso a Etiquetas de Servicio SKEEN,model_skeen_service_tag,base.group_user,1,1,1,1 access_skeen_service_tag_user,Acceso a Etiquetas de Servicio SKEEN,model_skeen_service_tag,base.group_user,1,1,1,1
access_skeen_cita_user,Acceso a Citas SKEEN,model_skeen_cita,base.group_user,1,1,1,1 access_skeen_cita_user,Acceso a Citas SKEEN,model_skeen_cita,base.group_user,1,1,1,1
access_skeen_cita_bloqueo_user,Acceso a Bloqueos de Agenda SKEEN,model_skeen_cita_bloqueo,base.group_user,1,1,1,1 access_skeen_cita_bloqueo_user,Acceso a Bloqueos de Agenda SKEEN,model_skeen_cita_bloqueo,base.group_user,1,1,1,1
access_skeen_cita_state_log_user,Acceso a Bitácora de Estados SKEEN,model_skeen_cita_state_log,base.group_user,1,1,1,1
access_skeen_lista_espera_user,Acceso a Lista de Espera SKEEN,model_skeen_lista_espera,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
3 access_skeen_service_tag_user Acceso a Etiquetas de Servicio SKEEN model_skeen_service_tag base.group_user 1 1 1 1
4 access_skeen_cita_user Acceso a Citas SKEEN model_skeen_cita base.group_user 1 1 1 1
5 access_skeen_cita_bloqueo_user Acceso a Bloqueos de Agenda SKEEN model_skeen_cita_bloqueo base.group_user 1 1 1 1
6 access_skeen_cita_state_log_user Acceso a Bitácora de Estados SKEEN model_skeen_cita_state_log base.group_user 1 1 1 1
7 access_skeen_lista_espera_user Acceso a Lista de Espera SKEEN model_skeen_lista_espera base.group_user 1 1 1 1

View File

@@ -11,7 +11,10 @@
<field name="all_day"/> <field name="all_day"/>
<field name="time_from" widget="float_time"/> <field name="time_from" widget="float_time"/>
<field name="time_to" widget="float_time"/> <field name="time_to" widget="float_time"/>
<field name="motivo_tipo"/>
<field name="motivo"/> <field name="motivo"/>
<field name="grupo" optional="hide"/>
<field name="created_by"/>
</tree> </tree>
</field> </field>
</record> </record>
@@ -27,12 +30,15 @@
<group> <group>
<field name="doctor_id"/> <field name="doctor_id"/>
<field name="date"/> <field name="date"/>
<field name="motivo_tipo"/>
<field name="motivo"/> <field name="motivo"/>
</group> </group>
<group> <group>
<field name="all_day"/> <field name="all_day"/>
<field name="time_from" widget="float_time" invisible="all_day"/> <field name="time_from" widget="float_time" invisible="all_day"/>
<field name="time_to" widget="float_time" invisible="all_day"/> <field name="time_to" widget="float_time" invisible="all_day"/>
<field name="grupo" readonly="1"/>
<field name="created_by" readonly="1"/>
</group> </group>
</group> </group>
</sheet> </sheet>

View File

@@ -29,6 +29,7 @@
<button name="action_arrive" string="Llegó" type="object" invisible="state != 'confirmed'" class="oe_highlight"/> <button name="action_arrive" string="Llegó" type="object" invisible="state != 'confirmed'" class="oe_highlight"/>
<button name="action_start" string="Iniciar" type="object" invisible="state != 'arrived'" class="oe_highlight"/> <button name="action_start" string="Iniciar" type="object" invisible="state != 'arrived'" class="oe_highlight"/>
<button name="action_done" string="Completar" type="object" invisible="state != 'in_progress'" class="oe_highlight"/> <button name="action_done" string="Completar" type="object" invisible="state != 'in_progress'" class="oe_highlight"/>
<button name="action_back" string="Retroceder" type="object" invisible="state not in ('confirmed', 'arrived', 'in_progress', 'done')"/>
<button name="action_cancel" string="Cancelar" type="object" invisible="state not in ('pending', 'confirmed', 'arrived', 'in_progress')"/> <button name="action_cancel" string="Cancelar" type="object" invisible="state not in ('pending', 'confirmed', 'arrived', 'in_progress')"/>
<button name="action_no_show" string="No Show" type="object" invisible="state not in ('confirmed', 'arrived')"/> <button name="action_no_show" string="No Show" type="object" invisible="state not in ('confirmed', 'arrived')"/>
<field name="state" widget="statusbar" statusbar_visible="pending,confirmed,arrived,in_progress,done"/> <field name="state" widget="statusbar" statusbar_visible="pending,confirmed,arrived,in_progress,done"/>
@@ -45,6 +46,7 @@
<field name="servicio_id"/> <field name="servicio_id"/>
<field name="servicio_price" readonly="1"/> <field name="servicio_price" readonly="1"/>
<field name="servicio_duration" readonly="1"/> <field name="servicio_duration" readonly="1"/>
<field name="duration"/>
<field name="date"/> <field name="date"/>
<field name="time" widget="float_time"/> <field name="time" widget="float_time"/>
</group> </group>
@@ -59,6 +61,17 @@
<field name="amount_paid"/> <field name="amount_paid"/>
</group> </group>
</page> </page>
<page string="Bitácora de estados">
<field name="state_log_ids" readonly="1">
<tree>
<field name="date_change"/>
<field name="from_state"/>
<field name="to_state"/>
<field name="user_name"/>
<field name="note"/>
</tree>
</field>
</page>
</notebook> </notebook>
</sheet> </sheet>
</form> </form>

View File

@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Vista de lista de lista de espera -->
<record id="view_skeen_lista_espera_tree" model="ir.ui.view">
<field name="name">skeen.lista.espera.tree</field>
<field name="model">skeen.lista.espera</field>
<field name="arch" type="xml">
<tree decoration-danger="priority=='alta' and state=='esperando'"
decoration-muted="state!='esperando'">
<field name="name"/>
<field name="partner_id"/>
<field name="partner_phone"/>
<field name="doctor_id"/>
<field name="servicio_id"/>
<field name="priority" widget="badge"
decoration-danger="priority=='alta'"
decoration-warning="priority=='normal'"/>
<field name="date_from"/>
<field name="date_to"/>
<field name="state" widget="badge"
decoration-success="state=='agendado'"
decoration-info="state=='esperando'"/>
<field name="create_date"/>
</tree>
</field>
</record>
<!-- Vista de formulario de lista de espera -->
<record id="view_skeen_lista_espera_form" model="ir.ui.view">
<field name="name">skeen.lista.espera.form</field>
<field name="model">skeen.lista.espera</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_descartar" string="Descartar" type="object"
invisible="state != 'esperando'" class="oe_highlight"/>
<field name="state" widget="statusbar"/>
</header>
<sheet>
<group>
<group>
<field name="name" readonly="1"/>
<field name="partner_id" options="{'no_create': True}"/>
<field name="partner_phone" readonly="1"/>
<field name="priority"/>
</group>
<group>
<field name="doctor_id"/>
<field name="servicio_id"/>
<field name="franja"/>
<field name="date_from"/>
<field name="date_to"/>
<field name="cita_id" readonly="1"/>
<field name="resolved_at" readonly="1"/>
<field name="created_by" readonly="1"/>
</group>
</group>
<group string="Nota">
<field name="notes" nolabel="1"/>
</group>
<group string="Historial">
<field name="historial" nolabel="1" readonly="1"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- Acción de lista de espera -->
<record id="action_skeen_lista_espera_tree" model="ir.actions.act_window">
<field name="name">Lista de Espera</field>
<field name="res_model">skeen.lista.espera</field>
<field name="view_mode">tree,form</field>
</record>
<!-- Menú Lista de Espera (bajo el menú Citas) -->
<menuitem id="menu_skeen_citas_lista_espera" name="Lista de Espera" parent="menu_skeen_citas"
action="action_skeen_lista_espera_tree" sequence="30"/>
</odoo>

View File

@@ -6,6 +6,7 @@ from odoo.http import request, Response
import base64 import base64
import json import json
import re import re
import uuid
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from .auth import sign_token, current_user, require_role, forbid_roles, verify_token from .auth import sign_token, current_user, require_role, forbid_roles, verify_token
from ..roles import ROLE_SELECTION, ROLE_KEYS, MENU_ROLES_KEY, get_menu_roles from ..roles import ROLE_SELECTION, ROLE_KEYS, MENU_ROLES_KEY, get_menu_roles
@@ -25,6 +26,14 @@ def json_response(data, status=200):
) )
# Orden del checklist de atención (espejo de STATE_FLOW en skeen.cita):
# Agendada -> Confirmada -> En cabina -> En consulta -> Completada
STATE_FLOW = ['pending', 'confirmed', 'arrived', 'in_progress', 'done']
# Estados que liberan el horario (no ocupan agenda)
INACTIVE_STATES = ('cancelled', 'no_show', 'waitlist')
def _parse_json_body(): def _parse_json_body():
"""Lee y parsea el body JSON de la petición""" """Lee y parsea el body JSON de la petición"""
try: try:
@@ -517,7 +526,7 @@ class SkeenFrontendController(http.Controller):
# pending_confirm=1: citas activas SIN 1ra confirmación telefónica # pending_confirm=1: citas activas SIN 1ra confirmación telefónica
if kw.get('pending_confirm') in ('1', 'true', 'True'): if kw.get('pending_confirm') in ('1', 'true', 'True'):
domain.append(('phone_confirmed', '=', False)) domain.append(('phone_confirmed', '=', False))
domain.append(('state', 'not in', ('cancelled', 'no_show', 'done'))) domain.append(('state', 'not in', INACTIVE_STATES + ('done',)))
page = max(int(kw.get('page', 1) or 1), 1) page = max(int(kw.get('page', 1) or 1), 1)
page_size = min(max(int(kw.get('page_size', 50) or 50), 1), 200) page_size = min(max(int(kw.get('page_size', 50) or 50), 1), 200)
Citas = request.env['skeen.cita'].sudo() Citas = request.env['skeen.cita'].sudo()
@@ -538,6 +547,14 @@ class SkeenFrontendController(http.Controller):
'status': 'success', 'status': 'success',
'appointments': dicts, 'appointments': dicts,
'bloqueos': self._bloqueos_rango(domain), 'bloqueos': self._bloqueos_rango(domain),
# La lista de espera viaja en la misma respuesta para que el
# auto refresco de la agenda sea un solo request por ciclo.
'lista_espera': [
self._lista_espera_to_dict(r)
for r in request.env['skeen.lista.espera'].sudo().search(
[('state', '=', 'esperando')],
order='priority_rank asc, create_date asc', limit=200)
],
'total': total, 'total': total,
'page': page, 'page': page,
'page_size': page_size, 'page_size': page_size,
@@ -572,6 +589,7 @@ class SkeenFrontendController(http.Controller):
'medium': data.get('medium', 'onsite'), 'medium': data.get('medium', 'onsite'),
'notes': data.get('notes', ''), 'notes': data.get('notes', ''),
'state': data.get('state', 'pending'), 'state': data.get('state', 'pending'),
'duration': int(data.get('duration') or 0),
} }
first_date = data.get('date') first_date = data.get('date')
first_time = data.get('time') first_time = data.get('time')
@@ -688,6 +706,7 @@ class SkeenFrontendController(http.Controller):
'medium': data.get('medium', 'onsite'), 'medium': data.get('medium', 'onsite'),
'notes': data.get('notes', ''), 'notes': data.get('notes', ''),
'state': data.get('state', 'pending'), 'state': data.get('state', 'pending'),
'duration': int(data.get('duration') or 0),
}) })
return json_response({ return json_response({
'status': 'success', 'status': 'success',
@@ -730,7 +749,7 @@ class SkeenFrontendController(http.Controller):
if not cita.exists(): if not cita.exists():
return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404) return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404)
data = _parse_json_body() data = _parse_json_body()
allowed = ['partner_id', 'servicio_id', 'date', 'time', 'doctor_id', 'branch', 'medium', 'notes', 'state'] allowed = ['partner_id', 'servicio_id', 'date', 'time', 'doctor_id', 'branch', 'medium', 'notes', 'state', 'duration']
vals = {k: data[k] for k in allowed if k in data} vals = {k: data[k] for k in allowed if k in data}
if 'time' in vals and not self._valid_agenda_time(vals['time']): if 'time' in vals and not self._valid_agenda_time(vals['time']):
return json_response( return json_response(
@@ -745,6 +764,7 @@ class SkeenFrontendController(http.Controller):
'time': vals.get('time', cita.time), 'time': vals.get('time', cita.time),
'doctor_id': vals.get('doctor_id', cita.doctor_id.id if cita.doctor_id else False), 'doctor_id': vals.get('doctor_id', cita.doctor_id.id if cita.doctor_id else False),
'servicio_id': vals.get('servicio_id', cita.servicio_id.id), 'servicio_id': vals.get('servicio_id', cita.servicio_id.id),
'duration': vals.get('duration', cita.duration),
'state': vals.get('state', cita.state), 'state': vals.get('state', cita.state),
} }
resp = {'status': 'error', 'message': str(e)} resp = {'status': 'error', 'message': str(e)}
@@ -771,27 +791,90 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/appointments/<int:appointment_id>/status', type='http', auth='none', methods=['POST'], csrf=False) @http.route('/skeen/frontend/v1/appointments/<int:appointment_id>/status', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('recepcion') @require_role('recepcion')
def update_appointment_status(self, appointment_id, **kw): def update_appointment_status(self, appointment_id, **kw):
"""Cambio de estado de la cita (checklist con retroceso).
Acciones:
confirm | arrive | start | done | cancel | no_show (flujo clásico)
next (avanza un paso)
back (retrocede un paso)
set_state + {state: '...'} (salto directo)
Todo cambio queda en la bitácora con el usuario y la hora.
"""
try: try:
cita = request.env['skeen.cita'].sudo().browse(appointment_id) cita = request.env['skeen.cita'].sudo().browse(appointment_id)
if not cita.exists(): if not cita.exists():
return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404) return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404)
data = _parse_json_body() data = _parse_json_body()
action = data.get('action') action = data.get('action')
user = request.frontend_user.login
note = (data.get('reason') or '').strip()
if action == 'confirm': if action == 'confirm':
cita.action_confirm() cita.set_state('confirmed', user=user)
elif action == 'arrive': elif action == 'arrive':
cita.action_arrive() cita.set_state('arrived', user=user)
elif action == 'start': elif action == 'start':
cita.action_start() cita.set_state('in_progress', user=user)
elif action == 'done': elif action == 'done':
cita.action_done() cita.set_state('done', user=user)
elif action == 'cancel': elif action == 'cancel':
cita.write({'state': 'cancelled', 'cancellation_reason': data.get('reason', '')}) cita.set_state('cancelled', user=user, note=note)
if note:
cita.write({'cancellation_reason': note})
elif action == 'no_show': elif action == 'no_show':
cita.action_no_show() cita.set_state('no_show', user=user, note=note)
elif action == 'waitlist':
cita.set_state('waitlist', user=user, note=note)
elif action == 'next':
idx = STATE_FLOW.index(cita.state) if cita.state in STATE_FLOW else -1
if idx < 0 or idx >= len(STATE_FLOW) - 1:
return json_response(
{'status': 'error', 'message': 'No hay un paso siguiente desde el estado actual'}, 400)
cita.set_state(STATE_FLOW[idx + 1], user=user)
elif action == 'back':
idx = STATE_FLOW.index(cita.state) if cita.state in STATE_FLOW else -1
if idx <= 0:
return json_response(
{'status': 'error', 'message': 'No hay un paso anterior desde el estado actual'}, 400)
cita.set_state(STATE_FLOW[idx - 1], user=user, note='Retroceso de estado')
elif action == 'set_state':
target = data.get('state')
if target not in STATE_FLOW + ['cancelled', 'no_show', 'waitlist']:
return json_response({'status': 'error', 'message': 'Estado no válido'}, 400)
cita.set_state(target, user=user, note='Cambio manual de estado')
else: else:
return json_response({'status': 'error', 'message': 'Acción no válida'}, 400) return json_response({'status': 'error', 'message': 'Acción no válida'}, 400)
return json_response({'status': 'success', 'appointment': self._appointment_to_dict(cita)}) return json_response({'status': 'success', 'appointment': self._appointment_to_dict(cita)})
except ValidationError as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 400)
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/appointments/<int:appointment_id>/state_history', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def appointment_state_history(self, appointment_id, **kw):
"""Bitácora de cambios de estado de la cita (quién, cuándo y de qué a qué)."""
try:
cita = request.env['skeen.cita'].sudo().browse(appointment_id)
if not cita.exists():
return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404)
logs = request.env['skeen.cita.state.log'].sudo().search(
[('cita_id', '=', cita.id)], order='create_date desc, id desc', limit=100)
selection = dict(cita._fields['state'].selection)
return json_response({
'status': 'success',
'history': [{
'id': l.id,
'from_state': l.from_state,
'from_label': selection.get(l.from_state, l.from_state or ''),
'to_state': l.to_state,
'to_label': selection.get(l.to_state, l.to_state or ''),
'user': l.user_name or '',
'note': l.note or '',
'date': l.date_change.strftime('%Y-%m-%d %H:%M:%S') if l.date_change else '',
} for l in logs],
})
except Exception as e: except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500) return json_response({'status': 'error', 'message': str(e)}, 500)
@@ -869,8 +952,8 @@ class SkeenFrontendController(http.Controller):
citas = Citas.browse(ids).exists() citas = Citas.browse(ids).exists()
found = set(citas.ids) found = set(citas.ids)
not_found = [i for i in dict.fromkeys(ids) if i not in found] not_found = [i for i in dict.fromkeys(ids) if i not in found]
elegibles = citas.filtered(lambda c: c.state not in ('cancelled', 'no_show', 'done')) elegibles = citas.filtered(lambda c: c.state not in INACTIVE_STATES + ('done',))
skipped_invalid_state = [c.id for c in citas if c.state in ('cancelled', 'no_show', 'done')] skipped_invalid_state = [c.id for c in citas if c.state in INACTIVE_STATES + ('done',)]
if confirmed: if confirmed:
elegibles.write({ elegibles.write({
'phone_confirmed': True, 'phone_confirmed': True,
@@ -892,18 +975,20 @@ class SkeenFrontendController(http.Controller):
def _agenda_conflict_payload(self, vals, exclude_id=None): def _agenda_conflict_payload(self, vals, exclude_id=None):
"""Detecta el origen del conflicto de agenda para los valores intentados. """Detecta el origen del conflicto de agenda para los valores intentados.
Devuelve (payload_extra, http_status): {'conflicting_appointment': ...} Devuelve {'conflicting_appointment': ...} cuando hay solapamiento con
cuando hay solapamiento con otra cita, o {} si no se pudo identificar. otra cita, o {} si no se pudo identificar. Replica la lógica de
Replica la lógica de _check_availability de skeen.cita. _check_availability de skeen.cita, incluida la duración real de la cita
(duración propia o, si es 0, la del servicio).
""" """
try: try:
date_str = vals.get('date') date_str = vals.get('date')
time_f = float(vals.get('time')) time_f = float(vals.get('time'))
state = vals.get('state') or 'pending' state = vals.get('state') or 'pending'
if not date_str or state in ('cancelled', 'no_show'): if not date_str or state in ('cancelled', 'no_show', 'waitlist'):
return {} return {}
servicio = request.env['skeen.servicio'].sudo().browse(int(vals.get('servicio_id') or 0)) servicio = request.env['skeen.servicio'].sudo().browse(int(vals.get('servicio_id') or 0))
dur = (servicio.duration_min if servicio.exists() and servicio.duration_min else 30) or 30 dur = int(vals.get('duration') or 0) or (
servicio.duration_min if servicio.exists() and servicio.duration_min else 30) or 30
hh, mm = int(time_f), int(round((float(time_f) % 1) * 60)) hh, mm = int(time_f), int(round((float(time_f) % 1) * 60))
start_dt = datetime.strptime(f'{date_str} {hh:02d}:{mm:02d}', '%Y-%m-%d %H:%M') start_dt = datetime.strptime(f'{date_str} {hh:02d}:{mm:02d}', '%Y-%m-%d %H:%M')
end_dt = start_dt + timedelta(minutes=dur) end_dt = start_dt + timedelta(minutes=dur)
@@ -911,7 +996,7 @@ class SkeenFrontendController(http.Controller):
domain = [ domain = [
('id', '!=', exclude_id), ('id', '!=', exclude_id),
('date', '=', date_str), ('date', '=', date_str),
('state', 'not in', ('cancelled', 'no_show')), ('state', 'not in', ('cancelled', 'no_show', 'waitlist')),
('doctor_id', '=', doctor_id), ('doctor_id', '=', doctor_id),
('datetime_start', '<', end_dt), ('datetime_start', '<', end_dt),
('datetime_end', '>', start_dt), ('datetime_end', '>', start_dt),
@@ -930,14 +1015,22 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/appointments/slots', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @http.route('/skeen/frontend/v1/appointments/slots', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
def available_slots(self, **kw): def available_slots(self, **kw):
"""Slots libres (cada 15 min) para una fecha y servicio.
Acepta `duration` para simular una duración distinta a la del servicio
(la agenda deja que recepción la ajuste). El hueco se descarta si el
rango completo inicio+duración choca con otra cita o con un bloqueo.
"""
try: try:
date_str = kw.get('date') date_str = kw.get('date')
servicio_id = int(kw.get('servicio_id', 0)) servicio_id = int(kw.get('servicio_id', 0))
doctor_id = int(kw.get('doctor_id', 0)) if kw.get('doctor_id') else None doctor_id = int(kw.get('doctor_id', 0)) if kw.get('doctor_id') else None
duration = int(kw.get('duration', 0) or 0) or None
if not date_str or not servicio_id: if not date_str or not servicio_id:
return json_response({'status': 'error', 'message': 'Falta date o servicio_id'}, 400) return json_response({'status': 'error', 'message': 'Falta date o servicio_id'}, 400)
d = datetime.strptime(date_str, '%Y-%m-%d').date() d = datetime.strptime(date_str, '%Y-%m-%d').date()
slots = request.env['skeen.cita'].sudo().get_available_slots(d, servicio_id, doctor_id=doctor_id) slots = request.env['skeen.cita'].sudo().get_available_slots(
d, servicio_id, doctor_id=doctor_id, duration=duration)
return json_response({'status': 'success', 'slots': slots}) return json_response({'status': 'success', 'slots': slots})
except Exception as e: except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500) return json_response({'status': 'error', 'message': str(e)}, 500)
@@ -975,7 +1068,12 @@ class SkeenFrontendController(http.Controller):
'all_day': b.all_day, 'all_day': b.all_day,
'time_from': b.time_from, 'time_from': b.time_from,
'time_to': b.time_to, 'time_to': b.time_to,
'motivo_tipo': b.motivo_tipo or 'otro',
'motivo_tipo_label': dict(b._fields['motivo_tipo'].selection).get(b.motivo_tipo or '', ''),
'motivo': b.motivo or '', 'motivo': b.motivo or '',
'motivo_label': b.nombre_motivo(),
'grupo': b.grupo or '',
'created_by': b.created_by or '',
} }
@http.route('/skeen/frontend/v1/bloqueos', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @http.route('/skeen/frontend/v1/bloqueos', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@@ -989,7 +1087,9 @@ class SkeenFrontendController(http.Controller):
domain.append(('date', '<=', kw['date_to'])) domain.append(('date', '<=', kw['date_to']))
if kw.get('doctor_id'): if kw.get('doctor_id'):
domain.append(('doctor_id', '=', int(kw.get('doctor_id')))) domain.append(('doctor_id', '=', int(kw.get('doctor_id'))))
bloqueos = request.env['skeen.cita.bloqueo'].sudo().search(domain, order='date desc', limit=500) if kw.get('grupo'):
domain.append(('grupo', '=', kw['grupo']))
bloqueos = request.env['skeen.cita.bloqueo'].sudo().search(domain, order='date desc, time_from', limit=500)
return json_response({ return json_response({
'status': 'success', 'status': 'success',
'bloqueos': [self._bloqueo_to_dict(b) for b in bloqueos], 'bloqueos': [self._bloqueo_to_dict(b) for b in bloqueos],
@@ -1000,28 +1100,157 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/bloqueos', type='http', auth='none', methods=['POST'], csrf=False) @http.route('/skeen/frontend/v1/bloqueos', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('recepcion') @require_role('recepcion')
def create_bloqueo(self, **kw): def create_bloqueo(self, **kw):
"""Crea uno o varios bloqueos de agenda.
Body:
doctor_ids: [1, 2] (o `doctor_id` simple) — multi-selección
date: 'YYYY-MM-DD' (o date_from/date_to para un rango)
days: [0, 2] (opcional) repetir solo esos días de la semana
dentro del rango, p. ej. "todos los lunes"
all_day: bool
time_from / time_to: float de horas (si no es todo el día)
motivo_tipo: vacaciones|junta|comida|personal|otro
motivo: texto libre
force: true permite bloquear aunque haya citas agendadas
Todos los registros creados comparten un `grupo` para poder borrarlos
o localizarlos como serie.
"""
try: try:
data = _parse_json_body() data = _parse_json_body()
if not data.get('doctor_id') or not data.get('date'): raw_doctors = data.get('doctor_ids') or ([data.get('doctor_id')] if data.get('doctor_id') else [])
return json_response({'status': 'error', 'message': 'Médico y fecha son obligatorios'}, 400) doctor_ids = []
for d in raw_doctors:
try:
doctor_ids.append(int(d))
except (TypeError, ValueError):
continue
doctor_ids = list(dict.fromkeys(doctor_ids))
if not doctor_ids:
return json_response({'status': 'error', 'message': 'Selecciona al menos un médico'}, 400)
doctores = request.env['hr.employee'].sudo().browse(doctor_ids).exists()
if len(doctores) != len(doctor_ids):
return json_response({'status': 'error', 'message': 'Médico no encontrado'}, 404)
date_from = data.get('date_from') or data.get('date')
date_to = data.get('date_to') or date_from
if not date_from:
return json_response({'status': 'error', 'message': 'La fecha es obligatoria'}, 400)
try:
ini = datetime.strptime(str(date_from)[:10], '%Y-%m-%d').date()
fin = datetime.strptime(str(date_to)[:10], '%Y-%m-%d').date()
except ValueError:
return json_response({'status': 'error', 'message': 'Fecha inválida'}, 400)
if fin < ini:
return json_response({'status': 'error', 'message': 'El rango de fechas está invertido'}, 400)
if (fin - ini).days > 366:
return json_response({'status': 'error', 'message': 'El rango no puede exceder un año'}, 400)
days = data.get('days') or []
try:
days = sorted({int(d) for d in days})
except (TypeError, ValueError):
return json_response({'status': 'error', 'message': 'Días inválidos'}, 400)
all_day = bool(data.get('all_day')) all_day = bool(data.get('all_day'))
try:
time_from = float(data.get('time_from', 9.0) or 9.0) time_from = float(data.get('time_from', 9.0) or 9.0)
time_to = float(data.get('time_to', 10.0) or 10.0) time_to = float(data.get('time_to', 10.0) or 10.0)
except (TypeError, ValueError):
return json_response({'status': 'error', 'message': 'Horas inválidas'}, 400)
if not all_day and time_to <= time_from: if not all_day and time_to <= time_from:
return json_response({'status': 'error', 'message': 'La hora final debe ser mayor a la inicial'}, 400) return json_response({'status': 'error', 'message': 'La hora final debe ser mayor a la inicial'}, 400)
doctor = request.env['hr.employee'].sudo().browse(int(data['doctor_id'])) if not all_day and (time_from < self.AGENDA_START - 1e-6 or time_to > self.AGENDA_END + 1e-6):
if not doctor.exists(): return json_response(
return json_response({'status': 'error', 'message': 'Médico no encontrado'}, 404) {'status': 'error', 'message': 'El rango de horas debe estar entre 8:00 y 19:00'}, 400)
bloqueo = request.env['skeen.cita.bloqueo'].sudo().create({
'doctor_id': doctor.id, motivo_tipo = data.get('motivo_tipo') or 'otro'
'date': data['date'], if motivo_tipo not in dict(request.env['skeen.cita.bloqueo']._fields['motivo_tipo'].selection):
motivo_tipo = 'otro'
motivo = (data.get('motivo') or '').strip()
fechas = []
cur = ini
while cur <= fin:
if not days or cur.weekday() in days:
fechas.append(cur)
cur += timedelta(days=1)
if not fechas:
return json_response(
{'status': 'error', 'message': 'Ninguna fecha del rango cae en los días seleccionados'}, 400)
grupo = str(uuid.uuid4())[:8]
Bloqueo = request.env['skeen.cita.bloqueo'].sudo()
creados = Bloqueo.browse()
creados = creados.with_context(
skeen_skip_bloqueo_citas=bool(data.get('force')),
).create([{
'doctor_id': doc.id,
'date': f.strftime('%Y-%m-%d'),
'all_day': all_day, 'all_day': all_day,
'time_from': time_from, 'time_from': time_from,
'time_to': time_to, 'time_to': time_to,
'motivo': data.get('motivo', ''), 'motivo_tipo': motivo_tipo,
}) 'motivo': motivo,
return json_response({'status': 'success', 'bloqueo': self._bloqueo_to_dict(bloqueo)}, 201) 'grupo': grupo,
'created_by': request.frontend_user.login,
} for doc in doctores for f in fechas])
self._auditar('agenda', 'skeen.cita.bloqueo', 'crear',
new_value='%s bloqueo(s)' % len(creados),
entity_label=', '.join(doctores.mapped('name')))
return json_response({
'status': 'success',
'grupo': grupo,
'creados': len(creados),
'bloqueos': [self._bloqueo_to_dict(b) for b in creados],
}, 201)
except ValidationError as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e), 'conflicto_citas': True}, 409)
except Exception as e: except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/bloqueos/<int:bloqueo_id>', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('recepcion')
def update_bloqueo(self, bloqueo_id, **kw):
"""Edita un bloqueo existente (fecha, horario, motivo, médico)."""
try:
if request.httprequest.method == 'OPTIONS':
return json_response({}, 200)
bloqueo = request.env['skeen.cita.bloqueo'].sudo().browse(bloqueo_id)
if not bloqueo.exists():
return json_response({'status': 'error', 'message': 'Bloqueo no encontrado'}, 404)
data = _parse_json_body()
vals = {}
if 'doctor_id' in data and data['doctor_id']:
vals['doctor_id'] = int(data['doctor_id'])
if data.get('date'):
vals['date'] = data['date']
if 'all_day' in data:
vals['all_day'] = bool(data['all_day'])
if 'time_from' in data and data['time_from'] is not None:
vals['time_from'] = float(data['time_from'])
if 'time_to' in data and data['time_to'] is not None:
vals['time_to'] = float(data['time_to'])
if 'motivo_tipo' in data:
vals['motivo_tipo'] = data['motivo_tipo'] or 'otro'
if 'motivo' in data:
vals['motivo'] = data['motivo'] or ''
if not vals:
return json_response({'status': 'error', 'message': 'Nada que actualizar'}, 400)
bloqueo.with_context(
skeen_skip_bloqueo_citas=bool(data.get('force')),
).write(vals)
self._auditar('agenda', 'skeen.cita.bloqueo', 'editar',
entity_label=bloqueo.doctor_id.name,
new_value=str(vals))
return json_response({'status': 'success', 'bloqueo': self._bloqueo_to_dict(bloqueo)})
except ValidationError as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e), 'conflicto_citas': True}, 409)
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500) return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/bloqueos/<int:bloqueo_id>', type='http', auth='none', methods=['DELETE'], csrf=False) @http.route('/skeen/frontend/v1/bloqueos/<int:bloqueo_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
@@ -1031,11 +1260,203 @@ class SkeenFrontendController(http.Controller):
bloqueo = request.env['skeen.cita.bloqueo'].sudo().browse(bloqueo_id) bloqueo = request.env['skeen.cita.bloqueo'].sudo().browse(bloqueo_id)
if not bloqueo.exists(): if not bloqueo.exists():
return json_response({'status': 'error', 'message': 'Bloqueo no encontrado'}, 404) return json_response({'status': 'error', 'message': 'Bloqueo no encontrado'}, 404)
etiqueta = '%s · %s' % (bloqueo.doctor_id.name, bloqueo.date)
bloqueo.unlink() bloqueo.unlink()
self._auditar('agenda', 'skeen.cita.bloqueo', 'eliminar', entity_label=etiqueta)
return json_response({'status': 'success', 'message': 'Bloqueo eliminado'}) return json_response({'status': 'success', 'message': 'Bloqueo eliminado'})
except Exception as e: except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500) return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/bloqueos/grupo/<string:grupo>', type='http', auth='none', methods=['DELETE'], csrf=False)
@require_role('recepcion')
def delete_bloqueo_grupo(self, grupo, **kw):
"""Elimina la serie completa (mismo `grupo`) de bloqueos."""
try:
Bloqueo = request.env['skeen.cita.bloqueo'].sudo()
bloqueos = Bloqueo.search([('grupo', '=', grupo)])
if not bloqueos:
return json_response({'status': 'error', 'message': 'Serie no encontrada'}, 404)
n = len(bloqueos)
bloqueos.unlink()
self._auditar('agenda', 'skeen.cita.bloqueo', 'eliminar-serie',
entity_label=grupo, old_value='%s bloqueo(s)' % n)
return json_response({'status': 'success', 'eliminados': n})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Lista de espera de pacientes
# ============================================================
def _lista_espera_to_dict(self, r):
return {
'id': r.id,
'reference': r.name,
'patient_id': r.partner_id.id,
'patient': r.partner_id.name,
'phone': r.partner_id.phone or r.partner_id.mobile or '',
'doctor_id': r.doctor_id.id if r.doctor_id else None,
'doctor': r.doctor_id.name if r.doctor_id else None,
'service_id': r.servicio_id.id if r.servicio_id else None,
'service': r.servicio_id.name if r.servicio_id else None,
'priority': r.priority,
'priority_label': dict(r._fields['priority'].selection).get(r.priority, ''),
'franja': r.franja or '',
'date_from': r.date_from.strftime('%Y-%m-%d') if r.date_from else None,
'date_to': r.date_to.strftime('%Y-%m-%d') if r.date_to else None,
'notes': r.notes or '',
'state': r.state,
'cita_id': r.cita_id.id if r.cita_id else None,
'created_by': r.created_by or '',
'create_date': r.create_date.strftime('%Y-%m-%d %H:%M') if r.create_date else None,
'resolved_at': r.resolved_at.strftime('%Y-%m-%d %H:%M') if r.resolved_at else None,
'espera_dias': max(0, (date.today() - r.create_date.date()).days) if r.create_date else 0,
}
@http.route('/skeen/frontend/v1/lista_espera', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def list_lista_espera(self, **kw):
"""Pacientes en lista de espera (ordenados por prioridad y antigüedad)."""
try:
domain = []
state = kw.get('state')
domain.append(('state', '=', state if state else 'esperando'))
if kw.get('doctor_id'):
domain.append(('doctor_id', '=', int(kw.get('doctor_id'))))
if kw.get('patient_id'):
domain.append(('partner_id', '=', int(kw.get('patient_id'))))
limit = min(max(int(kw.get('limit', 200) or 200), 1), 500)
records = request.env['skeen.lista.espera'].sudo().search(
domain, order='priority_rank asc, create_date asc', limit=limit)
return json_response({
'status': 'success',
'lista': [self._lista_espera_to_dict(r) for r in records],
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/lista_espera/compatibles', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def lista_espera_compatibles(self, **kw):
"""Pacientes en espera compatibles con un hueco que se acaba de liberar.
Filtra por médico (o sin médico = cualquiera) y por el rango de fechas
deseado. Marca `match_servicio` cuando además coincide el servicio, para
que la UI pueda priorizar el aviso.
"""
try:
doctor_id = int(kw.get('doctor_id')) if kw.get('doctor_id') else None
servicio_id = int(kw.get('servicio_id')) if kw.get('servicio_id') else None
fecha = kw.get('date')
domain = [('state', '=', 'esperando')]
if doctor_id:
domain += ['|', ('doctor_id', '=', doctor_id), ('doctor_id', '=', False)]
records = request.env['skeen.lista.espera'].sudo().search(
domain, order='priority_rank asc, create_date asc', limit=50)
if fecha:
records = records.filtered(lambda r: (
(not r.date_from or r.date_from.strftime('%Y-%m-%d') <= fecha)
and (not r.date_to or r.date_to.strftime('%Y-%m-%d') >= fecha)
))
out = []
for r in records:
d = self._lista_espera_to_dict(r)
d['match_servicio'] = bool(servicio_id and r.servicio_id and r.servicio_id.id == servicio_id)
d['match_doctor'] = bool(doctor_id and r.doctor_id and r.doctor_id.id == doctor_id)
out.append(d)
out.sort(key=lambda d: (0 if d['match_doctor'] else 1, 0 if d['match_servicio'] else 1))
return json_response({'status': 'success', 'lista': out, 'total': len(out)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/lista_espera', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('recepcion')
def create_lista_espera(self, **kw):
try:
data = _parse_json_body()
partner_id = data.get('partner_id') or data.get('patient_id')
if not partner_id:
return json_response({'status': 'error', 'message': 'El paciente es obligatorio'}, 400)
if data.get('priority') and data['priority'] not in ('alta', 'normal', 'baja'):
return json_response({'status': 'error', 'message': 'Prioridad no válida'}, 400)
record = request.env['skeen.lista.espera'].sudo().create({
'partner_id': int(partner_id),
'doctor_id': int(data['doctor_id']) if data.get('doctor_id') else False,
'servicio_id': int(data['service_id'] or data['servicio_id']) if (data.get('service_id') or data.get('servicio_id')) else False,
'priority': data.get('priority') or 'normal',
'franja': data.get('franja') or '',
'date_from': data.get('date_from') or False,
'date_to': data.get('date_to') or False,
'notes': data.get('notes') or '',
'created_by': request.frontend_user.login,
})
self._auditar('agenda', 'skeen.lista.espera', 'crear',
entity_label=record.partner_id.name,
new_value=record.name)
return json_response({'status': 'success', 'registro': self._lista_espera_to_dict(record)}, 201)
except ValidationError as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 409)
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/lista_espera/<int:registro_id>', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('recepcion')
def update_lista_espera(self, registro_id, **kw):
"""Edita la espera o la resuelve (state agendado/descartado, cita_id)."""
try:
if request.httprequest.method == 'OPTIONS':
return json_response({}, 200)
record = request.env['skeen.lista.espera'].sudo().browse(registro_id)
if not record.exists():
return json_response({'status': 'error', 'message': 'Registro no encontrado'}, 404)
data = _parse_json_body()
if data.get('state') not in (None, '', 'esperando', 'agendado', 'descartado'):
return json_response({'status': 'error', 'message': 'Estado no válido'}, 400)
vals = {}
for key, field in (
('doctor_id', 'doctor_id'), ('service_id', 'servicio_id'),
('servicio_id', 'servicio_id'), ('priority', 'priority'),
('franja', 'franja'), ('date_from', 'date_from'), ('date_to', 'date_to'),
('notes', 'notes'), ('partner_id', 'partner_id'),
):
if key in data:
vals[field] = data[key] or False
if data.get('cita_id'):
vals['cita_id'] = int(data['cita_id'])
new_state = data.get('state')
if new_state:
vals['state'] = new_state
if new_state in ('agendado', 'descartado'):
vals['resolved_at'] = fields.Datetime.now()
else:
vals['resolved_at'] = False
if vals:
record.write(vals)
if new_state:
etiquetas = {'agendado': 'Agendado', 'descartado': 'Descartado', 'esperando': 'Reabierto'}
record._log(etiquetas.get(new_state, new_state), user=request.frontend_user.login)
self._auditar('agenda', 'skeen.lista.espera', 'estado',
entity_label=record.partner_id.name, new_value=new_state)
return json_response({'status': 'success', 'registro': self._lista_espera_to_dict(record)})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/lista_espera/<int:registro_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
@require_role('recepcion')
def delete_lista_espera(self, registro_id, **kw):
try:
record = request.env['skeen.lista.espera'].sudo().browse(registro_id)
if not record.exists():
return json_response({'status': 'error', 'message': 'Registro no encontrado'}, 404)
etiqueta = '%s · %s' % (record.name, record.partner_id.name)
record.unlink()
self._auditar('agenda', 'skeen.lista.espera', 'eliminar', entity_label=etiqueta)
return json_response({'status': 'success', 'message': 'Registro eliminado'})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
def _appointment_to_dict(self, cita): def _appointment_to_dict(self, cita):
return { return {
'id': cita.id, 'id': cita.id,
@@ -1048,11 +1469,16 @@ class SkeenFrontendController(http.Controller):
'service_category': cita.servicio_id.category, 'service_category': cita.servicio_id.category,
'date': cita.date.strftime('%Y-%m-%d') if cita.date else None, 'date': cita.date.strftime('%Y-%m-%d') if cita.date else None,
'time': cita.time_str, 'time': cita.time_str,
'time_float': cita.time,
'state': cita.state, 'state': cita.state,
'state_label': dict(cita._fields['state'].selection).get(cita.state, cita.state),
'payment_state': cita.payment_state, 'payment_state': cita.payment_state,
'price': cita.servicio_price, 'price': cita.servicio_price,
'amount_paid': cita.amount_paid, 'amount_paid': cita.amount_paid,
'duration': cita.servicio_duration, # Duración efectiva (propia de la cita o la del servicio)
'duration': cita._duracion(),
'duration_override': cita.duration or 0,
'service_duration': cita.servicio_duration,
'doctor_id': cita.doctor_id.id if cita.doctor_id else None, 'doctor_id': cita.doctor_id.id if cita.doctor_id else None,
'doctor': cita.doctor_id.name if cita.doctor_id else None, 'doctor': cita.doctor_id.name if cita.doctor_id else None,
'branch': cita.branch, 'branch': cita.branch,
@@ -1694,7 +2120,7 @@ class SkeenFrontendController(http.Controller):
Citas = request.env['skeen.cita'].sudo() Citas = request.env['skeen.cita'].sudo()
citas = Citas.search([ citas = Citas.search([
('partner_id', '=', patient_id), ('partner_id', '=', patient_id),
('state', 'not in', ('cancelled', 'no_show')), ('state', 'not in', INACTIVE_STATES),
('servicio_id.package_sessions', '>', 0), ('servicio_id.package_sessions', '>', 0),
]) ])
grupos = {} grupos = {}
@@ -2454,7 +2880,7 @@ class SkeenFrontendController(http.Controller):
Citas = request.env['skeen.cita'].sudo() Citas = request.env['skeen.cita'].sudo()
citas = Citas.search( citas = Citas.search(
[('date', '>=', date.today().strftime('%Y-%m-%d')), [('date', '>=', date.today().strftime('%Y-%m-%d')),
('state', 'not in', ('cancelled', 'no_show')), '|', ('state', 'not in', INACTIVE_STATES), '|',
('partner_id.name', 'ilike', q), ('servicio_id.name', 'ilike', q)], ('partner_id.name', 'ilike', q), ('servicio_id.name', 'ilike', q)],
limit=5, order='date asc, time asc') limit=5, order='date asc, time asc')
Servicios = request.env['skeen.servicio'].sudo() Servicios = request.env['skeen.servicio'].sudo()
@@ -4656,7 +5082,7 @@ class SkeenFrontendController(http.Controller):
end = kw.get('end', start) end = kw.get('end', start)
citas = request.env['skeen.cita'].sudo().search([ citas = request.env['skeen.cita'].sudo().search([
('date', '>=', start), ('date', '<=', end), ('date', '>=', start), ('date', '<=', end),
('state', 'not in', ('cancelled', 'no_show')), ('state', 'not in', INACTIVE_STATES),
]) ])
por_hora = {} por_hora = {}
for c in citas: for c in citas:

View File

@@ -36,8 +36,13 @@ class SkeenWhatsAppController(http.Controller):
return {'status': 'error', 'message': str(e)} return {'status': 'error', 'message': str(e)}
@http.route('/skeen/api/v1/create_appointment', type='json', auth='api_key', methods=['POST'], csrf=False) @http.route('/skeen/api/v1/create_appointment', type='json', auth='api_key', methods=['POST'], csrf=False)
def create_appointment(self, phone, name, service_id, date, time, **kw): def create_appointment(self, phone, name, service_id, date, time, doctor_id=None, duration=None, **kw):
"""Crear una nueva cita""" """Crear una nueva cita (flujo Hermes/WACRM).
`doctor_id` y `duration` son opcionales. Si se indica médico, se validan
su horario semanal y sus bloqueos de agenda; sin médico, la cita se
rechaza solo si la clínica completa está bloqueada a esa hora.
"""
try: try:
# Buscar o crear paciente # Buscar o crear paciente
partner = request.env['res.partner'].search([('phone', '=', phone)], limit=1) partner = request.env['res.partner'].search([('phone', '=', phone)], limit=1)
@@ -54,13 +59,18 @@ class SkeenWhatsAppController(http.Controller):
time_float = float(time_parts[0]) + float(time_parts[1]) / 60.0 time_float = float(time_parts[0]) + float(time_parts[1]) / 60.0
# Crear cita # Crear cita
cita = request.env['skeen.cita'].create({ vals = {
'partner_id': partner.id, 'partner_id': partner.id,
'servicio_id': int(service_id), 'servicio_id': int(service_id),
'date': date, 'date': date,
'time': time_float, 'time': time_float,
'state': 'confirmed', 'state': 'confirmed',
}) }
if doctor_id:
vals['doctor_id'] = int(doctor_id)
if duration:
vals['duration'] = int(duration)
cita = request.env['skeen.cita'].create(vals)
return { return {
'status': 'success', 'status': 'success',