From d0eac00a40541b6ad19c1cba6dde0b8d2d35d836 Mon Sep 17 00:00:00 2001 From: Kimi Date: Thu, 24 Sep 2026 15:46:38 +0000 Subject: [PATCH] =?UTF-8?q?Agenda:=20cierre=20r=C3=A1pido=20de=20agendas?= =?UTF-8?q?=20por=20m=C3=A9dico,=20lista=20de=20espera,=20duraciones=2030/?= =?UTF-8?q?45/60,=20checklist=20de=20estados=20con=20bit=C3=A1cora=20y=20a?= =?UTF-8?q?uto-refresh=2030s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../src/components/agenda/BloqueosModal.tsx | 522 +++++++++++ .../src/components/agenda/EstadoChecklist.tsx | 210 +++++ .../src/components/agenda/ListaEspera.tsx | 295 ++++++ frontend/src/lib/autoRefresh.tsx | 180 ++++ frontend/src/pages/Agenda.tsx | 877 +++++++++++++----- frontend/src/pages/Dashboard.tsx | 3 +- frontend/src/services/odoo.ts | 137 ++- odoo-addons/skeen_citas/__manifest__.py | 1 + odoo-addons/skeen_citas/data/sequences.xml | 10 + odoo-addons/skeen_citas/models/__init__.py | 1 + odoo-addons/skeen_citas/models/bloqueo.py | 68 +- odoo-addons/skeen_citas/models/cita.py | 253 ++++- .../skeen_citas/models/lista_espera.py | 121 +++ .../skeen_citas/security/ir.model.access.csv | 2 + .../skeen_citas/views/bloqueo_views.xml | 6 + odoo-addons/skeen_citas/views/cita_views.xml | 13 + .../skeen_citas/views/lista_espera_views.xml | 79 ++ .../skeen_whatsapp/controllers/frontend.py | 496 +++++++++- .../skeen_whatsapp/controllers/main.py | 18 +- 19 files changed, 2964 insertions(+), 328 deletions(-) create mode 100644 frontend/src/components/agenda/BloqueosModal.tsx create mode 100644 frontend/src/components/agenda/EstadoChecklist.tsx create mode 100644 frontend/src/components/agenda/ListaEspera.tsx create mode 100644 frontend/src/lib/autoRefresh.tsx create mode 100644 odoo-addons/skeen_citas/models/lista_espera.py create mode 100644 odoo-addons/skeen_citas/views/lista_espera_views.xml diff --git a/frontend/src/components/agenda/BloqueosModal.tsx b/frontend/src/components/agenda/BloqueosModal.tsx new file mode 100644 index 0000000..c4c9b4a --- /dev/null +++ b/frontend/src/components/agenda/BloqueosModal.tsx @@ -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 = ({ + isOpen, onClose, date, doctors, isMedico, ownDoctorId, initial, onChanged, +}) => { + const [doctorIds, setDoctorIds] = useState([]); + const [dateFrom, setDateFrom] = useState(date); + const [dateTo, setDateTo] = useState(date); + const [repite, setRepite] = useState(false); + const [days, setDays] = useState([]); + const [allDay, setAllDay] = useState(false); + const [timeFrom, setTimeFrom] = useState('13:00'); + const [timeTo, setTimeTo] = useState('14:00'); + const [motivoTipo, setMotivoTipo] = useState('otro'); + const [motivo, setMotivo] = useState(''); + const [saving, setSaving] = useState(false); + const [editing, setEditing] = useState(null); + const [bloqueos, setBloqueos] = useState([]); + 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(); + [...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 ( + Cerrar} + > +
+ {/* Alta / edición */} +
+
+

+ {editing ? `Editando bloqueo #${editing.id}` : 'Nuevo bloqueo'} +

+ {editing && ( + + )} +
+ + {/* Médicos (multi-selección) */} +
+

Médicos *

+ {isMedico ? ( +

+ {doctors.find((d) => d.id === ownDoctorId)?.name || 'Mi agenda'} +

+ ) : ( + <> +
+ {doctors.map((d) => { + const active = doctorIds.includes(d.id); + return ( + + ); + })} +
+ + + )} +
+ +
+ { setDateFrom(e.target.value); if (!repite) setDateTo(e.target.value); }} + /> + {repite ? ( + setDateTo(e.target.value)} + /> + ) : ( +
+

+ Para bloquear varios días (p. ej. todos los lunes de octubre) activa el rango. +

+
+ )} +
+ + + + {repite && ( +
+

+ Días de la semana (vacío = todos) +

+
+ {DAYS.map((d) => { + const active = days.includes(d.value); + return ( + + ); + })} +
+

+ Se crearán bloqueos en {totalFechas} {totalFechas === 1 ? 'fecha' : 'fechas'} + {doctorIds.length > 1 ? ` × ${doctorIds.length} médicos = ${totalFechas * doctorIds.length} registros` : ''}. +

+
+ )} + +
+ + {!allDay && ( + <> + setTimeFrom(e.target.value)} /> + setTimeTo(e.target.value)} /> + + )} +
+ +
+

Motivo

+
+ {MOTIVOS.map((m) => ( + + ))} +
+