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:
522
frontend/src/components/agenda/BloqueosModal.tsx
Normal file
522
frontend/src/components/agenda/BloqueosModal.tsx
Normal 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;
|
||||||
210
frontend/src/components/agenda/EstadoChecklist.tsx
Normal file
210
frontend/src/components/agenda/EstadoChecklist.tsx
Normal 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;
|
||||||
295
frontend/src/components/agenda/ListaEspera.tsx
Normal file
295
frontend/src/components/agenda/ListaEspera.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
180
frontend/src/lib/autoRefresh.tsx
Normal file
180
frontend/src/lib/autoRefresh.tsx
Normal 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
@@ -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>
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -2,3 +2,4 @@
|
|||||||
|
|
||||||
from . import cita
|
from . import cita
|
||||||
from . import bloqueo
|
from . import bloqueo
|
||||||
|
from . import lista_espera
|
||||||
|
|||||||
@@ -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:
|
||||||
raise ValidationError(_('La hora final debe ser mayor a la inicial'))
|
if b.time_to <= b.time_from:
|
||||||
|
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'
|
||||||
|
|||||||
@@ -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 '')
|
||||||
|
|||||||
121
odoo-addons/skeen_citas/models/lista_espera.py
Normal file
121
odoo-addons/skeen_citas/models/lista_espera.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
|
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
79
odoo-addons/skeen_citas/views/lista_espera_views.xml
Normal file
79
odoo-addons/skeen_citas/views/lista_espera_views.xml
Normal 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>
|
||||||
@@ -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'))
|
||||||
time_from = float(data.get('time_from', 9.0) or 9.0)
|
try:
|
||||||
time_to = float(data.get('time_to', 10.0) or 10.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)
|
||||||
|
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:
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user