@cbm-common/service-group-repository
v0.0.1
Published
Repositorio Angular para la gestión completa de grupos de servicios con estructura jerárquica. Implementa operaciones CRUD para grupos de servicios organizados en niveles, con soporte para categorías, cuentas contables y estructura de árbol multinivel.
Readme
Service Group Repository
Repositorio Angular para la gestión completa de grupos de servicios con estructura jerárquica. Implementa operaciones CRUD para grupos de servicios organizados en niveles, con soporte para categorías, cuentas contables y estructura de árbol multinivel.
📦 Instalación
npm install @cbm-common/service-group-repository⚙️ Configuración
Configuración del Módulo
El módulo debe configurarse en el módulo raíz de la aplicación:
import { CbmServiceGroupModule } from '@cbm-common/service-group-repository';
@NgModule({
imports: [
CbmServiceGroupModule.forRoot({
baseUrl: 'https://api.cbm.com/service-groups'
})
]
})
export class AppModule {}Configuración Standalone
Para aplicaciones standalone, configura el módulo en el bootstrap:
import { CbmServiceGroupModule } from '@cbm-common/service-group-repository';
bootstrapApplication(AppComponent, {
providers: [
CbmServiceGroupModule.forRoot({
baseUrl: 'https://api.cbm.com/service-groups'
})
]
});🎯 Inyección de Dependencias
Inyección del Servicio
import { CbmServiceGroupService } from '@cbm-common/service-group-repository';
@Component({
selector: 'app-service-group-manager',
standalone: true,
imports: [CbmServiceGroupService]
})
export class ServiceGroupManagerComponent {
constructor(private serviceGroupService: CbmServiceGroupService) {}
}Inyección del Repositorio
import { CbmServiceGroupRepository } from '@cbm-common/service-group-repository';
@Component({
selector: 'app-service-group-list',
standalone: true,
imports: [CbmServiceGroupRepository]
})
export class ServiceGroupListComponent {
constructor(private serviceGroupRepo: CbmServiceGroupRepository) {}
}🏗️ Arquitectura del Repositorio
Patrón de Diseño
El repositorio sigue el patrón Repository Pattern con Dependency Injection:
CbmServiceGroupModule
├── ICbmServiceGroupModuleConfig (configuración)
├── CbmServiceGroupService (implementa ICbmServiceGroupRepository)
├── CbmServiceGroupRepository (wrapper del service)
├── CbmServiceGroupModel (modelos de datos)
└── HttpClient (cliente HTTP)Interfaz del Repositorio
export interface ICbmServiceGroupRepository {
list(params: CbmServiceGroupModel.ListParams): Observable<CbmServiceGroupModel.ListResponse>;
getOne(id: string): Observable<CbmServiceGroupModel.GetOneResponse>;
save(data: CbmServiceGroupModel.SaveBody): Observable<CbmServiceGroupModel.ConfirmResponse>;
update(id: string, data: CbmServiceGroupModel.UpdateBody): Observable<CbmServiceGroupModel.ConfirmResponse>;
changeStatus(id: string, data: CbmServiceGroupModel.ChangeStatusBody): Observable<CbmServiceGroupModel.ConfirmResponse>;
delete(id: string): Observable<CbmServiceGroupModel.ConfirmResponse>;
listAsTree(params: CbmServiceGroupModel.ListAsTreeParams): Observable<CbmServiceGroupModel.ListAsTreeResponse>;
}📊 Operaciones Disponibles
Listado de Grupos de Servicios
// Listado de grupos de servicios con filtros
list(params: {
enabled?: boolean; // Filtrar por estado (habilitado/deshabilitado)
name?: string; // Filtrar por nombre del grupo
service_group_id?: string; // Filtrar por grupo padre
}): Observable<ListResponse>Consulta Individual
// Obtener un grupo de servicios específico por ID
getOne(id: string): Observable<GetOneResponse>Creación de Grupo de Servicios
// Crear un nuevo grupo de servicios
save(data: {
service_group_id?: string; // ID del grupo padre (opcional)
initial?: boolean; // Indica si es un grupo inicial
level?: number; // Nivel jerárquico del grupo
name?: string; // Nombre del grupo de servicios
}): Observable<ConfirmResponse>Actualización de Grupo
// Actualizar un grupo de servicios existente
update(id: string, data: {
service_group_id?: string; // ID del grupo padre
initial?: boolean; // Estado inicial
level?: number; // Nivel jerárquico
name?: string; // Nombre del grupo
}): Observable<ConfirmResponse>Cambio de Estado
// Cambiar el estado de un grupo de servicios
changeStatus(id: string, data: {
enabled?: boolean; // Nuevo estado (true/false)
disabled_reason?: string; // Razón de deshabilitación (opcional)
}): Observable<ConfirmResponse>Eliminación de Grupo
// Eliminar un grupo de servicios
delete(id: string): Observable<ConfirmResponse>Listado Jerárquico (Árbol)
// Obtener estructura jerárquica de grupos y categorías
listAsTree(params: {
name?: string; // Filtrar por nombre
}): Observable<ListAsTreeResponse>📋 Modelos de Datos
ListResponse.Data
Información completa de un grupo de servicios:
interface Data {
_id: string;
company_id?: string; // ID de la compañía
service_group_id?: string; // ID del grupo padre
initial?: boolean; // Indica si es grupo inicial
level?: number; // Nivel jerárquico (1, 2, 3...)
name?: string; // Nombre del grupo
enabled?: boolean; // Estado del grupo
created_user?: string; // Usuario que creó el registro
created_at?: number; // Timestamp de creación
}ListResponse
Respuesta del listado de grupos de servicios:
interface ListResponse {
success: boolean; // Indica si la operación fue exitosa
data: Data[]; // Array de grupos de servicios
}GetOneResponse.Data
Información detallada de un grupo de servicios específico:
interface Data {
_id?: string;
company_id?: string; // ID de la compañía
service_group_id?: string; // ID del grupo padre
initial?: boolean; // Estado inicial
level?: number; // Nivel jerárquico
name?: string; // Nombre del grupo
enabled?: boolean; // Estado activo
created_user?: string; // Usuario creador
created_at?: number; // Fecha de creación
}ListAsTreeResponse
Estructura jerárquica de grupos y categorías:
interface ListAsTreeResponse {
success: boolean; // Indica si la operación fue exitosa
data: Children; // Estructura de árbol
}
type Children = Child[];
interface Child {
label: string; // Etiqueta para mostrar
value: Group | Category; // Datos del grupo o categoría
children?: Children; // Hijos en la jerarquía
}
interface Group {
_id: string;
company_id?: string;
initial?: boolean;
level?: number;
name?: string;
enabled?: boolean;
deleted?: boolean;
created_user?: string;
created_at?: number;
disabled_reason?: string;
updated_at?: number;
updated_user?: string;
}
interface Category {
_id: string;
company_id?: string;
name?: string;
nomenclature?: string; // Nomenclatura de la categoría
last_number?: number; // Último número usado
separator?: string; // Separador usado
income_account_id?: string; // ID de cuenta de ingresos
discount_account_id?: string; // ID de cuenta de descuentos
return_account_id?: string; // ID de cuenta de devoluciones
service_group_id?: string; // ID del grupo padre
enabled?: boolean;
deleted?: boolean;
created_user?: string;
created_at?: number;
updated_at?: number;
updated_user?: string;
}🚀 Ejemplos de Uso
Ejemplo Básico: Gestión de Grupos de Servicios
import { CbmServiceGroupService } from '@cbm-common/service-group-repository';
import { CbmServiceGroupModel } from '@cbm-common/service-group-repository';
@Component({
selector: 'app-service-group-management',
standalone: true,
template: `
<div class="service-group-management">
<h2>Gestión de Grupos de Servicios</h2>
<!-- Formulario de creación/edición -->
<div class="form-section">
<h3>{{ isEditing ? 'Editar' : 'Crear' }} Grupo de Servicios</h3>
<form (ngSubmit)="onSubmit()" #groupForm="ngForm">
<div class="form-group">
<label for="name">Nombre del Grupo:</label>
<input
id="name"
[(ngModel)]="groupFormData.name"
name="name"
required
placeholder="Ej: Servicios Técnicos">
</div>
<div class="form-group">
<label for="parentGroup">Grupo Padre:</label>
<select id="parentGroup" [(ngModel)]="groupFormData.service_group_id" name="parentGroup">
<option value="">Sin grupo padre</option>
<option *ngFor="let group of allGroups" [value]="group._id">
{{ group.name }} (Nivel {{ group.level }})
</option>
</select>
</div>
<div class="form-group">
<label for="level">Nivel Jerárquico:</label>
<input
id="level"
type="number"
[(ngModel)]="groupFormData.level"
name="level"
min="1"
max="10"
required>
</div>
<div class="form-group">
<label>
<input
type="checkbox"
[(ngModel)]="groupFormData.initial"
name="initial">
Grupo Inicial
</label>
</div>
<div class="form-actions">
<button type="submit" [disabled]="!groupForm.valid">
{{ isEditing ? 'Actualizar' : 'Crear' }} Grupo
</button>
<button type="button" (click)="cancelEdit()">Cancelar</button>
</div>
</form>
</div>
<!-- Tabla de grupos -->
<div class="table-section">
<h3>Grupos de Servicios</h3>
<!-- Filtros -->
<div class="filters">
<input
[(ngModel)]="filters.name"
placeholder="Buscar por nombre..."
(input)="applyFilters()">
<select [(ngModel)]="filters.enabled" (change)="applyFilters()">
<option value="">Todos los estados</option>
<option value="true">Habilitados</option>
<option value="false">Deshabilitados</option>
</select>
</div>
<!-- Tabla -->
<div class="table-container">
<table>
<thead>
<tr>
<th>Nombre</th>
<th>Nivel</th>
<th>Grupo Padre</th>
<th>Estado</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let group of filteredGroups">
<td>{{ group.name }}</td>
<td>{{ group.level }}</td>
<td>{{ getParentGroupName(group.service_group_id) }}</td>
<td>
<span class="status" [class.enabled]="group.enabled" [class.disabled]="!group.enabled">
{{ group.enabled ? 'Habilitado' : 'Deshabilitado' }}
</span>
</td>
<td>
<button (click)="editGroup(group)">Editar</button>
<button (click)="toggleStatus(group)" [disabled]="group.initial">
{{ group.enabled ? 'Deshabilitar' : 'Habilitar' }}
</button>
<button (click)="deleteGroup(group)" class="delete-btn" [disabled]="group.initial">
Eliminar
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
`,
styles: [`
.service-group-management { padding: 20px; }
.form-section, .table-section { margin-bottom: 30px; }
.form-group { margin-bottom: 15px; }
.form-group label { display: block; margin-bottom: 5px; font-weight: bold; }
.form-group input, .form-group select { width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; }
.form-actions { display: flex; gap: 10px; margin-top: 20px; }
.filters { display: flex; gap: 10px; margin-bottom: 20px; }
.table-container { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #ddd; }
th { background-color: #f8f9fa; font-weight: bold; }
.status.enabled { color: #28a745; font-weight: bold; }
.status.disabled { color: #dc3545; font-weight: bold; }
button { padding: 6px 12px; border: none; border-radius: 4px; cursor: pointer; }
button:hover { opacity: 0.8; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.delete-btn { background-color: #dc3545; color: white; }
`]
})
export class ServiceGroupManagementComponent implements OnInit {
allGroups: CbmServiceGroupModel.ListResponse.Data[] = [];
filteredGroups: CbmServiceGroupModel.ListResponse.Data[] = [];
isEditing = false;
currentGroupId = '';
filters: Partial<CbmServiceGroupModel.ListParams> = {
name: '',
enabled: undefined
};
groupFormData: Partial<CbmServiceGroupModel.SaveBody> = {
name: '',
service_group_id: '',
level: 1,
initial: false
};
constructor(private serviceGroupService: CbmServiceGroupService) {}
ngOnInit() {
this.loadGroups();
}
loadGroups() {
this.serviceGroupService.list({}).subscribe({
next: (response) => {
if (response.success) {
this.allGroups = response.data || [];
this.applyFilters();
}
},
error: (error) => {
console.error('Error al cargar grupos:', error);
}
});
}
applyFilters() {
let filtered = [...this.allGroups];
if (this.filters.name) {
filtered = filtered.filter(group =>
group.name?.toLowerCase().includes(this.filters.name!.toLowerCase())
);
}
if (this.filters.enabled !== undefined) {
filtered = filtered.filter(group => group.enabled === (this.filters.enabled === 'true'));
}
this.filteredGroups = filtered;
}
onSubmit() {
if (this.isEditing) {
this.updateGroup();
} else {
this.createGroup();
}
}
createGroup() {
this.serviceGroupService.save(this.groupFormData as CbmServiceGroupModel.SaveBody).subscribe({
next: (response) => {
if (response.success) {
this.resetForm();
this.loadGroups();
alert('Grupo creado exitosamente');
}
},
error: (error) => {
console.error('Error al crear grupo:', error);
}
});
}
updateGroup() {
this.serviceGroupService.update(this.currentGroupId, this.groupFormData as CbmServiceGroupModel.UpdateBody).subscribe({
next: (response) => {
if (response.success) {
this.resetForm();
this.loadGroups();
alert('Grupo actualizado exitosamente');
}
},
error: (error) => {
console.error('Error al actualizar grupo:', error);
}
});
}
editGroup(group: CbmServiceGroupModel.ListResponse.Data) {
this.isEditing = true;
this.currentGroupId = group._id;
this.groupFormData = {
name: group.name,
service_group_id: group.service_group_id,
level: group.level,
initial: group.initial
};
}
toggleStatus(group: CbmServiceGroupModel.ListResponse.Data) {
const newStatus = !group.enabled;
const reason = newStatus ? '' : prompt('Razón de deshabilitación:') || '';
this.serviceGroupService.changeStatus(group._id, {
enabled: newStatus,
disabled_reason: reason
}).subscribe({
next: (response) => {
if (response.success) {
this.loadGroups();
alert(`Grupo ${newStatus ? 'habilitado' : 'deshabilitado'} exitosamente`);
}
},
error: (error) => {
console.error('Error al cambiar estado:', error);
}
});
}
deleteGroup(group: CbmServiceGroupModel.ListResponse.Data) {
if (confirm(`¿Está seguro de eliminar el grupo "${group.name}"?`)) {
this.serviceGroupService.delete(group._id).subscribe({
next: (response) => {
if (response.success) {
this.loadGroups();
alert('Grupo eliminado exitosamente');
}
},
error: (error) => {
console.error('Error al eliminar grupo:', error);
}
});
}
}
cancelEdit() {
this.resetForm();
}
resetForm() {
this.isEditing = false;
this.currentGroupId = '';
this.groupFormData = {
name: '',
service_group_id: '',
level: 1,
initial: false
};
}
getParentGroupName(parentId?: string): string {
if (!parentId) return 'Sin padre';
const parent = this.allGroups.find(g => g._id === parentId);
return parent?.name || 'Desconocido';
}
}Ejemplo con Vista de Árbol Jerárquico
import { CbmServiceGroupService } from '@cbm-common/service-group-repository';
@Component({
selector: 'app-service-group-tree',
standalone: true,
template: `
<div class="service-group-tree">
<h2>Estructura Jerárquica de Grupos de Servicios</h2>
<!-- Filtros -->
<div class="filters">
<input
[(ngModel)]="filterName"
placeholder="Buscar grupos..."
(input)="loadTree()">
</div>
<!-- Vista de árbol -->
<div class="tree-container">
<div *ngFor="let node of treeData" class="tree-node">
<app-tree-node
[node]="node"
[level]="0"
(nodeSelected)="onNodeSelected($event)"
(nodeExpanded)="onNodeExpanded($event)">
</app-tree-node>
</div>
</div>
<!-- Panel de detalles -->
<div class="details-panel" *ngIf="selectedNode">
<h3>Detalles del {{ selectedNode.type }}</h3>
<div class="details-content">
<div class="detail-row">
<label>Nombre:</label>
<span>{{ selectedNode.data.name }}</span>
</div>
<div class="detail-row" *ngIf="selectedNode.type === 'group'">
<label>Nivel:</label>
<span>{{ selectedNode.data.level }}</span>
</div>
<div class="detail-row" *ngIf="selectedNode.type === 'category'">
<label>Nomenclatura:</label>
<span>{{ selectedNode.data.nomenclature }}</span>
</div>
<div class="detail-row">
<label>Estado:</label>
<span class="status" [class.enabled]="selectedNode.data.enabled">
{{ selectedNode.data.enabled ? 'Habilitado' : 'Deshabilitado' }}
</span>
</div>
<div class="detail-row" *ngIf="selectedNode.type === 'category'">
<label>Cuenta de Ingresos:</label>
<span>{{ selectedNode.data.income_account_id || 'No definida' }}</span>
</div>
</div>
</div>
</div>
`,
styles: [`
.service-group-tree { padding: 20px; }
.filters { margin-bottom: 20px; }
.filters input { width: 300px; padding: 8px; border: 1px solid #ddd; border-radius: 4px; }
.tree-container { border: 1px solid #ddd; border-radius: 8px; padding: 15px; background: #f8f9fa; }
.tree-node { margin-bottom: 5px; }
.details-panel { margin-top: 20px; padding: 15px; border: 1px solid #ddd; border-radius: 8px; }
.details-content { margin-top: 15px; }
.detail-row { display: flex; margin-bottom: 10px; }
.detail-row label { font-weight: bold; min-width: 140px; margin-right: 10px; }
.status.enabled { color: #28a745; }
.status:not(.enabled) { color: #dc3545; }
`]
})
export class ServiceGroupTreeComponent implements OnInit {
treeData: any[] = [];
filterName = '';
selectedNode: any = null;
constructor(private serviceGroupService: CbmServiceGroupService) {}
ngOnInit() {
this.loadTree();
}
loadTree() {
const params: CbmServiceGroupModel.ListAsTreeParams = {};
if (this.filterName) {
params.name = this.filterName;
}
this.serviceGroupService.listAsTree(params).subscribe({
next: (response) => {
if (response.success) {
this.treeData = this.transformToTreeNodes(response.data);
}
},
error: (error) => {
console.error('Error al cargar árbol:', error);
}
});
}
transformToTreeNodes(children: any[]): any[] {
return children.map(child => ({
label: child.label,
type: this.isGroup(child.value) ? 'group' : 'category',
data: child.value,
children: child.children ? this.transformToTreeNodes(child.children) : [],
expanded: false
}));
}
isGroup(value: any): boolean {
return value && typeof value.level === 'number';
}
onNodeSelected(node: any) {
this.selectedNode = node;
}
onNodeExpanded(node: any) {
node.expanded = !node.expanded;
}
}
// Componente auxiliar para nodos del árbol
@Component({
selector: 'app-tree-node',
standalone: true,
template: `
<div class="tree-node-item" [style.padding-left.px]="level * 20">
<div class="node-content" (click)="toggleExpanded()">
<span class="expand-icon" *ngIf="node.children && node.children.length > 0">
{{ node.expanded ? '▼' : '▶' }}
</span>
<span class="node-icon">{{ getNodeIcon() }}</span>
<span class="node-label" (click)="selectNode(); $event.stopPropagation()">
{{ node.label }}
</span>
<span class="node-type">{{ node.type === 'group' ? 'G' : 'C' }}</span>
</div>
<div *ngIf="node.expanded && node.children" class="node-children">
<app-tree-node
*ngFor="let child of node.children"
[node]="child"
[level]="level + 1"
(nodeSelected)="onChildSelected($event)"
(nodeExpanded)="onChildExpanded($event)">
</app-tree-node>
</div>
</div>
`,
styles: [`
.tree-node-item { cursor: pointer; }
.node-content { display: flex; align-items: center; padding: 5px; border-radius: 4px; }
.node-content:hover { background: rgba(0,0,0,0.05); }
.expand-icon { margin-right: 5px; color: #666; }
.node-icon { margin-right: 8px; font-size: 16px; }
.node-label { flex: 1; }
.node-type { font-size: 10px; color: #666; padding: 2px 6px; border-radius: 10px; background: #e9ecef; margin-left: 5px; }
.node-children { margin-left: 10px; }
`]
})
export class TreeNodeComponent {
@Input() node: any;
@Input() level = 0;
@Output() nodeSelected = new EventEmitter<any>();
@Output() nodeExpanded = new EventEmitter<any>();
toggleExpanded() {
this.nodeExpanded.emit(this.node);
}
selectNode() {
this.nodeSelected.emit(this.node);
}
onChildSelected(childNode: any) {
this.nodeSelected.emit(childNode);
}
onChildExpanded(childNode: any) {
this.nodeExpanded.emit(childNode);
}
getNodeIcon(): string {
return this.node.type === 'group' ? '📁' : '📄';
}
}Ejemplo de Selector de Grupos con Búsqueda
import { CbmServiceGroupService } from '@cbm-common/service-group-repository';
@Component({
selector: 'app-service-group-selector',
standalone: true,
template: `
<div class="service-group-selector">
<h3>Seleccionar Grupo de Servicios</h3>
<div class="selector-container">
<div class="search-section">
<input
[formControl]="searchControl"
placeholder="Buscar por nombre..."
(input)="onSearchInput($event)">
<button (click)="clearSearch()" *ngIf="searchControl.value">✕</button>
</div>
<div class="groups-list" *ngIf="filteredGroups.length > 0">
<div
*ngFor="let group of filteredGroups"
class="group-option"
[class.selected]="selectedGroup?._id === group._id"
(click)="selectGroup(group)">
<div class="group-info">
<div class="group-name">{{ group.name }}</div>
<div class="group-details">
Nivel {{ group.level }}
<span *ngIf="group.service_group_id" class="parent-indicator">
(Subgrupo)
</span>
</div>
</div>
<div class="group-status" [class.enabled]="group.enabled" [class.disabled]="!group.enabled">
{{ group.enabled ? '●' : '○' }}
</div>
</div>
</div>
<div class="no-results" *ngIf="searchControl.value && filteredGroups.length === 0">
<p>No se encontraron grupos que coincidan con "{{ searchControl.value }}"</p>
</div>
<div class="loading" *ngIf="loading">
<p>Cargando grupos de servicios...</p>
</div>
</div>
<div class="selected-group" *ngIf="selectedGroup">
<h4>Grupo Seleccionado:</h4>
<div class="selected-info">
<strong>{{ selectedGroup.name }}</strong>
<div class="group-meta">
Nivel: {{ selectedGroup.level }} |
Estado: {{ selectedGroup.enabled ? 'Habilitado' : 'Deshabilitado' }}
</div>
</div>
</div>
</div>
`,
styles: [`
.service-group-selector { max-width: 400px; }
.selector-container { border: 1px solid #ddd; border-radius: 8px; overflow: hidden; }
.search-section { padding: 15px; background: #f8f9fa; display: flex; align-items: center; }
.search-section input { flex: 1; padding: 8px; border: 1px solid #ddd; border-radius: 4px; }
.search-section button { margin-left: 10px; padding: 8px; border: none; border-radius: 4px; cursor: pointer; }
.groups-list { max-height: 300px; overflow-y: auto; }
.group-option { display: flex; justify-content: space-between; align-items: center; padding: 12px 15px; cursor: pointer; border-bottom: 1px solid #eee; }
.group-option:hover { background: #f8f9fa; }
.group-option.selected { background: #e3f2fd; }
.group-info { flex: 1; }
.group-name { font-weight: bold; color: #1976d2; }
.group-details { font-size: 12px; color: #666; margin-top: 2px; }
.parent-indicator { color: #ff9800; }
.group-status { font-size: 18px; }
.group-status.enabled { color: #28a745; }
.group-status.disabled { color: #dc3545; }
.no-results { padding: 20px; text-align: center; color: #666; }
.loading { padding: 20px; text-align: center; color: #666; }
.selected-group { margin-top: 15px; padding: 10px; background: #e8f5e8; border-radius: 4px; }
.selected-info { margin-top: 5px; }
.group-meta { font-size: 12px; color: #666; margin-top: 2px; }
`]
})
export class ServiceGroupSelectorComponent implements OnInit {
searchControl = new FormControl('');
allGroups: CbmServiceGroupModel.ListResponse.Data[] = [];
filteredGroups: CbmServiceGroupModel.ListResponse.Data[] = [];
selectedGroup: CbmServiceGroupModel.ListResponse.Data | null = null;
loading = false;
constructor(private serviceGroupService: CbmServiceGroupService) {}
ngOnInit() {
this.loadAllGroups();
}
loadAllGroups() {
this.loading = true;
this.serviceGroupService.list({}).subscribe({
next: (response) => {
if (response.success) {
this.allGroups = response.data || [];
this.filteredGroups = [...this.allGroups];
}
this.loading = false;
},
error: (error) => {
console.error('Error al cargar grupos:', error);
this.loading = false;
}
});
}
onSearchInput(event: any) {
const searchTerm = event.target.value.toLowerCase();
this.filterGroups(searchTerm);
}
filterGroups(searchTerm: string) {
if (!searchTerm) {
this.filteredGroups = [...this.allGroups];
} else {
this.filteredGroups = this.allGroups.filter(group =>
group.name?.toLowerCase().includes(searchTerm)
);
}
}
selectGroup(group: CbmServiceGroupModel.ListResponse.Data) {
this.selectedGroup = group;
// Emitir evento de selección
console.log('Grupo de servicios seleccionado:', group);
}
clearSearch() {
this.searchControl.setValue('');
this.filteredGroups = [...this.allGroups];
}
}⚠️ Manejo de Errores
Errores de Validación de Grupos
// Servicio de validación para grupos de servicios
@Injectable({ providedIn: 'root' })
export class ServiceGroupValidator {
validateServiceGroup(group: Partial<CbmServiceGroupModel.SaveBody>): ValidationResult {
const result: ValidationResult = { isValid: true, errors: [] };
// Validar nombre
if (!group.name || group.name.trim().length < 2) {
result.errors.push('El nombre del grupo debe tener al menos 2 caracteres');
}
// Validar nivel jerárquico
if (!group.level || group.level < 1 || group.level > 10) {
result.errors.push('El nivel jerárquico debe estar entre 1 y 10');
}
// Validar que no se pueda crear grupo inicial con padre
if (group.initial && group.service_group_id) {
result.errors.push('Un grupo inicial no puede tener grupo padre');
}
result.isValid = result.errors.length === 0;
return result;
}
validateGroupHierarchy(parentGroup: CbmServiceGroupModel.ListResponse.Data, childGroup: Partial<CbmServiceGroupModel.SaveBody>): ValidationResult {
const result: ValidationResult = { isValid: true, errors: [] };
// Validar que el nivel del hijo sea mayor que el del padre
if (childGroup.level && parentGroup.level && childGroup.level <= parentGroup.level) {
result.errors.push('El nivel del grupo hijo debe ser mayor que el del grupo padre');
}
// Validar profundidad máxima de jerarquía
if (childGroup.level && childGroup.level > 5) {
result.errors.push('La profundidad máxima de la jerarquía es de 5 niveles');
}
result.isValid = result.errors.length === 0;
return result;
}
}
// Uso en componentes
export class ServiceGroupFormComponent {
constructor(private validator: ServiceGroupValidator) {}
validateGroup() {
const validation = this.validator.validateServiceGroup(this.groupForm.value);
if (!validation.isValid) {
this.showValidationErrors(validation.errors);
}
}
}Manejo de Errores en Operaciones CRUD
// Manejo de errores específico para operaciones de grupos de servicios
loadGroupsWithErrorHandling(params: CbmServiceGroupModel.ListParams) {
this.serviceGroupService.list(params).subscribe({
next: (response) => {
if (response.success) {
this.groups = response.data;
this.showSuccessMessage(`Se encontraron ${response.data.length} grupos`);
} else {
this.showErrorMessage('Error en la respuesta del servidor');
}
},
error: (error) => {
console.error('Error HTTP:', error);
if (error.status === 400) {
this.showErrorMessage('Parámetros de búsqueda inválidos. Verifique los filtros.');
} else if (error.status === 401) {
this.showErrorMessage('Sesión expirada. Inicie sesión nuevamente.');
} else if (error.status === 403) {
this.showErrorMessage('No tiene permisos para consultar grupos de servicios.');
} else if (error.status === 404) {
this.showErrorMessage('No se encontraron grupos de servicios.');
} else if (error.status === 500) {
this.showErrorMessage('Error interno del servidor. Intente nuevamente.');
} else {
this.showErrorMessage('Error de conexión. Verifique su conexión a internet.');
}
}
});
}🔧 Configuración Avanzada
Configuración de Headers Personalizados
// Configuración de headers para consultas de grupos de servicios
import { HTTP_INTERCEPTORS } from '@angular/common/http';
@NgModule({
providers: [
CbmServiceGroupModule.forRoot({
baseUrl: 'https://api.cbm.com/service-groups'
}),
{
provide: HTTP_INTERCEPTORS,
useClass: ServiceGroupAuthInterceptor,
multi: true
}
]
})
export class AppModule {}Sistema de Reintentos para Consultas
// Reintentos automáticos para consultas de grupos de servicios
loadGroupsWithRetry(params: CbmServiceGroupModel.ListParams, maxRetries = 3) {
return this.serviceGroupService.list(params).pipe(
retry({
count: maxRetries,
delay: (error, retryCount) => {
console.log(`Reintento ${retryCount} para consulta de grupos de servicios`);
return timer(retryCount * 1000);
}
}),
catchError(error => {
console.error(`Falló después de ${maxRetries} intentos:`, error);
return throwError(() => error);
})
);
}Caché Inteligente para Estructura Jerárquica
// Servicio de caché para estructura jerárquica de grupos
@Injectable({ providedIn: 'root' })
export class ServiceGroupCacheService {
private cache = new Map<string, { data: any; timestamp: number }>();
private readonly CACHE_DURATION = 30 * 60 * 1000; // 30 minutos
get(key: string): any | null {
const cached = this.cache.get(key);
if (cached && Date.now() - cached.timestamp < this.CACHE_DURATION) {
return cached.data;
}
this.cache.delete(key);
return null;
}
set(key: string, data: any) {
this.cache.set(key, { data, timestamp: Date.now() });
}
invalidateGroupHierarchy() {
// Invalidar caché cuando se modifica la jerarquía
this.cache.clear();
}
getGroupHierarchy(): Observable<CbmServiceGroupModel.ListAsTreeResponse> {
const cacheKey = 'group_hierarchy';
const cached = this.get(cacheKey);
if (cached) {
return of(cached);
}
return this.serviceGroupService.listAsTree({}).pipe(
tap(response => {
if (response.success) {
this.set(cacheKey, response);
}
})
);
}
constructor(private serviceGroupService: CbmServiceGroupService) {}
}📋 Dependencias
Peer Dependencies (Requeridas)
{
"@angular/common": ">=20.1.5",
"@angular/core": ">=20.1.5"
}Dependencias Internas
{
"tslib": "^2.3.0"
}🛠️ Desarrollo
Estructura del Proyecto
service-group-repository/
├── src/
│ ├── lib/
│ │ ├── service-group.model.ts # Modelos de datos para grupos de servicios
│ │ ├── service-group.module.ts # Configuración del módulo
│ │ ├── service-group.service.ts # Servicio HTTP para grupos de servicios
│ │ ├── service-group.repository.ts # Interfaz del repositorio
│ │ └── index.ts # Exportaciones públicas
│ └── public-api.ts # API pública
├── ng-package.json # Configuración empaquetado
├── package.json # Dependencias
└── README.md # Esta documentaciónConstrucción
# Construir la librería
ng build service-group-repository
# Construir en modo watch
ng build service-group-repository --watch
# Construir para producción
ng build service-group-repository --configuration productionPruebas
# Ejecutar pruebas unitarias
ng test service-group-repository
# Ejecutar pruebas con coverage
ng test service-group-repository --code-coverage
# Pruebas end-to-end
ng e2e service-group-repository🎯 Mejores Prácticas
1. Gestión de Jerarquía de Grupos
// Servicio para gestión avanzada de jerarquía de grupos
@Injectable({ providedIn: 'root' })
export class ServiceGroupHierarchyService {
constructor(private serviceGroupService: CbmServiceGroupService) {}
// Obtener ruta completa de un grupo (padres -> grupo actual)
getGroupPath(groupId: string): Observable<CbmServiceGroupModel.ListResponse.Data[]> {
return this.serviceGroupService.list({}).pipe(
map(response => {
if (!response.success || !response.data) return [];
const groups = response.data;
const path: CbmServiceGroupModel.ListResponse.Data[] = [];
let currentGroup = groups.find(g => g._id === groupId);
while (currentGroup) {
path.unshift(currentGroup);
currentGroup = currentGroup.service_group_id
? groups.find(g => g._id === currentGroup!.service_group_id)
: null;
}
return path;
})
);
}
// Validar movimiento de grupo en jerarquía
validateGroupMove(groupId: string, newParentId?: string): Observable<ValidationResult> {
return forkJoin([
this.getGroupPath(groupId),
newParentId ? this.getGroupPath(newParentId) : of([])
]).pipe(
map(([groupPath, parentPath]) => {
const result: ValidationResult = { isValid: true, errors: [] };
// Evitar ciclos en la jerarquía
if (newParentId && groupPath.some(g => g._id === newParentId)) {
result.errors.push('No se puede mover el grupo a uno de sus descendientes');
}
// Validar profundidad máxima
const newLevel = parentPath.length + 1;
if (newLevel > 5) {
result.errors.push('La profundidad máxima de la jerarquía es de 5 niveles');
}
result.isValid = result.errors.length === 0;
return result;
})
);
}
// Obtener todos los descendientes de un grupo
getGroupDescendants(groupId: string): Observable<CbmServiceGroupModel.ListResponse.Data[]> {
return this.serviceGroupService.list({}).pipe(
map(response => {
if (!response.success || !response.data) return [];
const groups = response.data;
const descendants: CbmServiceGroupModel.ListResponse.Data[] = [];
const queue = [groupId];
while (queue.length > 0) {
const currentId = queue.shift()!;
const children = groups.filter(g => g.service_group_id === currentId);
descendants.push(...children);
queue.push(...children.map(c => c._id));
}
return descendants;
})
);
}
}2. Optimización de Consultas con Paginación Virtual
// Servicio de paginación virtual para grandes listas de grupos
@Injectable({ providedIn: 'root' })
export class ServiceGroupVirtualScrollService {
private pageSize = 50;
private loadedPages = new Set<number>();
private cache = new Map<number, CbmServiceGroupModel.ListResponse.Data[]>();
constructor(private serviceGroupService: CbmServiceGroupService) {}
// Cargar página específica bajo demanda
loadPage(page: number, filters: Partial<CbmServiceGroupModel.ListParams> = {}): Observable<CbmServiceGroupModel.ListResponse.Data[]> {
if (this.loadedPages.has(page)) {
return of(this.cache.get(page) || []);
}
return this.serviceGroupService.list({
...filters,
// Aquí irían parámetros de paginación si el backend los soporta
}).pipe(
map(response => {
if (response.success && response.data) {
this.loadedPages.add(page);
this.cache.set(page, response.data);
return response.data;
}
return [];
})
);
}
// Obtener elementos visibles en viewport
getVisibleItems(scrollTop: number, itemHeight: number, containerHeight: number): Observable<CbmServiceGroupModel.ListResponse.Data[]> {
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.floor((scrollTop + containerHeight) / itemHeight);
const startPage = Math.floor(startIndex / this.pageSize);
const endPage = Math.floor(endIndex / this.pageSize);
const pagesToLoad = [];
for (let page = startPage; page <= endPage; page++) {
if (!this.loadedPages.has(page)) {
pagesToLoad.push(page);
}
}
if (pagesToLoad.length === 0) {
// Todas las páginas necesarias ya están cargadas
return of(this.getItemsFromPages(startPage, endPage));
}
// Cargar páginas faltantes
return forkJoin(
pagesToLoad.map(page => this.loadPage(page))
).pipe(
map(() => this.getItemsFromPages(startPage, endPage))
);
}
private getItemsFromPages(startPage: number, endPage: number): CbmServiceGroupModel.ListResponse.Data[] {
const items: CbmServiceGroupModel.ListResponse.Data[] = [];
for (let page = startPage; page <= endPage; page++) {
const pageItems = this.cache.get(page) || [];
items.push(...pageItems);
}
return items;
}
clearCache() {
this.loadedPages.clear();
this.cache.clear();
}
}3. Servicio de Auditoría para Operaciones de Grupos
// Servicio de auditoría para operaciones de grupos de servicios
@Injectable({ providedIn: 'root' })
export class ServiceGroupAuditService {
logGroupOperation(
operation: string,
groupData: Partial<CbmServiceGroupModel.ListResponse.Data>,
userId: string,
additionalData?: any
) {
const auditEntry = {
timestamp: new Date().toISOString(),
operation: `SERVICE_GROUP_${operation.toUpperCase()}`,
groupId: groupData._id,
groupName: groupData.name,
groupLevel: groupData.level,
userId,
companyId: groupData.company_id,
additionalData,
severity: this.getOperationSeverity(operation)
};
console.log(`[SERVICE_GROUP_AUDIT] ${JSON.stringify(auditEntry)}`);
// Enviar a servicio de auditoría
this.auditService.record('service_group_operation', auditEntry);
}
logHierarchyChange(
groupId: string,
oldParentId?: string,
newParentId?: string,
userId: string
) {
const auditEntry = {
timestamp: new Date().toISOString(),
operation: 'SERVICE_GROUP_HIERARCHY_CHANGE',
groupId,
oldParentId,
newParentId,
userId,
severity: 'WARNING'
};
console.log(`[HIERARCHY_AUDIT] ${JSON.stringify(auditEntry)}`);
// Verificar impacto en jerarquía
this.checkHierarchyImpact(groupId, newParentId);
}
private getOperationSeverity(operation: string): string {
const severityMap: { [key: string]: string } = {
'create': 'INFO',
'update': 'INFO',
'delete': 'WARNING',
'status_change': 'WARNING',
'hierarchy_change': 'WARNING'
};
return severityMap[operation] || 'INFO';
}
private async checkHierarchyImpact(groupId: string, newParentId?: string) {
// Verificar si el cambio afecta otros grupos
const descendants = await this.hierarchyService.getGroupDescendants(groupId).toPromise();
if (descendants.length > 10) {
// Alertar sobre impacto significativo
this.alertService.notifyHierarchyImpact({
groupId,
affectedDescendants: descendants.length,
newParentId
});
}
}
constructor(
private auditService: AuditService,
private hierarchyService: ServiceGroupHierarchyService,
private alertService: AlertService
) {}
}🤝 Contribución
- Fork el repositorio
- Crea una rama para tu feature (
git checkout -b feature/nueva-funcionalidad) - Commit tus cambios (
git commit -am 'Agrega nueva funcionalidad') - Push a la rama (
git push origin feature/nueva-funcionalidad) - Abre un PullRequest
📄 Licencia
Este proyecto está bajo la Licencia MIT - ver el archivo LICENSE para más detalles.
📞 Soporte
Para soporte técnico o reportes de bugs, contacta al equipo de desarrollo de CBM:
- 📧 Email: [email protected]
- 💬 Slack: #desarrollo-cbm
- 📋 Issues: GitHub Issues
🔄 Changelog
v0.0.1
- ✅ Módulo de configuración con forRoot pattern simplificado
- ✅ Servicio que implementa interfaz del repositorio directamente
- ✅ Operaciones CRUD completas para grupos de servicios
- ✅ Soporte para estructura jerárquica multinivel
- ✅ Modelo de datos completo con grupos y categorías
- ✅ Integración completa con HttpClient
- ✅ Documentación completa en español
Nota: Esta librería está optimizada para sistemas de gestión de servicios que requieren organización jerárquica de grupos y categorías, con soporte completo para operaciones CRUD y validaciones de integridad de la estructura jerárquica.
Running end-to-end tests
For end-to-end (e2e) testing, run:
ng e2eAngular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the Angular CLI Overview and Command Reference page.
