Appearance
Patron de Migracion Sidebar TSX
Tipo: Patron de arquitectura frontend Ambito: Core + todos los modulos legacy (Contabilidad, Stock, CRM, Compras, Tesoreria, Ventas) Fecha: 2026-03-02 Estado: Implementado (6 modulos migrados)
Proposito
Este patron describe como migrar los sidebars PHP/JS legacy de cada modulo a un entry point React TSX, manteniendo compatibilidad total con la navegacion PHP (?loc=xxx), los modales legacy y el sistema de eventos del DOM.
Cuando aplicar este patron:
- Al agregar un nuevo modulo que requiere sidebar reactivo
- Al migrar un modulo legacy adicional al mismo patron
Cuando NO aplicar:
- Modulos que ya son SPAs completas con
HashRouter(CRM, Membresias) — esos usan{Module}App.tsxcompleto conLayout - Sidebars de paginas internas React — esas usan el componente
Sidebar.tsxdirectamente dentro del app
Arquitectura: Piezas que intervienen
PHP Session / Logica de negocio
|
v
php/components/mod-{modulo}/main-sidebar-{modulo}.php
- Prepara $sidebarConfig con permisos y flags
- Emite: <div id="sidebar-{modulo}-app" data-configuration='...'></div>
- Emite: <script type="module" src="dist/{modulo}/config/{Modulo}SidebarApp.js"></script>
|
v
ts/{modulo}/config/{Modulo}SidebarApp.tsx (entry point React)
- Lee data-configuration del DOM al DOMContentLoaded
- Gestiona estado dinamico: certState, ejercicio, tipoCuenta, periodoText
- Suscribe a custom events del DOM
- Define customHandlers para los items modal-trigger
- Llama mountApp(container, {Modulo}SidebarApp, { config })
|
v
ts/{modulo}/config/sidebar.ts (configuracion de menu)
- Exporta get{Modulo}SidebarMenuItems(config, ...dinamicParams): MenuItem[]
- Usa createStandardSidebar() de ts/core/config/standardSidebarSections.ts
- Aplica postProcess para convertir home.path -> home.href
- Inyecta permissions en section wrappers
|
v
ts/core/components/layout/Sidebar.tsx (componente core compartido)
- Recibe: moduleTitle, moduleLoc, menuItems, userPermissions, customHandlers,
customContentStart?, customContentEnd?
- Filtra items por userPermissions
- Renderiza href items como <a href="?loc=xxx"> (navegacion PHP full-page)
- Renderiza modal-trigger items via customHandlers[item.id]
- Detecta item activo via isHrefActive()Flujo de navegacion en items legacy: todos los links a paginas PHP usan href: '?loc=xxx', nunca path. El MemoryRouter es obligatorio porque Sidebar.tsx llama useLocation() internamente, pero no se usa para ruteo real.
Estructura de archivos por modulo
Por cada modulo migrado se crean exactamente dos archivos nuevos:
bautista-app/ts/
└── {modulo}/
└── config/
├── {Modulo}SidebarApp.tsx -- entry point React (auto-detectado por Vite)
└── sidebar.ts -- configuracion de menu itemsY se modifica un archivo PHP existente:
bautista-app/php/components/mod-{modulo}/
└── main-sidebar-{modulo}.php -- reemplaza el <aside> legacy por un <div> mount pointAuto-discovery de entry points: Vite detecta automaticamente todos los archivos *App.tsx dentro de ts/. No se necesita registrar el entry point manualmente en vite.config.ts. El output generado es dist/{modulo}/config/{Modulo}SidebarApp.js.
Componentes clave
createStandardSidebar(config)
Ubicacion: ts/core/config/standardSidebarSections.ts
Construye el array MenuItem[] con la estructura estandar de secciones:
- Home: "Menu modulo" (siempre presente)
additionalItemsBefore[]— items extras antes de las secciones (ej: Seleccionar Ejercicio en Contabilidad)- Bases
- Movimientos
- Informes
- Utilidades
additionalItemsAfter[]- CERRAR SESION (siempre al final)
Item home generado: usa path internamente. El sidebar.ts de cada modulo debe post-procesarlo a href via postProcess{Modulo}Sidebar(), porque la navegacion es PHP (?loc=xxx), no React Router.
Secciones vacias: si bases, movimientos, etc. son arrays vacios, esa seccion no se agrega.
postProcess{Modulo}Sidebar(items)
Funcion privada en cada sidebar.ts. Hace dos cosas:
- Convierte
home.path→home.href = '?loc={modulo}' - Agrega
permissiona los section wrappers (bases,movimientos,informes,utilidades) para queSidebar.tsxfiltre la seccion completa cuando el usuario no tiene el permiso de nivel superior
Patron invariante en todos los modulos; solo cambian los prefijos de permisos (ej: VENTAS_BASES, CONTAB_BASES, etc.).
ModeChanger
Ubicacion: ts/core/components/ModeChanger.tsx
Componente self-contained que gestiona el modo de transacciones. Se inyecta via customContentEnd={<ModeChanger />} en el Sidebar.
Estado interno: lee y escribe localStorage.tipo_transaccion (valores: "0", "1", "2").
Ciclo de modos:
| Valor | Clase CSS | Descripcion |
|---|---|---|
0 | bg-primary | Prueba (azul) |
1 | bg-success | Oficial (verde) |
2 | bg-consolidados | Consolidados |
Contrato de eventos: despacha y escucha CustomEvent('modeChanged', { detail: number }) en document. Otros componentes de la pagina (modales legacy, handlers) escuchan este evento para adaptar su comportamiento.
Lock por URL: si window.location.search contiene ?prueba, el click muestra un modal informativo y NO cambia el modo ni escribe localStorage.
Modulos que incluyen ModeChanger: Contabilidad, Stock, CRM, Compras, Tesoreria, Ventas. Modulos que NO incluyen ModeChanger: Stock (el legacy sidebar de stock no tenia este boton).
Nota historica: ModeChanger fue creado inicialmente en
ts/stock/config/y luego movido ats/core/components/al detectarse que es compartido por 5 modulos.
Patron PHP mount point
Estructura del archivo PHP
php/components/mod-{modulo}/main-sidebar-{modulo}.phpAntes de la migracion: archivo con ~100-200 lineas de HTML PHP-gateado (<aside>...</aside>) + <script src="js/view/mod-{modulo}/sidebar.js">.
Despues de la migracion: archivo minimo con la preparacion del config y el div mount point.
Patron de codificacion JSON: siempre usar JSON_HEX_APOS | JSON_HEX_QUOT en el json_encode para evitar que comillas simples y dobles dentro del JSON rompan el atributo HTML.
Patron del contenedor: el <div> mount point NO necesita las clases CSS del <aside> original (main-sidebar sidebar-dark-primary elevation-4) porque Sidebar.tsx renderiza su propio <aside> con esas clases internamente. Sin embargo, si el layout de la pagina depende de que esas clases existan en el DOM antes de que React hidrate, pueden mantenerse en el <div> como fallback.
Script de carga: tipo module apuntando al JS compilado en dist/. El script legacy sidebar.js se elimina una vez que el smoke test del modulo pasa.
Esquema de configuracion por modulo
Los campos que PHP inyecta en data-configuration varian por modulo. Solo se inyectan valores estaticos disponibles en el momento del render PHP. Los valores dinamicos (periodo, ejercicio) se obtienen via API fetch o custom events del DOM despues del montaje.
Stock:
permisos: array de strings de permisos del usuario
CtaCte:
permisos: array de permisostipoCuenta:"D"(Deudor → label "Recibo") o"A"(Acreedor → label "Orden de Pago")
Tesoreria:
permisos: array de permisos- Periodo: NO se inyecta desde PHP; se obtiene via API al montar
Compras:
permisos: array de permisos- Periodo: NO se inyecta desde PHP; se obtiene via API al montar
Contabilidad:
permisos: array de permisosejercicioInicial: objeto{ nro, desde, hasta }desde$_SESSION['EJERCICIO_SELECTED'](puede ser null)- Periodo: NO se inyecta; se obtiene via API al montar
Ventas:
permisos: array de permisoscartera: bool — flag de empresa para mostrar item "Carteras"factManual: bool — flag de empresa para mostrar sub-menu "Registro Manual"pedido: bool — flag de empresa para mostrar items de PedidosmoduloContabilidad: bool — muestra "Ref. Contables" en BasesmoduloCtacte: bool — muestra "Clientes" y "Posicion de IVA" en Informes
Convenciones de iconos
Los iconos siguen una jerarquia visual consistente en todos los modulos:
| Nivel | Icono | Uso |
|---|---|---|
| Seccion (colapsable) | Icono semantico segun tipo | fas fa-toolbox (Bases), fas fa-truck-moving (Movimientos), fas fa-file-pdf (Informes), fas fa-wrench (Utilidades) |
| Item hijo (primer nivel) | far fa-circle | Todos los items directos dentro de una seccion |
| Item nieto (segundo nivel) | far fa-dot-circle | Items dentro de sub-secciones colapsables anidadas |
Sub-secciones anidadas: cuando un item dentro de Bases, Movimientos, etc. tiene children, sus hijos usan far fa-dot-circle. Ejemplo: "Lista de precios" (circulo) → "Automatico por rango" (dot-circle), "Costo por margen" (dot-circle).
Items condicionales
Los items que solo aparecen segun flags de empresa o modulos habilitados se implementan con spread condicional en el array:
typescript
// Patron: spread condicional en el array de items
const basesItems: MenuItem[] = [
// item siempre visible
{ id: 'cat-iva', ... },
// item condicional: solo cuando config.cartera === true
...(config.cartera ? [{ id: 'cartera', ... } satisfies MenuItem] : []),
// item condicional: solo cuando config.moduloContabilidad === true
...(config.moduloContabilidad ? [{ id: 'ref-contables', ... } satisfies MenuItem] : []),
];Uso de satisfies MenuItem: garantiza que el objeto cumple el tipo MenuItem sin perder el tipo literal. Es el patron estandar para items dentro de spreads condicionales.
Flags que controlan items condicionales en Ventas:
config.cartera→ "Carteras" en Basesconfig.factManual→ sub-menu "Registro Manual" en Movimientosconfig.pedido→ "Comp. pendientes" en Bases + sub-menu "Pedidos" en Movimientosconfig.moduloContabilidad→ "Ref. Contables" en Basesconfig.moduloCtacte→ "Clientes" en Informes + "Posicion de IVA" en Informes
Marcado de item activo
Sidebar.tsx detecta automaticamente el item activo comparando window.location.search con el href de cada item via isHrefActive().
Requisito critico: los scripts legacy de PHP que seteaban clases CSS de "activo" directamente sobre los elementos del sidebar HTML deben ser eliminados al migrar a TSX. Esos scripts buscaban elementos por ID (idMainSideBarClientes, etc.) que ya no existen en el DOM una vez que el sidebar es React. Si permanecen, lanzan errores de consola o intentan manipular elementos inexistentes.
Patron customHandlers
Los items que abren modales (sin navegacion) se declaran en sidebar.ts sin href, path ni onClick. El id del item es la clave de lookup en el mapa customHandlers que provee {Modulo}SidebarApp.tsx.
Declaracion en sidebar.ts:
typescript
// Item modal-trigger: sin href, sin path, sin onClick
{
id: 'reimpresion-ventas',
label: 'Reimpresion de Comprobante',
icon: 'far fa-circle',
permission: 'VENTAS_UTILS_REIMPRESION',
// Sin href ni path — handler resuelto via customHandlers['reimpresion-ventas']
}Definicion en {Modulo}SidebarApp.tsx:
typescript
const customHandlers: Record<string, () => void> = {
'reimpresion-ventas': () => {
// Llamada al modulo JS legacy o a funcion React
},
};Resolucion en Sidebar.tsx: const itemOnClick = item.onClick ?? customHandlers?.[item.id].
Dos tipos de handlers:
Delegacion a modulo JS legacy: el handler llama directamente a la funcion importada del legacy JS. Se usa
static importal inicio del archivo para que el modulo este pre-cargado (sin delay de importacion dinamica al hacer click). Los imports llevan@ts-ignoreporque son modulos JS legacy sin tipos TypeScript.Delegacion a componente React: el handler llama a una funcion exportada por un modulo TSX del mismo dominio. Ejemplo en Ventas:
'comp-conceptos': () => showComprobantePorConceptosModal().
Comportamientos dinamicos por modulo
CtaCte: label dinamico Recibo / Orden de Pago
CtacteSidebarApp.tsx mantiene estado tipoCuenta inicializado desde config.tipoCuenta. Suscribe a CustomEvent('tipo-ctacte-change') en document para actualizarlo en tiempo real. El valor tipoCuenta se pasa como parametro a getCtacteSidebarMenuItems(config, tipoCuenta) y determina el label del item "Recibo" / "Orden de Pago" en Movimientos.
Tesoreria y Compras: periodo en curso
Ambos modulos usan el hook usePeriodoCurso(codigo) de ts/core/hooks/usePeriodoCurso.ts. El codigo es la constante del modulo (Periodo.TESORERIA, Periodo.COMPRAS). El hook hace fetch al endpoint de periodo y suscribe a CustomEvent('cambio-periodo-curso') filtrando por codigo. Retorna un string formateado "Periodo: Mar. de 2025" que se pasa como parametro a la funcion de menu items para mostrar en el sidebar como item no-interactivo.
Contabilidad: ejercicio y periodo
ContabilidadSidebarApp.tsx gestiona dos estados independientes: ejercicio (numero, desde, hasta) y periodoText (string). El ejercicio inicial puede venir de config.ejercicioInicial (sesion PHP). Al montar, si hay ejercicio inicial, lo propaga via evento ejercicioSeleccionado. Suscribe a:
ejercicioSeleccionado→ actualiza estado React + DOM del navbar (span#ejercicio)cambio-periodo-cursofiltrado porcodigo === CONTABILIDAD→ actualiza span#periododel navbar
El hook usePeriodoCurso(PERIODO_CONTABILIDAD) maneja el fetch y evento del periodo.
Contabilidad ademas hace fetch al endpoint de ejercicios abiertos al montar para determinar el comportamiento del handler 'seleccionar-ejercicio':
- 0 ejercicios → abre modal de definicion
- 1 ejercicio → muestra Swal info "Solo se posee un ejercicio contable"
>1ejercicios → abre DataTable modal de seleccion
Ventas: alertas ARCA (certState)
VentasSidebarApp.tsx gestiona estado certState: string | null. Estrategia de inicializacion:
- Suscribe a
window.addEventListener('certStateLoaded')— evento despachado por el script de verificacion de ARCA - Fallback tras
CERT_STATE_TIMEOUT(5000ms): leelocalStorage.getItem('last_shown_cert_state') - El useEffect retorna cleanup que remueve el listener y cancela el timeout
Los certAlerts son nodos JSX pasados via customContentStart al Sidebar. Se renderizan ANTES del primer item del menu.
Estados y sus efectos:
| certState | Alerta visible | Item Facturas |
|---|---|---|
'CERTIFICATE_EXPIRING_SOON' | Warning amarilla | Habilitado |
'CERTIFICATE_EXPIRED' | Danger roja | Habilitado |
'CERTIFICATE_INVALID' | Danger roja | Habilitado |
'CREDENTIALS_NOT_FOUND' | Danger roja | Deshabilitado (linkClassName: 'disabled pe-none text-muted') |
null | Ninguna | Habilitado |
Bug conocido: stale listener en modeChanged
Descripcion: en algunos handlers de {Modulo}SidebarApp.tsx que abren modales asincrono (ej: subdiario-ventas, reimpresion-ventas en Ventas), se necesita escuchar el evento modeChanged para actualizar el estado visual del modal (colores del header) cuando el usuario cambia de modo mientras el modal esta abierto.
El bug: si el listener modeChanged se agrega al modal al abrirlo y se olvida removerlo al cerrar, queda un listener "zombie" que referencia elementos del DOM ya eliminados. Esto produce errores de consola en el siguiente cambio de modo.
El patron correcto:
- Agregar el listener al abrir:
document.addEventListener('modeChanged', onModeChanged) - Removerlo en el evento de cierre del modal:
$(modal).on('hidden.bs.modal', () => { document.removeEventListener('modeChanged', onModeChanged); contenedor.remove(); })
Este patron esta implementado correctamente en VentasSidebarApp.tsx en los handlers 'subdiario-ventas' y 'reimpresion-ventas'.
IDs de contenedores mount point
| Modulo | Container ID |
|---|---|
| stock | sidebar-stock-app |
| ctacte | sidebar-ctacte-app |
| tesoreria | sidebar-tesoreria-app |
| compras | sidebar-compras-app |
| contabilidad | sidebar-contabilidad-app |
| ventas | sidebar-ventas-app |
Como agregar un nuevo modulo
Pasos para aplicar el patron a un modulo nuevo (o no migrado):
1. Preparar el PHP mount point
Reemplazar el contenido de php/components/mod-{modulo}/main-sidebar-{modulo}.php:
- Eliminar todo el bloque
<aside>...</aside>existente - Preparar el array
$sidebarConfigcon los campos necesarios del modulo - Emitir el
<div>mount point condata-configurationusandoJSON_HEX_APOS | JSON_HEX_QUOT - Agregar el
<script type="module">apuntando adist/{modulo}/config/{Modulo}SidebarApp.js - Verificar si hay templates HTML usados por modales legacy que deban mantenerse en el PHP (como
<template id="template_reimpresion">en Ventas)
Identificar y eliminar los scripts legacy que marcaban items como activos por ID de DOM (buscar referencias a idMainSideBar*).
2. Crear ts/{modulo}/config/sidebar.ts
- Importar
createStandardSidebardesdets/core/config/standardSidebarSections.ts - Importar
MenuItemdesdets/core/types/layout.types.ts - Definir la interfaz de configuracion
{Modulo}SidebarConfigcon los campos deldata-configuration - Definir la funcion
get{Modulo}SidebarMenuItems(config, ...params): MenuItem[] - Usar
createStandardSidebar({ moduleHome: '?loc=xxx', bases: [...], movimientos: [...], informes: [...], utilidades: [...] }) - Usar
href: '?loc=xxx'en todos los items de navegacion PHP - Usar items sin
href/path/onClickpara los modal-triggers (elides la clave del handler) - Agregar
far fa-circleen items de primer nivel,far fa-dot-circleen nietos - Implementar
postProcess{Modulo}Sidebar(items)para convertirhome.path→home.hrefy agregar permissions a las secciones
3. Crear ts/{modulo}/config/{Modulo}SidebarApp.tsx
- Importar
StrictMode, useState, useEffectde react - Importar
MemoryRouterde react-router-dom - Importar
Sidebardesdets/core/components/layout/Sidebar.js - Importar
ModeChangerdesdets/core/components/ModeChanger.js(si el modulo legacy tenia el boton M) - Importar
get{Modulo}SidebarMenuItemsy{Modulo}SidebarConfigdesde./sidebar.js - Importar
mountAppdesdets/main.js - Importar con
// @ts-ignorelos modulos JS legacy necesarios para los handlers (static imports) - Definir la interfaz de props y la funcion componente
- Gestionar estado dinamico del modulo con
useState - Suscribir a custom events del DOM con
useEffect(con cleanup en return) - Definir
customHandlers: Record<string, () => void>con un entry por cada modal-trigger - Renderizar
<StrictMode><MemoryRouter><Sidebar .../></MemoryRouter></StrictMode> - Incluir
customContentEnd={<ModeChanger />}si el modulo lo requiere - Agregar el bloque de auto-mount con
DOMContentLoadedal final del archivo
4. Verificar smoke test
Antes de eliminar el sidebar.js legacy del modulo, verificar:
- El sidebar renderiza visualmente (todas las secciones, labels correctos, logo)
- Los links
href: '?loc=xxx'abren las paginas PHP correctas - Los items permission-gateados solo aparecen para usuarios con permiso
- Los modal-triggers via
customHandlersabren sus modales - ModeChanger (si aplica) cicla correctamente: Prueba → Oficial → Consolidados → Prueba
- El evento
modeChangedes recibido por los componentes legacy de la pagina localStorage.tipo_transaccionse actualiza en cada click- El lock
?prueba=1impide el cambio de modo y muestra modal informativo - El comportamiento dinamico especifico del modulo funciona (certState, tipoCuenta, periodo, ejercicio)
npx tsc --noEmitpasa sin erroresnpm run buildcompleta sin errores
Diferencias con SPAs completas (CRM, Membresias)
| Aspecto | Sidebar TSX legacy | SPA completa (CRM/Membresias) |
|---|---|---|
| Router | MemoryRouter | HashRouter |
| Rutas | No usa rutas | Rutas React completas |
| Lo que renderiza | Solo Sidebar | Layout completo |
ConfigProvider | No requerido | Requerido |
| Permisos | Via userPermissions prop directo | Via ConfigContext |
| Navegacion | href a paginas PHP | path a rutas React |
Archivos de referencia
ts/core/config/standardSidebarSections.ts— utilidadcreateStandardSidebarts/core/components/ModeChanger.tsx— componente compartido de modots/core/components/layout/Sidebar.tsx— componente core de sidebarts/ventas/config/VentasSidebarApp.tsx— ejemplo mas complejo (certState + 12 handlers)ts/ventas/config/sidebar.ts— ejemplo de menu con items condicionales y nested sectionsts/contabilidad/config/ContabilidadSidebarApp.tsx— ejemplo con ejercicio y periodo dinamicots/contabilidad/config/sidebar.ts— ejemplo conadditionalItemsBeforephp/components/mod-venta/main-sidebar-venta.php— ejemplo de mount point PHP
Notas adicionales
- El patron esta implementado para 6 modulos: Contabilidad, Stock, CRM, Compras, Tesoreria, Ventas
- El orden de migracion historico fue: stock (piloto) → ctacte → tesoreria → compras → contabilidad → ventas
- Stock es el mas simple: un solo custom handler, sin ModeChanger
- Ventas es el mas complejo: 12 custom handlers, certState, items condicionales multiples
- Los modulos Tesoreria y Compras comparten el mismo hook
usePeriodoCursoparametrizado por codigo de modulo