Versión: 1.0
Fecha: 8 de Marzo, 2026
Arquitecto: Carlos Alberto Torres Camargo
Audiencia: Desarrolladores Frontend (Senior y Junior)
npm install)shared/ui con componentes base (DataTable, FormField, Modal)shared/data-access con AuthService e interceptores configuradoscd nebula-erp # Raíz del workspace Nx
git checkout develop
git pull origin develop
git checkout -b feature/NEB-16-frontend-centro-costos
Si el dominio accounting ya tiene sus librerías (domain, data-access, api, shell), saltar al paso 3. Si no existen, crearlas:
# Solo si NO existen aún
nx g @nx/angular:library domain \
--directory=libs/accounting/domain \
--tags="scope:accounting,type:domain" \
--standalone --skipModule
nx g @nx/angular:library data-access \
--directory=libs/accounting/data-access \
--tags="scope:accounting,type:data-access" \
--standalone --skipModule
nx g @nx/angular:library api \
--directory=libs/accounting/api \
--tags="scope:accounting,type:api" \
--standalone --skipModule
nx g @nx/angular:library shell \
--directory=libs/accounting/shell \
--tags="scope:accounting,type:shell" \
--standalone --skipModule --routing
Archivo: libs/accounting/domain/src/lib/centro-costos.model.ts
/**
* Modelo que refleja CentroCostosDto del backend.
* REGLA: No crear este modelo hasta que el DTO esté mergeado en nebula-models.
*/
export interface CentroCostos {
id: number;
codigo: string;
nombre: string;
descripcion?: string;
tipoCentroCostosId?: number;
tipoCentroCostosNombre?: string;
activo: boolean;
// Audit fields (de BusinessBaseDto)
createdAt?: string;
updatedAt?: string;
createdBy?: string;
updatedBy?: string;
}
export interface CentroCostosCreate {
codigo: string;
nombre: string;
descripcion?: string;
tipoCentroCostosId?: number;
}
export interface CentroCostosUpdate extends CentroCostosCreate {
id: number;
}
Exportar desde index.ts:
// libs/accounting/domain/src/index.ts
export * from './lib/centro-costos.model';
Archivo: libs/accounting/data-access/src/lib/centro-costos.service.ts
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import {
CentroCostos,
CentroCostosCreate,
CentroCostosUpdate
} from '@nebula/accounting/domain';
@Injectable({ providedIn: 'root' })
export class CentroCostosService {
private http = inject(HttpClient);
private baseUrl = '/api/v1/accounting/centro-costos';
// ── State (Signals) ──
private _items = signal<CentroCostos[]>([]);
private _selected = signal<CentroCostos | null>(null);
private _loading = signal(false);
private _error = signal<string | null>(null);
// ── Public Readonly ──
readonly items = this._items.asReadonly();
readonly selected = this._selected.asReadonly();
readonly loading = this._loading.asReadonly();
readonly error = this._error.asReadonly();
// ── Computed ──
readonly activos = computed(() =>
this._items().filter(item => item.activo)
);
readonly count = computed(() => this._items().length);
// ── Operations ──
loadAll(): void {
this._loading.set(true);
this._error.set(null);
this.http.get<CentroCostos[]>(`${this.baseUrl}/read`).subscribe({
next: (data) => {
this._items.set(data);
this._loading.set(false);
},
error: (err) => {
this._error.set(err.error?.message || 'Error al cargar centros de costos');
this._loading.set(false);
}
});
}
getById(id: number): void {
this._loading.set(true);
this.http.get<CentroCostos>(`${this.baseUrl}/get/${id}`).subscribe({
next: (data) => {
this._selected.set(data);
this._loading.set(false);
},
error: (err) => {
this._error.set(err.error?.message || 'Error al obtener centro de costos');
this._loading.set(false);
}
});
}
create(dto: CentroCostosCreate): void {
this._loading.set(true);
this.http.post<CentroCostos>(`${this.baseUrl}/create`, dto).subscribe({
next: (created) => {
this._items.update(list => [...list, created]);
this._loading.set(false);
},
error: (err) => {
this._error.set(err.error?.message || 'Error al crear centro de costos');
this._loading.set(false);
}
});
}
update(dto: CentroCostosUpdate): void {
this._loading.set(true);
this.http.put<CentroCostos>(`${this.baseUrl}/update`, dto).subscribe({
next: (updated) => {
this._items.update(list =>
list.map(item => item.id === updated.id ? updated : item)
);
this._selected.set(updated);
this._loading.set(false);
},
error: (err) => {
this._error.set(err.error?.message || 'Error al actualizar centro de costos');
this._loading.set(false);
}
});
}
delete(id: number): void {
this._loading.set(true);
this.http.delete<void>(`${this.baseUrl}/delete/${id}`).subscribe({
next: () => {
this._items.update(list =>
list.map(item => item.id === id ? { ...item, activo: false } : item)
);
this._loading.set(false);
},
error: (err) => {
this._error.set(err.error?.message || 'Error al eliminar centro de costos');
this._loading.set(false);
}
});
}
clearError(): void {
this._error.set(null);
}
clearSelected(): void {
this._selected.set(null);
}
}
nx g @nx/angular:library feature-centros-costo \
--directory=libs/accounting/feature-centros-costo \
--tags="scope:accounting,type:feature" \
--standalone --skipModule --routing --lazy
Archivo: libs/accounting/feature-centros-costo/src/lib/centro-costos-list/centro-costos-list.component.ts
import { Component, inject, OnInit, ChangeDetectionStrategy } from '@angular/core';
import { Router } from '@angular/router';
import { CentroCostosService } from '@nebula/accounting/data-access';
import { CentroCostos } from '@nebula/accounting/domain';
import { DataTableComponent, Column } from '@nebula/shared/ui';
import { ConfirmDialogComponent } from '@nebula/shared/ui';
@Component({
standalone: true,
imports: [DataTableComponent, ConfirmDialogComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="page-header">
<h2>Centros de Costos</h2>
<button class="btn-primary" (click)="onCreate()">
Nuevo Centro de Costos
</button>
</div>
@if (service.error()) {
<div class="alert alert-error">
{{ service.error() }}
<button (click)="service.clearError()">Cerrar</button>
</div>
}
<app-data-table
[data]="service.activos()"
[columns]="columns"
[loading]="service.loading()"
[paginator]="true"
[rows]="10"
(rowClick)="onView($event)"
(edit)="onEdit($event)"
(delete)="onDelete($event)"
/>
`
})
export class CentroCostosListComponent implements OnInit {
protected service = inject(CentroCostosService);
private router = inject(Router);
columns: Column[] = [
{ field: 'codigo', header: 'Código', sortable: true },
{ field: 'nombre', header: 'Nombre', sortable: true },
{ field: 'tipoCentroCostosNombre', header: 'Tipo' },
{ field: 'activo', header: 'Estado', type: 'boolean' }
];
ngOnInit(): void {
this.service.loadAll();
}
onCreate(): void {
this.router.navigate(['/contabilidad/centros-costo/nuevo']);
}
onView(item: CentroCostos): void {
this.router.navigate(['/contabilidad/centros-costo/ver', item.id]);
}
onEdit(item: CentroCostos): void {
this.router.navigate(['/contabilidad/centros-costo/editar', item.id]);
}
onDelete(item: CentroCostos): void {
if (confirm(`¿Está seguro de eliminar el centro de costos "${item.nombre}"?`)) {
this.service.delete(item.id);
}
}
}
Archivo: libs/accounting/feature-centros-costo/src/lib/centro-costos-new/centro-costos-new.component.ts
import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
import { Router } from '@angular/router';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { CentroCostosService } from '@nebula/accounting/data-access';
import { FormFieldComponent } from '@nebula/shared/ui';
@Component({
standalone: true,
imports: [ReactiveFormsModule, FormFieldComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="page-header">
<h2>Nuevo Centro de Costos</h2>
</div>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<app-form-field label="Código" [control]="form.controls.codigo"
errorMessage="Código obligatorio (máx. 20 caracteres)" />
<app-form-field label="Nombre" [control]="form.controls.nombre"
errorMessage="Nombre obligatorio (máx. 200 caracteres)" />
<app-form-field label="Descripción" [control]="form.controls.descripcion"
type="textarea" />
<div class="form-actions">
<button type="button" class="btn-secondary" (click)="onCancel()">
Cancelar
</button>
<button type="submit" class="btn-primary"
[disabled]="form.invalid || service.loading()">
@if (service.loading()) {
Guardando...
} @else {
Guardar
}
</button>
</div>
</form>
`
})
export class CentroCostosNewComponent {
protected service = inject(CentroCostosService);
private router = inject(Router);
private fb = inject(FormBuilder);
form: FormGroup = this.fb.group({
codigo: ['', [Validators.required, Validators.maxLength(20)]],
nombre: ['', [Validators.required, Validators.maxLength(200)]],
descripcion: [''],
tipoCentroCostosId: [null]
});
onSubmit(): void {
if (this.form.valid) {
this.service.create(this.form.value);
this.router.navigate(['/contabilidad/centros-costo']);
}
}
onCancel(): void {
this.router.navigate(['/contabilidad/centros-costo']);
}
}
Archivo: .../centro-costos-edit/centro-costos-edit.component.ts
import { Component, inject, OnInit, ChangeDetectionStrategy } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { CentroCostosService } from '@nebula/accounting/data-access';
import { FormFieldComponent } from '@nebula/shared/ui';
@Component({
standalone: true,
imports: [ReactiveFormsModule, FormFieldComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="page-header">
<h2>Editar Centro de Costos</h2>
</div>
@if (service.selected(); as item) {
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<app-form-field label="Código" [control]="form.controls.codigo"
errorMessage="Código obligatorio" />
<app-form-field label="Nombre" [control]="form.controls.nombre"
errorMessage="Nombre obligatorio" />
<app-form-field label="Descripción" [control]="form.controls.descripcion"
type="textarea" />
<div class="form-actions">
<button type="button" class="btn-secondary" (click)="onCancel()">
Cancelar
</button>
<button type="submit" class="btn-primary"
[disabled]="form.invalid || service.loading()">
Actualizar
</button>
</div>
</form>
} @else {
<p>Cargando...</p>
}
`
})
export class CentroCostosEditComponent implements OnInit {
protected service = inject(CentroCostosService);
private router = inject(Router);
private route = inject(ActivatedRoute);
private fb = inject(FormBuilder);
form: FormGroup = this.fb.group({
id: [null],
codigo: ['', [Validators.required, Validators.maxLength(20)]],
nombre: ['', [Validators.required, Validators.maxLength(200)]],
descripcion: [''],
tipoCentroCostosId: [null]
});
ngOnInit(): void {
const id = Number(this.route.snapshot.paramMap.get('id'));
this.service.getById(id);
// Cuando el selected se cargue, popular el form
// Usar effect() para reaccionar
const checkInterval = setInterval(() => {
const item = this.service.selected();
if (item) {
this.form.patchValue(item);
clearInterval(checkInterval);
}
}, 100);
}
onSubmit(): void {
if (this.form.valid) {
this.service.update(this.form.value);
this.router.navigate(['/contabilidad/centros-costo']);
}
}
onCancel(): void {
this.router.navigate(['/contabilidad/centros-costo']);
}
}
Archivo: .../centro-costos-view/centro-costos-view.component.ts
import { Component, inject, OnInit, ChangeDetectionStrategy } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';
import { CentroCostosService } from '@nebula/accounting/data-access';
@Component({
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="page-header">
<h2>Detalle Centro de Costos</h2>
<div>
<button class="btn-secondary" (click)="onBack()">Volver</button>
<button class="btn-primary" (click)="onEdit()">Editar</button>
</div>
</div>
@if (service.selected(); as item) {
<div class="detail-card">
<div class="detail-row">
<span class="label">Código:</span>
<span class="value">{{ item.codigo }}</span>
</div>
<div class="detail-row">
<span class="label">Nombre:</span>
<span class="value">{{ item.nombre }}</span>
</div>
<div class="detail-row">
<span class="label">Descripción:</span>
<span class="value">{{ item.descripcion || '—' }}</span>
</div>
<div class="detail-row">
<span class="label">Tipo:</span>
<span class="value">{{ item.tipoCentroCostosNombre || '—' }}</span>
</div>
<div class="detail-row">
<span class="label">Estado:</span>
<span class="value" [class.active]="item.activo">
{{ item.activo ? 'Activo' : 'Inactivo' }}
</span>
</div>
</div>
} @else {
<p>Cargando...</p>
}
`
})
export class CentroCostosViewComponent implements OnInit {
protected service = inject(CentroCostosService);
private router = inject(Router);
private route = inject(ActivatedRoute);
private id = 0;
ngOnInit(): void {
this.id = Number(this.route.snapshot.paramMap.get('id'));
this.service.getById(this.id);
}
onBack(): void {
this.router.navigate(['/contabilidad/centros-costo']);
}
onEdit(): void {
this.router.navigate(['/contabilidad/centros-costo/editar', this.id]);
}
}
Archivo: libs/accounting/feature-centros-costo/src/lib/centros-costo.routes.ts
import { Routes } from '@angular/router';
export const CENTROS_COSTO_ROUTES: Routes = [
{
path: '',
loadComponent: () =>
import('./centro-costos-list/centro-costos-list.component')
.then(c => c.CentroCostosListComponent)
},
{
path: 'nuevo',
loadComponent: () =>
import('./centro-costos-new/centro-costos-new.component')
.then(c => c.CentroCostosNewComponent)
},
{
path: 'editar/:id',
loadComponent: () =>
import('./centro-costos-edit/centro-costos-edit.component')
.then(c => c.CentroCostosEditComponent)
},
{
path: 'ver/:id',
loadComponent: () =>
import('./centro-costos-view/centro-costos-view.component')
.then(c => c.CentroCostosViewComponent)
}
];
Archivo: libs/accounting/shell/src/lib/accounting-shell.routes.ts
import { Routes } from '@angular/router';
export const ACCOUNTING_ROUTES: Routes = [
{
path: '',
children: [
{
path: 'centros-costo',
loadChildren: () =>
import('@nebula/accounting/feature-centros-costo')
.then(m => m.CENTROS_COSTO_ROUTES)
},
// ... otros features del dominio
{ path: '', redirectTo: 'centros-costo', pathMatch: 'full' }
]
}
];
# Verificar que no hay violaciones de boundaries
nx lint accounting-feature-centros-costo
# Build del feature
nx build nebula-shell
# Ejecutar localmente
nx serve nebula-shell
# Navegar a: http://localhost:4200/contabilidad/centros-costo
git add libs/accounting/
git commit -m "feat(accounting): implement frontend CRUD for centro de costos
- Domain model: CentroCostos interfaces
- Data-access: CentroCostosService with Signals state
- Feature: list, new, edit, view components (standalone + OnPush)
- Lazy-loaded routing
- Connected to backend API /api/v1/accounting/centro-costos"
git push origin feature/NEB-16-frontend-centro-costos
# Crear PR en GitLab
# Asignar a: Sr. Frontend (Gatekeeper)
# Labels: domain:accounting, stack:frontend, type:feature
HU asignada
│
├── 1. git checkout -b feature/NEB-XX
│
├── 2. Verificar/crear librerías del dominio (domain, data-access, shell)
│
├── 3. Crear modelo en domain/ (refleja DTO del backend)
│
├── 4. Crear service en data-access/ (HTTP + Signals state)
│
├── 5. Generar librería feature (nx g @nx/angular:library)
│
├── 6. Crear 4 componentes:
│ ├── list (Smart: DataTable + acciones)
│ ├── new (Smart: Reactive Form + validaciones)
│ ├── edit (Smart: Form pre-populated)
│ └── view (Smart: Detalle readonly)
│
├── 7. Configurar routing lazy-loaded
│
├── 8. Registrar en shell del dominio
│
├── 9. nx lint + nx build (verificar boundaries + compilación)
│
├── 10. nx serve → probar en navegador
│
├── 11. git commit -m "feat(domain): description"
│
└── 12. git push → PR → Gatekeeper review → Merge
standalone: trueChangeDetectionStrategy.OnPush@if, @for, no *ngIf, *ngFor)any en TypeScriptnx lint pasa sin errores (boundaries respetadas)nx build compila sin errores| Version | Fecha | Autor | Descripcion |
|---|---|---|---|
| 1.0.0 | 2026-03-08 | Carlos Torres | Creación de la guía paso a paso para módulos frontend |