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:
@@ -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);
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
81
frontend/src/lib/recursos.tsx
Normal file
81
frontend/src/lib/recursos.tsx
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user