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