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

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

View File

@@ -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 });