Files
basic-erp/apps/web/README.md
T
GeorgeandClaude Sonnet 5.5 68caf401a7 refactor(web): filas de formulario con varios controles combinables
El mockup muestra que una fila es etiqueta + uno o varios controles en la
misma línea (Cliente = input + "...", Canal / Zona = dos mitades, Serie +
Número = 72 px + resto, select + número + botón) y que la cabecera tiene a
lo sumo 2 columnas. Los campos de un solo control no lo permitían.

- Controles sin etiqueta combinables dentro de sbo-field: sbo-input,
  sbo-textarea, sbo-number, sbo-date, sbo-select y el botón .sbo-btn;
  ocupan el espacio sobrante o un width fijo.
- El primer control de la fila toma el id de la etiqueta (claimId); los
  demás se nombran con ariaLabel. El error de la fila marca todos sus
  controles (aria-invalid / aria-describedby).
- Los campos con etiqueta (sbo-text-field, sbo-number-field, ...,
  sbo-lookup-field) pasan a ser atajos de un solo control con la misma API.
- CSS global: .sbo-group y .sbo-btn reemplazan a .sbo-pick/.sbo-pair.
- CLAUDE.md apunta al mockup renombrado (tools/oferta_venta.html).
- Verificado en Chrome contra SAP real, con una cabecera que reproduce las
  filas compuestas del mockup.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 19:49:16 -05:00

6.5 KiB

web

Frontend Angular (standalone, zoneless, señales) del ERP base. Interfaz densa y ligera estilo SAP Business One.

npm run dev:web          # desde la raíz; http://localhost:4200 (proxy /api -> localhost:3000)
npm run build -w apps/web
npm test -w apps/web

Criterios de rendimiento

Es un ERP, no un sitio publicitario: rápido y ligero.

  • Sin librerías de UI (nada de Material/PrimeNG): componentes propios, densos (13 px).
  • Fuentes del sistema, sin webfonts; sin animaciones.
  • Zoneless + OnPush; rutas con loadComponent para cargar solo lo que se usa.
  • Presupuesto del bundle inicial: aviso a 260 kB, error a 400 kB (angular.json). Hoy: ~200 kB (56 kB comprimido).
  • Listas largas con paginación del servidor ($top/$skip) o virtualización.

Temas

El color sale solo de variables CSS (src/styles/themes/*.css). Tema por defecto: claro, con la paleta del mockup de Oferta de ventas.

Agregar un tema:

  1. src/styles/themes/<id>.css con un bloque :root[data-theme='<id>'] (copiar los tokens de light.css).
  2. @import en src/styles.css.
  3. Una entrada en src/app/core/theme/themes.ts.

Tokens: --app --shell --shell-ink --shell-quiet --win --panel --line --line-soft --ink --quiet --field --field-ro --field-b --focus --gold --grid-head --row-sel --row-alt --ok --ok-bg --warn --warn-bg --err --err-bg --btn --btn-b --btn-h --overlay --shadow-pop.

Tipos compartidos

@basic-erp/sap-types (libs/sap-types): usuario, respuestas de la API y el evaluador de permisos de menú, los mismos que usa el backend.

Autenticación y rutas

  • core/auth: SessionStore (sesión en localStorage, compartida entre pestañas, con timeout deslizante según el servidor), AuthService (login/logout/expire), interceptor (Bearer en /api, renueva actividad y atiende los 401) y guards (authGuard, guestGuard, permissionGuard).
  • core/menu: MenuService carga GET /api/user/menu una vez por sesión; el menú visible sale de MENU (@basic-erp/sap-types) filtrado por permisos.
  • app.routes.ts genera una ruta por pantalla del menú; las que aún no existen muestran "no disponible" (ComingSoonPage). Para construir una pantalla, se reemplaza su loadComponent.
  • Un 401 en cualquier llamada cierra la sesión local y lleva al login recordando la ruta (?returnUrl=, solo rutas internas).

Componentes compartidos (src/app/shared)

sbo-value-help — lista de valores ("elegir de la lista")

Búsqueda por columna sobre un recurso del proxy /api/sl, selección única o múltiple y paginación del servidor.

<sbo-value-help
  [open]="open()"
  title="Socios de negocio"
  resource="BusinessPartners"
  [columns]="columns"
  baseFilter="CardType eq 'cCustomer'"
  orderby="CardCode"
  selection="single"
  (selected)="onPick($event)"
  (closed)="open.set(false)" />
columns: VhColumn[] = [
  { key: 'CardCode', title: 'Código', width: '130px', mono: true },
  { key: 'CardName', title: 'Nombre' },
  { key: 'CurrentAccountBalance', title: 'Saldo', type: 'currency' },
];
  • Agregar una columna = una entrada en columns; el $select se deriva de ahí.
  • Columna activa de búsqueda: clic en el encabezado (solo columnas de texto, o searchable: true). Se aplica al pulsar Buscar/Enter con contains(), combinada con baseFilter entre paréntesis y con las comillas escapadas.
  • selection="single": un clic en la fila confirma y cierra. selection="multiple": casillas, "seleccionar página" y Confirmar (n); lo marcado se conserva al cambiar de página ([initialSelection] precarga filas).
  • type: text (defecto), number, currency (2 decimales), date (dd/MM/yyyy); format: (row) => string para algo a medida.
  • Teclado: Enter en la búsqueda, Enter sobre una fila, Esc para cerrar.
  • Cada apertura reinicia búsqueda, página y selección. Las peticiones obsoletas se cancelan solas (httpResource).

Campos de formulario (shared/form)

Una fila (sbo-field) es: etiqueta + uno o varios controles en la misma línea. La cabecera de un formulario tiene como máximo 2 columnas (.sbo-hdr con dos .sbo-col). Las combinaciones del mockup (Cliente = input + "…", Canal / Zona = dos mitades, Serie + Número = 72 px + resto, select + número + botón) salen de combinar controles:

<div class="sbo-hdr">
  <div class="sbo-col">
    <sbo-lookup-field label="Cliente" required [(value)]="cardCode" [lookup]="bp" (picked)="onBp($event)" />
    <sbo-text-field label="Nombre" readonly [value]="cardName()" />
    <sbo-field label="Canal / Zona">
      <sbo-input readonly ariaLabel="Canal" [value]="canal()" />
      <sbo-input readonly ariaLabel="Zona" [value]="zona()" />
    </sbo-field>
  </div>
  <div class="sbo-col">
    <sbo-field label="Número">
      <sbo-input width="72px" readonly ariaLabel="Serie" value="COT" />
      <sbo-input readonly ariaLabel="Número correlativo" [value]="numero()" />
    </sbo-field>
    <sbo-field label="Moneda / Importe">
      <sbo-select width="90px" ariaLabel="Moneda" [options]="monedas" [(value)]="moneda" />
      <sbo-number [decimals]="2" ariaLabel="Importe" [(value)]="importe" />
      <button type="button" class="sbo-btn">…</button>
    </sbo-field>
  </div>
</div>

Controles combinables (sin etiqueta, dentro de sbo-field; ocupan el espacio sobrante o un width fijo): sbo-input, sbo-textarea, sbo-number, sbo-date, sbo-select y el botón nativo <button class="sbo-btn">. Todos con enlace por señales [(value)], readonly, disabled, required, width y ariaLabel (obligatorio en los que no son el primero de la fila: el primero toma el id de la etiqueta). El error de la fila marca todos sus controles (borde rojo + mensaje, aria-invalid/aria-describedby).

Atajos con etiqueta (un control por fila): sbo-text-field, sbo-textarea-field, sbo-number-field, sbo-date-field, sbo-select-field y sbo-lookup-field (flecha dorada + botón "…" / F4 que abre sbo-value-help según [lookup] (VhLookup), emite (picked) con la fila; linkable + (linkClick)). Comunes: label, required, readonly, disabled, error.

Detalles: sbo-number muestra 1,234.50 fuera de foco y acepta 12,5 o 1,234.50 al escribir (decimals redondea); sbo-date usa texto ISO yyyy-MM-dd; sbo-select devuelve el valor con su tipo original.

Estilos globales en styles/forms.css (.sbo-in, .sbo-sel, .sbo-ta, .sbo-btn, .sbo-group, .sbo-hdr, .sbo-col). El ancho de etiqueta se ajusta con --label-w (165 px por defecto).