Módulo 29: Proyecto final
Objetivos del módulo
- Montar una aplicación Angular completa contra la API EMPRESA, de la estructura de carpetas al despliegue
- Iniciar sesión con JWT, conservar la sesión al recargar y añadir el token con un interceptor funcional
- Proteger rutas con guards funcionales y recordar a dónde quería ir el usuario
- Leer datos con
httpResourcey escribir conHttpClient, con estados de carga, error y vacío - Crear, editar y borrar empleados con Signal Forms: validación local, errores del servidor y campos condicionales
- Organizar rutas perezosas con rutas hijas que comparten datos con el componente padre
- Gestionar una cesta de pedido con NgRx SignalStore, entidades y
rxMethod - Dar estilo con Tailwind 4 sin llenar las plantillas de clases repetidas
- Probar servicios, guards, interceptores, esquemas, componentes y el store con Vitest, y el flujo completo con Playwright
- Saber exactamente qué hay que cambiar para compilar el proyecto con Angular 21, la LTS
1. Qué vas a construir
A lo largo del curso has visto cada pieza de Angular por separado. En este módulo las juntas en una sola aplicación, EMPRESA, que gestiona los datos de la API de ejemplo que conoces desde el módulo 00: empleados, departamentos, clientes, productos y pedidos.
No es una demostración de piezas sueltas. Es una aplicación pequeña pero real, con las decisiones que tendrías que tomar en un proyecto de verdad: dónde guardar el token, qué hacer cuando caduca, cuándo usar un store y cuándo no, cómo mostrar un error de la API en el campo que toca, y cómo comprobar que todo sigue funcionando cuando cambias algo.
| Pantalla | Ruta | Qué enseña |
|---|---|---|
| Entrar | /entrar | Signal Forms, sesión con JWT, guard inverso |
| Panel | /panel | httpResource, computed, pipes con LOCALE_ID |
| Empleados | /empleados | Búsqueda con retardo, filtros, orden, paginación, borrado con confirmación |
| Ficha | /empleados/:id | Rutas hijas, compartir el recurso del padre |
| Alta y edición | /empleados/nuevo, /empleados/:id/editar | Esquema de validación, errores 422, campos condicionales, soloAdmin |
| Pedidos | /pedidos, /pedidos/:id | Filtros, cambio de estado con PATCH, escribir en un recurso |
| Nuevo pedido | /pedidos/nuevo | SignalStore con entidades, rxMethod y exhaustMap |

Figura 1. El panel de mando, con los datos reales de la API.
📘 Concepto: Todo el código de la aplicación está en este módulo, fichero a fichero, y también en el proyecto descargable
empresa-angular.zip. Puedes leer el módulo de principio a fin y escribirlo tú, o descargar el ZIP, arrancarlo y leer el módulo con el código delante. Ambos coinciden: el texto se ha generado a partir de los ficheros del ZIP.
2. Puesta en marcha
2.1. Lo que necesitas
| Pieza | Versión | Dónde se vio |
|---|---|---|
| Node | ^22.22.3, ^24.15.0 o >=26.0.0 | Módulo 01 |
| Angular | 22.2 | Todo el curso |
NgRx SignalStore y @ngrx/operators | 22.0 | Módulo 19 |
| Tailwind CSS | 4.3 | Módulo 22 |
| Vitest y jsdom | 5 y 30 | Módulo 26 |
| API EMPRESA | empresa-api.zip | Módulo 00 |
2.2. Arrancar la API y la aplicación
Descomprime los dos ZIP donde quieras. En una terminal arranca la API:
cd empresa-api
node server.mjs
En otra, instala y arranca la aplicación:
cd empresa-angular
npm install
npm start
Abre http://localhost:4200 y entra como ana.garcia@empresa.es (rol ADMIN) o como luis.martinez@empresa.es (rol USER). La contraseña de los dos es secreto. La API guarda los datos en memoria: si la reinicias, todo vuelve a su estado inicial, así que puedes borrar y crear sin miedo.
2.3. Cómo se creó el proyecto
El esqueleto es el que genera ng new en Angular 22.2 (sin zone.js, con Vitest y ficheros sin sufijo, como app.ts). Después se añadieron las dependencias que faltaban:
ng new empresa-angular --routing --style=css --ssr=false
cd empresa-angular
npm install @ngrx/signals @ngrx/operators
npm install -D tailwindcss @tailwindcss/postcss postcss
Así queda package.json. Fíjate en que solo están las dependencias que la aplicación usa: ni Material ni @angular/animations.
Fichero package.json.
{
"name": "empresa-angular",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"test": "ng test --watch=false"
},
"private": true,
"packageManager": "npm@11.6.2",
"dependencies": {
"@angular/common": "^22.2.0",
"@angular/compiler": "^22.2.0",
"@angular/core": "^22.2.0",
"@angular/forms": "^22.2.0",
"@angular/platform-browser": "^22.2.0",
"@angular/router": "^22.2.0",
"@ngrx/operators": "^22.0.1",
"@ngrx/signals": "^22.0.1",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^22.2.5",
"@angular/cli": "^22.2.5",
"@angular/compiler-cli": "^22.2.0",
"@tailwindcss/postcss": "^4.3.3",
"jsdom": "^30.0.0",
"postcss": "^8.5.32",
"tailwindcss": "^4.3.3",
"typescript": "~6.0.2",
"vitest": "^5.0.0"
}
}
Tailwind 4 se integra a través de PostCSS, igual que en el módulo 22. El constructor de Angular lee este fichero de la raíz del proyecto:
Fichero .postcssrc.json.
{
"plugins": {
"@tailwindcss/postcss": {}
}
}
2.4. El proxy de desarrollo
La aplicación pide los datos a /api, una ruta relativa, y no a http://localhost:3000/api. En desarrollo, ng serve reenvía esas peticiones a la API gracias a este fichero:
Fichero proxy.conf.json.
{
"/api": {
"target": "http://localhost:3000",
"secure": false
}
}
Y en angular.json, dentro de serve, se le dice a ng serve que lo use:
"serve": {
"builder": "@angular/build:dev-server",
"options": {
"proxyConfig": "proxy.conf.json"
},
...
}
¿Por qué molestarse, si la API ya admite CORS? Por tres motivos:
- El navegador cree que la API está en el mismo origen. No hay petición previa
OPTIONSen cada escritura, ni cabeceras CORS que configurar. - El código no lleva direcciones escritas. En producción, el servidor web hace lo mismo que el proxy (lo verás en la sección 18), y el código no cambia.
- Cambiar de API es cambiar un fichero de configuración, no buscar URLs por el código. Si tu API usa otro puerto, cambias
targety listo.
⚠️ Importante: El proxy solo existe en
ng serve. Si abres la carpetadistcon cualquier servidor estático,/apidará 404. En producción, quien sirve la aplicación debe reenviar/api, como se explica en la sección 18.
3. Arquitectura
3.1. Capas
Figura 2. Las pantallas usan el núcleo, y el núcleo no conoce las pantallas.
La regla que ordena el proyecto es sencilla: las dependencias van hacia abajo. Una pantalla puede usar la sesión, pero la sesión no sabe nada de las pantallas. Así puedes probar la sesión sin montar ningún componente, y cambiar una pantalla sin miedo a romper el núcleo.
3.2. Carpetas
src/
├── index.html
├── main.ts
├── styles.css Tailwind y unas pocas clases propias
└── app/
├── app.ts Solo un <router-outlet>
├── app.config.ts Proveedores globales
├── app.routes.ts Rutas principales
├── core/ Núcleo sin interfaz: modelos, sesión, interceptor, guards...
├── comun/ Componentes reutilizables: errores de campo, paginador
├── marco/ Cabecera, navegación y avisos
├── entrar/ Pantalla de login
├── panel/ Panel de mando
├── empleados/ Lista, ficha, formulario y sus rutas
└── pedidos/ Lista, detalle, nuevo pedido, cesta y sus rutas
Las carpetas se organizan por funcionalidad (empleados, pedidos), no por tipo de fichero (components, services). Cuando trabajas en los pedidos, todo lo que necesitas está en una carpeta, y si un día sobra, la borras entera.
3.3. Dónde vive cada dato
Figura 3. Cada dato vive en el sitio más sencillo que cumple su función.
Es la misma conclusión del módulo 19, aplicada:
- Señal local para lo que solo le importa a una pantalla: el texto del buscador, la página actual. Desaparece con el componente, y no pasa nada.
httpResourcepara lo que pertenece al servidor. La aplicación no guarda una copia de los empleados: los pide cuando hacen falta. Así no hay que sincronizar nada.- Servicio con señales para lo compartido y sencillo: la sesión y los avisos.
- SignalStore para lo compartido con reglas: la cesta tiene un total calculado, un límite de stock, un envío que no debe repetirse… Ahí un store aporta estructura.
💡 Consejo: Si dudas, empieza por lo más sencillo. Pasar de una señal local a un servicio, o de un servicio a un store, es fácil cuando aparece la necesidad. Lo contrario, quitar un store que sobraba, cuesta más.
4. Modelos y dirección de la API
Todos los tipos que devuelve la API están en un fichero. Se han escrito mirando las respuestas reales, y eso incluye los nulos: la API tiene empleados sin teléfono ni departamento (Tomás, el 160), y un departamento sin presupuesto (Investigación, el 70). Si declaras telefono: string cuando puede llegar null, TypeScript no te avisará del fallo en la plantilla.
// src/app/core/modelos.ts
// Ruta relativa: en desarrollo la atiende el proxy de ng serve
export const API = '/api';
export interface Pagina<T> {
contenido: T[];
pagina: number;
tamano: number;
total: number;
paginas: number;
}
export type Rol = 'ADMIN' | 'USER';
export interface Usuario {
email: string;
nombre: string;
rol: Rol;
}
export interface RespuestaLogin {
token: string;
usuario: Usuario;
expiraEn: number; // segundos
}
export interface Departamento {
deptId: number;
nombre: string;
localidad: string;
presupuesto: number | null;
}
export interface Empleado {
empId: number;
nombre: string;
apellidos: string;
email: string;
telefono: string | null;
fechaAlta: string;
oficio: string;
salario: number;
comision: number | null;
jefeId: number | null;
deptId: number | null;
departamento: string | null;
}
export interface Producto {
productoId: number;
nombre: string;
descripcion: string;
categoria: string;
precio: number;
stock: number;
}
export interface Cliente {
clienteId: number;
nombre: string;
ciudad: string;
}
export type EstadoPedido = 'PENDIENTE' | 'ENVIADO' | 'ENTREGADO' | 'CANCELADO';
export interface LineaPedido {
numLinea: number;
productoId: number;
producto: string;
cantidad: number;
precioUnitario: number;
descuento: number;
importe: number;
}
export interface Pedido {
pedidoId: number;
clienteId: number;
cliente: string;
fechaPedido: string;
fechaEnvio: string | null;
estado: EstadoPedido;
lineas: LineaPedido[];
total: number;
}
export interface Estadisticas {
empleados: number;
departamentos: number;
clientes: number;
productos: number;
pedidos: number;
facturacion: number;
porDepartamento: {
deptId: number;
nombre: string;
empleados: number;
masaSalarial: number;
salarioMedio: number;
}[];
porEstado: Record<EstadoPedido, number>;
}
Pagina<T> es genérico porque todos los listados de la API devuelven la misma envoltura. EstadoPedido es una unión de literales: si escribes 'ENVIADA' en vez de 'ENVIADO', el compilador te avisa.
5. Arranque de la aplicación
5.1. Proveedores globales
// src/app/app.config.ts
import { registerLocaleData } from '@angular/common';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import localeEs from '@angular/common/locales/es';
import { ApplicationConfig, LOCALE_ID, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { routes } from './app.routes';
import { autenticacionInterceptor } from './core/autenticacion.interceptor';
registerLocaleData(localeEs);
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes, withComponentInputBinding()),
provideHttpClient(withInterceptors([autenticacionInterceptor])),
{ provide: LOCALE_ID, useValue: 'es' },
],
};
| Proveedor | Para qué |
|---|---|
provideBrowserGlobalErrorListeners | Lleva los errores no capturados del navegador al ErrorHandler de Angular |
withComponentInputBinding | Los parámetros de la ruta (:id, ?volver=) llegan a los input() del componente |
withInterceptors | Registra el interceptor de autenticación de la sección 7 |
LOCALE_ID y registerLocaleData | Los pipes currency, number y date usan el formato español: 2.400,00 € |
No hay provideZoneChangeDetection: la aplicación es zoneless, como cualquier proyecto nuevo desde Angular 21. Tampoco hace falta withFetch().
HttpClient usa fetch por defecto 🔄 v22.0
🔄 Cambia en Angular 22.0. En 22,
HttpClientusafetchpor defecto ywithFetch()está obsoleto. En la LTS (Angular 21) el defecto es XHR. Si usas la LTS: no tienes que cambiar nada en este proyecto, porque no usa progreso de subida ni nada propio de un backend; si quieresfetchtambién en 21, añadewithFetch()aprovideHttpClient.
5.2. El componente raíz
El componente raíz no pinta nada: solo contiene la salida del router. La cabecera no está aquí, sino en Marco (sección 9), porque la pantalla de login no debe llevarla.
// src/app/app.ts
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
template: `<router-outlet />`,
})
export class App {}
5.3. Estilos globales
Fichero src/styles.css.
@import 'tailwindcss';
/* Unas pocas clases propias evitan repetir la misma ristra de utilidades */
@layer components {
.titulo {
@apply mb-4 text-2xl font-bold text-indigo-900;
}
.tarjeta {
@apply rounded-lg border border-slate-200 bg-white p-4 shadow-sm;
}
.dato {
@apply text-2xl font-bold text-indigo-800;
}
.etiqueta {
@apply block text-sm font-medium text-slate-700;
}
.campo {
@apply mt-1 block w-full rounded border border-slate-300 bg-white px-3 py-2 text-slate-900 disabled:bg-slate-100;
}
.boton {
@apply inline-block cursor-pointer rounded bg-indigo-700 px-4 py-2 text-center font-medium text-white hover:bg-indigo-800 disabled:cursor-not-allowed disabled:opacity-50;
}
.boton-sec {
@apply inline-block cursor-pointer rounded border border-slate-300 bg-white px-3 py-1 text-slate-800 hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50;
}
}
body {
@apply bg-slate-50 text-slate-900;
}
Las clases .boton, .campo o .tarjeta se repiten en todas las pantallas. Escribir rounded border border-slate-300 bg-white px-3 py-2... en cada <input> haría las plantillas ilegibles y, peor, difíciles de cambiar. Dentro de @layer components, Tailwind las coloca en la capa adecuada: una utilidad escrita en la plantilla, como mt-0, sigue ganando a la clase .campo, y por eso en la lista de empleados class="campo mt-0" funciona.
Y el index.html solo cambia el idioma y el título:
<!-- src/index.html -->
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>EMPRESA</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
</html>
6. La sesión
6.1. El servicio Sesion
// src/app/core/sesion.ts
import { HttpClient } from '@angular/common/http';
import { computed, inject, Service, signal } from '@angular/core';
import { Observable, tap } from 'rxjs';
import { API, RespuestaLogin, Usuario } from './modelos';
const CLAVE = 'empresa.sesion';
interface SesionGuardada {
token: string;
usuario: Usuario;
caduca: number; // milisegundos desde 1970
}
function leerGuardada(): SesionGuardada | null {
try {
const guardada: SesionGuardada | null = JSON.parse(sessionStorage.getItem(CLAVE) ?? 'null');
return guardada && guardada.caduca > Date.now() ? guardada : null;
} catch {
return null; // el contenido no era JSON: mejor sin sesión que romper el arranque
}
}
@Service()
export class Sesion {
private readonly http = inject(HttpClient);
private readonly estado = signal<SesionGuardada | null>(leerGuardada());
readonly usuario = computed(() => this.estado()?.usuario ?? null);
readonly token = computed(() => this.estado()?.token ?? null);
readonly caduca = computed(() => this.estado()?.caduca ?? 0);
readonly activa = computed(() => this.estado() !== null);
readonly esAdmin = computed(() => this.usuario()?.rol === 'ADMIN');
entrar(email: string, password: string): Observable<RespuestaLogin> {
return this.http.post<RespuestaLogin>(`${API}/auth/login`, { email, password }).pipe(
tap(({ token, usuario, expiraEn }) => {
const sesion = { token, usuario, caduca: Date.now() + expiraEn * 1000 };
sessionStorage.setItem(CLAVE, JSON.stringify(sesion));
this.estado.set(sesion);
}),
);
}
salir() {
sessionStorage.removeItem(CLAVE);
this.estado.set(null);
}
}
Lo importante:
- El estado es una sola señal privada. Desde fuera solo se leen
computedde solo lectura (usuario,token,activa,esAdmin). Nadie puede poneractivaatruesin token. - La sesión se recupera al arrancar.
leerGuardadaleesessionStorageuna vez, al crear el servicio. Si la sesión ha caducado o el JSON está corrupto, empieza sin sesión. - La caducidad se guarda como instante absoluto. La API dice “dura 3600 segundos”. Si guardas eso, al recargar no sabes cuánto queda; si guardas
Date.now() + 3600 * 1000, sí. entrardevuelve el Observable. Quien llama decide qué hacer después (navegar, mostrar un error). El servicio solo guarda.
6.2. ¿Dónde guardar el token?
En el módulo 12 la sesión vivía solo en memoria y se perdía con F5. Una aplicación real no puede pedir la contraseña en cada recarga, así que hay que guardarla en algún sitio. Cada opción tiene un precio:
| Dónde | Sobrevive a F5 | Sobrevive a cerrar la pestaña | Lo lee un script malicioso (XSS) |
|---|---|---|---|
| Memoria (una señal) | No | No | Con dificultad |
sessionStorage | Sí | No | Sí |
localStorage | Sí | Sí | Sí |
Cookie HttpOnly | Sí | Según su caducidad | No |
Este proyecto usa sessionStorage: sobrevive a la recarga, se borra al cerrar la pestaña y no se comparte entre pestañas. Es un término medio razonable para una aplicación interna.
⚠️ Importante: Cualquier script que se ejecute en tu página puede leer
sessionStorageylocalStorage. La defensa de verdad es que no se ejecute código ajeno: Angular escapa las interpolaciones, y en el módulo 27 viste CSP y Trusted Types. La opción más segura es una cookieHttpOnly, que JavaScript no puede leer, pero exige que el servidor la emita y protegerse de CSRF. La API EMPRESA trabaja conAuthorization: Bearer, así que aquí no es posible.
6.3. @Service en lugar de @Injectable 🆕 v22.0
🆕 Desde Angular 22.0. No existe en la LTS (Angular 21). Si usas la LTS: usa
@Injectable({ providedIn: 'root' }), que hace lo mismo; compilar@Service()en 21 daTS2305: Module '"@angular/core"' has no exported member 'Service'.
@Service() registra el servicio en el inyector raíz, igual que @Injectable({ providedIn: 'root' }), pero no admite inyección por constructor: las dependencias se piden con inject(), como se hace aquí. Lo viste en el módulo 08.
6.4. Los avisos
Las pantallas necesitan decir “Guardado” o “No se puede borrar”. Un servicio con una lista de avisos en una señal basta, y Marco los pinta:
// src/app/core/avisos.ts
import { Service, signal } from '@angular/core';
export interface Aviso {
id: number;
texto: string;
tipo: 'ok' | 'error';
}
@Service()
export class Avisos {
private siguienteId = 1;
private readonly _lista = signal<Aviso[]>([]);
readonly lista = this._lista.asReadonly();
mostrar(texto: string, tipo: Aviso['tipo'] = 'ok') {
const id = this.siguienteId++;
this._lista.update((avisos) => [...avisos, { id, texto, tipo }]);
setTimeout(() => this.cerrar(id), 4000);
}
cerrar(id: number) {
this._lista.update((avisos) => avisos.filter((a) => a.id !== id));
}
}
setTimeout funciona sin Zone.js porque, al cambiar la señal _lista, Angular programa la detección de cambios por sí mismo. Lo comprobó la prueba de extremo a extremo: los avisos desaparecen a los cuatro segundos.
7. El interceptor de autenticación
Figura 4. El ciclo completo de una sesión.
// src/app/core/autenticacion.interceptor.ts
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { catchError, throwError } from 'rxjs';
import { API } from './modelos';
import { Sesion } from './sesion';
export const autenticacionInterceptor: HttpInterceptorFn = (peticion, siguiente) => {
const sesion = inject(Sesion);
const router = inject(Router);
const token = sesion.token();
// El token solo viaja a nuestra API, nunca a un servidor de terceros
const conToken =
token && peticion.url.startsWith(API)
? peticion.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
: peticion;
return siguiente(conToken).pipe(
catchError((error: HttpErrorResponse) => {
// Un 401 con token significa que ha caducado. Sin token, es un login fallido.
if (error.status === 401 && token) {
const volver = router.url;
sesion.salir();
router.navigate(['/entrar'], { queryParams: { volver, caducada: true } });
}
return throwError(() => error);
}),
);
};
Tres decisiones que conviene entender:
- El token solo va a nuestra API.
peticion.url.startsWith(API)evita regalar el token a un servidor de terceros si un día la aplicación pide algo fuera. Es un descuido frecuente, y caro: con ese token, cualquiera actúa en tu nombre durante una hora. - No todos los 401 son iguales. Si la petición llevaba token y la API responde 401, el token ha caducado o es falso: se cierra la sesión y se envía al usuario a
/entrarconcaducada=truey la dirección donde estaba. Si no llevaba token, es un login con la contraseña mal, y redirigir sería absurdo: la pantalla de login ya está mostrando el error. - El error se vuelve a lanzar.
throwError(() => error)deja que el componente que hizo la petición también se entere. El interceptor no se traga errores.
📘 Concepto: El interceptor es una función con
inject(), como viste en el módulo 12. Se ejecuta en un contexto de inyección, así que puede pedirSesionyRoutersin constructor.
8. Guards y rutas principales
8.1. Los guards
// src/app/core/guards.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { Avisos } from './avisos';
import { Sesion } from './sesion';
// Si no hay sesión, va a /entrar y recuerda la dirección que quería visitar
export const conSesion: CanActivateFn = (_ruta, estado) =>
inject(Sesion).activa() ||
inject(Router).createUrlTree(['/entrar'], { queryParams: { volver: estado.url } });
// Quien ya ha entrado no tiene nada que hacer en la pantalla de login
export const sinSesion: CanActivateFn = () =>
!inject(Sesion).activa() || inject(Router).createUrlTree(['/panel']);
export const soloAdmin: CanActivateFn = () => {
if (inject(Sesion).esAdmin()) return true;
inject(Avisos).mostrar('Esa pantalla es solo para administradores', 'error');
return inject(Router).createUrlTree(['/panel']);
};
Un guard funcional devuelve true para dejar pasar, o un UrlTree para redirigir. Devolver createUrlTree es mejor que llamar a router.navigate y devolver false: el router cancela la navegación actual y empieza la nueva de forma ordenada, sin dos navegaciones a la vez.
conSesion guarda en volver la dirección completa que se pedía (estado.url). Tras el login, Entrar navega allí con navigateByUrl. Como navigateByUrl solo navega dentro de la aplicación, alguien que te pase el enlace /entrar?volver=https://malicioso.example no consigue sacarte de ella.
8.2. Las rutas
Figura 5. El árbol de rutas. La hija editar necesita el :id de su padre.
// src/app/app.routes.ts
import { Routes } from '@angular/router';
import { conSesion, sinSesion } from './core/guards';
import { Entrar } from './entrar/entrar';
import { Marco } from './marco/marco';
export const routes: Routes = [
{ path: 'entrar', component: Entrar, canActivate: [sinSesion], title: 'Entrar · EMPRESA' },
{
path: '',
component: Marco,
canActivate: [conSesion],
children: [
{ path: '', pathMatch: 'full', redirectTo: 'panel' },
{
path: 'panel',
loadComponent: () => import('./panel/panel').then((m) => m.Panel),
title: 'Panel · EMPRESA',
},
{
path: 'empleados',
loadChildren: () => import('./empleados/empleados.routes').then((m) => m.RUTAS_EMPLEADOS),
},
{
path: 'pedidos',
loadChildren: () => import('./pedidos/pedidos.routes').then((m) => m.RUTAS_PEDIDOS),
},
],
},
{ path: '**', redirectTo: '' },
];
Marcoes un componente con su propio<router-outlet>. Todas las pantallas con cabecera son sus hijas, y el guardconSesionlas protege a todas desde un solo sitio.EntraryMarcose cargan de inicio; el resto, conloadComponentyloadChildren, en chunks aparte (sección 18).- El comodín
**manda cualquier dirección desconocida a la raíz, que a su vez redirige al panel o al login.
⚠️ Importante:
canActivateen una ruta padre solo se ejecuta al entrar en ella. Si la sesión caduca mientras navegas entre hijas, el guard no vuelve a comprobarla. Lo detecta el interceptor con el siguiente 401, y el ejercicio 2 lo resuelve también por tiempo.
9. El marco
// src/app/marco/marco.ts
import { Component, inject } from '@angular/core';
import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { Avisos } from '../core/avisos';
import { Sesion } from '../core/sesion';
import { CestaStore } from '../pedidos/cesta.store';
@Component({
selector: 'app-marco',
imports: [RouterLink, RouterLinkActive, RouterOutlet],
template: `
<header class="bg-indigo-900 text-white">
<div class="mx-auto flex max-w-5xl flex-wrap items-center gap-6 px-4 py-3">
<a routerLink="/panel" class="text-lg font-bold tracking-wide">EMPRESA</a>
<nav class="flex flex-1 gap-1" aria-label="Principal">
@for (enlace of enlaces; track enlace.ruta) {
<a [routerLink]="enlace.ruta" routerLinkActive="bg-indigo-700" class="rounded px-3 py-1 hover:bg-indigo-800">
{{ enlace.texto }}
</a>
}
<a routerLink="/pedidos/nuevo" class="rounded px-3 py-1 hover:bg-indigo-800">
Cesta ({{ cesta.unidades() }})
</a>
</nav>
<span class="text-sm">{{ sesion.usuario()?.nombre }} · {{ sesion.usuario()?.rol }}</span>
<button type="button" class="rounded border border-indigo-300 px-3 py-1 text-sm" (click)="salir()">
Salir
</button>
</div>
</header>
<main class="mx-auto max-w-5xl px-4 py-6">
<router-outlet />
</main>
<div class="fixed right-4 bottom-4 flex w-80 flex-col gap-2" aria-live="polite">
@for (aviso of avisos.lista(); track aviso.id) {
<p
role="status"
class="rounded px-4 py-3 text-white shadow-lg"
[class]="aviso.tipo === 'ok' ? 'bg-emerald-700' : 'bg-red-700'"
>
{{ aviso.texto }}
</p>
}
</div>
`,
})
export class Marco {
protected readonly sesion = inject(Sesion);
protected readonly avisos = inject(Avisos);
protected readonly cesta = inject(CestaStore);
private readonly router = inject(Router);
protected readonly enlaces = [
{ ruta: '/panel', texto: 'Panel' },
{ ruta: '/empleados', texto: 'Empleados' },
{ ruta: '/pedidos', texto: 'Pedidos' },
];
protected salir() {
this.sesion.salir();
this.cesta.vaciar(); // el siguiente usuario no debe heredar la cesta
this.router.navigate(['/entrar']);
}
}
routerLinkActiveresalta el enlace de la sección activa.- El contador de la cesta lee
cesta.unidades(), una señal del store de la sección 16. Se actualiza en cuanto añades algo, en cualquier pantalla. - Al salir se vacía la cesta. Si no, el siguiente usuario en ese navegador heredaría el pedido a medias de otro.
- Los avisos están en una región
aria-live="polite"conrole="status": los lectores de pantalla los anuncian sin interrumpir, como viste en el módulo 21. ?.ensesion.usuario()?.nombrees seguro en una interpolación: sin usuario muestra un texto vacío tanto en 22 (undefined) como en 21 (null).
10. Login con Signal Forms 🔄 v22.0
🔄 Cambia en Angular 22.0. Signal Forms es estable desde 22. En la LTS (Angular 21) existe en
@angular/forms/signals, pero es experimental y puede cambiar. Si usas la LTS: este formulario de login compila y funciona igual en 21.2; los cambios del formulario de empleados están en la sección 14.

Figura 6. El 401 del login aparece como error del formulario, no como aviso flotante.
// src/app/entrar/entrar.ts
import { booleanAttribute, Component, inject, input, signal } from '@angular/core';
import { email, form, FormField, FormRoot, required } from '@angular/forms/signals';
import { Router } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { ErroresCampo } from '../comun/errores-campo';
import { mensajeDeError } from '../core/errores-api';
import { Sesion } from '../core/sesion';
@Component({
selector: 'app-entrar',
imports: [FormField, FormRoot, ErroresCampo],
template: `
<main class="flex min-h-screen items-center justify-center bg-slate-100 p-4">
<form [formRoot]="f" class="tarjeta w-full max-w-sm">
<h1 class="mb-1 text-2xl font-bold text-indigo-900">EMPRESA</h1>
<p class="mb-4 text-slate-600">Inicia sesión para continuar</p>
@if (caducada()) {
<p class="mb-4 rounded bg-amber-100 p-2 text-sm text-amber-900">
Tu sesión ha caducado. Vuelve a entrar.
</p>
}
<label class="etiqueta">
Correo electrónico
<input class="campo" type="email" autocomplete="username" [formField]="f.email" />
</label>
<app-errores-campo [campo]="f.email" />
<label class="etiqueta mt-3">
Contraseña
<input class="campo" type="password" autocomplete="current-password" [formField]="f.password" />
</label>
<app-errores-campo [campo]="f.password" />
@for (error of f().errors(); track $index) {
<p role="alert" class="mt-3 text-sm text-red-700">{{ error.message }}</p>
}
<button class="boton mt-4 w-full" [disabled]="f().submitting()">
{{ f().submitting() ? 'Entrando...' : 'Entrar' }}
</button>
</form>
</main>
`,
})
export class Entrar {
private readonly sesion = inject(Sesion);
private readonly router = inject(Router);
// Llegan de la dirección gracias a withComponentInputBinding
readonly volver = input('/panel');
readonly caducada = input(false, { transform: booleanAttribute });
private readonly datos = signal({ email: '', password: '' });
protected readonly f = form(
this.datos,
(p) => {
required(p.email, { message: 'Escribe tu correo' });
email(p.email, { message: 'El correo no es válido' });
required(p.password, { message: 'Escribe tu contraseña' });
},
{
submission: {
action: async () => {
const { email, password } = this.datos();
try {
await firstValueFrom(this.sesion.entrar(email, password));
await this.router.navigateByUrl(this.volver());
return undefined;
} catch (error) {
return { kind: 'servidor', message: mensajeDeError(error) };
}
},
},
},
);
}
El formulario usa lo que aprendiste en el módulo 18:
form(datos, esquema, { submission })crea el árbol de campos. La acción de envío devuelveundefinedsi todo ha ido bien, o un error que Signal Forms coloca en la raíz del formulario (f().errors()).[formRoot]="f"evita la recarga de la página, añadenovalidatey lanza la acción al pulsar el botón. Mientras se ejecuta,f().submitting()estruey el botón se desactiva: no hay doble envío.volverycaducadasoninput()que llegan de la dirección gracias awithComponentInputBinding.booleanAttributeconvierte el texto"true"entrue.- Si el usuario pulsa «Entrar» con los campos vacíos,
submitmarca todos los campos como tocados y no llama a la acción. Los mensajes aparecen sin una sola línea extra.
10.1. Errores bajo cada campo
Este componente se usa en todos los formularios:
// src/app/comun/errores-campo.ts
import { Component, input } from '@angular/core';
import { FieldTree } from '@angular/forms/signals';
@Component({
selector: 'app-errores-campo',
template: `
@if (campo()().touched() && campo()().invalid()) {
@for (error of campo()().errors(); track $index) {
<p class="mt-1 text-sm text-red-700">{{ error.message }}</p>
}
}
`,
})
export class ErroresCampo {
readonly campo = input.required<FieldTree<unknown>>();
}
Solo muestra errores de campos tocados. Si los mostraras desde el principio, el usuario vería el formulario lleno de rojo antes de escribir nada.
10.2. Los errores de la API
// src/app/core/errores-api.ts
import { HttpErrorResponse } from '@angular/common/http';
import { FieldTree, isFieldTree, TreeValidationResult, ValidationError } from '@angular/forms/signals';
// Texto legible para cualquier error de la API
export function mensajeDeError(error: unknown): string {
if (error instanceof HttpErrorResponse) {
return error.status === 0
? 'No hay conexión con el servidor'
: (error.error?.mensaje ?? `Error ${error.status}`);
}
return 'Error inesperado';
}
// Convierte un 422 de la API en errores colocados en cada campo del formulario
export function erroresDeApi(error: unknown, campos: unknown): TreeValidationResult {
const detalles: Record<string, string> | undefined =
error instanceof HttpErrorResponse && error.status === 422 ? error.error?.detalles : undefined;
if (!detalles) return { kind: 'servidor', message: mensajeDeError(error) };
const lista: ValidationError.WithOptionalFieldTree[] = [];
for (const [nombre, message] of Object.entries(detalles)) {
const campo = (campos as Record<string, unknown>)[nombre];
lista.push(
isFieldTree(campo)
? { kind: 'servidor', message, fieldTree: campo as FieldTree<unknown> }
: { kind: 'servidor', message: `${nombre}: ${message}` },
);
}
return lista;
}
mensajeDeError saca un texto legible de cualquier error: status 0 significa que la petición ni llegó (API apagada, sin red), y el resto lleva mensaje en el cuerpo, como documenta la API. erroresDeApi es la función del módulo 18: convierte un 422 con detalles en errores asociados a cada campo.
isFieldTree 🆕 v22.1
🆕 Desde Angular 22.1. No existe en la LTS (Angular 21): importarla da
TS2724. Si usas la LTS: trata la propiedad comoFieldTree<unknown> | undefinedy comprueba que exista, como en la sección 19.
11. El panel de mando 🔄 v22.0
🔄 Cambia en Angular 22.0.
httpResourcees estable desde 22. En la LTS (Angular 21) existe y se usa igual, pero es experimental. Si usas la LTS: todo el proyecto usahttpResourcesin cambios; revisa las notas de versión al actualizar.
// src/app/panel/panel.ts
import { CurrencyPipe, DecimalPipe } from '@angular/common';
import { httpResource } from '@angular/common/http';
import { Component, computed } from '@angular/core';
import { API, EstadoPedido, Estadisticas } from '../core/modelos';
@Component({
selector: 'app-panel',
imports: [CurrencyPipe, DecimalPipe],
template: `
<h1 class="titulo">Panel de mando</h1>
@if (estadisticas.error()) {
<p role="alert" class="text-red-700">No se han podido cargar los datos.</p>
<button type="button" class="boton-sec mt-2" (click)="estadisticas.reload()">Reintentar</button>
} @else if (estadisticas.hasValue()) {
@let datos = estadisticas.value();
<section class="grid grid-cols-2 gap-4 md:grid-cols-4" aria-label="Totales">
<div class="tarjeta">
<p class="dato">{{ datos.empleados }}</p>
<p class="text-slate-600">Empleados</p>
</div>
<div class="tarjeta">
<p class="dato">{{ datos.clientes }}</p>
<p class="text-slate-600">Clientes</p>
</div>
<div class="tarjeta">
<p class="dato">{{ datos.pedidos }}</p>
<p class="text-slate-600">Pedidos</p>
</div>
<div class="tarjeta">
<p class="dato">{{ datos.facturacion | currency: 'EUR' : 'symbol' : '1.0-0' }}</p>
<p class="text-slate-600">Facturación</p>
</div>
</section>
<div class="mt-6 grid gap-4 md:grid-cols-2">
<section class="tarjeta">
<h2 class="mb-3 font-semibold">Masa salarial por departamento</h2>
@for (d of barras(); track d.deptId) {
<div class="mb-2 text-sm">
<div class="flex justify-between">
<span>{{ d.nombre }}</span>
<span>{{ d.masaSalarial | number: '1.0-0' }} €</span>
</div>
<div class="h-2 rounded bg-slate-200">
<div class="h-2 rounded bg-indigo-600" [style.width.%]="d.porcentaje"></div>
</div>
</div>
}
</section>
<section class="tarjeta">
<h2 class="mb-3 font-semibold">Pedidos por estado</h2>
<ul>
@for (e of estados; track e) {
<li class="flex justify-between border-b border-slate-200 py-2">
<span>{{ e }}</span>
<strong>{{ datos.porEstado[e] }}</strong>
</li>
}
</ul>
</section>
</div>
} @else {
<p>Cargando...</p>
}
`,
})
export class Panel {
protected readonly estadisticas = httpResource<Estadisticas>(() => `${API}/estadisticas`);
protected readonly estados: EstadoPedido[] = ['PENDIENTE', 'ENVIADO', 'ENTREGADO', 'CANCELADO'];
// Un valor derivado de los datos: no hay que guardarlo ni sincronizarlo
protected readonly barras = computed(() => {
if (!this.estadisticas.hasValue()) return [];
const filas = this.estadisticas.value().porDepartamento;
const mayor = Math.max(...filas.map((d) => d.masaSalarial), 1);
return filas.map((d) => ({ ...d, porcentaje: (d.masaSalarial / mayor) * 100 }));
});
}
El panel es la pantalla más sencilla, y sirve para fijar el patrón que repiten todas las demás:
@if (recurso.error())primero. Si comprobarashasValue()primero, tras un error en una recarga seguirías viendo los datos viejos sin saber que han fallado.@else if (recurso.hasValue())para los datos, con@letpara no repetirestadisticas.value().@elsepara la carga.
barras es un computed: se calcula a partir del recurso, y no hay que guardarlo ni sincronizarlo. Cada barra mide en proporción al departamento con más masa salarial. Investigación no tiene empleados y su barra queda vacía, sin dividir entre cero, porque Math.max(..., 1) asegura un divisor de al menos 1.
12. La lista de empleados

Figura 7. La lista de empleados, vista por un administrador.
// src/app/empleados/lista-empleados.ts
import { CurrencyPipe } from '@angular/common';
import { HttpClient, httpResource } from '@angular/common/http';
import { Component, computed, inject, linkedSignal, signal } from '@angular/core';
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { RouterLink } from '@angular/router';
import { debounceTime, distinctUntilChanged, firstValueFrom } from 'rxjs';
import { Paginador } from '../comun/paginador';
import { Avisos } from '../core/avisos';
import { mensajeDeError } from '../core/errores-api';
import { API, Departamento, Empleado, Pagina } from '../core/modelos';
import { Sesion } from '../core/sesion';
@Component({
selector: 'app-lista-empleados',
imports: [CurrencyPipe, Paginador, RouterLink],
templateUrl: './lista-empleados.html',
})
export class ListaEmpleados {
private readonly http = inject(HttpClient);
private readonly avisos = inject(Avisos);
protected readonly sesion = inject(Sesion);
protected readonly texto = signal('');
protected readonly deptId = signal('');
protected readonly orden = signal('apellidos');
protected readonly confirmando = signal<number | null>(null);
// La búsqueda se retrasa para no lanzar una petición por tecla
private readonly busqueda = signal('');
// Si cambia un filtro, se vuelve a la página 1
protected readonly pagina = linkedSignal({
source: () => [this.busqueda(), this.deptId(), this.orden()],
computation: () => 1,
});
protected readonly departamentos = httpResource<Pagina<Departamento>>(
() => `${API}/departamentos?tamano=100`,
);
protected readonly empleados = httpResource<Pagina<Empleado>>(() => ({
url: `${API}/empleados`,
params: {
q: this.busqueda(),
orden: this.orden(),
pagina: this.pagina(),
tamano: 8,
...(this.deptId() ? { deptId: this.deptId() } : {}),
},
}));
// Conserva la tabla anterior mientras llega la nueva, para que no parpadee
protected readonly datos = linkedSignal<Pagina<Empleado> | undefined, Pagina<Empleado> | undefined>({
source: () => (this.empleados.hasValue() ? this.empleados.value() : undefined),
computation: (nuevo, anterior) => nuevo ?? anterior?.value,
});
protected readonly vacia = computed(() => this.datos()?.contenido.length === 0);
constructor() {
toObservable(this.texto)
.pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed())
.subscribe((texto) => this.busqueda.set(texto));
}
protected ordenarPor(campo: string) {
this.orden.update((actual) => (actual === campo ? `-${campo}` : campo));
}
protected async borrar(e: Empleado) {
this.confirmando.set(null);
try {
await firstValueFrom(this.http.delete(`${API}/empleados/${e.empId}`));
this.avisos.mostrar(`${e.nombre} ${e.apellidos} se ha borrado`);
this.empleados.reload();
} catch (error) {
this.avisos.mostrar(mensajeDeError(error), 'error');
}
}
}
<!-- src/app/empleados/lista-empleados.html -->
<div class="mb-4 flex items-center justify-between">
<h1 class="titulo mb-0">Empleados</h1>
@if (sesion.esAdmin()) {
<a class="boton" routerLink="nuevo">Nuevo empleado</a>
}
</div>
<div class="mb-4 flex flex-wrap gap-3">
<input
class="campo mt-0 max-w-xs"
type="search"
placeholder="Buscar por nombre u oficio"
aria-label="Buscar empleados"
[value]="texto()"
(input)="texto.set($any($event.target).value)"
/>
<select
class="campo mt-0 max-w-xs"
aria-label="Filtrar por departamento"
[value]="deptId()"
(change)="deptId.set($any($event.target).value)"
>
<option value="">Todos los departamentos</option>
@if (departamentos.hasValue()) {
@for (d of departamentos.value().contenido; track d.deptId) {
<option [value]="d.deptId">{{ d.nombre }}</option>
}
}
</select>
</div>
@if (empleados.error()) {
<p role="alert" class="text-red-700">No se ha podido cargar la lista.</p>
<button type="button" class="boton-sec mt-2" (click)="empleados.reload()">Reintentar</button>
} @else if (vacia()) {
<p>No hay empleados con esos criterios.</p>
} @else if (datos(); as pagina1) {
<div class="tarjeta overflow-x-auto p-0" [class.opacity-60]="empleados.isLoading()">
<table class="w-full text-left text-sm">
<thead class="bg-slate-100">
<tr>
<th class="p-3"><button type="button" (click)="ordenarPor('apellidos')">Empleado</button></th>
<th class="p-3">Oficio</th>
<th class="p-3">Departamento</th>
<th class="p-3 text-right"><button type="button" (click)="ordenarPor('salario')">Salario</button></th>
<th class="p-3"><span class="sr-only">Acciones</span></th>
</tr>
</thead>
<tbody>
@for (e of pagina1.contenido; track e.empId) {
<tr class="border-t border-slate-200">
<td class="p-3">
<a class="font-medium text-indigo-800 hover:underline" [routerLink]="[e.empId]">
{{ e.apellidos }}, {{ e.nombre }}
</a>
</td>
<td class="p-3">{{ e.oficio }}</td>
<td class="p-3">{{ e.departamento ?? 'Sin departamento' }}</td>
<td class="p-3 text-right">{{ e.salario | currency: 'EUR' }}</td>
<td class="p-3 text-right whitespace-nowrap">
<a class="text-indigo-800 hover:underline" [routerLink]="[e.empId, 'editar']">Editar</a>
@if (sesion.esAdmin()) {
@if (confirmando() === e.empId) {
<button type="button" class="ml-3 font-semibold text-red-700" (click)="borrar(e)">Confirmar</button>
<button type="button" class="ml-2" (click)="confirmando.set(null)">No</button>
} @else {
<button type="button" class="ml-3 text-red-700" (click)="confirmando.set(e.empId)">Borrar</button>
}
}
</td>
</tr>
}
</tbody>
</table>
</div>
<p class="mt-2 text-sm text-slate-600">{{ pagina1.total }} {{ pagina1.total === 1 ? 'empleado' : 'empleados' }}</p>
<app-paginador [(pagina)]="pagina" [paginas]="pagina1.paginas" />
} @else {
<p>Cargando...</p>
}
12.1. Un recurso que depende de cuatro señales
empleados es un httpResource cuya petición se construye con busqueda, orden, pagina y deptId. Cuando cualquiera cambia, el recurso cancela la petición anterior y lanza otra. No hay subscribe, ni switchMap, ni condiciones de carrera: es lo que estudiaste en el módulo 13.
params acepta un objeto. El filtro por departamento se añade solo si hay uno elegido, con ...(condición ? { deptId } : {}). Mandar deptId= vacío haría que la API filtrara por un departamento inexistente.
12.2. Búsqueda con retardo
Si el recurso dependiera directamente de texto, cada tecla lanzaría una petición. Por eso hay dos señales: texto, unida al <input>, y busqueda, que se actualiza 300 ms después de la última tecla. El paso intermedio lo hace RxJS, como en el módulo 15: toObservable, debounceTime, distinctUntilChanged y takeUntilDestroyed.
💡 Consejo: Angular 22 trae
debounced, que hace lo mismo sin RxJS, pero sigue siendo experimental. En un proyecto que vas a mantener, la versión con RxJS es estable en 21 y en 22.
12.3. Volver a la página 1 al cambiar un filtro
Si estás en la página 3 y buscas “garcia”, que tiene un solo resultado, la página 3 no existe. pagina es un linkedSignal que se reinicia a 1 cada vez que cambia su origen (búsqueda, departamento u orden), pero que puedes cambiar a mano con el paginador. Sin linkedSignal necesitarías un effect que escribiera en una señal, algo que el módulo 05 desaconseja.
12.4. Que la tabla no parpadee
Cuando cambias de página, el recurso pasa a loading y value() deja de tener datos hasta que llegan los nuevos. Si la plantilla leyera empleados.value() directamente, la tabla desaparecería y volvería a aparecer en cada clic.
datos es otro linkedSignal, esta vez con la forma { source, computation }. Su computation recibe el valor nuevo y el anterior: si no hay nuevo, devuelve el anterior. La tabla se mantiene, se atenúa con opacity-60 mientras carga y se sustituye cuando llegan los datos.
12.5. Borrar con confirmación
confirm() del navegador funciona, pero bloquea la página, no se puede maquetar y en las pruebas hay que interceptarlo. Aquí el botón «Borrar» pone el empId en confirmando, y esa fila muestra «Confirmar» y «No» en su lugar. Si la API se niega (409 por subordinados, 403 si no eres ADMIN), el aviso muestra su mensaje:
No se puede borrar a un empleado que tiene subordinados
Tras borrar, empleados.reload() vuelve a pedir la página. No hace falta quitar la fila a mano: el servidor es la fuente de la verdad.
12.6. El paginador
// src/app/comun/paginador.ts
import { Component, input, model } from '@angular/core';
@Component({
selector: 'app-paginador',
template: `
<nav class="mt-4 flex items-center gap-3 text-sm" aria-label="Paginación">
<button type="button" class="boton-sec" [disabled]="pagina() <= 1" (click)="pagina.set(pagina() - 1)">
Anterior
</button>
<span>Página {{ pagina() }} de {{ paginas() }}</span>
<button type="button" class="boton-sec" [disabled]="pagina() >= paginas()" (click)="pagina.set(pagina() + 1)">
Siguiente
</button>
</nav>
`,
})
export class Paginador {
readonly pagina = model.required<number>();
readonly paginas = input.required<number>();
}
model.required<number>() permite el enlace en los dos sentidos: [(pagina)]="pagina". Funciona con un linkedSignal porque también es una señal escribible.
13. La ficha y las rutas hijas
// src/app/empleados/empleados.routes.ts
import { Routes } from '@angular/router';
import { soloAdmin } from '../core/guards';
import { FichaEmpleado } from './ficha-empleado';
import { FormularioEmpleado } from './formulario-empleado';
import { ListaEmpleados } from './lista-empleados';
import { ResumenEmpleado } from './resumen-empleado';
export const RUTAS_EMPLEADOS: Routes = [
{ path: '', component: ListaEmpleados, title: 'Empleados · EMPRESA' },
{ path: 'nuevo', component: FormularioEmpleado, canActivate: [soloAdmin], title: 'Nuevo empleado · EMPRESA' },
{
path: ':id',
component: FichaEmpleado,
title: 'Ficha de empleado · EMPRESA',
children: [
{ path: '', component: ResumenEmpleado },
{ path: 'editar', component: FormularioEmpleado, title: 'Editar empleado · EMPRESA' },
],
},
];
/empleados/:id carga FichaEmpleado, que pinta el nombre y dos pestañas, y dentro de su <router-outlet> muestra una de sus hijas: el resumen ('') o el formulario (editar).
// src/app/empleados/ficha-empleado.ts
import { httpResource } from '@angular/common/http';
import { Component, input } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { API, Empleado } from '../core/modelos';
// Componente padre de /empleados/:id: lleva la cabecera y las pestañas
@Component({
selector: 'app-ficha-empleado',
imports: [RouterLink, RouterLinkActive, RouterOutlet],
template: `
@if (empleado.error()) {
<p role="alert" class="text-red-700">Ese empleado no existe.</p>
<a class="boton-sec mt-2" routerLink="/empleados">Volver a la lista</a>
} @else if (empleado.hasValue()) {
<h1 class="titulo">{{ empleado.value().nombre }} {{ empleado.value().apellidos }}</h1>
<nav class="mb-4 flex gap-2" aria-label="Secciones de la ficha">
<a
class="boton-sec"
routerLink="./"
routerLinkActive="ring-2 ring-indigo-500"
[routerLinkActiveOptions]="{ exact: true }"
>
Datos
</a>
<a class="boton-sec" routerLink="editar" routerLinkActive="ring-2 ring-indigo-500">Editar</a>
</nav>
<router-outlet />
} @else {
<p>Cargando...</p>
}
`,
})
export class FichaEmpleado {
readonly id = input.required<string>();
readonly empleado = httpResource<Empleado>(() => `${API}/empleados/${this.id()}`);
}
// src/app/empleados/resumen-empleado.ts
import { CurrencyPipe, DatePipe } from '@angular/common';
import { Component, inject } from '@angular/core';
import { FichaEmpleado } from './ficha-empleado';
@Component({
selector: 'app-resumen-empleado',
imports: [CurrencyPipe, DatePipe],
template: `
@if (ficha.empleado.hasValue()) {
@let e = ficha.empleado.value();
<dl class="tarjeta grid max-w-2xl grid-cols-2 gap-3">
<div><dt class="etiqueta">Oficio</dt><dd>{{ e.oficio }}</dd></div>
<div><dt class="etiqueta">Departamento</dt><dd>{{ e.departamento ?? 'Sin departamento' }}</dd></div>
<div><dt class="etiqueta">Correo</dt><dd>{{ e.email }}</dd></div>
<div><dt class="etiqueta">Teléfono</dt><dd>{{ e.telefono ?? 'No consta' }}</dd></div>
<div><dt class="etiqueta">Fecha de alta</dt><dd>{{ e.fechaAlta | date: 'longDate' }}</dd></div>
<div><dt class="etiqueta">Salario</dt><dd>{{ e.salario | currency: 'EUR' }}</dd></div>
</dl>
}
`,
})
export class ResumenEmpleado {
// El dato ya lo cargó el padre: no hace falta otra petición
protected readonly ficha = inject(FichaEmpleado);
}
ResumenEmpleado no hace otra petición: inyecta el componente padre y lee su recurso. Un componente es también un proveedor en el árbol de inyectores, y el componente que muestra un <router-outlet> es antecesor de lo que se muestre dentro. Es la jerarquía de inyectores del módulo 08 en acción.

Figura 8. La ficha tras editar el salario. «No consta» aparece porque el teléfono es null.
Herencia de parámetros: paramsInheritanceStrategy 🔄 v22.0
🔄 Cambia en Angular 22.0. El valor por defecto de
paramsInheritanceStrategypasa de'emptyOnly'a'always'. En la LTS (Angular 21), la hijaeditarno recibe el:iddel padre y el formulario de edición se comporta como un alta, sin ningún error. Si usas la LTS: añadewithRouterConfig({ paramsInheritanceStrategy: 'always' })aprovideRouter.
FormularioEmpleado está en la ruta editar, que no tiene :id: el parámetro es de su padre. Que lo reciba depende de esta opción:
'always'(por defecto en 22): las hijas heredan siempre los parámetros de sus antecesores.'emptyOnly'(por defecto en 21): solo heredan las hijas conpath: ''y las que cuelgan de un padre sin componente.
Por eso, en Angular 21, ResumenEmpleado (path: '') funcionaría y editar no. Lo hemos comprobado con este mismo proyecto compilado en 21.2.25: al abrir /empleados/101/editar aparece el título «Nuevo empleado» con todos los campos vacíos, sin ningún error. Es de los peores fallos posibles, porque si el usuario rellena el formulario, crea un empleado nuevo en lugar de modificar el 101.
14. El formulario de empleados

Figura 9. Envío con el formulario vacío: errores en cada campo y el foco en el primero.
14.1. El esquema
// src/app/empleados/esquema-empleado.ts
import { disabled, email, hidden, min, required, schema, validate } from '@angular/forms/signals';
import { Empleado } from '../core/modelos';
// Los <select> trabajan con texto: deptId y jefeId se convierten al enviar
export interface EmpleadoForm {
nombre: string;
apellidos: string;
email: string;
telefono: string;
fechaAlta: string;
oficio: string;
salario: number | null;
comision: number | null;
deptId: string;
jefeId: string;
}
const hoy = () => new Date().toISOString().slice(0, 10);
export const empleadoVacio = (): EmpleadoForm => ({
nombre: '',
apellidos: '',
email: '',
telefono: '',
fechaAlta: hoy(),
oficio: '',
salario: null,
comision: null,
deptId: '',
jefeId: '',
});
export function aFormulario(e: Empleado | undefined): EmpleadoForm {
if (!e) return empleadoVacio();
return {
nombre: e.nombre,
apellidos: e.apellidos,
email: e.email,
telefono: e.telefono ?? '',
fechaAlta: e.fechaAlta,
oficio: e.oficio,
salario: e.salario,
comision: e.comision,
deptId: e.deptId === null ? '' : String(e.deptId),
jefeId: e.jefeId === null ? '' : String(e.jefeId),
};
}
export const aApi = (f: EmpleadoForm) => ({
...f,
telefono: f.telefono || null,
comision: esVendedor(f.oficio) ? f.comision : null,
deptId: f.deptId ? Number(f.deptId) : null,
jefeId: f.jefeId ? Number(f.jefeId) : null,
});
export const esVendedor = (oficio: string) => oficio.trim().toUpperCase() === 'VENDEDOR';
export const esquemaEmpleado = schema<EmpleadoForm>((p) => {
required(p.nombre, { message: 'El nombre es obligatorio' });
required(p.apellidos, { message: 'Los apellidos son obligatorios' });
required(p.email, { message: 'El correo es obligatorio' });
email(p.email, { message: 'El correo no es válido' });
required(p.oficio, { message: 'El oficio es obligatorio' });
required(p.fechaAlta, { message: 'La fecha de alta es obligatoria' });
required(p.salario, { message: 'El salario es obligatorio' });
min(p.salario, 1, { message: 'El salario debe ser mayor que cero' });
validate(p.fechaAlta, ({ value }) =>
value() > hoy() ? { kind: 'futura', message: 'La fecha no puede ser futura' } : undefined,
);
// Sin departamento no hay lista de posibles jefes
disabled(p.jefeId, { when: ({ valueOf }) => (valueOf(p.deptId) ? false : 'Elige antes un departamento') });
// Solo los vendedores cobran comisión
hidden(p.comision, { when: ({ valueOf }) => !esVendedor(valueOf(p.oficio)) });
});
Por qué un modelo de formulario distinto del de la API. Un <select> trabaja con texto: aunque el <option> tenga [value]="d.deptId", el valor que lee es "30". EmpleadoForm usa string para deptId y jefeId, y aApi los convierte a número, o a null si están vacíos, justo antes de enviar. aFormulario hace el camino contrario, convirtiendo cada null de la API en ''.
Los nulos de la API. telefono, deptId, jefeId y comision pueden llegar a null y no son obligatorios. Si el esquema exigiera el departamento, el empleado 160, que no tiene, no podría guardarse: el botón no haría nada visible si el campo inválido no está a la vista. Por eso solo se valida lo que la API exige de verdad.
validate para la fecha. No hay validador integrado que compare dos textos de fecha. fechaAlta es un string en formato AAAA-MM-DD, y en ese formato la comparación alfabética coincide con la cronológica.
💡 Consejo:
minDateymaxDate(🆕 v22.0) funcionan con campos de tipoDate, no con textos. Si tu modelo usaDate, son la alternativa; aquí<input type="date">ya trabaja con texto y es más simple mantenerlo.
disabled y hidden con { when } 🔄 v22.0
🔄 Cambia en Angular 22.0. Pasar la condición directamente, como
hidden(p.x, fn), queda obsoleto; se escribehidden(p.x, { when: fn }). Si usas la LTS: escribedisabled(p.jefeId, fn)yhidden(p.comision, fn); en 21,{ when }daTS2353: Object literal may only specify known properties, and 'when' does not exist.
disabledcon motivo. Si la función devuelve un texto, el campo se desactiva y el texto aparece endisabledReasons(). El formulario lo muestra bajo el campo Jefe: «Elige antes un departamento». Un campo desactivado no se valida ni bloquea el envío.hidden. La comisión solo tiene sentido para los vendedores. El campo sigue en el modelo, perohidden()estruey la plantilla no lo pinta.aApienvíacomision: nullsi el oficio no es de vendedor, para no guardar comisiones de un oficio anterior.
14.2. El componente
// src/app/empleados/formulario-empleado.ts
import { HttpClient, httpResource } from '@angular/common/http';
import { Component, computed, inject, input, linkedSignal } from '@angular/core';
import { form, FormField, FormRoot } from '@angular/forms/signals';
import { Router, RouterLink } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { ErroresCampo } from '../comun/errores-campo';
import { Avisos } from '../core/avisos';
import { erroresDeApi } from '../core/errores-api';
import { API, Departamento, Empleado, Pagina } from '../core/modelos';
import { aApi, aFormulario, esquemaEmpleado } from './esquema-empleado';
import { FichaEmpleado } from './ficha-empleado';
@Component({
selector: 'app-formulario-empleado',
imports: [FormField, FormRoot, ErroresCampo, RouterLink],
templateUrl: './formulario-empleado.html',
})
export class FormularioEmpleado {
private readonly http = inject(HttpClient);
private readonly router = inject(Router);
private readonly avisos = inject(Avisos);
// Solo existe al editar, porque entonces estamos dentro de la ficha
private readonly ficha = inject(FichaEmpleado, { optional: true });
// Sin id es un alta; con id, una edición
readonly id = input<string>();
protected readonly esEdicion = computed(() => this.id() !== undefined);
protected readonly empleado = httpResource<Empleado>(() =>
this.id() ? `${API}/empleados/${this.id()}` : undefined,
);
protected readonly departamentos = httpResource<Pagina<Departamento>>(
() => `${API}/departamentos?tamano=100`,
);
// Se reinicia cuando llega el empleado, y es escribible para el formulario
protected readonly datos = linkedSignal(() =>
aFormulario(this.empleado.hasValue() ? this.empleado.value() : undefined),
);
protected readonly f = form(this.datos, esquemaEmpleado, {
submission: {
action: async (campos) => {
try {
const guardado = await firstValueFrom(this.guardar());
this.ficha?.empleado.reload();
this.avisos.mostrar(`Guardado: ${guardado.nombre} ${guardado.apellidos}`);
await this.router.navigate(['/empleados', guardado.empId]);
return undefined;
} catch (error) {
return erroresDeApi(error, campos);
}
},
onInvalid: (campos) => campos().errorSummary()[0]?.fieldTree().focusBoundControl(),
},
});
// Los posibles jefes dependen del departamento elegido
protected readonly jefes = httpResource<Pagina<Empleado>>(() => {
const deptId = this.f.deptId().value();
return deptId ? `${API}/departamentos/${deptId}/empleados?tamano=100` : undefined;
});
private guardar() {
const cuerpo = aApi(this.datos());
return this.esEdicion()
? this.http.put<Empleado>(`${API}/empleados/${this.id()}`, cuerpo)
: this.http.post<Empleado>(`${API}/empleados`, cuerpo);
}
}
<!-- src/app/empleados/formulario-empleado.html -->
<form [formRoot]="f" class="tarjeta max-w-2xl">
<h2 class="mb-4 text-xl font-semibold">{{ esEdicion() ? 'Editar empleado' : 'Nuevo empleado' }}</h2>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="etiqueta">Nombre <input class="campo" [formField]="f.nombre" /></label>
<app-errores-campo [campo]="f.nombre" />
</div>
<div>
<label class="etiqueta">Apellidos <input class="campo" [formField]="f.apellidos" /></label>
<app-errores-campo [campo]="f.apellidos" />
</div>
<div>
<label class="etiqueta">Correo <input class="campo" type="email" [formField]="f.email" /></label>
<app-errores-campo [campo]="f.email" />
</div>
<div>
<label class="etiqueta">Teléfono <input class="campo" [formField]="f.telefono" /></label>
</div>
<div>
<label class="etiqueta">Oficio <input class="campo" [formField]="f.oficio" /></label>
<app-errores-campo [campo]="f.oficio" />
</div>
<div>
<label class="etiqueta">Fecha de alta <input class="campo" type="date" [formField]="f.fechaAlta" /></label>
<app-errores-campo [campo]="f.fechaAlta" />
</div>
<div>
<label class="etiqueta">Salario <input class="campo" type="number" [formField]="f.salario" /></label>
<app-errores-campo [campo]="f.salario" />
</div>
@if (!f.comision().hidden()) {
<div>
<label class="etiqueta">Comisión <input class="campo" type="number" [formField]="f.comision" /></label>
</div>
}
<div>
<label class="etiqueta">
Departamento
<select class="campo" [formField]="f.deptId">
<option value="">Sin departamento</option>
@if (departamentos.hasValue()) {
@for (d of departamentos.value().contenido; track d.deptId) {
<option [value]="d.deptId">{{ d.nombre }}</option>
}
}
</select>
</label>
</div>
<div>
<label class="etiqueta">
Jefe
<select class="campo" [formField]="f.jefeId">
<option value="">Sin jefe</option>
@if (jefes.hasValue()) {
@for (j of jefes.value().contenido; track j.empId) {
<option [value]="j.empId">{{ j.nombre }} {{ j.apellidos }}</option>
}
}
</select>
</label>
@for (motivo of f.jefeId().disabledReasons(); track $index) {
<p class="mt-1 text-sm text-slate-600">{{ motivo.message }}</p>
}
</div>
</div>
@for (error of f().errors(); track $index) {
<p role="alert" class="mt-3 text-sm text-red-700">{{ error.message }}</p>
}
<div class="mt-5 flex gap-3">
<button class="boton" [disabled]="f().submitting()">
{{ f().submitting() ? 'Guardando...' : 'Guardar' }}
</button>
<a class="boton-sec" routerLink="/empleados">Cancelar</a>
</div>
</form>
Un solo componente para alta y edición. Si hay id, el recurso empleado carga sus datos; si no, la función devuelve undefined y el recurso queda en reposo sin pedir nada. linkedSignal recalcula datos cuando llega el empleado, y como es escribible, form puede trabajar con él.
Los posibles jefes dependen del departamento. jefes se recalcula al cambiar f.deptId().value(). Sin departamento, devuelve undefined y el recurso no hace petición.
Inyección opcional del padre. En edición, el formulario está dentro de FichaEmpleado y, tras guardar, recarga su recurso para que la cabecera muestre el nombre nuevo. En el alta no hay ficha: inject(FichaEmpleado, { optional: true }) devuelve null, y this.ficha?.empleado.reload() no hace nada.
onInvalid. Si pulsas «Guardar» con errores, el foco va al primer campo con problemas. En un formulario largo, sin esto el usuario no sabría qué falla.
Errores del servidor en su campo. Prueba a dar de alta un empleado con el correo ana.garcia@empresa.es: la API responde 422 con { "email": "Ese email ya está en uso" }, y erroresDeApi lo coloca bajo el campo Correo. Cuando cambias el correo, el error desaparece solo.
⚠️ Importante: Cargar un
<select>cuyas opciones llegan por la red es delicado. Si el valor se fija antes de que existan las opciones, el navegador no tiene ninguna que seleccionar y se queda en la primera.[formField]lo resuelve: lo comprobamos editando al empleado 123 y el desplegable mostró su departamento (30) y su jefe (120) en cuanto llegaron las listas. Con[value]a mano no ocurre lo mismo, como verás en la sección 16.
15. Pedidos

Figura 10. Pedidos filtrados por estado.
// src/app/pedidos/pedidos.routes.ts
import { Routes } from '@angular/router';
import { DetallePedido } from './detalle-pedido';
import { ListaPedidos } from './lista-pedidos';
import { NuevoPedido } from './nuevo-pedido';
export const RUTAS_PEDIDOS: Routes = [
{ path: '', component: ListaPedidos, title: 'Pedidos · EMPRESA' },
{ path: 'nuevo', component: NuevoPedido, title: 'Nuevo pedido · EMPRESA' },
{ path: ':id', component: DetallePedido, title: 'Detalle de pedido · EMPRESA' },
];
// src/app/pedidos/etiqueta-estado.ts
import { Component, computed, input } from '@angular/core';
import { EstadoPedido } from '../core/modelos';
const COLORES: Record<EstadoPedido, string> = {
PENDIENTE: 'bg-amber-100 text-amber-900',
ENVIADO: 'bg-sky-100 text-sky-900',
ENTREGADO: 'bg-emerald-100 text-emerald-900',
CANCELADO: 'bg-slate-200 text-slate-700',
};
@Component({
selector: 'app-etiqueta-estado',
template: `<span class="rounded px-2 py-1 text-xs font-semibold" [class]="color()">{{ estado() }}</span>`,
})
export class EtiquetaEstado {
readonly estado = input.required<EstadoPedido>();
protected readonly color = computed(() => COLORES[this.estado()]);
}
COLORES contiene clases completas. Tailwind no ejecuta tu código: busca nombres de clase en los ficheros. Si escribieras `bg-${color}-100`, la clase no existiría en el CSS final, como explica el módulo 22.
// src/app/pedidos/lista-pedidos.ts
import { CurrencyPipe, DatePipe } from '@angular/common';
import { httpResource } from '@angular/common/http';
import { Component, linkedSignal, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { Paginador } from '../comun/paginador';
import { API, EstadoPedido, Pagina, Pedido } from '../core/modelos';
import { EtiquetaEstado } from './etiqueta-estado';
@Component({
selector: 'app-lista-pedidos',
imports: [CurrencyPipe, DatePipe, EtiquetaEstado, Paginador, RouterLink],
template: `
<div class="mb-4 flex items-center justify-between">
<h1 class="titulo mb-0">Pedidos</h1>
<a class="boton" routerLink="nuevo">Nuevo pedido</a>
</div>
<div class="mb-4 flex gap-2" role="group" aria-label="Filtrar por estado">
@for (e of estados; track e.valor) {
<button
type="button"
class="boton-sec"
[class.ring-2]="estado() === e.valor"
[attr.aria-pressed]="estado() === e.valor"
(click)="estado.set(e.valor)"
>
{{ e.texto }}
</button>
}
</div>
@if (pedidos.error()) {
<p role="alert" class="text-red-700">No se ha podido cargar la lista.</p>
} @else if (pedidos.hasValue()) {
<div class="tarjeta overflow-x-auto p-0">
<table class="w-full text-left text-sm">
<thead class="bg-slate-100">
<tr>
<th class="p-3">Pedido</th>
<th class="p-3">Cliente</th>
<th class="p-3">Fecha</th>
<th class="p-3">Estado</th>
<th class="p-3 text-right">Total</th>
</tr>
</thead>
<tbody>
@for (p of pedidos.value().contenido; track p.pedidoId) {
<tr class="border-t border-slate-200">
<td class="p-3">
<a class="font-medium text-indigo-800 hover:underline" [routerLink]="[p.pedidoId]">{{ p.pedidoId }}</a>
</td>
<td class="p-3">{{ p.cliente }}</td>
<td class="p-3">{{ p.fechaPedido | date: 'shortDate' }}</td>
<td class="p-3"><app-etiqueta-estado [estado]="p.estado" /></td>
<td class="p-3 text-right">{{ p.total | currency: 'EUR' }}</td>
</tr>
}
</tbody>
</table>
</div>
<app-paginador [(pagina)]="pagina" [paginas]="pedidos.value().paginas" />
} @else {
<p>Cargando...</p>
}
`,
})
export class ListaPedidos {
protected readonly estados: { valor: EstadoPedido | ''; texto: string }[] = [
{ valor: '', texto: 'Todos' },
{ valor: 'PENDIENTE', texto: 'Pendientes' },
{ valor: 'ENVIADO', texto: 'Enviados' },
{ valor: 'ENTREGADO', texto: 'Entregados' },
{ valor: 'CANCELADO', texto: 'Cancelados' },
];
protected readonly estado = signal<EstadoPedido | ''>('');
protected readonly pagina = linkedSignal({ source: this.estado, computation: () => 1 });
protected readonly pedidos = httpResource<Pagina<Pedido>>(() => ({
url: `${API}/pedidos`,
params: {
orden: '-pedidoId',
pagina: this.pagina(),
tamano: 10,
...(this.estado() ? { estado: this.estado() } : {}),
},
}));
}
Los botones de filtro llevan aria-pressed para que un lector de pantalla diga cuál está activo. pagina vuelve a 1 al cambiar el estado, con el mismo linkedSignal de los empleados.
15.1. Detalle y cambio de estado
// src/app/pedidos/detalle-pedido.ts
import { CurrencyPipe, DatePipe } from '@angular/common';
import { HttpClient, httpResource } from '@angular/common/http';
import { Component, computed, inject, input, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { Avisos } from '../core/avisos';
import { mensajeDeError } from '../core/errores-api';
import { API, EstadoPedido, Pedido } from '../core/modelos';
import { EtiquetaEstado } from './etiqueta-estado';
// La API admite cualquier cambio de estado: las reglas del negocio están aquí
const SIGUIENTES: Record<EstadoPedido, EstadoPedido[]> = {
PENDIENTE: ['ENVIADO', 'CANCELADO'],
ENVIADO: ['ENTREGADO'],
ENTREGADO: [],
CANCELADO: [],
};
@Component({
selector: 'app-detalle-pedido',
imports: [CurrencyPipe, DatePipe, EtiquetaEstado, RouterLink],
template: `
<a class="text-sm text-indigo-800 hover:underline" routerLink="/pedidos">Volver a pedidos</a>
@if (pedido.error()) {
<p role="alert" class="mt-3 text-red-700">Ese pedido no existe.</p>
} @else if (pedido.hasValue()) {
@let p = pedido.value();
<h1 class="titulo mt-2">Pedido {{ p.pedidoId }}</h1>
<div class="tarjeta">
<p>
{{ p.cliente }} · {{ p.fechaPedido | date: 'longDate' }}
<app-etiqueta-estado class="ml-2" [estado]="p.estado" />
</p>
<table class="mt-4 w-full text-left text-sm">
<thead>
<tr>
<th class="py-2">Producto</th>
<th class="py-2 text-right">Cantidad</th>
<th class="py-2 text-right">Precio</th>
<th class="py-2 text-right">Importe</th>
</tr>
</thead>
<tbody>
@for (l of p.lineas; track l.numLinea) {
<tr class="border-t border-slate-200">
<td class="py-2">{{ l.producto }}</td>
<td class="py-2 text-right">{{ l.cantidad }}</td>
<td class="py-2 text-right">{{ l.precioUnitario | currency: 'EUR' }}</td>
<td class="py-2 text-right">{{ l.importe | currency: 'EUR' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr class="border-t-2 border-slate-400 font-semibold">
<td class="py-2" colspan="3">Total</td>
<td class="py-2 text-right">{{ p.total | currency: 'EUR' }}</td>
</tr>
</tfoot>
</table>
<div class="mt-4 flex gap-2">
@for (estado of siguientes(); track estado) {
<button type="button" class="boton" [disabled]="cambiando()" (click)="cambiarEstado(estado)">
Marcar como {{ estado }}
</button>
}
</div>
</div>
} @else {
<p class="mt-3">Cargando...</p>
}
`,
})
export class DetallePedido {
private readonly http = inject(HttpClient);
private readonly avisos = inject(Avisos);
readonly id = input.required<string>();
protected readonly pedido = httpResource<Pedido>(() => `${API}/pedidos/${this.id()}`);
protected readonly cambiando = signal(false);
protected readonly siguientes = computed(() =>
this.pedido.hasValue() ? SIGUIENTES[this.pedido.value().estado] : [],
);
protected async cambiarEstado(estado: EstadoPedido) {
this.cambiando.set(true);
try {
const actualizado = await firstValueFrom(
this.http.patch<Pedido>(`${API}/pedidos/${this.id()}`, { estado }),
);
// La respuesta ya trae el pedido entero: se escribe en el recurso sin pedirlo otra vez
this.pedido.set(actualizado);
this.avisos.mostrar(`Pedido ${actualizado.pedidoId}: ${estado}`);
} catch (error) {
this.avisos.mostrar(mensajeDeError(error), 'error');
} finally {
this.cambiando.set(false);
}
}
}
La API acepta cualquier estado en PATCH, incluso volver de ENTREGADO a PENDIENTE. Las reglas del negocio están en SIGUIENTES, y la pantalla solo ofrece los botones permitidos. En una aplicación real, el servidor también debería validarlo: la interfaz se puede saltar.
Escribir en el recurso. La respuesta del PATCH trae el pedido completo, así que this.pedido.set(actualizado) lo pone directamente en el recurso. No hace falta otra petición GET. Si la respuesta no trajera el objeto, usarías reload().

Figura 11. Un pedido recién creado y marcado como enviado. Solo queda el paso a entregado.
16. La cesta con SignalStore
Crear un pedido tiene reglas: no se puede pedir más que el stock, hace falta un cliente y al menos una línea, el total se calcula, y un doble clic no debe crear dos pedidos. Además, la cesta debe sobrevivir a la navegación, porque el usuario puede ir al panel y volver. Es el caso que justifica un store.
// src/app/pedidos/cesta.store.ts
import { HttpClient } from '@angular/common/http';
import { computed, inject } from '@angular/core';
import { Router } from '@angular/router';
import { tapResponse } from '@ngrx/operators';
import { patchState, signalStore, withComputed, withMethods, withState } from '@ngrx/signals';
import {
removeAllEntities,
removeEntity,
setEntity,
updateEntity,
withEntities,
} from '@ngrx/signals/entities';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { exhaustMap, pipe, tap } from 'rxjs';
import { Avisos } from '../core/avisos';
import { mensajeDeError } from '../core/errores-api';
import { API, Pedido, Producto } from '../core/modelos';
export interface LineaCesta {
id: number; // withEntities espera una propiedad id
nombre: string;
precio: number;
cantidad: number;
stock: number;
}
export const CestaStore = signalStore(
{ providedIn: 'root' },
withEntities<LineaCesta>(),
withState({ clienteId: '', enviando: false }),
withComputed(({ entities, clienteId, enviando }) => ({
unidades: computed(() => entities().reduce((suma, l) => suma + l.cantidad, 0)),
total: computed(() => entities().reduce((suma, l) => suma + l.cantidad * l.precio, 0)),
sePuedeConfirmar: computed(() => entities().length > 0 && clienteId() !== '' && !enviando()),
})),
withMethods((store) => ({
elegirCliente(clienteId: string) {
patchState(store, { clienteId });
},
anadir(p: Producto) {
const linea = store.entityMap()[p.productoId];
if (linea) {
this.cambiarCantidad(p.productoId, linea.cantidad + 1);
} else {
patchState(
store,
setEntity({ id: p.productoId, nombre: p.nombre, precio: p.precio, cantidad: 1, stock: p.stock }),
);
}
},
cambiarCantidad(id: number, cantidad: number) {
if (cantidad < 1) {
patchState(store, removeEntity(id));
return;
}
// La regla de negocio vive aquí, no en cada botón de la plantilla
patchState(
store,
updateEntity({ id, changes: (l) => ({ cantidad: Math.min(cantidad, l.stock) }) }),
);
},
quitar(id: number) {
patchState(store, removeEntity(id));
},
vaciar() {
patchState(store, removeAllEntities(), { clienteId: '' });
},
})),
withMethods((store, http = inject(HttpClient), avisos = inject(Avisos), router = inject(Router)) => ({
// exhaustMap ignora los clics que lleguen mientras hay un envío en marcha
confirmar: rxMethod<void>(
pipe(
tap(() => patchState(store, { enviando: true })),
exhaustMap(() =>
http
.post<Pedido>(`${API}/pedidos`, {
clienteId: Number(store.clienteId()),
lineas: store.entities().map((l) => ({ productoId: l.id, cantidad: l.cantidad })),
})
.pipe(
tapResponse({
next: (pedido) => {
store.vaciar();
avisos.mostrar(`Pedido ${pedido.pedidoId} creado`);
router.navigate(['/pedidos', pedido.pedidoId]);
},
error: (error) => avisos.mostrar(mensajeDeError(error), 'error'),
finalize: () => patchState(store, { enviando: false }),
}),
),
),
),
),
})),
);
Repasa lo que aprendiste en el módulo 19:
| Pieza | Qué aporta aquí |
|---|---|
{ providedIn: 'root' } | Una única cesta para toda la aplicación, que sobrevive a la navegación |
withEntities<LineaCesta>() | entities, entityMap e ids, con actualizadores como setEntity o updateEntity |
withState | El cliente elegido y si hay un envío en marcha |
withComputed | unidades, total y sePuedeConfirmar, calculados, nunca guardados |
withMethods | Toda modificación pasa por aquí. Desde fuera no se puede llamar a patchState, porque el estado está protegido |
rxMethod + exhaustMap | Mientras un envío está en marcha, los clics siguientes se ignoran |
tapResponse | Trata respuesta y error dentro del flujo interno: un error no mata el rxMethod |
Dos bloques withMethods. El segundo usa store.vaciar(), definido en el primero. Los métodos de un bloque solo ven lo declarado en los bloques anteriores.
exhaustMap frente a otros operadores. switchMap cancelaría el primer envío y haría el segundo: el pedido podría crearse igualmente, porque cancelar la petición no deshace lo que el servidor ya ha hecho. concatMap haría los dos, uno detrás de otro. exhaustMap ignora el segundo, que es lo que queremos. La prueba de extremo a extremo hace doble clic en «Confirmar pedido» y cuenta las peticiones: una.
// src/app/pedidos/nuevo-pedido.ts
import { CurrencyPipe } from '@angular/common';
import { httpResource } from '@angular/common/http';
import { Component, inject } from '@angular/core';
import { API, Cliente, Pagina, Producto } from '../core/modelos';
import { CestaStore } from './cesta.store';
@Component({
selector: 'app-nuevo-pedido',
imports: [CurrencyPipe],
template: `
<h1 class="titulo">Nuevo pedido</h1>
<div class="grid gap-6 md:grid-cols-[3fr_2fr]">
<section aria-label="Catálogo">
@if (productos.hasValue()) {
<ul class="grid gap-3 sm:grid-cols-2">
@for (p of productos.value().contenido; track p.productoId) {
<li class="tarjeta flex flex-col p-3">
<strong>{{ p.nombre }}</strong>
<span class="text-sm text-slate-600">{{ p.categoria }} · {{ p.stock }} en stock</span>
<span class="mt-1 font-semibold">{{ p.precio | currency: 'EUR' }}</span>
<button type="button" class="boton-sec mt-2" (click)="cesta.anadir(p)">Añadir</button>
</li>
}
</ul>
} @else if (productos.error()) {
<p role="alert" class="text-red-700">No se ha podido cargar el catálogo.</p>
} @else {
<p>Cargando...</p>
}
</section>
<aside class="tarjeta self-start" aria-label="Cesta">
<h2 class="mb-3 font-semibold">Cesta</h2>
<label class="etiqueta">
Cliente
<!-- [selected] en cada opción: las opciones llegan después que el valor -->
<select class="campo" (change)="cesta.elegirCliente($any($event.target).value)">
<option value="">Elige un cliente</option>
@if (clientes.hasValue()) {
@for (c of clientes.value().contenido; track c.clienteId) {
<option [value]="c.clienteId" [selected]="'' + c.clienteId === cesta.clienteId()">
{{ c.nombre }} ({{ c.ciudad }})
</option>
}
}
</select>
</label>
@for (l of cesta.entities(); track l.id) {
<div class="mt-3 border-t border-slate-200 pt-3 text-sm">
<div class="flex justify-between">
<span>{{ l.nombre }}</span>
<button type="button" class="text-red-700" [attr.aria-label]="'Quitar ' + l.nombre" (click)="cesta.quitar(l.id)">
Quitar
</button>
</div>
<div class="mt-1 flex items-center gap-2">
<button type="button" class="boton-sec px-2 py-0" [attr.aria-label]="'Una menos de ' + l.nombre" (click)="cesta.cambiarCantidad(l.id, l.cantidad - 1)">-</button>
<span>{{ l.cantidad }}</span>
<button type="button" class="boton-sec px-2 py-0" [attr.aria-label]="'Una más de ' + l.nombre" (click)="cesta.cambiarCantidad(l.id, l.cantidad + 1)">+</button>
<span class="ml-auto">{{ l.cantidad * l.precio | currency: 'EUR' }}</span>
</div>
</div>
} @empty {
<p class="mt-3 text-slate-600">La cesta está vacía.</p>
}
<p class="mt-4 flex justify-between text-lg font-semibold">
<span>Total</span>
<span>{{ cesta.total() | currency: 'EUR' }}</span>
</p>
<button type="button" class="boton mt-3 w-full" [disabled]="!cesta.sePuedeConfirmar()" (click)="cesta.confirmar()">
{{ cesta.enviando() ? 'Enviando...' : 'Confirmar pedido' }}
</button>
</aside>
</div>
`,
})
export class NuevoPedido {
protected readonly cesta = inject(CestaStore);
protected readonly productos = httpResource<Pagina<Producto>>(
() => `${API}/productos?disponibles=true&tamano=100&orden=nombre`,
);
protected readonly clientes = httpResource<Pagina<Cliente>>(
() => `${API}/clientes?tamano=100&orden=nombre`,
);
}

Figura 12. El catálogo y la cesta. El contador de la cabecera también lee el store.
⚠️ Importante: El
<select>de clientes no usa[value]en el<select>, sino[selected]en cada<option>. La primera versión de esta pantalla usaba[value]="cesta.clienteId()", y la prueba de extremo a extremo descubrió el fallo: al volver a la cesta desde el panel, el cliente aparecía en blanco aunque el store lo conservaba. El valor se aplicaba al<select>antes de que el recurso trajera las opciones. Con[selected], cada opción decide si es la elegida en el momento en que se crea.
17. Pruebas
17.1. Pruebas unitarias con Vitest
El proyecto trae cuatro ficheros de prueba. Se ejecutan con:
npm test
Test Files 4 passed (4)
Tests 21 passed (21)
Núcleo: sesión, interceptor y guards.
// src/app/core/core.spec.ts
import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router, UrlTree } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { autenticacionInterceptor } from './autenticacion.interceptor';
import { conSesion, soloAdmin } from './guards';
import { API, Usuario } from './modelos';
import { Sesion } from './sesion';
const ANA: Usuario = { email: 'ana.garcia@empresa.es', nombre: 'Ana García', rol: 'ADMIN' };
function preparar() {
TestBed.configureTestingModule({
providers: [
provideRouter([]),
provideHttpClient(withInterceptors([autenticacionInterceptor])),
provideHttpClientTesting(),
],
});
return {
sesion: TestBed.inject(Sesion),
http: TestBed.inject(HttpClient),
api: TestBed.inject(HttpTestingController),
};
}
async function iniciarSesion(sesion: Sesion, api: HttpTestingController, usuario = ANA) {
const pendiente = firstValueFrom(sesion.entrar(usuario.email, 'secreto'));
api.expectOne(`${API}/auth/login`).flush({ token: 'abc', usuario, expiraEn: 3600 });
await pendiente;
}
describe('Sesion', () => {
beforeEach(() => sessionStorage.clear());
it('guarda la sesión y la borra al salir', async () => {
const { sesion, api } = preparar();
await iniciarSesion(sesion, api);
expect(sesion.activa()).toBe(true);
expect(sesion.esAdmin()).toBe(true);
expect(sessionStorage.getItem('empresa.sesion')).toContain('abc');
sesion.salir();
expect(sesion.activa()).toBe(false);
expect(sessionStorage.getItem('empresa.sesion')).toBeNull();
});
it('recupera la sesión guardada al recargar', () => {
const guardada = { token: 'abc', usuario: ANA, caduca: Date.now() + 60_000 };
sessionStorage.setItem('empresa.sesion', JSON.stringify(guardada));
expect(preparar().sesion.usuario()?.nombre).toBe('Ana García');
});
it('descarta una sesión caducada', () => {
const guardada = { token: 'abc', usuario: ANA, caduca: Date.now() - 1 };
sessionStorage.setItem('empresa.sesion', JSON.stringify(guardada));
expect(preparar().sesion.activa()).toBe(false);
});
});
describe('autenticacionInterceptor', () => {
beforeEach(() => sessionStorage.clear());
it('añade el token a las peticiones de la API, pero no a las ajenas', async () => {
const { sesion, http, api } = preparar();
await iniciarSesion(sesion, api);
http.get(`${API}/empleados`).subscribe();
http.get('https://otro.example/datos').subscribe();
const nuestra = api.expectOne(`${API}/empleados`);
const ajena = api.expectOne('https://otro.example/datos');
expect(nuestra.request.headers.get('Authorization')).toBe('Bearer abc');
expect(ajena.request.headers.has('Authorization')).toBe(false);
});
it('cierra la sesión si la API responde 401', async () => {
const { sesion, http, api } = preparar();
const navegar = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
await iniciarSesion(sesion, api);
http.get(`${API}/auth/yo`).subscribe({ error: () => undefined });
api.expectOne(`${API}/auth/yo`).flush({}, { status: 401, statusText: 'No autorizado' });
expect(sesion.activa()).toBe(false);
expect(navegar.mock.calls[0][0]).toEqual(['/entrar']);
});
});
describe('guards', () => {
beforeEach(() => sessionStorage.clear());
const ejecutar = (guard: typeof conSesion, url = '/empleados') =>
TestBed.runInInjectionContext(() => guard({} as never, { url } as never));
it('conSesion redirige a /entrar y recuerda la dirección', () => {
preparar();
const resultado = ejecutar(conSesion) as UrlTree;
expect(resultado.toString()).toBe('/entrar?volver=%2Fempleados');
});
it('conSesion deja pasar a quien ha entrado', async () => {
const { sesion, api } = preparar();
await iniciarSesion(sesion, api);
expect(ejecutar(conSesion)).toBe(true);
});
it('soloAdmin rechaza a un usuario normal', async () => {
const { sesion, api } = preparar();
await iniciarSesion(sesion, api, { ...ANA, rol: 'USER' });
expect((ejecutar(soloAdmin) as UrlTree).toString()).toBe('/panel');
});
});
provideHttpClientTesting sustituye la red por HttpTestingController: tú decides qué responde cada petición con flush. Los guards son funciones, así que se prueban llamándolas dentro de TestBed.runInInjectionContext, sin montar ningún router de verdad.
El esquema del formulario.
// src/app/empleados/esquema-empleado.spec.ts
import { ApplicationRef, Injector, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { form } from '@angular/forms/signals';
import { aApi, empleadoVacio, EmpleadoForm, esquemaEmpleado } from './esquema-empleado';
function crearFormulario(cambios: Partial<EmpleadoForm> = {}) {
const datos = signal<EmpleadoForm>({ ...empleadoVacio(), ...cambios });
const injector = TestBed.inject(Injector);
return { datos, f: form(datos, esquemaEmpleado, { injector }) };
}
describe('esquemaEmpleado', () => {
it('un formulario vacío no es válido', () => {
const { f } = crearFormulario();
expect(f().valid()).toBe(false);
expect(f.nombre().errors()[0].message).toBe('El nombre es obligatorio');
});
it('un empleado completo es válido', () => {
const { f } = crearFormulario({
nombre: 'Eva',
apellidos: 'Ruiz',
email: 'eva@empresa.es',
oficio: 'ANALISTA',
salario: 2000,
});
expect(f().valid()).toBe(true);
});
it('el campo jefe explica por qué está desactivado', async () => {
const { f } = crearFormulario();
expect(f.jefeId().disabled()).toBe(true);
expect(f.jefeId().disabledReasons()[0].message).toBe('Elige antes un departamento');
f.deptId().value.set('30');
await TestBed.inject(ApplicationRef).whenStable();
expect(f.jefeId().disabled()).toBe(false);
});
it('la comisión solo aparece para los vendedores', () => {
const { f } = crearFormulario({ oficio: 'ANALISTA' });
expect(f.comision().hidden()).toBe(true);
f.oficio().value.set('vendedor');
expect(f.comision().hidden()).toBe(false);
});
it('aApi convierte los textos en lo que espera la API', () => {
const cuerpo = aApi({ ...empleadoVacio(), oficio: 'ANALISTA', comision: 10, deptId: '30' });
expect(cuerpo.deptId).toBe(30);
expect(cuerpo.jefeId).toBeNull();
expect(cuerpo.comision).toBeNull();
});
});
Para probar un esquema no hace falta ningún componente. form necesita un inyector, que se le pasa con la opción injector. Fíjate en el await whenStable() del tercer caso: el estado disabled es una señal derivada y se recalcula al estabilizarse la aplicación.
El componente del panel.
// src/app/panel/panel.spec.ts
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { ApplicationRef, LOCALE_ID } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { registerLocaleData } from '@angular/common';
import localeEs from '@angular/common/locales/es';
import { API, Estadisticas } from '../core/modelos';
import { Panel } from './panel';
registerLocaleData(localeEs);
const DATOS: Estadisticas = {
empleados: 24,
departamentos: 7,
clientes: 14,
productos: 19,
pedidos: 24,
facturacion: 1500,
porDepartamento: [
{ deptId: 10, nombre: 'DIRECCIÓN', empleados: 2, masaSalarial: 1000, salarioMedio: 500 },
{ deptId: 20, nombre: 'VENTAS', empleados: 2, masaSalarial: 500, salarioMedio: 250 },
],
porEstado: { PENDIENTE: 1, ENVIADO: 2, ENTREGADO: 3, CANCELADO: 4 },
};
describe('Panel', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
{ provide: LOCALE_ID, useValue: 'es' },
],
});
});
// whenStable() esperaría a la petición pendiente, que nadie responde:
// tick() ejecuta la detección de cambios y deja la petición lanzada
function crear() {
const fixture = TestBed.createComponent(Panel);
TestBed.tick();
return { fixture, api: TestBed.inject(HttpTestingController) };
}
it('pinta los totales y escala las barras respecto al mayor', async () => {
const { fixture, api } = crear();
api.expectOne(`${API}/estadisticas`).flush(DATOS);
await TestBed.inject(ApplicationRef).whenStable();
await fixture.whenStable();
const html: HTMLElement = fixture.nativeElement;
expect(html.textContent).toContain('Empleados');
expect(html.querySelector('.dato')?.textContent).toBe('24');
const anchos = [...html.querySelectorAll<HTMLElement>('.bg-indigo-600')].map((b) => b.style.width);
expect(anchos).toEqual(['100%', '50%']);
});
it('muestra un error con opción de reintentar', async () => {
const { fixture, api } = crear();
api.expectOne(`${API}/estadisticas`).flush({}, { status: 500, statusText: 'Error' });
await TestBed.inject(ApplicationRef).whenStable();
await fixture.whenStable();
const html: HTMLElement = fixture.nativeElement;
expect(html.querySelector('[role=alert]')?.textContent).toContain('No se han podido cargar');
});
});
⚠️ Importante: No llames a
await fixture.whenStable()antes de responder a la petición.whenStableespera a que terminen las tareas pendientes, y la petición de un recurso es una de ellas: si nadie la responde, la prueba se queda esperando hasta agotar los 5 segundos. Nos pasó al escribir esta prueba.TestBed.tick()ejecuta la detección de cambios, lo que lanza la petición, y después ya puedes responder y esperar.
El store.
// src/app/pedidos/cesta.store.spec.ts
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { API, Producto } from '../core/modelos';
import { CestaStore } from './cesta.store';
const RATON = { productoId: 6, nombre: 'Ratón', precio: 20, stock: 2 } as Producto;
const MONITOR = { productoId: 9, nombre: 'Monitor', precio: 100, stock: 5 } as Producto;
describe('CestaStore', () => {
let cesta: InstanceType<typeof CestaStore>;
let api: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting()],
});
cesta = TestBed.inject(CestaStore);
api = TestBed.inject(HttpTestingController);
});
it('acumula unidades y calcula el total', () => {
cesta.anadir(RATON);
cesta.anadir(RATON);
cesta.anadir(MONITOR);
expect(cesta.unidades()).toBe(3);
expect(cesta.total()).toBe(140);
});
it('no deja pedir más unidades que el stock', () => {
cesta.anadir(RATON);
cesta.cambiarCantidad(RATON.productoId, 9);
expect(cesta.entityMap()[RATON.productoId].cantidad).toBe(2);
});
it('quita la línea cuando la cantidad llega a cero', () => {
cesta.anadir(RATON);
cesta.cambiarCantidad(RATON.productoId, 0);
expect(cesta.entities()).toEqual([]);
});
it('exige cliente y alguna línea para confirmar', () => {
expect(cesta.sePuedeConfirmar()).toBe(false);
cesta.anadir(RATON);
expect(cesta.sePuedeConfirmar()).toBe(false);
cesta.elegirCliente('1001');
expect(cesta.sePuedeConfirmar()).toBe(true);
});
it('un doble clic envía un solo pedido, y al terminar vacía la cesta', () => {
cesta.elegirCliente('1001');
cesta.anadir(RATON);
const navegar = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
cesta.confirmar();
cesta.confirmar();
const peticion = api.expectOne(`${API}/pedidos`);
expect(peticion.request.body).toEqual({
clienteId: 1001,
lineas: [{ productoId: 6, cantidad: 1 }],
});
peticion.flush({ pedidoId: 5025 });
expect(navegar).toHaveBeenCalledWith(['/pedidos', 5025]);
expect(cesta.entities()).toEqual([]);
expect(cesta.enviando()).toBe(false);
});
it('conserva la cesta si la API rechaza el pedido', () => {
cesta.elegirCliente('1001');
cesta.anadir(RATON);
cesta.confirmar();
api
.expectOne(`${API}/pedidos`)
.flush({ mensaje: 'No hay stock suficiente' }, { status: 409, statusText: 'Conflicto' });
expect(cesta.unidades()).toBe(1);
expect(cesta.enviando()).toBe(false);
});
});
La prueba del doble clic llama dos veces a confirmar() y comprueba con expectOne que solo hay una petición. Si cambiaras exhaustMap por concatMap, la prueba fallaría con “Expected one matching request, found 2”. navigate se sustituye por un espía: sin él, el router intentaría ir a /pedidos/5025, una ruta que no existe en la configuración vacía de la prueba, y lanzaría NG04002.
💡 Consejo: Si
ng testfalla conTimeout waiting for workeren un ordenador cargado o en CI, crea unvitest.config.tscontest: { pool: 'threads', maxWorkers: 2 }y pásaselo conng test --runner-config vitest.config.ts.
17.2. El flujo completo con Playwright
Las pruebas unitarias no detectan que dos piezas encajan mal. Para este módulo se escribió una prueba de extremo a extremo con Playwright, como en el módulo 26, que recorre la aplicación contra la API real. Esta es su salida:
OK guard redirige a /entrar y recuerda volver
OK login fallido no redirige y muestra el error
OK login devuelve a la dirección pedida: Luis Martínez Ruiz
OK ficha hija recibe id: luis.martinez@empresa.es
OK la sesión sobrevive a F5
OK panel con estadísticas reales: 24 | 14 | 24 | 61.617 €
OK búsqueda con retardo: 1 empleado
OK alta inválida: errores y foco en el primer campo
OK el 422 de la API aparece en su campo
OK alta creada con id 161
OK edición (PUT) refleja el salario en la ficha
OK guardar con telefono y deptId nulos funciona y conserva jefeId
OK los <select> muestran el valor cuando las opciones llegan después
OK borrado con subordinados: aviso del 409
OK borrado real y recarga de la lista
OK filtro de pedidos: 3 pendientes
OK cesta del store: Cesta (3)
OK el store conserva cesta y cliente al navegar
OK pedido creado con un solo POST: 5025
OK PATCH de estado escrito en el recurso
OK los avisos desaparecen solos
OK 401 con token: interceptor cierra sesión y avisa
OK USER: sin botones de ADMIN y soloAdmin lo manda al panel
OK salir cierra la sesión de verdad
Errores de consola: ninguno
Esta prueba encontró dos fallos que ninguna prueba unitaria había visto: el <select> de clientes de la sección 16 y el texto «1 empleados» de la lista. Un fragmento, para que veas el estilo:
// Doble clic: un solo POST
let posts = 0;
p.on('request', (r) => {
if (r.method() === 'POST' && r.url().endsWith('/api/pedidos')) posts++;
});
await p.getByRole('button', { name: 'Confirmar pedido' }).dblclick();
await p.waitForURL(/pedidos\/\d+$/);
assert.equal(posts, 1);
Para probar la sesión caducada sin esperar una hora, la prueba cambia el token guardado por uno falso, recarga y pulsa «Guardar»: la API responde 401 y la aplicación acaba en /entrar?volver=...&caducada=true.
18. Compilar y desplegar
npm run build
Esta es la salida real:
Initial chunk files | Names | Raw size | Estimated transfer size
chunk-CK2PDUQe.js | - | 212.41 kB | 62.17 kB
main-XHGYJTBE.js | main | 170.78 kB | 43.30 kB
styles-OXV5RIXG.css | styles | 15.73 kB | 3.27 kB
| Initial total | 398.91 kB | 108.74 kB
Lazy chunk files | Names | Raw size | Estimated transfer size
chunk-C-3pV1tu.js | empleados-routes | 15.29 kB | 4.72 kB
chunk-D28DQKZ5.js | pedidos-routes | 11.14 kB | 3.52 kB
chunk-C0VuL8eM.js | panel | 2.99 kB | 1.39 kB
chunk-BUIma-HS.js | - | 950 bytes | 950 bytes
- La carga inicial transfiere unos 109 kB comprimidos, por debajo del aviso de 500 kB del presupuesto de
angular.json. - Empleados, pedidos y el panel están en chunks perezosos: quien solo mira el panel nunca descarga el formulario de empleados.
- El CSS ocupa 3 kB comprimido: Tailwind solo incluye las clases que encuentra en los ficheros.
Para publicarlo, sirve dist/empresa-angular/browser con un servidor web que haga dos cosas: devolver index.html para cualquier ruta que no sea un fichero (si no, recargar en /empleados/101 daría 404) y reenviar /api a la API. Con Nginx, que viste en el módulo 27:
location /api/ {
proxy_pass http://api:3000;
}
location / {
try_files $uri $uri/ /index.html;
}
19. Compilarlo con Angular 21 (LTS)
Hemos compilado y ejecutado este mismo proyecto con Angular 21.2.25, NgRx 21.1.1, TypeScript 5.9.3 y Vitest 4.1.12. Con los cambios de esta sección, el build, las 21 pruebas unitarias y el flujo completo de Playwright pasan igual que en 22.
19.1. Dependencias
En package.json:
"dependencies": {
"@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0",
"@angular/core": "^21.2.0",
"@angular/forms": "^21.2.0",
"@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0",
"@ngrx/operators": "^21.0.0",
"@ngrx/signals": "^21.0.0",
...
},
"devDependencies": {
"@angular/build": "^21.2.0",
"@angular/cli": "^21.2.0",
"@angular/compiler-cli": "^21.2.0",
"jsdom": "^28.0.0",
"typescript": "~5.9.2",
"vitest": "^4.0.0",
...
}
Borra package-lock.json y node_modules antes de npm install, para que npm no intente conservar las versiones de 22.
19.2. Lo que da error al compilar
Sin tocar el código, ng build en 21 da exactamente estos cinco errores:
TS2305: Module '"@angular/core"' has no exported member 'Service'. (sesion.ts y avisos.ts)
TS2353: Object literal may only specify known properties, and 'when' does not exist
in type 'NoInfer<LogicFn<string, string | boolean, Child>>'. (esquema-empleado.ts)
TS2353: Object literal may only specify known properties, and 'when' does not exist
in type 'LogicFn<number, boolean, Child>'. (esquema-empleado.ts)
TS2724: '"@angular/forms/signals"' has no exported member named 'isFieldTree'. (errores-api.ts)
| Fichero | Angular 22 | Angular 21 |
|---|---|---|
core/sesion.ts, core/avisos.ts | @Service() | @Injectable({ providedIn: 'root' }), importando Injectable |
empleados/esquema-empleado.ts | disabled(p.jefeId, { when: fn }) | disabled(p.jefeId, fn) |
empleados/esquema-empleado.ts | hidden(p.comision, { when: fn }) | hidden(p.comision, fn) |
core/errores-api.ts | isFieldTree(campo) | Comprobar que la propiedad exista |
Así queda erroresDeApi en 21:
// src/app/core/errores-api.ts
import { HttpErrorResponse } from '@angular/common/http';
import { FieldTree, TreeValidationResult, ValidationError } from '@angular/forms/signals';
// Texto legible para cualquier error de la API
export function mensajeDeError(error: unknown): string {
if (error instanceof HttpErrorResponse) {
return error.status === 0
? 'No hay conexión con el servidor'
: (error.error?.mensaje ?? `Error ${error.status}`);
}
return 'Error inesperado';
}
// Convierte un 422 de la API en errores colocados en cada campo del formulario
export function erroresDeApi(error: unknown, campos: unknown): TreeValidationResult {
const detalles: Record<string, string> | undefined =
error instanceof HttpErrorResponse && error.status === 422 ? error.error?.detalles : undefined;
if (!detalles) return { kind: 'servidor', message: mensajeDeError(error) };
const lista: ValidationError.WithOptionalFieldTree[] = [];
for (const [nombre, message] of Object.entries(detalles)) {
const campo = (campos as Record<string, FieldTree<unknown> | undefined>)[nombre];
lista.push(
campo
? { kind: 'servidor', message, fieldTree: campo }
: { kind: 'servidor', message: `${nombre}: ${message}` },
);
}
return lista;
}
19.3. Lo que compila, pero se comporta distinto
Esto es lo peligroso: el compilador no avisa.
La herencia de parámetros. Es imprescindible. Sin ella, la edición de empleados se convierte en un alta, como se explicó en la sección 13. En app.config.ts:
// src/app/app.config.ts
import { registerLocaleData } from '@angular/common';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import localeEs from '@angular/common/locales/es';
import { ApplicationConfig, LOCALE_ID, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter, withComponentInputBinding, withRouterConfig } from '@angular/router';
import { routes } from './app.routes';
import { autenticacionInterceptor } from './core/autenticacion.interceptor';
registerLocaleData(localeEs);
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(
routes,
withComponentInputBinding(),
withRouterConfig({ paramsInheritanceStrategy: 'always' }),
),
provideHttpClient(withInterceptors([autenticacionInterceptor])),
{ provide: LOCALE_ID, useValue: 'es' },
],
};
OnPush explícito. En 22, un componente sin changeDetection usa OnPush; en 21 usa Eager (el antiguo Default). No es necesario para compilar, y esta aplicación se basa en señales: antes de añadirlo comprobamos en 21 la pantalla de edición y funcionaba igual. Aun así, para tener el mismo comportamiento que en 22, añade OnPush a los catorce componentes:
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'app-panel',
...
})
Si un día añades código que modifica un objeto sin pasar por una señal, OnPush hará que la vista no se entere. Es mejor descubrirlo en 21 que al actualizar a 22.
19.4. Lo que no cambia
HttpClientusa XHR en 21 yfetchen 22. La aplicación no lo nota.?.en plantillas danullen 21 yundefineden 22. Aquí solo se usa en interpolaciones, que muestran texto vacío en ambos casos.httpResource,resourcey Signal Forms son experimentales en 21, pero las APIs que usa el proyecto existen y funcionan igual en 21.2.FormRootlanzaNG01915en 21 si el formulario no tienesubmission. Los dos formularios del proyecto la declaran, así que no afecta.- NgRx:
tapResponsecon objeto,rxMethod,withEntitiesywatchStatefuncionan igual en 21.1.1. - Tailwind 4, el proxy y la estructura del proyecto no dependen de la versión de Angular.
💡 Consejo: Si empiezas en 21 y luego actualizas,
ng updateaplica migraciones que conservan el comportamiento: añadeChangeDetectionStrategy.Eagerdonde no había estrategia, y dejawithXhr()si usabas el backend por defecto. No migraparamsInheritanceStrategy, pero tampoco hace falta: si ya pusiste'always', el comportamiento es el mismo. Lo viste en el módulo 28.
20. Errores frecuentes
| Síntoma | Causa | Solución |
|---|---|---|
Todas las peticiones dan 404 en ng serve | Falta proxyConfig o la API no está arrancada | Revisa angular.json y que node server.mjs siga en marcha |
Tras desplegar, F5 en /empleados/101 da 404 | El servidor no devuelve index.html para las rutas de Angular | try_files ... /index.html (sección 18) |
| La edición de empleados se comporta como un alta | Angular 21 sin paramsInheritanceStrategy: 'always' | Sección 19.3 |
| «Guardar» no hace nada | Hay un campo inválido que no está a la vista, o el esquema exige algo que la API permite nulo | onInvalid para enfocar; valida solo lo que la API exige |
El <select> aparece en blanco al volver a una pantalla | [value] aplicado antes de que lleguen las opciones | [formField], o [selected] en cada <option> |
Una clase de Tailwind construida con ${} no tiene estilo | Tailwind no ve la clase en el código fuente | Mapa con clases completas, como COLORES |
| La prueba de un componente se queda colgada 5 s | await whenStable() con una petición sin responder | TestBed.tick(), flush y después whenStable() |
NG04002: Cannot match any routes en una prueba | El código navega a una ruta que la configuración de la prueba no tiene | Espía Router.navigate o declara la ruta |
| Un doble clic crea dos pedidos | mergeMap/concatMap, o ningún control del envío | exhaustMap en un rxMethod |
| Tras salir, otro usuario ve la cesta anterior | El store es global y nadie lo vacía | cesta.vaciar() en salir() |
21. Ejercicios
Las soluciones resueltas se han compilado y probado sobre una copia del proyecto, con una prueba de Vitest por ejercicio (26 pruebas en total con las del proyecto).
Ejercicio 1: Cambios sin guardar
Si el usuario edita un empleado y pulsa otro enlace sin guardar, pierde los cambios. Crea un guard canDeactivate que pregunte antes de salir, solo si hay cambios.
Ver solución
Un guard genérico que funciona con cualquier componente que sepa decir si tiene cambios:
// src/app/ejercicios/cambios-sin-guardar.guard.ts
import { CanDeactivateFn } from '@angular/router';
// Cualquier componente que sepa decir si tiene cambios pendientes
export interface ConCambios {
hayCambios(): boolean;
}
export const cambiosSinGuardar: CanDeactivateFn<ConCambios> = (componente) =>
!componente.hayCambios() || confirm('Hay cambios sin guardar. ¿Quieres salir igualmente?');
En FormularioEmpleado, el método público:
// Ejercicio 1
hayCambios() {
return this.f().dirty() && !this.f().submitting();
}
Y en la ruta editar:
{
path: 'editar',
component: FormularioEmpleado,
canDeactivate: [cambiosSinGuardar],
title: 'Editar empleado · EMPRESA',
},
!submitting() es importante: al guardar, la acción navega a la ficha mientras el envío sigue en marcha, y el formulario aún está dirty. Sin esa condición, el usuario vería la pregunta justo después de guardar. La prueba:
it('1. cambiosSinGuardar pregunta solo si hay cambios', () => {
const confirmar = vi.spyOn(window, 'confirm').mockReturnValue(false);
const ejecutar = (hay: boolean) =>
cambiosSinGuardar({ hayCambios: () => hay }, {} as never, {} as never, {} as never);
expect(ejecutar(false)).toBe(true);
expect(confirmar).not.toHaveBeenCalled();
expect(ejecutar(true)).toBe(false);
expect(confirmar).toHaveBeenCalledOnce();
});
Ejercicio 2: Cerrar la sesión al caducar
Ahora la caducidad solo se detecta cuando la API responde 401. Haz que la aplicación cierre la sesión justo cuando caduca, aunque el usuario no haga nada.
Ver solución
// src/app/ejercicios/caducidad.ts
import { effect, inject } from '@angular/core';
import { Router } from '@angular/router';
import { Avisos } from '../core/avisos';
import { Sesion } from '../core/sesion';
// Llámala en el constructor de Marco: cierra la sesión justo cuando caduca
export function vigilarCaducidad() {
const sesion = inject(Sesion);
const router = inject(Router);
const avisos = inject(Avisos);
effect((onCleanup) => {
if (!sesion.activa()) return;
const espera = Math.max(0, sesion.caduca() - Date.now());
const temporizador = setTimeout(() => {
const volver = router.url;
sesion.salir();
avisos.mostrar('Tu sesión ha caducado', 'error');
router.navigate(['/entrar'], { queryParams: { volver, caducada: true } });
}, espera);
// Si la sesión cambia o el componente se destruye, se cancela el temporizador
onCleanup(() => clearTimeout(temporizador));
});
}
Se llama en el constructor de Marco:
constructor() {
vigilarCaducidad();
}
Al ser un effect, se vuelve a ejecutar si cambia la sesión (otro login con otra caducidad), y onCleanup cancela el temporizador anterior. Si el usuario sale antes, activa() pasa a false, el efecto se reejecuta y el temporizador viejo se cancela. La prueba usa los temporizadores falsos de Vitest:
vi.advanceTimersByTime(59_000);
expect(sesion.activa()).toBe(true);
vi.advanceTimersByTime(1_000);
expect(sesion.activa()).toBe(false);
Ejercicio 3: Departamentos
Añade la pantalla /departamentos con nombre, localidad y presupuesto. Ojo: el departamento 70 tiene presupuesto null.
Ver solución
// src/app/ejercicios/lista-departamentos.ts
import { CurrencyPipe } from '@angular/common';
import { httpResource } from '@angular/common/http';
import { Component } from '@angular/core';
import { API, Departamento, Pagina } from '../core/modelos';
@Component({
selector: 'app-lista-departamentos',
imports: [CurrencyPipe],
template: `
<h1 class="titulo">Departamentos</h1>
@if (departamentos.hasValue()) {
<table class="tarjeta w-full text-left text-sm">
<thead>
<tr><th class="p-2">Nombre</th><th class="p-2">Localidad</th><th class="p-2 text-right">Presupuesto</th></tr>
</thead>
<tbody>
@for (d of departamentos.value().contenido; track d.deptId) {
<tr class="border-t border-slate-200">
<td class="p-2">{{ d.nombre }}</td>
<td class="p-2">{{ d.localidad }}</td>
<td class="p-2 text-right">
@if (d.presupuesto === null) {
<span class="text-slate-500">Sin presupuesto</span>
} @else {
{{ d.presupuesto | currency: 'EUR' : 'symbol' : '1.0-0' }}
}
</td>
</tr>
}
</tbody>
</table>
} @else if (departamentos.error()) {
<p role="alert" class="text-red-700">No se han podido cargar los departamentos.</p>
} @else {
<p>Cargando...</p>
}
`,
})
export class ListaDepartamentos {
protected readonly departamentos = httpResource<Pagina<Departamento>>(
() => `${API}/departamentos?tamano=100&orden=nombre`,
);
}
La ruta, dentro de las hijas de Marco, y el enlace en enlaces:
{
path: 'departamentos',
loadComponent: () => import('./ejercicios/lista-departamentos').then((m) => m.ListaDepartamentos),
title: 'Departamentos · EMPRESA',
},
Si pasaras null al pipe currency, mostraría una celda vacía sin explicación. Comprobarlo con @if deja claro que el dato falta. La prueba comprueba las dos filas: 500.000 € y «Sin presupuesto».
Ejercicio 4: La cesta sobrevive a F5
La cesta está en memoria: si recargas, se pierde. Guárdala en sessionStorage y recupérala al arrancar.
Ver solución
Se añade un withHooks al final de CestaStore (importa withHooks y watchState de @ngrx/signals, y setAllEntities de @ngrx/signals/entities):
// Ejercicio 4: la cesta sobrevive a F5
withHooks({
onInit(store) {
try {
const guardada = JSON.parse(sessionStorage.getItem('empresa.cesta') ?? 'null');
if (guardada) {
patchState(store, setAllEntities(guardada.lineas as LineaCesta[]), { clienteId: guardada.clienteId });
}
} catch {
sessionStorage.removeItem('empresa.cesta');
}
// Se ejecuta al principio y tras cada cambio. Recibe el estado en bruto:
// entities es un computed y no viene incluido
watchState(store, ({ clienteId, ids, entityMap }) => {
const lineas = ids.map((id) => entityMap[id]);
sessionStorage.setItem('empresa.cesta', JSON.stringify({ clienteId, lineas }));
});
},
}),
watchState recibe el estado guardado, no las señales calculadas. Por eso se reconstruyen las líneas a partir de ids y entityMap, que conservan el orden. vaciar() al salir sigue funcionando: deja la cesta vacía y watchState guarda esa cesta vacía.
⚠️ Importante: Lo intentamos primero como una feature reutilizable con
signalStoreFeature({ state: type<...>() }, withHooks(...)), y TypeScript la rechazó al componerla conwithEntitiesywithState(TS2769: No overload matches this call). UnwithHooksdentro del propio store es más sencillo y está tipado sin esfuerzo.
Ejercicio 5: Reintentar lecturas
Si la API tarda en arrancar o tiene un fallo momentáneo, una lectura falla y el usuario tiene que recargar. Escribe un interceptor que reintente dos veces las peticiones GET que fallen por la red o por un error 5xx.
Ver solución
// src/app/ejercicios/reintentos.interceptor.ts
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { retry, throwError, timer } from 'rxjs';
// Reintenta dos veces las lecturas que fallan por la red (0) o por el servidor (5xx)
export const reintentosInterceptor: HttpInterceptorFn = (peticion, siguiente) => {
if (peticion.method !== 'GET') return siguiente(peticion); // escribir dos veces no es seguro
return siguiente(peticion).pipe(
retry({
count: 2,
delay: (error: HttpErrorResponse, intento) =>
error.status === 0 || error.status >= 500 ? timer(300 * intento) : throwError(() => error),
}),
);
};
Se registra antes que el de autenticación: withInterceptors([reintentosInterceptor, autenticacionInterceptor]). Así cada reintento vuelve a pasar por el de autenticación y lleva el token.
- Solo
GET: repetir unPOSTpodría crear dos pedidos. - Solo
0y5xx: un 404 o un 422 no van a cambiar por mucho que insistas. - La espera crece con cada intento (300 ms, 600 ms). Reintentar al instante contra un servidor saturado solo empeora las cosas.
La prueba responde un 503, avanza el reloj falso 300 ms y comprueba que llega una segunda petición; con un 404 comprueba con expectNone que no hay reintento.
Ejercicio 6 (propuesto): Filtros en la dirección
Si filtras los empleados por Ventas y recargas, el filtro se pierde, y no puedes mandarle a nadie el enlace a “la página 2 de Ventas”. Lleva q, deptId, orden y pagina a los parámetros de consulta (/empleados?deptId=30&pagina=2). Pista: con withComponentInputBinding llegan como input(); para cambiarlos, navega con router.navigate([], { queryParams, queryParamsHandling: 'merge' }). Un valor null quita el parámetro de la dirección. El paginador ya no podrá usar [(pagina)], porque un input() no es escribible.
Ejercicio 7 (propuesto): CRUD de productos
Añade la gestión de productos, con categoría en un desplegable (/api/categorias), precio y stock. La API devuelve 409 si borras un producto que aparece en pedidos: muéstralo bien.
Ejercicio 8 (propuesto): Modo oscuro
Añade un botón en la cabecera que alterne entre tema claro y oscuro, con una señal en un servicio, la variante dark: de Tailwind y la preferencia guardada en localStorage, como en el módulo 22.
Ejercicio 9 (propuesto): Ficha de cliente con sus pedidos
Crea /clientes/:id con los datos del cliente y una lista de sus pedidos (/api/clientes/:id/pedidos), reutilizando EtiquetaEstado. Dos de los clientes no tienen correo: la ficha no debe mostrar «null».
Ejercicio 10 (propuesto): Despliegue con Docker
Usa el Dockerfile y la configuración de Nginx del módulo 27 para servir el proyecto, y un docker-compose.yml con dos servicios: la aplicación y la API EMPRESA. Comprueba que F5 en /empleados/101 funciona y que /api llega a la API.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Proxy de desarrollo | proxy.conf.json, proxyConfig | El código pide /api; el entorno decide a dónde va |
| Sesión | @Service, signal, sessionStorage | Una señal privada, computed públicos y la caducidad como instante absoluto |
| Interceptor | HttpInterceptorFn, catchError | Token solo a la API propia; 401 con token cierra la sesión |
| Guards | CanActivateFn, createUrlTree | Devolver un UrlTree y recordar volver |
| Lecturas | httpResource | Petición derivada de señales, cancelación automática, error antes que valor |
| Valor durante la carga | linkedSignal({ source, computation }) | El valor anterior evita que la tabla parpadee |
| Reiniciar la página | linkedSignal | Vuelve a 1 al cambiar un filtro, sin effect |
| Rutas hijas | children, inject(Padre) | La hija lee el recurso del padre; paramsInheritanceStrategy 🔄 v22.0 |
| Formularios | form, schema, FormRoot, submission | Un esquema, errores del servidor en su campo, onInvalid |
| Campos condicionales | disabled, hidden con { when } | 🔄 v22.0; en 21, la función directa |
| Escritura en recursos | resource.set, reload | Usa la respuesta si trae el objeto; si no, recarga |
| Store | signalStore, withEntities, rxMethod | Para lo compartido con reglas; exhaustMap evita dobles envíos |
| Estilos | Tailwind 4, @layer components | Clases completas, componentes para lo repetido |
| Pruebas | HttpTestingController, TestBed.tick, Playwright | Unitarias para las reglas; extremo a extremo para que encajen |
| Angular 21 | @Injectable, withRouterConfig, OnPush | Cinco errores de compilación y un cambio silencioso imprescindible |