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

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT } from '../services/odoo';
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, RECURSOS_DEFAULT } from '../services/odoo';
// Ajustes de la clínica compartidos por la sesión (nombre, moneda e impuestos).
// Se leen una sola vez del servidor y se avisa a quien los use cuando cambian.
@@ -11,6 +11,7 @@ const normalizar = (settings?: Partial<ClinicSettings> | null): ClinicSettings =
impuestos: { ...IMPUESTOS_DEFAULT, ...(settings?.impuestos || {}) },
notificaciones: { ...NOTIFICACIONES_DEFAULT, ...(settings?.notificaciones || {}) },
membresias: { ...MEMBRESIAS_DEFAULT, ...(settings?.membresias || {}) },
recursos: { ...RECURSOS_DEFAULT, ...(settings?.recursos || {}) },
});
let actual: ClinicSettings = normalizar(null);

View File

@@ -28,6 +28,7 @@ export const MENU_ITEMS: MenuItem[] = [
{ label: 'Comisiones', href: '/reportes?tab=comisiones' },
{ label: 'Adeudos', href: '/reportes?tab=adeudos' },
{ label: 'Horas agenda', href: '/reportes?tab=horas-agenda' },
{ label: 'Recursos', href: '/reportes?tab=recursos', termKey: 'room_plural' },
{ label: 'Paquetes', href: '/reportes?tab=paquetes', termKey: 'package_plural' },
{ label: 'Membresías', href: '/membresias' },
{ label: 'Vendedores', href: '/reportes?tab=vendedores' },

View File

@@ -6,6 +6,7 @@
export const PERM_KEYS: string[] = [
'agenda.ver', 'agenda.agendar', 'agenda.editar', 'agenda.eliminar',
'agenda.mover', 'agenda.visitas', 'agenda.todas', 'agenda.bloquear',
'recursos.ver', 'recursos.editar',
'pacientes.ver', 'pacientes.expediente', 'pacientes.crear', 'pacientes.editar',
'pacientes.eliminar', 'pacientes.exportar', 'pacientes.saldos',
'visitas.ver', 'visitas.crear', 'visitas.editar', 'visitas.eliminar', 'visitas.archivos',
@@ -34,6 +35,7 @@ const FORM_WRITE = ['admin', 'medico', 'recepcion', 'enfermeria', 'gerencia'];
const READ_PERM_KEYS = new Set([
'agenda.ver', 'agenda.todas',
'recursos.ver',
'pacientes.ver', 'pacientes.expediente', 'pacientes.exportar', 'pacientes.saldos',
'visitas.ver',
'inventario.ver',
@@ -49,6 +51,7 @@ const READ_PERM_KEYS = new Set([
const DEFAULT_PERM_ROLES: Record<string, string[]> = {
'agenda.ver': ALL, 'agenda.agendar': W, 'agenda.editar': W, 'agenda.eliminar': W,
'agenda.mover': W, 'agenda.visitas': W, 'agenda.todas': W, 'agenda.bloquear': W,
'recursos.ver': ALL, 'recursos.editar': AG,
'pacientes.ver': ALL, 'pacientes.expediente': ALL, 'pacientes.crear': W,
'pacientes.editar': W, 'pacientes.eliminar': W, 'pacientes.exportar': ALL,
'pacientes.saldos': ALL,

View File

@@ -6,9 +6,13 @@ import { odooApi, type BookingConfig } from '../services/odoo';
// responden 404 cuando el módulo está desactivado.
/**
* Config del portal público. Devuelve `config=null` cuando está desactivado
* (404) o el backend no responde: en ese caso el portal se muestra como "no
* Config del portal público. Devuelve `config=null` cuando está desactivado o
* el backend no responde: en ese caso el portal se muestra como "no
* disponible" y no se ofrece ningún enlace.
*
* Con las reservas en línea apagadas el endpoint responde `activo:false` (200);
* si una versión anterior responde 404, se captura en silencio y se trata como
* "no configurado" (nunca se registra como error en consola).
*/
export const useBookingConfig = () => {
const [config, setConfig] = useState<BookingConfig | null>(null);

View File

@@ -0,0 +1,81 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import type { FC, ReactNode } from 'react';
import { odooApi, type Recurso } from '../services/odoo';
import { useAuth } from './auth';
// Respaldo histórico: los dos valores que estaban hardcodeados en la lista de
// espera antes de que existieran los recursos. Si /recursos falla, la lista de
// espera sigue mostrando "Cabina"/"Consultoría" igual que antes.
export const RECURSOS_FALLBACK: Recurso[] = [
{ id: -1, code: 'cabina', name: 'Cabina', tipo: 'cabina', tipo_label: 'Cabina', activo: true, sequence: 10, sucursales: [], capacidad: 0, costo_hora: 0, color: '', notas: '' },
{ id: -2, code: 'consultoria', name: 'Consultoría', tipo: 'consultorio', tipo_label: 'Consultorio', activo: true, sequence: 20, sucursales: [], capacidad: 0, costo_hora: 0, color: '', notas: '' },
];
const NOMBRES_HISTORICOS: Record<string, string> = { cabina: 'Cabina', consultoria: 'Consultoría' };
interface RecursosContextValue {
/** Recursos activos, ordenados por sequence. */
recursos: Recurso[];
loading: boolean;
/** Nombre del recurso por código; cae al histórico y luego al propio código. */
labelFor: (code?: string | null) => string;
refresh: () => void;
}
const RecursosContext = createContext<RecursosContextValue | undefined>(undefined);
export const RecursosProvider: FC<{ children: ReactNode }> = ({ children }) => {
const { token } = useAuth();
const [recursos, setRecursos] = useState<Recurso[]>(RECURSOS_FALLBACK);
const [loading, setLoading] = useState(false);
const [attempt, setAttempt] = useState(0);
const refresh = useCallback(() => setAttempt((n) => n + 1), []);
useEffect(() => {
if (!token) return;
let alive = true;
setLoading(true);
odooApi.getRecursos()
.then((res) => {
if (!alive || res.status !== 'success' || !res.recursos?.length) return;
setRecursos(res.recursos);
})
.catch(() => {
// fallback: RECURSOS_FALLBACK
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [token, attempt]);
const porCodigo = useMemo(() => {
const m = new Map<string, Recurso>();
for (const r of recursos) m.set(r.code, r);
return m;
}, [recursos]);
const labelFor = useCallback(
(code?: string | null) => {
if (!code) return '';
return porCodigo.get(code)?.name || NOMBRES_HISTORICOS[code] || code;
},
[porCodigo],
);
const value = useMemo(
() => ({ recursos, loading, labelFor, refresh }),
[recursos, loading, labelFor, refresh],
);
return <RecursosContext.Provider value={value}>{children}</RecursosContext.Provider>;
};
export const useRecursos = (): RecursosContextValue => {
const ctx = useContext(RecursosContext);
if (!ctx) throw new Error('useRecursos debe usarse dentro de RecursosProvider');
return ctx;
};