fase 3.1: recursos fisicos en agenda (modelo skeen.recurso con semilla cabina/consultoria, asignacion multiple a citas con validacion de solape, bloqueos por recurso, lista de espera desde recursos, vista/filtro por recurso, reporte de ocupacion); apagado por defecto

This commit is contained in:
2026-10-11 11:33:59 +00:00
parent 8ff13b9c2a
commit ee8a844d96
29 changed files with 1986 additions and 78 deletions

View File

@@ -70,6 +70,9 @@ export interface Appointment {
is_first_visit?: boolean;
doctor_id?: number | null;
doctor?: string;
/** Recursos asignados (cabina, sala, equipo). Vacío si el módulo está apagado. */
recurso_ids?: number[];
recursos?: RecursoBrief[];
branch?: string;
medium?: string;
/** Origen de la reserva: 'internal' (default) o 'public_portal' (portal en línea) */
@@ -94,10 +97,60 @@ export interface Appointment {
/** Motivos predefinidos de un bloqueo de agenda */
export type BloqueoMotivo = 'vacaciones' | 'junta' | 'comida' | 'personal' | 'otro';
// ---- Recursos de la agenda (cabinas, salas, consultorios, equipos) ----
export interface RecursoBrief {
id: number;
name: string;
code: string;
color: string;
}
export interface Recurso {
id: number;
code: string;
name: string;
tipo: RecursoTipo;
tipo_label: string;
activo: boolean;
sequence: number;
sucursales: { id: number; code: string; name: string }[];
capacidad: number;
costo_hora: number;
color: string;
notas: string;
}
export type RecursoTipo = 'cabina' | 'sala' | 'consultorio' | 'equipo' | 'otro';
export interface RecursoInput {
name: string;
code?: string;
tipo?: RecursoTipo;
activo?: boolean;
sequence?: number;
sucursal_ids?: number[];
capacidad?: number;
costo_hora?: number;
color?: string;
notas?: string;
motivo?: string;
}
export const RECURSO_TIPOS: { value: RecursoTipo; label: string }[] = [
{ value: 'cabina', label: 'Cabina' },
{ value: 'sala', label: 'Sala' },
{ value: 'consultorio', label: 'Consultorio' },
{ value: 'equipo', label: 'Equipo' },
{ value: 'otro', label: 'Otro' },
];
export interface Bloqueo {
id: number;
doctor_id: number;
doctor: string;
doctor_id: number | null;
doctor: string | null;
/** Recursos bloqueados (cabina, sala, equipo). Vacío si solo bloquea médico. */
recurso_ids?: number[];
recursos?: RecursoBrief[];
date: string;
all_day: boolean;
time_from: number;
@@ -124,8 +177,8 @@ export interface ListaEspera {
priority: 'alta' | 'normal' | 'baja';
priority_label: string;
franja: '' | 'manana' | 'tarde';
// Dónde espera: consultoría (médico) o cabina (tratamiento)
sala?: '' | 'consultoria' | 'cabina';
// Dónde espera: el código de un recurso de la agenda (cabina, sala...)
sala?: string;
sala_label?: string;
date_from: string | null;
date_to: string | null;
@@ -1015,6 +1068,7 @@ export interface ClinicSettings {
reservas_online: ReservasOnlineSettings;
notificaciones: NotificacionesSettings;
membresias: MembresiasSettings;
recursos: RecursosSettings;
}
// ---- Reservas en línea (portal público de auto-agendado) ----
@@ -1049,6 +1103,46 @@ export const RESERVAS_ONLINE_DEFAULT: ReservasOnlineSettings = {
};
// ---- Notificaciones y recordatorios de citas ----
// ---- Recursos físicos de la agenda ----
export interface RecursosSettings {
/** Enciende el módulo de recursos. Apagado = la agenda se ve igual que hoy. */
activo: boolean;
/** Impide dos citas/bloqueos en el mismo recurso a la misma hora. */
validar_solape: boolean;
/** Exige elegir al menos un recurso al crear la cita. */
obligatorio_al_agendar: boolean;
/** Permite asignar más de un recurso a una cita. */
permitir_multiples: boolean;
/** Muestra el recurso (chip/color) en la agenda. */
mostrar_en_agenda: boolean;
/** Ofrece el filtro por recurso junto al de doctores. */
filtrar_como_doctores: boolean;
}
export const RECURSOS_DEFAULT: RecursosSettings = {
activo: false,
validar_solape: true,
obligatorio_al_agendar: false,
permitir_multiples: true,
mostrar_en_agenda: true,
filtrar_como_doctores: true,
};
export interface RecursosReportRow {
recurso_id: number;
recurso: string;
code: string;
tipo: RecursoTipo;
tipo_label: string;
color: string;
citas: number;
horas_ocupadas: number;
horas_disponibles: number;
ocupacion_pct: number;
costo_hora: number;
ingresos_estimados: number;
}
export type NotificacionCanal = 'whatsapp' | 'email';
export type NotificacionCanalConfig = NotificacionCanal | 'ambos';
export type NotificacionTipo = 'recordatorio' | 'confirmacion' | 'cumpleanos' | 'post_visita' | 'recuperacion' | 'manual';
@@ -1541,6 +1635,7 @@ export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
reservas_online: RESERVAS_ONLINE_DEFAULT,
notificaciones: NOTIFICACIONES_DEFAULT,
membresias: MEMBRESIAS_DEFAULT,
recursos: RECURSOS_DEFAULT,
};
export interface FrontendUser {
@@ -2190,6 +2285,40 @@ export const odooApi = {
return data;
},
// ---- Recursos de la agenda (cabinas, salas, consultorios, equipos) ----
async getRecursos(opts?: { includeInactive?: boolean }): Promise<{ status: string; recursos: Recurso[]; total: number; message?: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/recursos`, {
params: opts?.includeInactive ? { include_inactive: 1 } : undefined,
});
return data;
},
async createRecurso(payload: RecursoInput): Promise<{ status: string; recurso: Recurso; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/recursos`, payload);
return data;
},
async updateRecurso(id: number, payload: Partial<RecursoInput>): Promise<{ status: string; recurso: Recurso; message?: string }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/recursos/${id}`, payload);
return data;
},
async deleteRecurso(id: number): Promise<{ status: string; recurso: Recurso; en_uso: number; message: string }> {
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/recursos/${id}`);
return data;
},
async getRecursosReport(start: string, end: string): Promise<{
status: string; start: string; end: string; dias: number; horas_disponibles: number;
recursos: RecursosReportRow[]; totales: { citas: number; horas_ocupadas: number; ingresos_estimados: number };
message?: string;
}> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/recursos`, {
params: { start, end },
});
return data;
},
// ---- Ajustes de la clínica ----
async getClinicSettings(): Promise<{ status: string; settings: ClinicSettings; message?: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/clinic-settings`);
@@ -2492,7 +2621,7 @@ export const odooApi = {
return data;
},
async createQuickAppointment(payload: { name: string; phone: string; date: string; time: number; doctor_id?: number; notes?: string; duration?: number; branch?: 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; branch?: string; recurso_ids?: 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;
},
@@ -2596,6 +2725,7 @@ export const odooApi = {
async createBloqueo(payload: {
doctor_ids: number[];
recurso_ids?: number[];
date_from: string;
date_to?: string;
days?: number[];
@@ -2611,7 +2741,7 @@ export const odooApi = {
},
async updateBloqueo(id: number, payload: Partial<{
doctor_id: number; date: string; all_day: boolean; time_from: number; time_to: number;
doctor_id: number; recurso_ids: 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);