Componente de selección de productos/módulos, guarda de rutas, servicio de obtención de módulos desde API, modelos, constantes y estilos responsive.
🎨 ProductsComponent
🛡️ productsGuard
🌐 ProductsService
📦 Módulos + GridCard
🔍 Búsqueda + persistencia
export { ProductsComponent } from './lib/feature-products/src/lib/products/products.component';
export { PRODUCTS\_ROUTES } from './lib/products.routes';
🚪 Exportaciones para consumo externo
✔️ ProductsComponent: componente standalone para mostrar catálogo de productos/módulos.
✔️ PRODUCTS_ROUTES: configuración de rutas para lazy loading desde la aplicación principal.
guards/products.guard.ts
export const productsGuard: CanActivateFn = (\_route, \_state) => {
const productsService = inject(ProductsService);
const router = inject(Router);
return productsService.getModules().pipe(
map((data: Module\[\]) => {
const getCleanRoute = (item: Module) => {
const rawRoute = item.route || '';
return rawRoute.replace(/^\\/+|\\/+$/g, '') ||
item.name.toLowerCase().replace(/\\s+/g, '-');
};
if (data.length === 1) {
const singleModule = data\[0\];
const singleRoute = getCleanRoute(singleModule);
localStorage.setItem('selectedProductId', singleModule.id.toString());
localStorage.setItem('selectedRoute', singleModule.name.toLowerCase());
localStorage.setItem('selectedModule', JSON.stringify(selectedModuleInfo));
return router.parseUrl(\`/layout/${singleRoute}\`);
}
return data.length > 1 ? true : router.parseUrl('/layout/');
}),
);
};
🛡️ Guarda de rutas - Redirección inteligente
🎨
feature-products/products.component.ts
@Component({
selector: 'app-products',
standalone: true,
imports: \[CommonModule, FormsModule, LucideAngularModule,
ButtonComponent, GridCardComponent, InputComponent\],
templateUrl: './products.component.html',
styleUrls: \['./products.component.scss'\],
})
export class ProductsComponent implements OnInit {
searchTerm = signal('');
showBackButton = signal(false);
modulesRaw = toSignal(this.productsService.getModules(), { initialValue: \[\] });
modules = computed(() => this.modulesRaw().map(item => ({
id: item.id,
title: item.name,
subtitle: item.description,
icon: item.icon as IconName,
route: \`/layout/${item.route}\`,
})));
filteredModules = computed(() =>
this.modules().filter(m =>
m.title.toLowerCase().includes(this.searchTerm().toLowerCase()) ||
m.subtitle?.toLowerCase().includes(this.searchTerm().toLowerCase())
)
);
selectProduct(event: { item: any }) {
localStorage.setItem('selectedProductId', product.id);
localStorage.setItem('selectedRoute', cleanRoute);
}
}
🎨 Componente de selección de productos
✔️ Signals + computed: estado reactivo con Angular Signals.
✔️ toSignal: convierte Observable del servicio a signal para integración reactiva.
✔️ Búsqueda en tiempo real: filteredModules filtra por título y subtítulo.
✔️ Persistencia de búsqueda: StateService guarda el término de búsqueda entre navegaciones.
✔️ Botón "Volver": aparece si viene con parámetro autoRedirect=true (navegación desde otra sección).
✔️ Selección de producto: guarda ID y ruta en localStorage para uso en el resto de la app.
products.component.html
<div class="products-container">
<header class="products-header">
<h1 class="page-title">
<div class="title-wrapper">
@if (!showBackButton()) {
<lib-button prefixIcon="CircleArrowLeft" (clicked)="onBack()">
Volver
</lib-button>
}
<div>Productos</div>
</div>
</h1>
</header>
<div class="search-products">
<lib-input \[value\]="searchTerm()"
(valueChange)="onSearchChange($event)"
placeholder="Buscar"
suffixIcon="Search">
</lib-input>
</div>
<div class="modules-grid">
<lib-gridcard \[items\]="filteredModules()"
(cardClick)="selectProduct($event)">
</lib-gridcard>
</div>
</div>
📄 UI con GridCard reutilizable
✔️ lib-button: botón de volver con ícono CircleArrowLeft.
✔️ lib-input: campo de búsqueda con ícono de lupa y limpieza.
✔️ lib-gridcard: componente de nebula-ui-kit que muestra tarjetas en grid responsive.
✔️ Estructura semántica: header con título y subtítulo descriptivo.
✔️ Responsive: el ancho del buscador se adapta a diferentes breakpoints (20% en desktop, 35% en tablet, 100% en móvil).
🎨
products.component.scss
.products-container {
padding: 42px;
min-height: 100vh;
background: var(--color-bg-content);
display: flex;
flex-direction: column;
gap: 32px;
}
.modules-grid { gap: 32px; }
.grid-content {
height: 67vh;
overflow-y: auto;
&::-webkit-scrollbar { width: 5px; }
}
@media (max-width: 768px) {
.products-container { padding: 24px; }
.module-card .module-name { font-size: 24px; }
}
@media (max-width: 480px) {
.module-card .module-name { font-size: 20px; }
}
🎨 Diseño moderno y adaptable
✔️ Variables CSS: usa --color-bg-content, --color-title, --color-subtitle para tema consistente.
✔️ Scroll personalizado: barra de desplazamiento estilizada para el grid de productos.
✔️ Breakpoints: 1024px, 768px, 480px para adaptarse a tablets y móviles.
✔️ Tarjetas con hover: efecto de elevación y cambio de borde al pasar el cursor.
✔️ Grid responsivo: el componente lib-gridcard maneja automáticamente el layout responsivo.
🌐
data-access/products.service.ts
@Injectable({ providedIn: 'root' })
export class ProductsService {
private readonly http = inject(HttpService);
private readonly MENU\_ENDPOINT = '/simappe-admin/api/v1/menu';
getModules(): Observable<AppSummary\[\]> {
const userDataJson = localStorage.getItem('user\_data');
const userData = JSON.parse(userDataJson);
const roleIds = userData.roles.map(role => role.id);
return this.http.post(\`${this.MENU\_ENDPOINT}/read-by-roles-grouped\`, roleIds).pipe(
map(response => {
const content = response?.response?.content || response?.content || \[\];
const applications = this.extractApplicationsFromGrouped(content);
return applications.map(app => ({
id: app.applicationId,
name: app.applicationName,
description: app.applicationDescription,
icon: this.convertToLucideIcon(app.applicationFavicon),
route: app.moduleUrl || this.buildApplicationRoute(app),
}));
})
);
}
}
🌐 Obtención de módulos desde API
✔️ Endpoint: /simappe-admin/api/v1/menu/read-by-roles-grouped
✔️ Autenticación: usa user_data de localStorage para obtener roles del usuario.
✔️ Extracción de aplicaciones: extractApplicationsFromGrouped procesa la respuesta anidada y elimina duplicados por applicationId.
✔️ Mapeo de iconos: ICON_MAPPING convierte iconos del backend a nombres de Lucide compatibles con nebula-ui-kit.
✔️ Construcción de ruta: usa moduleUrl del primer módulo o genera una ruta desde el nombre de la aplicación.
📦
domain/products.model.ts
export interface Role {
id: number;
name: string;
authority: string;
}
export interface Module {
id?: string;
name: string;
description: string;
icon: string;
route?: string;
}
export interface ModuleItem {
id?: string;
title: string;
subtitle: string;
icon: IconName;
route: string;
}
export interface AppSummary {
id: string;
name: string;
description: string;
icon: string;
route?: string;
}
📦 Tipado completo para productos y módulos
✔️ Role: estructura de roles del usuario desde el backend.
✔️ Module: módulo/producto disponible (nombre, descripción, icono, ruta).
✔️ ModuleItem: adaptación para lib-gridcard (title, subtitle, icon, route).
✔️ AppSummary: resumen de aplicación usado internamente en ProductsService.
⚙️
const/products.constant.ts + icons-mapping.ts
// products.constant.ts
export const PRODUCTS\_ERRORS = {
NO\_USER\_DATA: 'No hay datos de usuario en localStorage',
NO\_ROLES: 'El usuario no tiene roles asignados',
} as const;
export const PATH\_ROUTES = {
LAYOUT: '/layout',
};
// icons-mapping.constants.ts
export const ICON\_MAPPING: Record<string, string> = {
'lucide lucide-landmark': 'ReceiptText',
'lucide-landmark': 'ReceiptText',
'lucide lucide-pie-chart': 'ReceiptText',
'lucide lucide-receipt': 'ReceiptText',
'lucide lucide-database': 'ReceiptText',
};
⚙️ Constantes centralizadas y mapeo de iconos
✔️ PRODUCTS_ERRORS: mensajes de error para el servicio (falta de datos, roles).
✔️ PATH_ROUTES: rutas base reutilizables en toda la librería.
✔️ ICON_MAPPING: convierte iconos del backend (formato lucide lucide-xxx) a nombres compatibles con Lucide Angular (ej: ReceiptText).
✔️ readonly as const: garantiza inmutabilidad y mejor inferencia de tipos.
Secuencia completa
📋 Paso a paso del módulo de productos:
1. Usuario completa login exitosamente
2. Redirigido a /layout/products (o ruta protegida por productsGuard)
3. productsGuard ejecuta getModules()
4. Si 1 módulo: guarda datos en localStorage y redirige directamente al módulo
5. Si múltiples módulos: permite acceso a ProductsComponent
6. ProductsComponent carga y muestra GridCard con módulos disponibles
7. Usuario busca o selecciona un producto
8. Al seleccionar: guarda selectedProductId y selectedRoute
9. Redirige a /layout/{ruta} (dashboard del módulo)
💾 Persistencia: búsqueda guardada en StateService, selección de producto en localStorage.
🔗 Integración con layout: El módulo de productos trabaja en conjunto con @nebula/layout. Una vez seleccionado un producto, el layout carga el sidebar y tabs específicos para ese módulo (accounting, tesorería, budget).
📁 const/
products.constant.ts, icons-mapping.constants.ts
📁 domain/
products.model.ts → interfaces Role, Module, ModuleItem, AppSummary
📁 data-access/
ProductsService → comunicación con API de menú
🎨 feature-products/
ProductsComponent standalone + template + estilos
🛡️ guards/
productsGuard → CanActivate con redirección inteligente
✨ Mejores prácticas implementadas: Lazy loading ready, signals para estado reactivo, guarda de rutas inteligente, persistencia de estado de búsqueda, mapeo de iconos centralizado, diseño responsive completo y separación clara de responsabilidades.
nx test products → pruebas unitarias para ProductsService y ProductsComponent.
📦 @nebula/products · Módulo de selección de productos/módulos con guarda inteligente, GridCard reutilizable y persistencia de estado.