ConTodo ERP — Arquitectura Frontend: React + TypeScript + Tailwind, Design System, UX/UI, i18n y Multimoneda
ConTodo ERP — Documento de Arquitectura Frontend
Propósito. Este artefacto define la arquitectura del cliente web de ConTodo, un ERP SaaS multi-tenant para PYMEs y medianas empresas de Perú y LATAM. Cubre estructura de carpetas, gestión de estado (TanStack Query + Zustand), routing, capa de datos contra la API Rails, el design system con tokens, los componentes de plataforma reutilizables (DataTable, FormBuilder, KPI cards), accesibilidad WCAG 2.1 AA, internacionalización (es-PE base) y el tratamiento de multimoneda y multiempresa en la UI. Es la fuente de verdad para el equipo Frontend, QA y Diseño, y consume los contratos definidos por Backend y Arquitectura.
1. Principios y decisiones de arquitectura
Un ERP no es una landing: es una aplicación de alta densidad de datos, transaccional, usada 8 horas al día por usuarios expertos (contadores, jefes de almacén, vendedores). Esto dicta prioridades distintas a las de un sitio de marketing.
| Principio | Decisión | Justificación |
|---|---|---|
| Velocidad percibida sobre velocidad de carga inicial | SPA con code-splitting por módulo (lazy routes). | El usuario abre la app una vez al día; lo crítico es que navegar entre Ventas y Kardex sea instantáneo, no el TTFB. |
| Estado de servidor ≠ estado de cliente | TanStack Query para datos del servidor; Zustand para UI/sesión. | Separar caché/sincronización (server state) de toggles, filtros y wizard state (client state) evita el 80% de los bugs de estado. |
| Tipado de extremo a extremo | TypeScript estricto + tipos generados desde OpenAPI del backend Rails. | El esquema fiscal (CPE, PLE) es complejo; los errores deben detectarse en compilación, no en producción frente a SUNAT. |
| Composición sobre configuración | Componentes primitivos (Radix UI headless) + Tailwind. | Control total de accesibilidad y estilo sin pelear contra un framework de componentes opinado. |
| Multi-tenant aislado en cliente | tenantId/companyId resueltos en boot, inyectados en cada request y cache key. | Evita fuga de datos entre empresas en la caché de TanStack Query. |
1.1 ¿Por qué Vite SPA y no Next.js?
Se evaluaron tres alternativas. La decisión es Vite + React Router (SPA).
| Criterio | Vite SPA (elegido) | Next.js (App Router) | Remix |
|---|---|---|---|
| SEO | Irrelevante (app tras login) | Ventaja desperdiciada | Ventaja desperdiciada |
| Complejidad de despliegue | Mínima: estáticos en S3 + CloudFront | Requiere SSR runtime (Node) | Requiere runtime |
| Acople al backend | Limpio: Rails como API pura | Tienta a duplicar lógica en BFF | Tienta a duplicar lógica |
| Costo infra | Bajo (CDN puro) | Servidor SSR siempre encendido | Servidor siempre encendido |
| HMR / DX | Excelente | Bueno | Bueno |
Trade-off aceptado: perdemos SSR/streaming. No importa: todo el producto vive detrás de autenticación y no necesita indexación. Servir el bundle desde CloudFront + S3 alinea con el stack AWS objetivo y reduce el TCO frente a un runtime SSR permanente.
2. Estructura del proyecto
Organización feature-first (vertical slices) en lugar de por tipo de archivo. Cada módulo del ERP es una carpeta autónoma.
src/
├── app/ # Bootstrap: providers, router, error boundaries
│ ├── App.tsx
│ ├── providers/ # QueryClientProvider, I18nProvider, ThemeProvider
│ └── router/ # Definición de rutas + guards
├── shared/ # Código transversal sin dominio
│ ├── ui/ # Design system: Button, Input, DataTable, Modal...
│ ├── lib/ # apiClient, formatters, money, dates
│ ├── hooks/ # useDebounce, useMediaQuery, usePermission
│ ├── i18n/ # locales, formatMoney, formatNumber
│ └── types/ # tipos generados desde OpenAPI
├── features/ # Un slice por módulo del ERP
│ ├── auth/
│ ├── seguridad/ # roles, permisos
│ ├── empresas/ # multiempresa, sucursales
│ ├── inventario/
│ ├── kardex/
│ ├── compras/
│ ├── ventas/
│ ├── produccion/
│ ├── logistica/
│ ├── importaciones/
│ ├── contabilidad/
│ ├── tesoreria/
│ ├── estados-financieros/
│ ├── rrhh/
│ ├── planillas/
│ ├── crm/
│ ├── bi/
│ └── ia/
└── stores/ # Zustand stores globales (session, tenant, ui)
Anatomía interna de un feature (features/ventas/):
ventas/
├── api/ # queries y mutations TanStack Query (useVentasList, useCreateFactura)
├── components/ # UI específica del módulo (FacturaForm, VentasFilters)
├── hooks/ # lógica de negocio del feature
├── pages/ # rutas (VentasListPage, FacturaDetailPage)
├── schemas/ # validación Zod
└── types.ts # tipos del dominio Ventas
Regla de dependencias: features/* puede importar de shared/*, nunca al revés; un feature no importa de otro feature directamente (si lo necesita, el contrato sube a shared). Se impone con ESLint (eslint-plugin-boundaries).
3. Gestión de estado
3.1 Server state — TanStack Query
Todo dato que vive en el backend (facturas, kardex, asientos) se gestiona con TanStack Query v5. No se duplica en stores.
// shared/lib/queryKeys.ts — claves jerárquicas, siempre incluyen companyId
export const qk = {
ventas: {
all: (companyId: string) => ['ventas', companyId] as const,
list: (companyId: string, filters: VentasFilters) =>
[...qk.ventas.all(companyId), 'list', filters] as const,
detail: (companyId: string, id: string) =>
[...qk.ventas.all(companyId), 'detail', id] as const,
},
};
El companyId en la cache key es innegociable: garantiza que al cambiar de empresa la caché no mezcle datos de otro tenant.
| Configuración | Valor | Razón |
|---|---|---|
staleTime | 30s (listas), 5min (catálogos) | Catálogos (monedas, almacenes) cambian poco; transacciones, más. |
gcTime | 5min | Libera memoria en sesiones largas. |
retry | 1 (mutaciones: 0) | No reintentar emisión de comprobantes (riesgo de duplicado fiscal). |
| Optimistic updates | Sólo en toggles no fiscales | Jamás optimismo en emisión SUNAT. |
3.2 Client state — Zustand
Estado efímero o de sesión que no pertenece al servidor.
| Store | Contenido | Persistencia |
|---|---|---|
useSessionStore | usuario, token, permisos | sessionStorage (no localStorage, por seguridad) |
useTenantStore | empresa activa, sucursal activa, moneda base | localStorage |
useUIStore | sidebar colapsado, tema, densidad de tablas | localStorage |
usePreferencesStore | idioma, formato de fecha/número | localStorage |
// stores/tenantStore.ts
interface TenantState {
companyId: string | null;
branchId: string | null;
baseCurrency: 'PEN' | 'USD';
setCompany: (id: string) => void; // dispara queryClient.clear() del scope anterior
}
3.3 Diagrama de flujo de estado
4. Routing y control de acceso
React Router v6 con rutas anidadas, lazy loading y guards basados en permisos (RBAC, alineado al módulo Seguridad del backend).
// app/router/PermissionGuard.tsx
function PermissionGuard({ permission, children }: Props) {
const can = usePermission(permission); // lee de useSessionStore
if (!can) return <Forbidden403 />;
return children;
}
Cada módulo se carga con React.lazy() + Suspense, produciendo un chunk por módulo. Un usuario de RRHH nunca descarga el JS de Importaciones.
5. Capa de datos y contrato con el backend
- Cliente HTTP:
axioscon interceptores para inyectarAuthorization,X-Company-Idy manejar refresh de token (401) de forma transparente. - Tipos: generados con
openapi-typescriptdesde el spec OpenAPI que publica Rails. CI falla si el spec cambia y los tipos no se regeneran → contrato verificado en build. - Validación:
Zoden formularios; los schemas se derivan del mismo dominio para evitar drift entre validación cliente y servidor. - Errores: un
ApiErrornormalizado mapea códigos del backend (incl. errores SUNAT, p.ej.0157 - RUC no habido) a mensajes i18n accionables.
6. Design System y tokens
Design system propio ("Telar", guiño al vertical textil) construido sobre Radix UI primitives + Tailwind, distribuido como paquete interno @contodo/ui.
6.1 Tokens de diseño (Tailwind theme + CSS variables)
| Categoría | Token | Valor (light) | Uso |
|---|---|---|---|
| Color brand | --color-primary | #1E5BBF (azul confianza) | Acciones primarias, links |
| Color éxito | --color-success | #16A34A | Comprobante aceptado SUNAT |
| Color peligro | --color-danger | #DC2626 | Rechazo SUNAT, eliminar |
| Color alerta | --color-warning | #D97706 | Pendiente, por vencer |
| Neutral | --color-surface | #FFFFFF / #0F172A (dark) | Fondos |
| Espaciado | escala 4px | 4,8,12,16,24,32 | Ritmo vertical |
| Radio | --radius | 8px | Bordes |
| Tipografía | Inter (UI) + Roboto Mono (números/montos) | — | Montos siempre tabulares |
Modo oscuro y densidad (cómoda / compacta) se controlan por data- attributes en <html>, leídos por useUIStore. La densidad compacta es clave para usuarios contables que ven 50+ filas por pantalla.
6.2 Números y montos
Toda cifra monetaria usa fuente monoespaciada con tabular-nums, alineada a la derecha, con separador de miles según locale (1,234.56 en es-PE). Componente <Money> centraliza esto y nunca se renderiza un monto con toFixed() suelto.
7. Componentes de plataforma reutilizables
Estos componentes son el corazón de la productividad del equipo: el 70% de las pantallas del ERP son listas y formularios.
7.1 <DataTable>
Tabla de alto rendimiento sobre TanStack Table + virtualización (TanStack Virtual) para manejar 10k+ filas (kardex, mayor contable).
| Capacidad | Detalle |
|---|---|
| Server-side | Paginación, orden y filtros delegados al backend vía query params |
| Selección | Múltiple con acciones bulk (anular, exportar) |
| Columnas | Visibilidad, orden y ancho configurables; persistidos por usuario |
| Densidad | Cómoda / compacta |
| Export | CSV / Excel (XLSX) del lado cliente o vía endpoint |
| A11y | Navegación por teclado, role=grid, anuncios ARIA |
7.2 <FormBuilder>
Renderiza formularios desde un schema declarativo (Zod + metadata), reduciendo boilerplate en los cientos de formularios del ERP.
const facturaForm = {
fields: [
{ name: 'cliente', type: 'asyncSelect', source: 'clientes', required: true },
{ name: 'moneda', type: 'currency', default: 'PEN' },
{ name: 'tipoCambio', type: 'number', visibleIf: f => f.moneda !== 'PEN' },
{ name: 'items', type: 'lineItems', schema: detalleFacturaSchema },
],
};
Integra React Hook Form + Zod resolver. El campo tipoCambio aparece condicionalmente: caso real de multimoneda.
7.3 <KpiCard> y BI
Tarjetas de KPI para dashboards (ventas del mes, cuentas por cobrar, stock crítico) con sparkline, comparativo vs. período anterior y estado de color semántico. Los gráficos del módulo BI usan Recharts (composable, accesible, ligero).
7.4 Otros primitivos
<CompanySwitcher>, <CurrencyInput>, <RucLookup> (consulta padrón SUNAT con debounce), <DocumentStatusBadge> (estados CPE), <DateRangePicker> (con presets fiscales: mes, ejercicio), <Drawer>, <CommandPalette> (Ctrl+K para navegación rápida entre módulos).
8. UX — Flujos clave
8.1 Onboarding y selección de empresa
El usuario multiempresa, tras login, ve un selector de empresa/sucursal. Cambiar de empresa limpia la caché del scope anterior y reescribe X-Company-Id.
8.2 Emisión de comprobante electrónico (flujo crítico)
Decisión UX: la emisión nunca usa optimistic update; el usuario ve un estado "Procesando" explícito hasta recibir el CDR de SUNAT. La integridad fiscal pesa más que la fluidez percibida.
8.3 Patrones transversales
- Guardado de borradores en formularios largos (factura, asiento) para no perder trabajo.
- Acciones en lote desde DataTable (anular varias, exportar PLE).
- Command palette (Ctrl+K) para usuarios power que viven en el teclado.
- Skeletons en vez de spinners para reducir percepción de espera.
9. Internacionalización (i18n) y multimoneda
9.1 i18n
- Librería: i18next + react-i18next, con detección y persistencia de locale en
usePreferencesStore. - Locale base: es-PE. Plan:
es-CO,es-CL,es-MX,es-ECyen-US(para inversores/usuarios bilingües). - Formato de fechas/números delegado a
Intl(Intl.DateTimeFormat,Intl.NumberFormat) — no hardcodear separadores. - Claves namespaced por feature (
ventas.factura.emitir) y extracción automática en CI para detectar claves faltantes.
| Locale | Fecha | Número | Moneda default |
|---|---|---|---|
| es-PE | 03/06/2026 | 1,234.56 | PEN (S/) |
| es-CO | 03/06/2026 | 1.234,56 | COP ($) |
| es-CL | 03-06-2026 | 1.234,56 | CLP ($) |
Importante: la terminología fiscal (SUNAT, PLE, SIRE, detracción) es específica de Perú y vive en namespaces por país; el motor i18n separa traducción de UI de la localización fiscal.
9.2 Multimoneda en la UI
Distinción clave entre tres roles de moneda:
| Concepto | Significado | Tratamiento UI |
|---|---|---|
| Moneda base de la empresa | Moneda funcional (PEN típicamente) | Reportes consolidados, EE.FF. |
| Moneda del documento | Moneda en que se emite/recibe (PEN/USD) | Captura en el formulario |
| Moneda de presentación | Cómo el usuario quiere ver totales | Toggle en dashboards |
- El componente
<CurrencyInput>muestra el símbolo correcto y aplica el formato del locale. - Cuando la moneda del documento ≠ base, aparece tipo de cambio (sugerido desde el endpoint de TC SUNAT, editable con auditoría).
- Los dashboards permiten alternar moneda de presentación; la conversión la calcula el backend para garantizar consistencia con la contabilidad (el frontend nunca inventa conversiones contables).
10. Accesibilidad (WCAG 2.1 AA)
| Área | Compromiso |
|---|---|
| Contraste | ≥ 4.5:1 texto normal; validado en CI con axe |
| Teclado | Todo flujo operable sin mouse; foco visible |
| Lectores de pantalla | Componentes Radix (ARIA correcto de fábrica) |
| Formularios | label asociado, errores con aria-describedby |
| Movimiento | Respeta prefers-reduced-motion |
| Tests | jest-axe en componentes + auditoría manual por release |
Razón de negocio: accesibilidad amplía el mercado (sector público, empresas con políticas de inclusión) y reduce riesgo legal en licitaciones LATAM.
11. Calidad, testing y performance
| Capa | Herramienta | Objetivo |
|---|---|---|
| Unit | Vitest | Lógica pura, hooks, formatters |
| Componentes | Testing Library + jest-axe | Comportamiento + a11y |
| E2E | Playwright | Flujos críticos (emisión, login, kardex) |
| Tipos | tsc strict | Cero any en CI |
| Lint | ESLint + boundaries | Reglas de dependencia entre features |
| Visual | Storybook + Chromatic | Catálogo del design system, regresión visual |
Presupuesto de performance: bundle inicial (shell + auth + dashboard) ≤ 250 KB gzip; cada módulo lazy ≤ 150 KB; Lighthouse Performance ≥ 90 en hardware medio. Métricas Web Vitals reportadas a BI para monitoreo continuo.
12. Diagrama global de arquitectura frontend
13. Riesgos y mitigaciones
| Riesgo | Impacto | Mitigación |
|---|---|---|
| Fuga de datos entre empresas en caché | Alto (fiscal/legal) | companyId en toda query key + queryClient.clear() al cambiar empresa |
| Drift entre tipos FE y API | Medio | Generación de tipos desde OpenAPI con gate en CI |
| Rendimiento en tablas grandes (kardex/mayor) | Medio | Virtualización + paginación server-side |
| Sobre-ingeniería del design system | Medio | Construir componentes bajo demanda real, no especulativa |
| Optimismo en operaciones fiscales | Alto | Prohibido por política; estados explícitos hasta CDR |
| Fatiga de traducción multi-país | Bajo-Medio | Extracción automática + separar UI i18n de localización fiscal |
14. Oportunidades
- Modo offline-first (PWA + IndexedDB) para vendedores en campo y zonas de baja conectividad en provincias.
- App móvil reutilizando lógica con React Native (compartir
shared/liby tipos). - Asistente IA embebido (módulo IA) como command palette conversacional que ejecuta acciones del ERP.
- Design system como activo comercial: white-label para partners/integradores.
- Micro-frontends a futuro si los módulos crecen y se desacoplan equipos (Module Federation sobre la misma base de tokens).
Conclusión. La arquitectura prioriza densidad de datos, integridad fiscal y productividad del equipo: SPA Vite con feature slices, separación estricta server/client state, un design system propio accesible, y un tratamiento de multimoneda/multiempresa que respeta la verdad contable del backend. El diseño es incremental (componentes bajo demanda) y deja abiertas las puertas de offline, móvil e IA sin reescrituras.