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:
@@ -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();
|
||||
|
||||
67
frontend/src/lib/brand-colors.ts
Normal file
67
frontend/src/lib/brand-colors.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
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) })),
|
||||
}));
|
||||
@@ -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;
|
||||
|
||||
87
frontend/src/lib/service-categories.tsx
Normal file
87
frontend/src/lib/service-categories.tsx
Normal 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;
|
||||
};
|
||||
86
frontend/src/lib/sucursales.tsx
Normal file
86
frontend/src/lib/sucursales.tsx
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user