fase 1 productizacion: capa de configuracion (glosario multi-giro, sucursales y categorias configurables, ajustes de clinica por instancia, colores de marca, horarios, URLs de pago dinamicas) + script de provisioning de instancia nueva con datos demo + frontend consumiendo la config

This commit is contained in:
2026-10-11 05:50:18 +00:00
parent 28118ce732
commit f92018f88e
50 changed files with 4314 additions and 210 deletions

View File

@@ -0,0 +1,174 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import type { FC, ReactNode } from 'react';
import { odooApi, GLOSSARY_KEYS, type GlossaryKey, type GlossaryPack, type GlossaryTerms } from '../services/odoo';
import { useAuth } from './auth';
// Terminología por defecto = pack 'clinica' del backend, que coincide con los
// literales que usa hoy producción. Es también el respaldo si el endpoint falla,
// para que la interfaz nunca se rompa por un glosario no disponible.
export const GLOSARIO_CLINICA: GlossaryTerms = {
patient_singular: 'Paciente',
patient_plural: 'Pacientes',
appointment_singular: 'Cita',
appointment_plural: 'Citas',
visit_singular: 'Visita',
visit_plural: 'Visitas',
record_singular: 'Expediente',
record_plural: 'Expedientes',
doctor_singular: 'Médico',
doctor_plural: 'Médicos',
staff_singular: 'Personal',
staff_plural: 'Personal',
branch_singular: 'Sucursal',
branch_plural: 'Sucursales',
room_singular: 'Cabina',
room_plural: 'Cabinas',
service_singular: 'Servicio',
service_plural: 'Servicios',
package_singular: 'Paquete',
package_plural: 'Paquetes',
prescription_singular: 'Receta',
prescription_plural: 'Recetas',
treatment_singular: 'Tratamiento',
treatment_plural: 'Tratamientos',
};
const GLOSARIO_CACHE_KEY = 'skeen_glosario';
interface GlosarioContextValue {
terms: GlossaryTerms;
pack: string;
packs: GlossaryPack[];
loading: boolean;
/** Traduce una clave del glosario; cae al valor del pack clínica y luego a la clave. */
term: (key: GlossaryKey) => string;
/** Reemplaza los términos en memoria tras un PUT en Configuración. */
setTerms: (pack: string, terms: GlossaryTerms) => void;
refresh: () => void;
}
const GlosarioContext = createContext<GlosarioContextValue | undefined>(undefined);
const leerCache = (): { pack: string; terms: GlossaryTerms } => {
try {
const raw = localStorage.getItem(GLOSARIO_CACHE_KEY);
if (!raw) return { pack: 'clinica', terms: GLOSARIO_CLINICA };
const parsed = JSON.parse(raw) as { pack?: string; terms?: Partial<GlossaryTerms> };
const terms: GlossaryTerms = { ...GLOSARIO_CLINICA };
for (const k of GLOSSARY_KEYS) {
const v = parsed.terms?.[k];
if (v) terms[k] = v;
}
return { pack: parsed.pack || 'clinica', terms };
} catch {
return { pack: 'clinica', terms: GLOSARIO_CLINICA };
}
};
export const GlosarioProvider: FC<{ children: ReactNode }> = ({ children }) => {
const { token } = useAuth();
const cached = useMemo(leerCache, []);
const [terms, setTermsState] = useState<GlossaryTerms>(cached.terms);
const [pack, setPack] = useState<string>(cached.pack);
const [packs, setPacks] = useState<GlossaryPack[]>([]);
const [loading, setLoading] = useState(false);
const [attempt, setAttempt] = useState(0);
const refresh = useCallback(() => setAttempt((n) => n + 1), []);
const setTerms = useCallback((nextPack: string, nextTerms: GlossaryTerms) => {
setPack(nextPack);
setTermsState(nextTerms);
try {
localStorage.setItem(GLOSARIO_CACHE_KEY, JSON.stringify({ pack: nextPack, terms: nextTerms }));
} catch {
// localStorage bloqueado: seguimos en memoria
}
}, []);
// Carga el glosario vigente al iniciar sesión y refresca al reintentar.
useEffect(() => {
if (!token) return;
let alive = true;
setLoading(true);
odooApi.getGlosario()
.then((res) => {
if (!alive || res.status !== 'success') return;
const next: GlossaryTerms = { ...GLOSARIO_CLINICA };
for (const k of GLOSSARY_KEYS) {
if (res.terms?.[k]) next[k] = res.terms[k];
}
setTerms(res.pack || 'clinica', next);
})
.catch(() => {
// fallback: caché o pack clínica
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [token, attempt, setTerms]);
// Packs disponibles solo hacen falta en Configuración; se piden con la sesión.
useEffect(() => {
if (!token) return;
let alive = true;
odooApi.getGlosarioPacks()
.then((res) => {
if (alive && res.status === 'success' && res.packs) setPacks(res.packs);
})
.catch(() => {});
return () => {
alive = false;
};
}, [token]);
const term = useCallback(
(key: GlossaryKey): string => terms[key] || GLOSARIO_CLINICA[key] || key,
[terms],
);
const value = useMemo(
() => ({ terms, pack, packs, loading, term, setTerms, refresh }),
[terms, pack, packs, loading, term, setTerms, refresh],
);
return <GlosarioContext.Provider value={value}>{children}</GlosarioContext.Provider>;
};
export const useGlosario = (): GlosarioContextValue => {
const ctx = useContext(GlosarioContext);
if (!ctx) throw new Error('useGlosario debe usarse dentro de GlosarioProvider');
return ctx;
};
// Etiquetas del menú lateral que se traducen al glosario. Solo se listan las que
// hoy coinciden con un término clínico (Pacientes, Visitas, Médicos...); así con
// la configuración por defecto el menú se ve igual que siempre. Los nombres de
// módulo (Agenda, Dashboard, Punto de Venta...) no se tocan.
export const MENU_LABEL_TERMS: Record<string, GlossaryKey> = {
Pacientes: 'patient_plural',
Visitas: 'visit_plural',
Expedientes: 'record_plural',
Médicos: 'doctor_plural',
Servicios: 'service_plural',
Paquetes: 'package_plural',
};
/** Devuelve la etiqueta del menú traducida según el glosario vigente. */
export const traducirEtiqueta = (label: string, term: (key: GlossaryKey) => string): string => {
const key = MENU_LABEL_TERMS[label];
return key ? term(key) : label;
};
/** Copia superficial del menú (grupos con items) aplicando el glosario a las etiquetas. */
export const traducirMenu = <T extends { label: string; items: { label: string }[] }>(
groups: T[],
term: (key: GlossaryKey) => string,
): T[] =>
groups.map((g) => ({
...g,
items: g.items.map((it) => ({ ...it, label: traducirEtiqueta(it.label, term) })),
}));