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

@@ -1,6 +1,7 @@
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
import type { FC, ReactNode } from 'react';
import { odooApi, type FrontendUser, type FrontendRole, type AgendaPerm, type ClinicBrand, BRAND_DEFAULT } from '../services/odoo';
import { applyBrandColors } from './brand-colors';
const TOKEN_KEY = 'skeen_token';
const USER_KEY = 'skeen_user';
@@ -120,6 +121,11 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
}
}, []);
// Colores de marca en runtime: solo pisan el tema si la clínica los configuró.
useEffect(() => {
applyBrandColors(brand);
}, [brand]);
const refreshBrand = useCallback(async () => {
try {
const res = await odooApi.getBrand();

View File

@@ -0,0 +1,67 @@
import type { ClinicBrand } from '../services/odoo';
// Colores de marca en runtime.
//
// Los temas (index.css) definen sus colores en variables CSS sobre el elemento
// raíz. Si la clínica configuró colores de marca, se pisan como estilo en línea
// de <html>, que tiene más prioridad que el selector [data-theme]. Si están
// vacíos se quitan, de modo que la instancia sin configurar se ve exactamente
// igual que siempre en los tres temas.
const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
// Variables que controla el color primario de la marca.
const PROPS_PRIMARIO = ['--color-coral', '--color-coral-hover', '--accent', '--accent-hover', '--accent-bg'];
const PROPS_ACENTO = ['--color-lilac'];
const normalizeHex = (hex: string): string => {
let h = hex.trim();
if (h.length === 4) {
h = `#${h[1]}${h[1]}${h[2]}${h[2]}${h[3]}${h[3]}`;
}
return h.toLowerCase();
};
const hexToRgb = (hex: string): [number, number, number] => {
const h = normalizeHex(hex);
return [
parseInt(h.slice(1, 3), 16),
parseInt(h.slice(3, 5), 16),
parseInt(h.slice(5, 7), 16),
];
};
/** Oscurece un color hex un porcentaje (0-1) para derivar el estado hover. */
const darken = (hex: string, amount: number): string => {
const [r, g, b] = hexToRgb(hex);
const f = (v: number) => Math.max(0, Math.round(v * (1 - amount)));
return `#${[f(r), f(g), f(b)].map((v) => v.toString(16).padStart(2, '0')).join('')}`;
};
const rgba = (hex: string, alpha: number): string => {
const [r, g, b] = hexToRgb(hex);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
};
/** Aplica (o limpia) los colores de marca sobre el tema activo. */
export const applyBrandColors = (brand: ClinicBrand): void => {
if (typeof document === 'undefined') return;
const root = document.documentElement;
for (const p of [...PROPS_PRIMARIO, ...PROPS_ACENTO]) root.style.removeProperty(p);
const colores = brand.colores || {};
const primario = colores.primario && HEX_RE.test(colores.primario) ? normalizeHex(colores.primario) : '';
const acento = colores.acento && HEX_RE.test(colores.acento) ? normalizeHex(colores.acento) : '';
if (primario) {
const hover = darken(primario, 0.12);
root.style.setProperty('--color-coral', primario);
root.style.setProperty('--color-coral-hover', hover);
root.style.setProperty('--accent', primario);
root.style.setProperty('--accent-hover', hover);
root.style.setProperty('--accent-bg', rgba(primario, 0.12));
}
if (acento) {
root.style.setProperty('--color-lilac', acento);
}
};

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) })),
}));

View File

@@ -1,11 +1,23 @@
// Lista canónica de secciones del sistema (para permisos de menú por usuario)
export const MENU_ITEMS: { label: string; href: string }[] = [
import type { GlossaryKey } from '../services/odoo';
// Lista canónica de secciones del sistema (para permisos de menú por usuario).
// `termKey` marca las secciones cuyo nombre es un término configurable del
// glosario (Pacientes, Visitas, Médicos...): con el pack por defecto se ven
// exactamente como siempre. Los nombres de módulo (Agenda, Dashboard, Punto de
// Venta...) no son términos y no se traducen.
export interface MenuItem {
label: string;
href: string;
termKey?: GlossaryKey;
}
export const MENU_ITEMS: MenuItem[] = [
{ label: 'Dashboard', href: '/' },
{ label: 'Agenda', href: '/agenda' },
{ label: 'Pacientes', href: '/pacientes' },
{ label: 'Visitas', href: '/visitas' },
{ label: 'Médicos', href: '/medicos' },
{ label: 'Servicios', href: '/servicios' },
{ label: 'Pacientes', href: '/pacientes', termKey: 'patient_plural' },
{ label: 'Visitas', href: '/visitas', termKey: 'visit_plural' },
{ label: 'Médicos', href: '/medicos', termKey: 'doctor_plural' },
{ label: 'Servicios', href: '/servicios', termKey: 'service_plural' },
{ label: 'Punto de Venta', href: '/pos' },
{ label: 'Ventas', href: '/ventas' },
{ label: 'Pagos', href: '/pagos' },
@@ -16,7 +28,7 @@ export const MENU_ITEMS: { label: string; href: string }[] = [
{ label: 'Comisiones', href: '/reportes?tab=comisiones' },
{ label: 'Adeudos', href: '/reportes?tab=adeudos' },
{ label: 'Horas agenda', href: '/reportes?tab=horas-agenda' },
{ label: 'Paquetes', href: '/reportes?tab=paquetes' },
{ label: 'Paquetes', href: '/reportes?tab=paquetes', termKey: 'package_plural' },
{ label: 'Vendedores', href: '/reportes?tab=vendedores' },
{ label: 'Concentrado', href: '/reportes?tab=concentrado' },
{ label: 'Recomendaciones', href: '/reportes?tab=recomendaciones' },
@@ -28,3 +40,7 @@ export const MENU_ITEMS: { label: string; href: string }[] = [
{ label: 'Mensajes WACRM', href: '/wacrm/messages' },
{ label: 'Leads WACRM', href: '/wacrm/leads' },
];
/** Etiqueta de la sección traducida al glosario vigente. */
export const menuItemLabel = (item: MenuItem, term: (key: GlossaryKey) => string): string =>
item.termKey ? term(item.termKey) : item.label;

View File

@@ -0,0 +1,87 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import type { FC, ReactNode } from 'react';
import { odooApi, type ServiceCategory } from '../services/odoo';
import { useAuth } from './auth';
// Respaldo histórico: las 4 categorías hardcodeadas antes del endpoint, en el
// mismo orden. Con la configuración por defecto el catálogo se ve igual.
export const CATEGORIAS_FALLBACK: ServiceCategory[] = [
{ id: -1, code: 'consulta', name: 'Consulta', sequence: 10, active: true, is_system: true },
{ id: -2, code: 'tratamiento', name: 'Tratamiento', sequence: 20, active: true, is_system: true },
{ id: -3, code: 'procedimiento', name: 'Procedimiento', sequence: 30, active: true, is_system: true },
{ id: -4, code: 'paquete', name: 'Paquete', sequence: 40, active: true, is_system: true },
];
const NOMBRES_HISTORICOS: Record<string, string> = {
consulta: 'Consulta',
tratamiento: 'Tratamiento',
procedimiento: 'Procedimiento',
paquete: 'Paquete',
};
interface ServiceCategoriesContextValue {
/** Categorías activas, ordenadas por sequence. */
categories: ServiceCategory[];
loading: boolean;
/** Nombre de la categoría por código; cae al histórico y luego al código. */
labelFor: (code?: string | null) => string;
refresh: () => void;
}
const ServiceCategoriesContext = createContext<ServiceCategoriesContextValue | undefined>(undefined);
export const ServiceCategoriesProvider: FC<{ children: ReactNode }> = ({ children }) => {
const { token } = useAuth();
const [categories, setCategories] = useState<ServiceCategory[]>(CATEGORIAS_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.getServiceCategories()
.then((res) => {
if (!alive || res.status !== 'success' || !res.categories?.length) return;
setCategories(res.categories);
})
.catch(() => {
// fallback: CATEGORIAS_FALLBACK
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [token, attempt]);
const porCodigo = useMemo(() => {
const m = new Map<string, ServiceCategory>();
for (const c of categories) m.set(c.code, c);
return m;
}, [categories]);
const labelFor = useCallback(
(code?: string | null) => {
if (!code) return '';
return porCodigo.get(code)?.name || NOMBRES_HISTORICOS[code] || code;
},
[porCodigo],
);
const value = useMemo(
() => ({ categories, loading, labelFor, refresh }),
[categories, loading, labelFor, refresh],
);
return <ServiceCategoriesContext.Provider value={value}>{children}</ServiceCategoriesContext.Provider>;
};
export const useServiceCategories = (): ServiceCategoriesContextValue => {
const ctx = useContext(ServiceCategoriesContext);
if (!ctx) throw new Error('useServiceCategories debe usarse dentro de ServiceCategoriesProvider');
return ctx;
};

View File

@@ -0,0 +1,86 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import type { FC, ReactNode } from 'react';
import { odooApi, type Sucursal } from '../services/odoo';
import { useAuth } from './auth';
// Respaldo histórico: las dos sucursales que existían hardcodeadas antes de que
// el endpoint existiera. Si /sucursales falla, el frontend se sigue viendo igual.
export const SUCURSALES_FALLBACK: Sucursal[] = [
{ id: -1, code: 'rosarito', name: 'Rosarito', sequence: 10, active: true, is_default: true, address: '', phone: '', notes: '' },
{ id: -2, code: 'tijuana', name: 'Tijuana', sequence: 20, active: true, is_default: false, address: '', phone: '', notes: '' },
];
const NOMBRES_HISTORICOS: Record<string, string> = { rosarito: 'Rosarito', tijuana: 'Tijuana' };
interface SucursalesContextValue {
/** Sucursales activas, ordenadas por sequence. */
sucursales: Sucursal[];
loading: boolean;
defaultCode: string;
/** Nombre de la sucursal por código; cae al histórico y luego al propio código. */
labelFor: (code?: string | null) => string;
refresh: () => void;
}
const SucursalesContext = createContext<SucursalesContextValue | undefined>(undefined);
export const SucursalesProvider: FC<{ children: ReactNode }> = ({ children }) => {
const { token } = useAuth();
const [sucursales, setSucursales] = useState<Sucursal[]>(SUCURSALES_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.getSucursales()
.then((res) => {
if (!alive || res.status !== 'success' || !res.sucursales?.length) return;
setSucursales(res.sucursales);
})
.catch(() => {
// fallback: SUCURSALES_FALLBACK
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [token, attempt]);
const porCodigo = useMemo(() => {
const m = new Map<string, Sucursal>();
for (const s of sucursales) m.set(s.code, s);
return m;
}, [sucursales]);
const defaultCode = useMemo(
() => (sucursales.find((s) => s.is_default) || sucursales[0])?.code || '',
[sucursales],
);
const labelFor = useCallback(
(code?: string | null) => {
if (!code) return '';
return porCodigo.get(code)?.name || NOMBRES_HISTORICOS[code] || code;
},
[porCodigo],
);
const value = useMemo(
() => ({ sucursales, loading, defaultCode, labelFor, refresh }),
[sucursales, loading, defaultCode, labelFor, refresh],
);
return <SucursalesContext.Provider value={value}>{children}</SucursalesContext.Provider>;
};
export const useSucursales = (): SucursalesContextValue => {
const ctx = useContext(SucursalesContext);
if (!ctx) throw new Error('useSucursales debe usarse dentro de SucursalesProvider');
return ctx;
};