npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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ón

Construcció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 production

Pruebas

# 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

  1. Fork el repositorio
  2. Crea una rama para tu feature (git checkout -b feature/nueva-funcionalidad)
  3. Commit tus cambios (git commit -am 'Agrega nueva funcionalidad')
  4. Push a la rama (git push origin feature/nueva-funcionalidad)
  5. 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:

🔄 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 e2e

Angular 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.