ConTodo
Frontend Architect

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.

PrincipioDecisiónJustificación
Velocidad percibida sobre velocidad de carga inicialSPA 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 clienteTanStack 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 extremoTypeScript 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ónComponentes primitivos (Radix UI headless) + Tailwind.Control total de accesibilidad y estilo sin pelear contra un framework de componentes opinado.
Multi-tenant aislado en clientetenantId/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).

CriterioVite SPA (elegido)Next.js (App Router)Remix
SEOIrrelevante (app tras login)Ventaja desperdiciadaVentaja desperdiciada
Complejidad de despliegueMínima: estáticos en S3 + CloudFrontRequiere SSR runtime (Node)Requiere runtime
Acople al backendLimpio: Rails como API puraTienta a duplicar lógica en BFFTienta a duplicar lógica
Costo infraBajo (CDN puro)Servidor SSR siempre encendidoServidor siempre encendido
HMR / DXExcelenteBuenoBueno

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ónValorRazón
staleTime30s (listas), 5min (catálogos)Catálogos (monedas, almacenes) cambian poco; transacciones, más.
gcTime5minLibera memoria en sesiones largas.
retry1 (mutaciones: 0)No reintentar emisión de comprobantes (riesgo de duplicado fiscal).
Optimistic updatesSólo en toggles no fiscalesJamás optimismo en emisión SUNAT.

3.2 Client state — Zustand

Estado efímero o de sesión que no pertenece al servidor.

StoreContenidoPersistencia
useSessionStoreusuario, token, permisossessionStorage (no localStorage, por seguridad)
useTenantStoreempresa activa, sucursal activa, moneda baselocalStorage
useUIStoresidebar colapsado, tema, densidad de tablaslocalStorage
usePreferencesStoreidioma, formato de fecha/númerolocalStorage
// 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: axios con interceptores para inyectar Authorization, X-Company-Id y manejar refresh de token (401) de forma transparente.
  • Tipos: generados con openapi-typescript desde el spec OpenAPI que publica Rails. CI falla si el spec cambia y los tipos no se regeneran → contrato verificado en build.
  • Validación: Zod en formularios; los schemas se derivan del mismo dominio para evitar drift entre validación cliente y servidor.
  • Errores: un ApiError normalizado 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íaTokenValor (light)Uso
Color brand--color-primary#1E5BBF (azul confianza)Acciones primarias, links
Color éxito--color-success#16A34AComprobante aceptado SUNAT
Color peligro--color-danger#DC2626Rechazo SUNAT, eliminar
Color alerta--color-warning#D97706Pendiente, por vencer
Neutral--color-surface#FFFFFF / #0F172A (dark)Fondos
Espaciadoescala 4px4,8,12,16,24,32Ritmo vertical
Radio--radius8pxBordes
TipografíaInter (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).

CapacidadDetalle
Server-sidePaginación, orden y filtros delegados al backend vía query params
SelecciónMúltiple con acciones bulk (anular, exportar)
ColumnasVisibilidad, orden y ancho configurables; persistidos por usuario
DensidadCómoda / compacta
ExportCSV / Excel (XLSX) del lado cliente o vía endpoint
A11yNavegació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-EC y en-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.
LocaleFechaNúmeroMoneda default
es-PE03/06/20261,234.56PEN (S/)
es-CO03/06/20261.234,56COP ($)
es-CL03-06-20261.234,56CLP ($)

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:

ConceptoSignificadoTratamiento UI
Moneda base de la empresaMoneda funcional (PEN típicamente)Reportes consolidados, EE.FF.
Moneda del documentoMoneda en que se emite/recibe (PEN/USD)Captura en el formulario
Moneda de presentaciónCómo el usuario quiere ver totalesToggle 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)

ÁreaCompromiso
Contraste≥ 4.5:1 texto normal; validado en CI con axe
TecladoTodo flujo operable sin mouse; foco visible
Lectores de pantallaComponentes Radix (ARIA correcto de fábrica)
Formularioslabel asociado, errores con aria-describedby
MovimientoRespeta prefers-reduced-motion
Testsjest-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

CapaHerramientaObjetivo
UnitVitestLógica pura, hooks, formatters
ComponentesTesting Library + jest-axeComportamiento + a11y
E2EPlaywrightFlujos críticos (emisión, login, kardex)
Tipostsc strictCero any en CI
LintESLint + boundariesReglas de dependencia entre features
VisualStorybook + ChromaticCatá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

RiesgoImpactoMitigació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 APIMedioGeneración de tipos desde OpenAPI con gate en CI
Rendimiento en tablas grandes (kardex/mayor)MedioVirtualización + paginación server-side
Sobre-ingeniería del design systemMedioConstruir componentes bajo demanda real, no especulativa
Optimismo en operaciones fiscalesAltoProhibido por política; estados explícitos hasta CDR
Fatiga de traducción multi-paísBajo-MedioExtracció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/lib y 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.