Un listado se declara UNA vez, por campo. De esa unica linea salen la cabecera, el formato de la celda, el papel del campo en la busqueda, el color del estado y el criterio con el que viaja al servidor.
Antes cada pantalla escribia por separado lo mismo cuatro veces: la columna de la tabla, la etiqueta traducida, el criterio de busqueda y el mapeo del valor a texto. Cuatro sitios que se desincronizan en cuanto alguien agrega un campo.
| Lo que la pantalla escribia | Lo que escribe ahora |
|---|---|
| Columna, etiqueta, ancho y alineacion | col.text('codigo', CLAVE, { width: '160px' }) |
| Criterio de busqueda por ese campo | Nada: sale del tipo de dato |
| Traduccion de la cabecera | Nada: la columna guarda la CLAVE y el componente la resuelve |
| Mapeo de un enum a su texto | Nada: el mismo listado nombra la celda y alimenta el selector |
| Titulos, subtitulos, icono, contador, tabla vacia | Nada: salen del prefijo i18n de la pantalla |
La etiqueta viaja como CLAVE, nunca como texto ya resuelto. Cambiar de idioma repinta la grilla sin que la pantalla reconstruya su configuracion.
| Paquete | nebula-ui-kit |
| Version | 0.16.0 — publicada en Nexus hasta 0.15.0; la ranura de filtros de 0.16.0 aun no se publica |
| Registry de consumo | https://nexus.centricasoluciones.com/repository/centrica-frontend-all/ |
| Selector | lib-smart-table |
| Componente | NebulaSmartTableComponent<T> |
| Declaracion de columnas | NebulaColumnHelper<T> · NebulaSmartColumn<T> |
| Configuracion | NebulaSmartTableConfig<T> |
| Consulta que emite | NebulaTableQuery |
| Base de pantalla | NebulaSmartCrudComponent<T, S> — @nebula-shared/data-access |
| Base de almacen | NebulaQueryStore<T, C, U> — @nebula-shared/data-access |
| Traductor | NEBULA_TRANSLATE (InjectionToken) |
| Angular | 21 · componente standalone, OnPush, signals |
| Contrato de datos | page-response del ecosistema: page, size, searchsBy, ordersBy |
| En produccion de DEV desde | 2 de Septiembre de 2026 |
No tiene
api-docs. Es una API de codigo, no un contrato HTTP: se consume importando el componente, no llamando una ruta. El contrato que si viaja por red es elpage-responsedel microservicio que sirve el listado.
El listado se consume por page-response, como todo listado del ERP. Lo que esta seccion precisa es quien hace esa peticion, porque no es el componente de la grilla.
La grilla compone la consulta y la entrega; no la ejecuta. La cadena es de cuatro pasos:
lib-smart-table la compone y la emite (no toca la red)
| queryChange
la pantalla le suma sus filtros propios (no toca la red)
| applyQuery
el almacen pide la pagina que toca (no toca la red)
| consultarPagina
el servicio POST /api/v1/.../page-response <-- aqui sale la peticion
Por que importa esta separacion: dice donde se toca cada cosa. Para cambiar a que servicio se pide, se toca el almacen. Para cambiar por que campos se busca, se toca la declaracion de columnas. La grilla no se toca en ninguno de los dos casos, y por eso sirve igual a un listado de consecutivos que a uno de terceros.
Y el filtrado ocurre en el servidor, sobre el total de registros. Una grilla que filtrara en el navegador solo encontraria lo que cupo en la pagina ya cargada.
page-responseLa consulta que emite la grilla tiene la forma exacta que espera el patron canonico. No se consume por /read ni por /page.
| No decide | Quien decide |
|---|---|
| Que datos se piden y a que servicio | El almacen de la pantalla |
| Si una accion de fila esta permitida | Las banderas del record que sirve el backend |
| Como se traduce una clave | La aplicacion, por NEBULA_TRANSLATE |
| Que campos existen | El record aplanado que devuelve page-response |
npm install nebula-ui-kit@0.14.0 --legacy-peer-deps
La aplicacion debe proveer el traductor. El kit no sabe de idiomas ni de bundles: los componentes que reciben CLAVES las pasan por NEBULA_TRANSLATE. Si nadie lo provee, la clave se devuelve tal cual y la grilla funciona, pero se ven los identificadores crudos.
{
// La funcion lee el idioma como signal ANTES de traducir: asi, invocada dentro de un
// `computed`, ese calculo se rehace solo al cambiar de idioma y la grilla se retraduce.
provide: NEBULA_TRANSLATE,
useFactory: () => {
const translation = inject(TranslationService);
return (key: string) => {
translation.lang();
return translation.translate(key);
};
},
}
En nebula-erp ya esta provisto en apps/nebula-shell/src/app/app.config.ts.
| Pieza | De donde sale | Que aporta |
|---|---|---|
NebulaSmartTableComponent |
nebula-ui-kit |
La grilla: cabecera, celdas, busqueda, orden, paginacion, herramientas |
NebulaSmartCrudComponent |
@nebula-shared/data-access |
La pantalla: titulos, icono, validaciones, acciones de fila, puente con el almacen |
NebulaQueryStore |
@nebula-shared/data-access |
El almacen: recibe la consulta compuesta y pide la pagina |
Se pueden usar por separado. Una pantalla que no sea un CRUD —dos grillas de asignacion, por ejemplo— usa el componente directamente y gobierna su propia consulta.
NebulaColumnHelper<T> construye columnas por TIPO DE DATO. El tipo decide de una vez el formato de la celda, la alineacion y el operador con el que el campo entra en la busqueda.
| Metodo | Tipo | Por defecto |
|---|---|---|
id(claveEtiqueta, ajustes?) |
Identificador | Ancho 80px, centrado, fuera de la busqueda |
text(campo, claveEtiqueta, ajustes?) |
Texto libre | Busca por coincidencia parcial |
number(campo, claveEtiqueta, ajustes?) |
Entero | A la derecha; en la busqueda casa por igualdad |
decimal(campo, claveEtiqueta, ajustes?) |
Decimal | A la derecha, dos decimales |
percent(campo, claveEtiqueta, ajustes?) |
Porcentaje | El valor viaja como fraccion |
currency(campo, claveEtiqueta, ajustes?) |
Importe | A la derecha |
date · datetime · time |
Fecha y hora | Centrado, con su formato |
boolean(campo, claveEtiqueta, opciones?, ajustes?) |
Booleano | Centrado; por defecto si/no |
select(campo, claveEtiqueta, opciones, ajustes?) |
Listado cerrado | La celda muestra la ETIQUETA del valor |
badge(campo, claveEtiqueta, opciones, ajustes?) |
Listado como distintivo | select con la celda pintada y su color |
email · tel · url |
Texto con formato | Entra en la busqueda |
El identificador no entra en la busqueda libre a proposito: una coincidencia parcial sobre una columna numerica no casa nunca, y una igualdad con el tipo cambiado devuelve vacio.
Cualquier valor por defecto se ajusta con el ultimo parametro:
col.text('descripcion', I18N.COLUMNS.DESCRIPTION, {
sortable: false, // no ordena
globalSearchable: false, // no entra en la busqueda libre
});
El MISMO arreglo nombra la celda de la grilla y alimenta el selector del formulario. label es la CLAVE del valor, no su texto; variant es el color cuando la columna se pinta como distintivo.
readonly estadoOptions: NebulaSelectOption[] = [
{ value: 'VIGENTE', label: I18N.STATUS.VIGENTE, variant: 'success' },
{ value: 'SUSPENDIDO', label: I18N.STATUS.SUSPENDIDO, variant: 'warning' },
{ value: 'EXPIRADO', label: I18N.STATUS.EXPIRADO, variant: 'error' },
];
En la grilla: col.badge('estado', I18N.COLUMNS.STATUS, this.estadoOptions).
En el formulario: this.opcionesDeFormulario(this.estadoOptions), que devuelve el mismo listado con las etiquetas ya resueltas.
Todo lo que no se declara queda apagado. NebulaSmartTableConfig<T>:
| Campo | Que enciende |
|---|---|
columns |
Las columnas. Es lo unico obligatorio |
actions · actionsLabelKey |
Botones de fila y la cabecera de su columna |
selectable · multiple |
Seleccion de filas |
rowClickable · defaultAction |
La fila entera dispara una accion |
pageSize · pageSizeOptions |
Paginacion |
initialSort |
Orden con el que abre |
globalSearch · globalSearchPlaceholderKey |
Barra de busqueda por termino |
resizableColumns |
Cambiar el ancho arrastrando el divisor |
reorderableColumns |
Mover columnas arrastrando la cabecera |
multiSort |
Ordenar por varias columnas a la vez |
densitySelector · density |
Alto de fila |
columnVisibility |
Ocultar y mostrar columnas |
help |
La ayuda, que explica SOLO lo que esa grilla ofrece |
counterSingularKey · counterPluralKey |
Contador de resultados |
emptyMessageKey · emptyDescriptionKey |
Textos de la tabla vacia |
storageKey |
Nombre con el que se recuerda el ajuste del usuario |
ariaLabel |
Etiqueta accesible |
El boton de restaurar la vista no se declara. Aparece cuando la grilla ofrece algo que el usuario pueda haber cambiado, y se apaga cuando no hay nada que restaurar.
0.16.0Un filtro propio de la pantalla se proyecta DENTRO de la grilla, para que comparta linea con el buscador. En su propia franja dejaba muerto todo el ancho que la busqueda no usa.
<lib-smart-table [config]="smartConfig()" ...>
<div nstFilters>
<lib-select [options]="estadoFilterOptions()" [ngModel]="estadoFilter()"
(ngModelChange)="onEstadoFilterChange($event)" [externalLabel]="true" />
</div>
</lib-smart-table>
No se declara, y no se abre sola. La ranura existe siempre; si la pantalla no proyecta nada, la barra queda exactamente como antes. Eso la hace opcional por naturaleza, sin bandera que recordar.
La grilla ademas ajusta la escala de lo que se proyecta —letra, alto y el hueco del mensaje de validacion— para que un control que viene de un formulario no descuadre la fila que comparte con el buscador.
Cuando NO usarla. Con muchos filtros la fila se parte en dos renglones y se cambia un hueco por un amontonamiento: ahi conviene la franja propia. La ranura esta pensada para UNO o dos controles.
interface NebulaTableQuery {
page: number;
size: number;
searchsBy: NebulaSearchCriteria[]; // { key, operation, value, linkOperator? }
ordersBy: NebulaOrderCriteria[]; // { column, direction }
}
Se envia tal cual a page-response. El termino de la busqueda libre viaja como un grupo OR sobre todas las columnas buscables; lo que se le sume se une con AND, que es como agrupa el constructor de consultas del backend:
(codigo LIKE 'x' OR nombre LIKE 'x' OR modulo LIKE 'x') AND estado = 'ACTIVO'
Con multiSort encendido, ordersBy lleva los criterios en orden de prioridad; sin el, lleva uno solo.
Catalogo global del ERP servido por nebula-masters. Es el ejemplo mas corto que ejercita todo: cuatro columnas, tres acciones, un formulario y un catalogo externo.

Todo lo que se ve ahi sale de las cuatro llamadas a col.text() y del prefijo de traduccion, y nada esta escrito en la pantalla: el titulo y su icono, el subtitulo, el contador «Mostrando 10 de 23 tipos de consecutivo», el marcador del buscador, las cabeceras, la ficha del orden vigente, las herramientas y la columna de acciones.
El icono del titulo es el que declara el MENU para esa opcion, no uno que la pantalla elija: el icono de una opcion es UNO y se ve igual en el sidebar, en la pestaña y en el encabezado.
Como se reparte la barra, de arriba abajo:
| Franja | Que lleva |
|---|---|
| Encabezado | Titulo con el icono de la opcion, subtitulo y las acciones de pagina |
| Buscador | La busqueda libre a la derecha; a la izquierda, la ranura de filtros propios si la pantalla proyecta alguno |
| Ordenamiento | La ficha del orden vigente a la izquierda y los mandos de la grilla a la derecha, cerrando la fila |
| Tabla | Cabeceras, filas y la columna de acciones |
| Pie | El contador de resultados a la izquierda y la paginacion a la derecha |
El contador vive en el PIE. Antes ocupaba una franja propia de una sola linea encima de la tabla; bajarlo aprovecha una fila que ya existe y devuelve ese alto al contenido, que es lo que se ha venido a ver.
Una sola linea sobre BaseCrudService, que ya sabe hablar page-response:
/**
* Pagina de tipos de consecutivo, con la consulta que compone la grilla.
*
* Se consume por el patron canonico `page-response`, que resuelve el nombre del modulo y evita
* que la grilla vuelva al maestro fila a fila.
*/
getPageRecord(query: NebulaTableQuery): Observable<PageResponse<ConsecutivoTipo>> {
return this.getPageRecordQuery<ConsecutivoTipo>(query);
}
@Injectable({ providedIn: 'root' })
export class ConsecutivoTipoStore extends NebulaQueryStore<
ConsecutivoTipo,
CreateConsecutivoTipoPayload,
UpdateConsecutivoTipoPayload
> {
protected service = inject(ConsecutivoTipoService);
protected storageKey = 'consecutivo_tipo_state';
protected consultarPagina(query: NebulaTableQuery): Observable<PageResponse<ConsecutivoTipo>> {
return this.service.getPageRecord(query);
}
}
El almacen ya no arma criterios de busqueda. Quien decide por que se busca y como se ordena es la declaracion de columnas.
export class ConsecutivoTipoListComponent extends NebulaSmartCrudComponent<
ConsecutivoTipo,
ConsecutivoTipoStore
> {
public override store = inject(ConsecutivoTipoStore);
protected override fb = inject(FormBuilder);
protected override destroy$ = new Subject<void>();
protected readonly i18nPrefix = 'MASTERS.CONSECUTIVE_TYPE';
protected readonly icono = 'Tags';
/** Columnas del listado. */
protected columnas(): NebulaSmartColumn<ConsecutivoTipo>[] {
const col = new NebulaColumnHelper<ConsecutivoTipo>();
return [
col.text('codigo', I18N.COLUMNS.CODE, { width: '160px' }),
col.text('nombre', I18N.COLUMNS.NAME),
col.text('moduloNombre', I18N.COLUMNS.MODULE, { width: '180px', align: 'center' }),
// La descripcion no ordena ni entra en la busqueda: es texto largo y libre.
col.text('descripcion', I18N.COLUMNS.DESCRIPTION, {
sortable: false,
globalSearchable: false,
}),
];
}
protected override acciones(): ActionConfig<ConsecutivoTipo>[] {
return [
{ label: this.t('ACTIONS.VIEW'), icon: 'view', action: 'view' },
{ label: this.t('ACTIONS.EDIT'), icon: 'edit', action: 'edit', color: 'primary' },
{ label: this.t('ACTIONS.DELETE'), icon: 'delete', action: 'delete', color: 'warn' },
];
}
protected override nombreDeAjuste(): string {
return 'masters_consecutive_type';
}
protected override opcionesDeGrilla() {
return { initialSort: { column: 'codigo', direction: 'asc' as const } };
}
protected override getFormConfig() {
return {
codigo: ['', [Validators.required, Validators.maxLength(50)]],
nombre: ['', [Validators.required, Validators.maxLength(150)]],
modulo: ['', [Validators.maxLength(50)]],
descripcion: ['', [Validators.maxLength(500)]],
};
}
}
Eso es toda la pantalla. No declara titulos, ni subtitulos, ni el contador, ni los textos de la tabla vacia, ni el marcador del buscador: las trece salen del prefijo MASTERS.CONSECUTIVE_TYPE.
<app-page-container
#pageContainer
[title]="pageTitle()"
[subtitle]="pageSubtitle()"
[icon]="pageIcon()"
[actions]="headerActions()"
(action)="handleHeaderAction($event)"
>
@if (pageMode() === 'list') {
<lib-smart-table
[config]="smartConfig()"
[data]="store.items()"
[loading]="store.loading()"
[totalElements]="store.totalElements()"
(queryChange)="onQueryChange($event)"
(action)="onTableAction($event)"
></lib-smart-table>
} @else {
<!-- el formulario -->
}
</app-page-container>
#pageContainer no es decorativo: es donde se publican los avisos de las acciones de fila.
Del prefijo MASTERS.CONSECUTIVE_TYPE la base deriva estas, y hay que sembrarlas:
| Clave | Donde se ve |
|---|---|
TITLE.LIST · TITLE.CREATE · TITLE.EDIT · TITLE.VIEW |
Titulo, segun el modo |
SUBTITLE.LIST · SUBTITLE.CREATE · SUBTITLE.EDIT · SUBTITLE.VIEW |
Subtitulo |
COUNTER.SINGULAR · COUNTER.PLURAL |
«Mostrando 2 de 2 tipos de consecutivo» |
SEARCH_PLACEHOLDER |
Marcador del buscador |
EMPTY.MESSAGE · EMPTY.DESCRIPTION |
Tabla vacia |
Mas las de sus columnas (COLUMNS.*) y las comunes del ERP (ACTIONS.VIEW, COLUMNS.ACTIONS, LAYOUT.VALIDATION.*), que ya estan sembradas.
Las claves viven en simappe_admin, no en el repositorio. Se siembran con un .sql versionado en los dos idiomas. El bundle se sirve con cache de 1 hora en memoria y 24 horas en Redis: una clave recien sembrada no se ve al instante.
NebulaSmartCrudComponent exige tres declaraciones —i18nPrefix, icono y columnas()— y ofrece el resto con un valor por defecto que se sobrescribe solo cuando hace falta:
| Metodo | Para que |
|---|---|
acciones() |
Botones de fila. Sin acciones, la columna no se dibuja |
opcionesDeGrilla() |
Encender o apagar capacidades de la grilla |
criteriosPropios() |
Filtros propios de la pantalla, que viajan siempre con la consulta |
nombreDeAjuste() |
Con que nombre recuerda la grilla su configuracion |
opcionesDeFormulario(listado) |
El mismo listado de la grilla, resuelto para un selector |
opcionesVivas(listado) |
Igual, pero como signal: se recalcula al cambiar de idioma |
ejecutarAccionDeFila(peticion) |
Confirmar, ejecutar y avisar del resultado |
getError(campo) |
Mensajes de validacion; se extiende delegando en super |
criteriosPropios() es el punto por el que una pantalla conserva su filtro particular sin renunciar a heredar. La grilla aporta el termino libre y el orden; esto aporta lo que el negocio exige:
protected override criteriosPropios(): NebulaSearchCriteria[] {
return [{ key: 'estado', operation: 'EQUAL', value: 'ACTIVO' }];
}
Los criterios propios van DETRAS del grupo OR de la busqueda, de modo que se unen con AND. Tras cambiarlos, la pantalla llama a recargar().
Un listado que alimenta un selector se declara con opcionesVivas(), no con opcionesDeFormulario(). El segundo resuelve las etiquetas UNA vez, al construir la pantalla, y las congela: la tabla cambiaba de idioma y el filtro se quedaba en el anterior. El primero devuelve un signal que lee el idioma antes de traducir, de modo que se recalcula solo.
readonly estadoFilterOptions = this.opcionesVivas([
{ value: '', label: I18N.FILTER.ALL_STATUS },
{ value: 'ACTIVO', label: I18N.STATUS.ACTIVE },
]);
| Gesto | Se recuerda |
|---|---|
| Buscar por termino | No |
| Ordenar por una columna, o por varias con la tecla de mayusculas | No |
| Cambiar el ancho de una columna | Si |
| Mover una columna | Si |
| Ocultar y mostrar columnas | Si |
| Cambiar la densidad | Si |
| Cambiar el tamaño de pagina | Si |
| Restaurar la vista | Ademas OLVIDA lo recordado |
Lo que se recuerda vive en el navegador de quien lo ajusto, bajo el nombre de storageKey. No viaja al servidor ni se comparte entre usuarios. Sin storageKey no se recuerda nada.
Voltear el sentido de una columna conserva el resto del orden (0.15.0). Antes, pulsar una columna que ya estaba en el orden lo descartaba todo y dejaba solo esa: quien ordenaba por tres columnas y corregia el sentido de la primera perdia las otras dos. Ahora el ciclo es ascendente ⇄ descendente sobre esa columna, y para sacarla del orden se usa su aspa en la ficha del orden vigente.
Son gestos que nadie descubre si no se los cuentan, asi que la grilla los cuenta: el boton de ayuda abre la ficha de lo que se puede hacer con ella.

La ficha no es una lista fija: enumera SOLO lo que esa grilla tiene encendido. Una que no permita mover columnas no menciona el gesto, porque explicar lo que no esta disponible confunde mas de lo que ayuda. Se enciende con help: true, y sus textos salen del bundle como todo lo demas.
| Sintoma | Causa |
|---|---|
| Las cabeceras muestran identificadores crudos | Falta sembrar las claves, o la aplicacion no provee NEBULA_TRANSLATE |
| Una celda de listado muestra el codigo y no su texto | El valor no figura en las options de esa columna |
| La grilla no pide datos al abrir | El almacen espera la primera consulta de la grilla; sin (queryChange) enlazado no llega nunca |
| La busqueda no encuentra nada | La columna es globalSearchable: false, o el campo no existe en el record aplanado |
| El orden por una columna no surte efecto | El nombre de la columna no coincide con el de la vista que ordena el backend |
| El ajuste no se conserva | Falta nombreDeAjuste() / storageKey |
| La grilla no se retraduce al cambiar de idioma | El traductor no lee el idioma como signal antes de traducir |
| El filtro propio no cambia de idioma, pero la tabla si | El listado se resolvio con opcionesDeFormulario(), que congela las etiquetas; usar opcionesVivas() |
| Una celda de estado no sigue el cambio de idioma | El rotulo sale de un campo que el backend ya tradujo con el idioma de la SESION. Si el contrato manda tambien el CODIGO, declarar la columna como badge sobre el codigo |
| Paginar repite filas y se salta otras | La consulta sale sin ordersBy y el backend no impone ORDER BY. Declarar un orden base, y añadir el desempate por id cuando el usuario elige columna |
| El orden o la busqueda por una columna «no hace nada», pero responde 200 | El backend DESCARTA EN SILENCIO el criterio cuyo campo no existe en la vista. Traducir el nombre de la columna al del campo consultado |
| Version | Fecha | Autor | Descripcion |
|---|---|---|---|
| 1.2.0 | 2026-09-03 | Carlos Torres | Se actualiza a 0.16.0: la ranura de filtros propios, el contador que baja al pie con el reparto de la barra, el ciclo de ordenamiento que conserva el resto del orden y opcionesVivas() para los listados que siguen el cambio de idioma. Captura renovada |
| 1.1.0 | 2026-09-02 | Carlos Torres | Se agregan las dos capturas: el listado de Tipos de Consecutivo, para ver de que se habla, y la ficha de ayuda de la grilla |
| 1.0.1 | 2026-09-02 | Carlos Torres | Se aclara quien hace la peticion: el listado SI se consume por page-response, y la cadena de cuatro pasos muestra en cual de ellos sale. El titulo anterior se leia como que no habia consulta |
| 1.0.0 | 2026-09-02 | Carlos Torres | Publicacion de la primera API frontend: la grilla declarativa de listados, con la base de pantalla, la base de almacen y el ejemplo de uso sobre Tipos de Consecutivo |