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 httpResource y escribir con HttpClient, 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

Pantalla del panel de mando: cuatro tarjetas con totales, barras de masa salarial por departamento y pedidos por estado

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:

  1. El navegador cree que la API está en el mismo origen. No hay petición previa OPTIONS en cada escritura, ni cabeceras CORS que configurar.
  2. 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.
  3. Cambiar de API es cambiar un fichero de configuración, no buscar URLs por el código. Si tu API usa otro puerto, cambias target y listo.

⚠️ Importante: El proxy solo existe en ng serve. Si abres la carpeta dist con cualquier servidor estático, /api dará 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

Diagrama en tres capas: pantallas arriba, núcleo compartido en medio (Sesion, Avisos, Guards, CestaStore) y salida a la red abajo (httpResource, HttpClient e interceptor) hacia la API

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

Cuatro columnas: señal local, httpResource, servicio con señales y SignalStore, con los datos que guarda cada una en la aplicación

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.
  • httpResource para 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, HttpClient usa fetch por defecto y withFetch() 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 quieres fetch también en 21, añade withFetch() a provideHttpClient.

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 computed de solo lectura (usuario, token, activa, esAdmin). Nadie puede poner activa a true sin token.
  • La sesión se recupera al arrancar. leerGuardada lee sessionStorage una 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í.
  • entrar devuelve 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 sessionStorage y localStorage. 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 cookie HttpOnly, que JavaScript no puede leer, pero exige que el servidor la emita y protegerse de CSRF. La API EMPRESA trabaja con Authorization: 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 da TS2305: 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

Diagrama de secuencia entre navegador, guard, interceptor y API: redirección a entrar, login, vuelta a la ruta pedida, petición con Bearer y cierre de sesión ante un 401

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:

  1. 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.
  2. 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 /entrar con caducada=true y 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.
  3. 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 pedir Sesion y Router sin 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

Árbol de rutas: entrar con sinSesion, la ruta vacía con Marco y conSesion, y debajo panel, empleados y pedidos perezosos; empleados tiene lista, nuevo con soloAdmin y la ficha :id con sus hijas

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: '' },
];
  • Marco es un componente con su propio <router-outlet>. Todas las pantallas con cabecera son sus hijas, y el guard conSesion las protege a todas desde un solo sitio.
  • Entrar y Marco se cargan de inicio; el resto, con loadComponent y loadChildren, 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: canActivate en 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']);
  }
}
  • routerLinkActive resalta 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" con role="status": los lectores de pantalla los anuncian sin interrumpir, como viste en el módulo 21.
  • ?. en sesion.usuario()?.nombre es 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.

Pantalla de login con el correo de Ana, la contraseña tecleada y el mensaje rojo Email o contraseña incorrectos bajo los campos

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 devuelve undefined si 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ñade novalidate y lanza la acción al pulsar el botón. Mientras se ejecuta, f().submitting() es true y el botón se desactiva: no hay doble envío.
  • volver y caducada son input() que llegan de la dirección gracias a withComponentInputBinding. booleanAttribute convierte el texto "true" en true.
  • Si el usuario pulsa «Entrar» con los campos vacíos, submit marca 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 como FieldTree<unknown> | undefined y comprueba que exista, como en la sección 19.


11. El panel de mando 🔄 v22.0

🔄 Cambia en Angular 22.0. httpResource es estable desde 22. En la LTS (Angular 21) existe y se usa igual, pero es experimental. Si usas la LTS: todo el proyecto usa httpResource sin 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:

  1. @if (recurso.error()) primero. Si comprobaras hasValue() primero, tras un error en una recarga seguirías viendo los datos viejos sin saber que han fallado.
  2. @else if (recurso.hasValue()) para los datos, con @let para no repetir estadisticas.value().
  3. @else para 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

Tabla de empleados con buscador, filtro de departamento, columnas de oficio, departamento y salario, enlaces Editar y Borrar, y paginación de 3 páginas

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.

Ficha de Eva Ruiz Prueba con las pestañas Datos y Editar y una tarjeta con oficio, departamento, correo, teléfono No consta, fecha de alta y salario 2.400,00 euros

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 paramsInheritanceStrategy pasa de 'emptyOnly' a 'always'. En la LTS (Angular 21), la hija editar no recibe el :id del padre y el formulario de edición se comporta como un alta, sin ningún error. Si usas la LTS: añade withRouterConfig({ paramsInheritanceStrategy: 'always' }) a provideRouter.

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 con path: '' 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

Formulario Nuevo empleado tras pulsar Guardar vacío: mensajes rojos bajo nombre, apellidos, correo, oficio y salario, foco en Nombre y el jefe desactivado con el texto Elige antes un departamento

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: minDate y maxDate (🆕 v22.0) funcionan con campos de tipo Date, no con textos. Si tu modelo usa Date, 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 escribe hidden(p.x, { when: fn }). Si usas la LTS: escribe disabled(p.jefeId, fn) y hidden(p.comision, fn); en 21, { when } da TS2353: Object literal may only specify known properties, and 'when' does not exist.

  • disabled con motivo. Si la función devuelve un texto, el campo se desactiva y el texto aparece en disabledReasons(). 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, pero hidden() es true y la plantilla no lo pinta. aApi envía comision: null si 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

Lista de pedidos filtrada por Pendientes, con columnas de pedido, cliente, fecha, estado con etiqueta de color y total

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().

Detalle del pedido 5025 en estado ENVIADO con dos líneas, total de 178,80 euros y el botón Marcar como ENTREGADO

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`,
  );
}

Pantalla Nuevo pedido con el catálogo de productos a la izquierda y la cesta a la derecha: cliente Talleres Martín, dos ratones y un monitor, total 178,80 euros

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. whenStable espera 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 test falla con Timeout waiting for worker en un ordenador cargado o en CI, crea un vitest.config.ts con test: { pool: 'threads', maxWorkers: 2 } y pásaselo con ng 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

  • HttpClient usa XHR en 21 y fetch en 22. La aplicación no lo nota.
  • ?. en plantillas da null en 21 y undefined en 22. Aquí solo se usa en interpolaciones, que muestran texto vacío en ambos casos.
  • httpResource, resource y Signal Forms son experimentales en 21, pero las APIs que usa el proyecto existen y funcionan igual en 21.2.
  • FormRoot lanza NG01915 en 21 si el formulario no tiene submission. Los dos formularios del proyecto la declaran, así que no afecta.
  • NgRx: tapResponse con objeto, rxMethod, withEntities y watchState funcionan 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 update aplica migraciones que conservan el comportamiento: añade ChangeDetectionStrategy.Eager donde no había estrategia, y deja withXhr() si usabas el backend por defecto. No migra paramsInheritanceStrategy, 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 con withEntities y withState (TS2769: No overload matches this call). Un withHooks dentro 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 un POST podría crear dos pedidos.
  • Solo 0 y 5xx: 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