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