Módulo: Presupuesto / Parámetros
Última actualización: 2026-10-01
Autor: Centrica
El Modal de CUIPO es un componente reutilizable que permite al usuario buscar y seleccionar un CUIPO (Catálogo Único de Identificación de Programas y Proyectos) desde un diálogo modal con capacidades de:
codigo, nombreCuipo, descripcion)Está implementado sobre la librería nebula-ui-kit mediante el componente genérico SelectDialogComponent.
┌─────────────────────────────────────────────────────────────┐
│ CuipoModalService │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ openSelector(title): Observable<Cuipo | null> │ │
│ └───────────────────────────────────────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────┐ ┌──────────────────┐ │
│ │ DialogService│ │ CuipoService │ │
│ │ (nebula-ui) │ │ (HTTP / Backend) │ │
│ └─────────────┘ └──────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌───────────────────┐ │
│ │ SelectDialog │ │ API: │ │
│ │ Component │ │ /api/v1/budget/ │ │
│ │ │ │ parametros/cuipo │ │
│ └─────────────────┘ └───────────────────┘ │
└─────────────────────────────────────────────────────────────┘
| Archivo | Responsabilidad |
|---|---|
cuipo-modal.service.ts |
Orquesta la apertura del modal, define columnas, loadDataFn, displayFn y transforma la respuesta. |
cuipo.service.ts |
Cliente HTTP. Realiza el POST paginado al backend y mapea la respuesta a PageResponse<Cuipo>. |
cuipo.models.ts |
Modelo de dominio Cuipo. |
cuipo-modal.service.spec.ts |
Tests unitarios del servicio del modal. |
cuipo.service.spec.ts |
Tests unitarios del servicio HTTP. |
select-dialog.component.ts |
Componente genérico de la librería nebula-ui-kit. |
export interface Cuipo {
id: number;
codigo: string;
nombreCuipo: string;
nombreCompleto: string;
sectorInstitucional: string;
ambito: string;
sujecionRegimen: string;
descripcion: string;
}
CuipoModalService.openSelector(title: string): Observable<Cuipo | null>Abre el diálogo de selección y emite:
| Resultado | Valor emitido |
|---|---|
| Usuario selecciona un ítem | Cuipo (el primer elemento seleccionado) |
| Usuario cierra sin seleccionar | null |
| El diálogo devuelve un array vacío | null |
| Parámetro | Tipo | Descripción |
|---|---|---|
title |
string |
Título que se mostrará en la cabecera del modal. |
import { CuipoModalService } from './cuipo-modal.service';
import { Cuipo } from './cuipo.models';
@Component({ /* ... */ })
export class MiComponente {
private readonly cuipoModal = inject(CuipoModalService);
seleccionarCuipo(): void {
this.cuipoModal.openSelector('Seleccionar CUIPO')
.subscribe((cuipo: Cuipo | null) => {
if (cuipo) {
console.log('CUIPO seleccionado:', cuipo);
// Lógica de negocio...
}
});
}
}
El modal se configura con las siguientes opciones pasadas a DialogService.open:
{
data: {
title: title,
columns: [
{ key: 'codigo', label: t(COMMON.COLUMNS.CODE), sortable: true, width: '15%' },
{ key: 'nombreCuipo', label: t(COMMON.COLUMNS.NAME), sortable: true, width: '40%' },
{ key: 'descripcion', label: t(COMMON.COLUMNS.DESCRIPTION), sortable: true, width: '45%' },
],
multiSelect: false,
searchPlaceholder: t(TREASURY.COMMON.MODALS.SEARCH.THIRD_PARTY.PLACEHOLDER),
loadDataFn: loadDataFn,
itemIdKey: 'id',
selectedItems: [],
displayFn: (item) => `${item.codigoDisplay || item.codigo} - ${item.nombreCuipo}`,
},
size: 'lg',
width: '800px',
}
| Propiedad | Valor | Descripción |
|---|---|---|
size |
'lg' |
Tamaño del modal. |
width |
'800px' |
Ancho fijo del diálogo. |
multiSelect |
false |
Solo permite seleccionar un CUIPO. |
itemIdKey |
'id' |
Clave usada para identificar ítems seleccionados. |
selectedItems |
[] |
Inicialmente sin selección previa. |
loadDataFn)const loadDataFn = async (params) => {
try {
const page = params.page - 1; // El modal usa base 1, el backend base 0
const size = params.limit;
const searchTerm = params.search || '';
const response = await lastValueFrom(
this.cuipoService.getCuipo(page, size, searchTerm)
);
return { data: response.content, total: response.totalElements };
} catch (error) {
return { data: [], total: 0 }; // Fallback ante error
}
};
Contrato esperado por el modal:
{
data: any[]; // Ítems de la página actual
total: number; // Total de elementos (para paginación)
}
⚠️ Nota: El modal entrega
pageen base 1; el backend espera base 0. Por eso se resta1.
CuipoService.getCuipo)Realiza un POST con el siguiente body:
{
"page": "0",
"size": "10",
"searchsBy": [
{ "key": "codigo", "operation": "MATCH", "value": "term", "linkOperator": "OR" },
{ "key": "nombreCuipo", "operation": "MATCH", "value": "term", "linkOperator": "OR" },
{ "key": "descripcion", "operation": "MATCH", "value": "term" }
],
"ordersBy": [{ "key": "codigo", "direction": "ASC" }]
}
Comportamiento de searchsBy:
searchTerm está vacío o solo contiene espacios → searchsBy: []OR (los dos primeros) y el tercero sin linkOperator.Endpoint:
POST /api/v1/budget/parametros/cuipo
La respuesta del backend es normalizada a PageResponse<Cuipo>:
{
content: data?.content || [],
totalElements: data?.totalElements || 0,
totalPages: data?.totalPages || 0,
size: data?.size || size,
number: data?.number || page,
numberOfElements: data?.content?.length || 0,
first: page === 0,
last: page >= (data?.totalPages || 1) - 1,
empty: data?.content?.length === 0,
}
afterClosed)return dialogRef.afterClosed().pipe(
map((selected: Cuipo[]) =>
selected && selected.length > 0 ? selected[0] : null
)
);
| Constante | Clave | Uso |
|---|---|---|
COMMON.COLUMNS.CODE |
Texto para la columna Código | Cabecera de columna |
COMMON.COLUMNS.NAME |
Texto para la columna Nombre | Cabecera de columna (con fallback 'Nombre') |
COMMON.COLUMNS.DESCRIPTION |
Texto para la columna Descripción | Cabecera de columna |
TREASURY.COMMON.MODALS.SEARCH.THIRD_PARTY.PLACEHOLDER |
Placeholder del buscador | Input de búsqueda |
El método t(key, params?) es un wrapper sobre TranslationService.translate.
displayFn)displayFn: (item) => {
const codigo = item.codigoDisplay || item.codigo;
return `${codigo} - ${item.nombreCuipo}`;
}
Ejemplos:
| Entrada | Salida |
|---|---|
{ codigoDisplay: 'CD-99', codigo: 'C-99', nombreCuipo: 'Test' } |
CD-99 - Test |
{ codigo: 'C-99', nombreCuipo: 'Test' } |
C-99 - Test |
Ambos servicios cuentan con cobertura de pruebas unitarias con Vitest + TestBed de Angular.
CuipoModalService – Casos cubiertost() delega en TranslationService con/sin parámetros.openSelector() abre el diálogo con la configuración correcta (columnas, tamaño, i18n).loadDataFn transforma correctamente la paginación (base 1 → base 0).loadDataFn usa '' si no hay término de búsqueda.loadDataFn retorna { data: [], total: 0 } ante error HTTP.displayFn usa codigoDisplay con fallback a codigo.afterClosed retorna el primer ítem, o null en caso de array vacío/undefined.CuipoService – Casos cubiertosbaseUrl.getCuipo con término vacío → searchsBy: [].getCuipo con término válido → 3 criterios MATCH.npx vitest run cuipo
SelectDialogComponent) entrega page en base 1, mientras que el backend espera base 0. La conversión (params.page - 1) está encapsulada en loadDataFn.loadDataFn retorna un estado vacío en lugar de propagar la excepción. Esto evita romper la UI del modal.this.t(COMMON.COLUMNS.NAME) || 'Nombre' para garantizar un valor visible si la traducción falla.multiSelect: false y afterClosed solo emite el primer elemento del array para simplificar el consumo.CuipoModalService usa inject() (Angular 14+) en lugar de constructor. Es providedIn: 'root' (singleton).columns en openSelector.nebula-ui-kit – DialogService@nebula-shared/data-access – BaseCrudServiceSelectDialogComponent| Fecha | Versión | Cambios |
|---|---|---|
| 2026-10-01 | 1.0.0 | Versión inicial del modal de CUIPO. |
arma .md
markdown
# Modal de Selección de CUIPO
> **Módulo:** Presupuesto / Parámetros
> **Última actualización:** 2026-10-01
> **Autor:** Centrica
---
## 📋 Descripción General
El **Modal de CUIPO** es un componente reutilizable que permite al usuario buscar y seleccionar un **CUIPO** (Catálogo Único de Identificación de Programas y Proyectos) desde un diálogo modal con capacidades de:
- 🔍 Búsqueda por texto en múltiples campos (`codigo`, `nombreCuipo`, `descripcion`)
- 📄 Paginación server-side
- 📊 Columnas ordenables
- ✅ Selección única (single-select)
- 🌐 Internacionalización (i18n)
- 🎨 Formato de visualización personalizado
Está implementado sobre la librería **`nebula-ui-kit`** mediante el componente genérico `SelectDialogComponent`.
---
## 🏗️ Arquitectura
┌─────────────────────────────────────────────────────────────┐
│ CuipoModalService │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ openSelector(title): Observable<Cuipo | null> │ │
│ └───────────────────────────────────────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────┐ ┌──────────────────┐ │
│ │ DialogService│ │ CuipoService │ │
│ │ (nebula-ui) │ │ (HTTP / Backend) │ │
│ └─────────────┘ └──────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌───────────────────┐ │
│ │ SelectDialog │ │ API: │ │
│ │ Component │ │ /api/v1/budget/ │ │
│ │ │ │ parametros/cuipo │ │
│ └─────────────────┘ └───────────────────┘ │
└─────────────────────────────────────────────────────────────┘
### Archivos involucrados
| Archivo | Responsabilidad |
|---|---|
| `cuipo-modal.service.ts` | Orquesta la apertura del modal, define columnas, `loadDataFn`, `displayFn` y transforma la respuesta. |
| `cuipo.service.ts` | Cliente HTTP. Realiza el `POST` paginado al backend y mapea la respuesta a `PageResponse<Cuipo>`. |
| `cuipo.models.ts` | Modelo de dominio `Cuipo`. |
| `cuipo-modal.service.spec.ts` | Tests unitarios del servicio del modal. |
| `cuipo.service.spec.ts` | Tests unitarios del servicio HTTP. |
| `select-dialog.component.ts` | Componente genérico de la librería `nebula-ui-kit`. |
---
## 📦 Modelo de Dominio
```typescript
export interface Cuipo {
id: number;
codigo: string;
nombreCuipo: string;
nombreCompleto: string;
sectorInstitucional: string;
ambito: string;
sujecionRegimen: string;
descripcion: string;
}
CuipoModalService.openSelector(title: string): Observable<Cuipo | null>Abre el diálogo de selección y emite:
| Resultado | Valor emitido |
|---|---|
| Usuario selecciona un ítem | Cuipo (el primer elemento seleccionado) |
| Usuario cierra sin seleccionar | null |
| El diálogo devuelve un array vacío | null |
| Parámetro | Tipo | Descripción |
|---|---|---|
title |
string |
Título que se mostrará en la cabecera del modal. |
import { CuipoModalService } from './cuipo-modal.service';
import { Cuipo } from './cuipo.models';
@Component({ /* ... */ })
export class MiComponente {
private readonly cuipoModal = inject(CuipoModalService);
seleccionarCuipo(): void {
this.cuipoModal.openSelector('Seleccionar CUIPO')
.subscribe((cuipo: Cuipo | null) => {
if (cuipo) {
console.log('CUIPO seleccionado:', cuipo);
// Lógica de negocio...
}
});
}
}
El modal se configura con las siguientes opciones pasadas a DialogService.open:
{
data: {
title: title,
columns: [
{ key: 'codigo', label: t(COMMON.COLUMNS.CODE), sortable: true, width: '15%' },
{ key: 'nombreCuipo', label: t(COMMON.COLUMNS.NAME), sortable: true, width: '40%' },
{ key: 'descripcion', label: t(COMMON.COLUMNS.DESCRIPTION), sortable: true, width: '45%' },
],
multiSelect: false,
searchPlaceholder: t(TREASURY.COMMON.MODALS.SEARCH.THIRD_PARTY.PLACEHOLDER),
loadDataFn: loadDataFn,
itemIdKey: 'id',
selectedItems: [],
displayFn: (item) => `${item.codigoDisplay || item.codigo} - ${item.nombreCuipo}`,
},
size: 'lg',
width: '800px',
}
| Propiedad | Valor | Descripción |
|---|---|---|
size |
'lg' |
Tamaño del modal. |
width |
'800px' |
Ancho fijo del diálogo. |
multiSelect |
false |
Solo permite seleccionar un CUIPO. |
itemIdKey |
'id' |
Clave usada para identificar ítems seleccionados. |
selectedItems |
[] |
Inicialmente sin selección previa. |
loadDataFn)const loadDataFn = async (params) => {
try {
const page = params.page - 1; // El modal usa base 1, el backend base 0
const size = params.limit;
const searchTerm = params.search || '';
const response = await lastValueFrom(
this.cuipoService.getCuipo(page, size, searchTerm)
);
return { data: response.content, total: response.totalElements };
} catch (error) {
return { data: [], total: 0 }; // Fallback ante error
}
};
Contrato esperado por el modal:
{
data: any[]; // Ítems de la página actual
total: number; // Total de elementos (para paginación)
}
⚠️ Nota: El modal entrega
pageen base 1; el backend espera base 0. Por eso se resta1.
CuipoService.getCuipo)Realiza un POST con el siguiente body:
{
"page": "0",
"size": "10",
"searchsBy": [
{ "key": "codigo", "operation": "MATCH", "value": "term", "linkOperator": "OR" },
{ "key": "nombreCuipo", "operation": "MATCH", "value": "term", "linkOperator": "OR" },
{ "key": "descripcion", "operation": "MATCH", "value": "term" }
],
"ordersBy": [{ "key": "codigo", "direction": "ASC" }]
}
Comportamiento de searchsBy:
searchTerm está vacío o solo contiene espacios → searchsBy: []OR (los dos primeros) y el tercero sin linkOperator.Endpoint:
POST /api/v1/budget/parametros/cuipo
La respuesta del backend es normalizada a PageResponse<Cuipo>:
{
content: data?.content || [],
totalElements: data?.totalElements || 0,
totalPages: data?.totalPages || 0,
size: data?.size || size,
number: data?.number || page,
numberOfElements: data?.content?.length || 0,
first: page === 0,
last: page >= (data?.totalPages || 1) - 1,
empty: data?.content?.length === 0,
}
afterClosed)return dialogRef.afterClosed().pipe(
map((selected: Cuipo[]) =>
selected && selected.length > 0 ? selected[0] : null
)
);
| Constante | Clave | Uso |
|---|---|---|
COMMON.COLUMNS.CODE |
Texto para la columna Código | Cabecera de columna |
COMMON.COLUMNS.NAME |
Texto para la columna Nombre | Cabecera de columna (con fallback 'Nombre') |
COMMON.COLUMNS.DESCRIPTION |
Texto para la columna Descripción | Cabecera de columna |
TREASURY.COMMON.MODALS.SEARCH.THIRD_PARTY.PLACEHOLDER |
Placeholder del buscador | Input de búsqueda |
El método t(key, params?) es un wrapper sobre TranslationService.translate.
displayFn)displayFn: (item) => {
const codigo = item.codigoDisplay || item.codigo;
return `${codigo} - ${item.nombreCuipo}`;
}
Ejemplos:
| Entrada | Salida |
|---|---|
{ codigoDisplay: 'CD-99', codigo: 'C-99', nombreCuipo: 'Test' } |
CD-99 - Test |
{ codigo: 'C-99', nombreCuipo: 'Test' } |
C-99 - Test |
Ambos servicios cuentan con cobertura de pruebas unitarias con Vitest + TestBed de Angular.
CuipoModalService – Casos cubiertost() delega en TranslationService con/sin parámetros.openSelector() abre el diálogo con la configuración correcta (columnas, tamaño, i18n).loadDataFn transforma correctamente la paginación (base 1 → base 0).loadDataFn usa '' si no hay término de búsqueda.loadDataFn retorna { data: [], total: 0 } ante error HTTP.displayFn usa codigoDisplay con fallback a codigo.afterClosed retorna el primer ítem, o null en caso de array vacío/undefined.CuipoService – Casos cubiertosbaseUrl.getCuipo con término vacío → searchsBy: [].getCuipo con término válido → 3 criterios MATCH.npx vitest run cuipo
SelectDialogComponent) entrega page en base 1, mientras que el backend espera base 0. La conversión (params.page - 1) está encapsulada en loadDataFn.loadDataFn retorna un estado vacío en lugar de propagar la excepción. Esto evita romper la UI del modal.this.t(COMMON.COLUMNS.NAME) || 'Nombre' para garantizar un valor visible si la traducción falla.multiSelect: false y afterClosed solo emite el primer elemento del array para simplificar el consumo.CuipoModalService usa inject() (Angular 14+) en lugar de constructor. Es providedIn: 'root' (singleton).columns en openSelector.| Fecha | Versión | Cambios |
|---|---|---|
| 2026-10-01 | 1.0.0 | Versión inicial del modal de CUIPO. |