Módulo 26: Proyecto final

Objetivos del módulo

  • Construir de principio a fin una aplicación React completa contra una API real: la aplicación de gestión EMPRESA
  • Organizar el código por funcionalidades y separar lo compartido de lo específico
  • Iniciar sesión con JWT, guardar la sesión con Zustand y proteger rutas con React Router 8
  • Usar TanStack Query para todo el estado de servidor: listas paginadas, detalles, mutaciones, invalidación y una actualización optimista
  • Usar Zustand para el estado de cliente: sesión, tema y avisos
  • Escribir formularios con React Hook Form y Zod, y llevar los errores 422 del servidor a cada campo
  • Tratar los conflictos 409 de la API sin perder lo que el usuario estaba haciendo
  • Dar estilo con Tailwind 4 y un modo oscuro basado en variables CSS
  • Probar la aplicación con Vitest, Testing Library y MSW, y de extremo a extremo con Playwright
  • Generar el build de producción y saber qué cambia si tu proyecto usa React 19.1 o anterior

💡 Consejo: Este módulo reúne lo que has aprendido en el curso. Aquí no se explica de nuevo cada API: cuando algo se usa por primera vez, tienes el enlace al módulo donde se vio a fondo. Lo que sí encontrarás son las decisiones de diseño, sus motivos y los errores que aparecen al juntar todas las piezas. Todo el código del proyecto está en el texto, fichero a fichero, y además puedes descargarlo entero: empresa-react.zip.

⚠️ Importante: El proyecto usa React 19.3.1 y React Router 8.5.1, que exige React 19.2.7 o superior. La sección 17 explica qué cambiar si tienes React 19.1, 19.0 o 18.3. Se ha comprobado instalando esas versiones sobre el mismo proyecto y pasando todas sus pruebas.


1. Qué vas a construir

La aplicación EMPRESA es el típico panel de gestión interna: una pantalla de entrada, un menú lateral y varias secciones con tablas y formularios. Funciona contra la API de ejemplo que has usado durante el curso.

Pantalla de inicio de sesión con el mensaje de error de contraseña incorrecta

Figura 1. Inicio de sesión. La contraseña incorrecta devuelve un 401 y su mensaje se muestra tal cual.

Lo que hace la aplicación:

Pantalla Ruta Qué hace
Inicio de sesión /login Pide email y contraseña, guarda el token y te devuelve a donde ibas
Panel / Seis indicadores y dos gráficos de barras con /api/estadisticas
Empleados /empleados Búsqueda, filtro por departamento, orden por columnas y paginación, todo en la URL
Alta y edición de empleado /empleados/nuevo, /empleados/:id/editar Formulario con validación en cliente y errores del servidor por campo
Departamentos /departamentos Listado, alta, edición y borrado
Pedidos /pedidos Listado con filtro por estado, orden y paginación
Detalle de pedido /pedidos/:id Líneas, total y cambio de estado optimista
Nuevo pedido /pedidos/nuevo Líneas dinámicas con useFieldArray y total estimado en vivo

Y además, en todas las pantallas:

  • Si no has iniciado sesión, cualquier ruta te lleva a /login?volver=..., y al entrar vuelves a esa ruta.
  • La sesión sobrevive a una recarga. Si caduca, la próxima petición que la API rechace te devuelve al login con un aviso.
  • Solo el rol ADMIN ve los botones de borrar, porque la API solo deja borrar a ese rol.
  • Un modo oscuro que se recuerda entre visitas.

Panel con seis indicadores y dos gráficos de barras

Figura 2. El panel. Los gráficos son tablas HTML con barras dentro: un lector de pantalla los lee como una tabla normal.

Las piezas y por qué estas

Necesidad Herramienta Módulo Por qué esta y no otra
Rutas React Router 8 (modo Datos) 18 createBrowserRouter permite carga perezosa por ruta y pantallas de error por rama
Datos de la API TanStack Query 5 19 Caché, reintentos, invalidación y optimismo ya resueltos
Sesión, tema, avisos Zustand 5 20 Se lee fuera de React (el cliente HTTP lo necesita) y persist lo guarda solo
Formularios React Hook Form + Zod 4 21 Validación declarativa, tipos derivados del esquema y setError para el servidor
Estilos Tailwind 4 07 Sin ficheros CSS por componente; el modo oscuro con variables
Pruebas Vitest, Testing Library, MSW, Playwright 22 Unidad y componente sin API; extremo a extremo con la API real

En el módulo 20 quedó dicho: este curso usa TanStack Query para los datos y Zustand o Context para lo demás. Aquí se cumple al pie de la letra. Ningún dato de la API vive en Zustand, y ningún dato de la interfaz vive en la caché de Query. Si dudas de dónde va algo, hazte esta pregunta: ¿quién tiene la verdad? Si la tiene el servidor, y tú solo guardas una copia que puede quedarse vieja, es estado de servidor. Si la tiene el navegador, es estado de cliente.

📘 Concepto: El estado de servidor es una copia local de algo que vive en otro sitio. Puede caducar, otro usuario puede cambiarlo y hay que pedirlo de forma asíncrona. El estado de cliente nace y muere en el navegador: el tema elegido, un aviso en pantalla, el token de esta sesión. Mezclarlos en un mismo store obliga a reescribir a mano la caché, los reintentos y la invalidación, que es justo lo que TanStack Query ya hace.

Prepara la API

Descarga empresa-api.zip, descomprímelo y arráncalo en una terminal aparte:

cd empresa-api
node server.mjs
API EMPRESA en http://localhost:3000/api
Usuarios: ana.garcia@empresa.es (ADMIN) y luis.martinez@empresa.es (USER), contraseña "secreto"

Los datos viven en memoria. Cada vez que reinicias la API vuelven al estado inicial, lo que viene muy bien para repetir las pruebas.

Antes de escribir una línea de React, comprueba que la API responde. Si no lo haces, el primer error que veas en la aplicación será un Failed to fetch y no sabrás si el fallo está en tu código o en la API:

curl http://localhost:3000/api
curl -X POST http://localhost:3000/api/auth/login -H "Content-Type: application/json" -d '{"email":"ana.garcia@empresa.es","password":"secreto"}'

La primera devuelve {"nombre":"API EMPRESA",...} y la segunda un objeto con token, usuario y expiraEn: 3600. En PowerShell, curl es un alias de otra orden: usa curl.exe o Invoke-RestMethod.


2. Arquitectura: organizar por funcionalidades

En los módulos anteriores cada ejemplo cabía en un fichero o dos. Una aplicación de este tamaño tiene unos 50, y hay que decidir dónde va cada uno. Hay dos grandes formas de hacerlo:

  • Por tipo técnico: components/, hooks/, services/, pages/. Es lo que sale al principio, pero para tocar “empleados” abres cinco carpetas.
  • Por funcionalidad: funcionalidades/empleados/ contiene su API, su esquema, sus páginas y sus pruebas. Lo que usan varias funcionalidades va a compartido/.

Este proyecto usa la segunda, que es la que se explica en el módulo 25.

Diagrama de capas: router, funcionalidades, estado de servidor y de cliente, cliente HTTP y API

Figura 3. Arquitectura. Las páginas usan TanStack Query para los datos y Zustand para el resto. Todas las peticiones pasan por un único cliente HTTP.

empresa-react/
├── e2e/flujo.spec.ts             pruebas extremo a extremo (Playwright)
├── public/favicon.svg
├── src/
│   ├── app/                      arranque: router y QueryClient
│   ├── compartido/
│   │   ├── api/                  cliente HTTP, tipos y errores de formulario
│   │   ├── estado/               stores de Zustand: sesión, avisos, tema
│   │   ├── hooks/                useFiltrosUrl
│   │   ├── ui/                   Boton, Campo, Tabla, DialogoConfirmar, Disposicion...
│   │   └── formato.ts            moneda y fechas con Intl
│   ├── funcionalidades/
│   │   ├── sesion/               login y ruta protegida
│   │   ├── panel/
│   │   ├── empleados/
│   │   ├── departamentos/
│   │   └── pedidos/
│   ├── pruebas/                  preparación de Vitest y MSW
│   ├── index.css
│   └── main.tsx
├── index.html
├── package.json
├── playwright.config.ts
├── tsconfig*.json
└── vite.config.ts

Tres reglas sencillas mantienen el orden:

  1. compartido/ no importa nada de funcionalidades/. Si un componente compartido necesita saber qué es un empleado, no es compartido.
  2. Una funcionalidad puede usar otra solo a través de su api.ts. El formulario de empleados importa departamentosQueries para rellenar el desplegable, pero no toca sus componentes.
  3. Cada funcionalidad tiene un api.ts con sus consultas y mutaciones. Las páginas nunca llaman a fetch directamente.

💡 Consejo: Fíjate en que cada página termina con export { PaginaX as Component }. Es el nombre que React Router busca cuando carga una ruta de forma perezosa con lazy. Así cada página acaba en su propio fichero JavaScript y solo se descarga cuando visitas esa ruta.


3. Crear el proyecto

Crea el proyecto con Vite e instala las dependencias, como en el módulo 01. En PowerShell escribe "--" entre comillas, o se pierde y obtienes la plantilla sin React:

npm create vite@latest empresa-react -- --template react-ts
cd empresa-react
npm install react-router @tanstack/react-query zustand react-hook-form @hookform/resolvers zod
npm install -D tailwindcss @tailwindcss/vite vitest jsdom @testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-dom msw @playwright/test @types/node
npx playwright install chromium

Borra src/App.tsx, src/App.css, src/assets y public/icons.svg: no se usan. Después sustituye o crea los ficheros de configuración.

package.json

{
  "name": "empresa-react",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "engines": {
    "node": ">=22.22.0"
  },
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "preview": "vite preview",
    "lint": "oxlint",
    "test": "vitest run",
    "test:watch": "vitest",
    "test:e2e": "playwright test"
  },
  "dependencies": {
    "@hookform/resolvers": "^7.0.1",
    "@tanstack/react-query": "^5.105.1",
    "react": "^19.3.1",
    "react-dom": "^19.3.1",
    "react-hook-form": "^7.91.0",
    "react-router": "^8.5.1",
    "zod": "^4.6.7",
    "zustand": "^5.0.16"
  },
  "devDependencies": {
    "@playwright/test": "^1.64.1",
    "@tailwindcss/vite": "^4.3.3",
    "@testing-library/dom": "^10.4.2",
    "@testing-library/jest-dom": "^7.0.1",
    "@testing-library/react": "^16.3.3",
    "@testing-library/user-event": "^14.6.8",
    "@types/node": "^24.19.2",
    "@types/react": "^19.3.0",
    "@types/react-dom": "^19.3.0",
    "@vitejs/plugin-react": "^6.1.2",
    "jsdom": "^30.1.3",
    "msw": "^3.0.3",
    "oxlint": "^1.89.0",
    "tailwindcss": "^4.3.3",
    "typescript": "~7.0.2",
    "vite": "^8.3.5",
    "vitest": "^5.0.5"
  }
}

Dos detalles de este package.json:

  • engines declara Node 22.22 o superior, que es lo que exige React Router 8. npm solo avisa si tu Node es anterior, no se niega a instalar, pero el aviso te ahorra buscar el origen de un error raro.
  • npm test ejecuta Vitest una vez y termina (vitest run). En desarrollo, npm run test:watch lo deja vigilando.

vite.config.ts

import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vitest/config';

export default defineConfig({
  plugins: [react(), tailwindcss()],
  test: {
    environment: 'jsdom',
    setupFiles: './src/pruebas/preparacion.ts',
    include: ['src/**/*.test.{ts,tsx}'],
  },
});

defineConfig se importa de vitest/config y no de vite. Es la misma función, pero con los tipos de la clave test. Así la configuración de Vite y la de Vitest viven en un solo fichero y no se desincronizan.

tsconfig.json

{
  "files": [],
  "references": [
    { "path": "./tsconfig.app.json" },
    { "path": "./tsconfig.node.json" }
  ]
}

tsconfig.app.json

{
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
    "target": "es2023",
    "lib": ["ES2023", "DOM"],
    "module": "esnext",
    "types": ["vite/client"],
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "moduleDetection": "force",
    "noEmit": true,
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "erasableSyntaxOnly": true,
    "noFallthroughCasesInSwitch": true
  },
  "include": ["src"]
}

tsconfig.node.json

{
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
    "target": "es2023",
    "lib": ["ES2023"],
    "module": "esnext",
    "types": ["node"],
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "moduleDetection": "force",
    "noEmit": true,
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "erasableSyntaxOnly": true,
    "noFallthroughCasesInSwitch": true
  },
  "include": ["vite.config.ts", "playwright.config.ts", "e2e"]
}

tsconfig.app.json cubre todo src, pruebas incluidas, así que npm run build también comprueba los tipos de las pruebas. tsconfig.node.json cubre lo que se ejecuta en Node: la configuración de Vite y Playwright y las pruebas E2E. Con "strict": true explícito no dependes del valor por defecto de cada versión de TypeScript.

index.html

<!doctype html>
<html lang="es">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>EMPRESA</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

public/favicon.svg

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#3949ab"/><path d="M10 8h12v3h-8.5v3.5H21v3h-7.5V21H22v3H10z" fill="#fff"/></svg>

.env.example

# Copia este fichero como .env.local si tu API no está en el puerto 3000
VITE_API_URL=http://localhost:3000/api

.gitignore

node_modules
dist
*.tsbuildinfo
.env.local
test-results
playwright-report

La URL de la API se lee de VITE_API_URL. Solo las variables que empiezan por VITE_ llegan al navegador, y se escriben en el JavaScript al compilar: si cambias la URL, tienes que volver a hacer el build. Nunca pongas un secreto en una variable VITE_, porque cualquiera lo leería en el código descargado.

src/main.tsx

import { QueryClientProvider } from '@tanstack/react-query';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router/dom';
import { queryClient } from './app/queryClient';
import { router } from './app/router';
import './index.css';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <QueryClientProvider client={queryClient}>
      <RouterProvider router={router} />
    </QueryClientProvider>
  </StrictMode>,
);

QueryClientProvider envuelve al router, y no al revés, porque las páginas, que están dentro del router, usan useQuery.


4. Estilos con Tailwind 4 y modo oscuro

En el módulo 07 viste Tailwind 4 con su plugin de Vite. Aquí se añade una idea que ahorra mucho código: colores con significado.

src/index.css

@import 'tailwindcss';

/* El modo oscuro depende de la clase .dark, no de la preferencia del sistema */
@custom-variant dark (&:where(.dark, .dark *));

/* Colores con significado: los componentes no necesitan variantes dark: */
@theme inline {
  --color-fondo: var(--fondo);
  --color-superficie: var(--superficie);
  --color-borde: var(--borde);
  --color-texto: var(--texto);
  --color-suave: var(--suave);
  --color-enlace: var(--enlace);
  --color-marca: #3949ab;
  --color-peligro: var(--peligro);
  --color-peligro-fondo: var(--peligro-fondo);
  --color-exito: var(--exito);
  --color-exito-fondo: var(--exito-fondo);
}

:root {
  --fondo: #f5f6fa;
  --superficie: #ffffff;
  --borde: #c5cae9;
  --texto: #263238;
  --suave: #546e7a;
  --enlace: #283593;
  --peligro: #b71c1c;
  --peligro-fondo: #ffebee;
  --exito: #1b5e20;
  --exito-fondo: #e8f5e9;
}

.dark {
  --fondo: #12141c;
  --superficie: #1d2030;
  --borde: #3a3f5c;
  --texto: #eceff1;
  --suave: #b0bec5;
  --enlace: #c5cae9;
  --peligro: #ffcdd2;
  --peligro-fondo: #4a1c1f;
  --exito: #c8e6c9;
  --exito-fondo: #1f3a24;
  color-scheme: dark;
}

body {
  @apply bg-fondo text-texto;
  font-family: 'Segoe UI', system-ui, sans-serif;
}

Sin esta técnica, cada componente necesitaría dos clases por color: bg-white dark:bg-slate-900, text-gray-800 dark:text-gray-100… Con @theme inline, Tailwind genera utilidades como bg-superficie o text-suave que apuntan a una variable CSS. La clase .dark cambia el valor de las variables, y todos los componentes cambian a la vez sin una sola variante dark:.

  • @custom-variant dark hace que dark: dependa de la clase .dark del <html> y no de la preferencia del sistema. Así el usuario elige con un botón.
  • inline es importante: sin él, Tailwind guardaría el valor de la variable al compilar, y el cambio de tema no tendría efecto en esas utilidades.
  • Los colores del modo oscuro no son los del claro invertidos. Se han elegido para mantener el contraste: el rojo de error pasa de #b71c1c sobre rosa claro a #ffcdd2 sobre granate.

El panel en modo oscuro

Figura 4. El mismo panel con la clase .dark. Ningún componente tiene variantes dark:, salvo las barras del gráfico.


5. El cliente de la API

Todas las peticiones pasan por una sola función. Es la pieza más importante del proyecto, porque decide qué es un error y qué hacer con él.

src/compartido/api/tipos.ts

// Lo que devuelve la API, tal como lo documenta su README.
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;
}

export interface Departamento {
  deptId: number;
  nombre: string;
  localidad: string | null;
  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 Cliente {
  clienteId: number;
  nombre: string;
  ciudad: string;
}

export interface Producto {
  productoId: number;
  nombre: string;
  precio: number;
  stock: number;
}

export const ESTADOS_PEDIDO = ['PENDIENTE', 'ENVIADO', 'ENTREGADO', 'CANCELADO'] as const;
export type EstadoPedido = (typeof ESTADOS_PEDIDO)[number];

export interface LineaPedido {
  numLinea: number;
  productoId: number;
  producto: string | null;
  cantidad: number;
  precioUnitario: number;
  descuento: number;
  importe: number;
}

export interface Pedido {
  pedidoId: number;
  clienteId: number;
  cliente: string | null;
  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>;
}

Los tipos copian lo que documenta la API, incluidos los campos que pueden ser null. Son reales: el departamento 70, INVESTIGACIÓN, tiene presupuesto: null, y hay empleados sin teléfono o sin departamento. Si declaras presupuesto: number, TypeScript te deja escribir formatearEuros(d.presupuesto) y la tabla muestra “NaN €”. Con number | null el compilador te obliga a decidir qué mostrar.

src/compartido/api/cliente.ts

import { useSesion } from '../estado/sesion';

const BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:3000/api';

// Un único tipo de error para toda la aplicación. El status 0 significa
// que ni siquiera hubo respuesta: la API está parada o no hay red.
export class ErrorApi extends Error {
  status: number;
  detalles?: Record<string, string>;

  constructor(status: number, mensaje: string, detalles?: Record<string, string>) {
    super(mensaje);
    this.name = 'ErrorApi';
    this.status = status;
    this.detalles = detalles;
  }
}

interface Opciones {
  metodo?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
  cuerpo?: unknown;
  senal?: AbortSignal;
}

export async function api<T>(ruta: string, { metodo = 'GET', cuerpo, senal }: Opciones = {}): Promise<T> {
  // getState() lee el store fuera de React: aquí no hay ningún componente.
  const { token, cerrarSesion } = useSesion.getState();
  const cabeceras: Record<string, string> = {};
  if (cuerpo !== undefined) cabeceras['Content-Type'] = 'application/json';
  if (token) cabeceras.Authorization = `Bearer ${token}`;

  let respuesta: Response;
  try {
    respuesta = await fetch(BASE + ruta, {
      method: metodo,
      headers: cabeceras,
      body: cuerpo === undefined ? undefined : JSON.stringify(cuerpo),
      signal: senal,
    });
  } catch (error) {
    // Una petición cancelada no es un fallo: TanStack Query la gestiona.
    if (error instanceof DOMException && error.name === 'AbortError') throw error;
    throw new ErrorApi(0, 'No se puede conectar con la API. Comprueba que está arrancada.');
  }

  if (respuesta.status === 204) return undefined as T;
  const datos = await respuesta.json().catch(() => null);

  if (!respuesta.ok) {
    // Enviamos un token y la API lo rechaza: la sesión ya no vale.
    // Sin token, un 401 es solo una contraseña incorrecta en el login.
    if (respuesta.status === 401 && token) cerrarSesion('caducada');
    throw new ErrorApi(respuesta.status, datos?.mensaje ?? `Error ${respuesta.status}`, datos?.detalles);
  }
  return datos as T;
}

export function textoError(error: unknown): string {
  return error instanceof Error ? error.message : 'Ha ocurrido un error inesperado';
}

Lo que resuelve este fichero:

  • Un único tipo de error. fetch no lanza una excepción con un 404 ni con un 500; solo cuando no hay respuesta. Aquí todo se convierte en ErrorApi con su status, y el resto de la aplicación solo mira eso.
  • El código 0 para “sin respuesta”. Así la configuración de reintentos y los mensajes pueden distinguir un fallo de red de un error de la API.
  • El token se lee con getState(). api() no es un componente ni un hook, así que no puede usar useSesion(). Zustand permite leer el store desde cualquier función, y es uno de los motivos para elegirlo en lugar de Context, que solo se lee dentro de React.
  • El 401 cierra la sesión, pero solo si había token. Sin esa condición, una contraseña incorrecta en el login, que también es un 401, marcaría la sesión como caducada.
  • AbortError se relanza tal cual. TanStack Query cancela las peticiones que ya no hacen falta (cambias de página antes de que llegue la respuesta). Si lo convirtieras en un error, verías “No se puede conectar” cada vez que cambias rápido de página.

Cada código de respuesta y lo que hace la aplicación con él

Figura 5. Un único punto convierte las respuestas en datos o en ErrorApi, y cada código tiene un destino en la interfaz.


6. Estado de cliente con Zustand

Hay tres stores, y los tres son pequeños. Ese es el tamaño normal del estado de cliente cuando los datos de la API viven en TanStack Query.

La sesión

src/compartido/estado/sesion.ts

import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { RespuestaLogin, Usuario } from '../api/tipos';

interface EstadoSesion {
  token: string | null;
  usuario: Usuario | null;
  expiraEl: number | null;
  motivoCierre: 'caducada' | null;
  abrirSesion: (respuesta: RespuestaLogin) => void;
  cerrarSesion: (motivo?: 'caducada') => void;
}

export const useSesion = create<EstadoSesion>()(
  persist(
    (set) => ({
      token: null,
      usuario: null,
      expiraEl: null,
      motivoCierre: null,
      abrirSesion: ({ token, usuario, expiraEn }) =>
        set({ token, usuario, expiraEl: Date.now() + expiraEn * 1000, motivoCierre: null }),
      cerrarSesion: (motivo) => set({ token: null, usuario: null, expiraEl: null, motivoCierre: motivo ?? null }),
    }),
    {
      name: 'empresa-sesion',
      // Las funciones no se guardan, y el motivo solo tiene sentido en esta visita.
      partialize: ({ token, usuario, expiraEl }) => ({ token, usuario, expiraEl }),
      // Al recargar, una sesión ya caducada no llega a usarse.
      onRehydrateStorage: () => (estado) => {
        if (estado?.expiraEl && estado.expiraEl <= Date.now()) estado.cerrarSesion('caducada');
      },
    },
  ),
);

export const useEsAdmin = () => useSesion((s) => s.usuario?.rol === 'ADMIN');
  • persist guarda el store en localStorage con la clave empresa-sesion. Al recargar, la sesión sigue ahí. Lo viste en el módulo 20.
  • partialize elige qué se guarda. motivoCierre no se guarda a propósito: el aviso de “sesión caducada” tiene sentido en esta visita, no la semana que viene.
  • expiraEl convierte los segundos relativos de la API (expiraEn: 3600) en un instante absoluto. Si guardaras 3600, al recargar no sabrías cuánto queda.
  • onRehydrateStorage se ejecuta al leer localStorage. Si la sesión guardada ya caducó, se descarta antes de que ninguna pantalla llegue a usarla.

⚠️ Importante: Guardar el token en localStorage tiene un riesgo: si alguien consigue ejecutar JavaScript en tu página (un ataque XSS), puede leerlo. Es la opción habitual en una SPA servida como ficheros estáticos, y React ya escapa el texto por defecto, como viste en el módulo 02. Cuando tienes un servidor propio, la opción más segura es una cookie httpOnly, que JavaScript no puede leer; el módulo 23 lo explica. En cualquier caso, la seguridad real está en la API: ocultar un botón en React no impide que alguien llame a la API a mano.

Avisos y tema

src/compartido/estado/avisos.ts

import { create } from 'zustand';

export interface Aviso {
  id: number;
  tipo: 'exito' | 'error';
  texto: string;
}

interface EstadoAvisos {
  avisos: Aviso[];
  avisar: (tipo: Aviso['tipo'], texto: string) => void;
  quitar: (id: number) => void;
}

let siguienteId = 1;

export const useAvisos = create<EstadoAvisos>((set, get) => ({
  avisos: [],
  avisar: (tipo, texto) => {
    const id = siguienteId++;
    set((s) => ({ avisos: [...s.avisos, { id, tipo, texto }] }));
    setTimeout(() => get().quitar(id), 5000);
  },
  quitar: (id) => set((s) => ({ avisos: s.avisos.filter((a) => a.id !== id) })),
}));

src/compartido/estado/tema.ts

import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface EstadoTema {
  oscuro: boolean;
  alternar: () => void;
}

export const useTema = create<EstadoTema>()(
  persist(
    (set) => ({
      oscuro: false,
      alternar: () => set((s) => ({ oscuro: !s.oscuro })),
    }),
    { name: 'empresa-tema' },
  ),
);

// La clase se aplica fuera de React para que no haya parpadeo al cargar.
const aplicar = (oscuro: boolean) => document.documentElement.classList.toggle('dark', oscuro);
aplicar(useTema.getState().oscuro);
useTema.subscribe((s) => aplicar(s.oscuro));

El store de tema aplica la clase .dark fuera de React, en el momento en que se importa el módulo y con subscribe. Si lo hicieras en un useEffect de un componente, la primera pintura saldría en claro y luego cambiaría a oscuro: el típico parpadeo. Además, así el tema funciona también en la pantalla de login, que no está dentro de Disposicion.

Los avisos se quitan solos a los 5 segundos con un setTimeout dentro de la acción. Una acción de Zustand es una función normal, y puede hacer cosas asíncronas sin ningún middleware.


7. Estado de servidor con TanStack Query

src/app/queryClient.ts

import { QueryClient } from '@tanstack/react-query';
import { ErrorApi } from '../compartido/api/cliente';

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // Durante 30 segundos, los datos en caché se consideran frescos.
      staleTime: 30_000,
      retry: (intentos, error) => {
        // Repetir un 404 o un 403 no lo arregla; un fallo de red, quizá sí.
        if (error instanceof ErrorApi && error.status >= 400 && error.status < 500) return false;
        return intentos < 2;
      },
    },
  },
});

Dos decisiones globales:

  • staleTime de 30 segundos. Con el valor por defecto, 0, cada vez que vuelves a una pantalla se repite la petición. Con 30 segundos, ir de Empleados al Panel y volver es instantáneo. Tras una mutación no hay que esperar a que caduque: se invalida a mano, como verás.
  • Sin reintentos para los 4xx. Un 404 o un 403 no se arreglan repitiendo la petición. Por defecto TanStack Query reintenta tres veces con espera creciente, y la pantalla tardaría varios segundos en mostrar “Empleado no encontrado”. Los fallos de red (status 0) y los 5xx sí se reintentan dos veces.

En cada funcionalidad, el fichero api.ts sigue el mismo patrón, el de las fábricas de consultas del módulo 19:

export const empleadosQueries = {
  lista: (filtros) => queryOptions({ queryKey: ['empleados', 'lista', filtros], queryFn: ... }),
  detalle: (id) => queryOptions({ queryKey: ['empleados', 'detalle', id], queryFn: ... }),
};

Las claves forman un árbol. Todas las de empleados empiezan por 'empleados', así que invalidateQueries({ queryKey: ['empleados'] }) invalida a la vez la lista con cualquier filtro, las opciones del desplegable y todos los detalles. Es la forma más sencilla de no olvidarte de ninguna.


8. Rutas, inicio de sesión y rutas protegidas

src/app/router.tsx

import { createBrowserRouter } from 'react-router';
import { Disposicion } from '../compartido/ui/Disposicion';
import { PaginaError, PaginaNoEncontrada } from '../compartido/ui/PaginaError';
import { Cargando } from '../compartido/ui/Estados';
import { RutaProtegida } from '../funcionalidades/sesion/RutaProtegida';

export const router = createBrowserRouter(
  [
    {
      errorElement: <PaginaError />,
      hydrateFallbackElement: <Cargando />,
      children: [
        { path: '/login', lazy: () => import('../funcionalidades/sesion/PaginaLogin') },
        {
          element: <RutaProtegida />,
          children: [
            {
              element: <Disposicion />,
              // Un error de render en una página no se lleva por delante el menú.
              errorElement: <PaginaError />,
              children: [
                { path: '/', lazy: () => import('../funcionalidades/panel/PaginaPanel') },
                { path: '/empleados', lazy: () => import('../funcionalidades/empleados/PaginaEmpleados') },
                { path: '/empleados/nuevo', lazy: () => import('../funcionalidades/empleados/PaginaFormEmpleado') },
                { path: '/empleados/:id/editar', lazy: () => import('../funcionalidades/empleados/PaginaFormEmpleado') },
                { path: '/departamentos', lazy: () => import('../funcionalidades/departamentos/PaginaDepartamentos') },
                { path: '/departamentos/nuevo', lazy: () => import('../funcionalidades/departamentos/PaginaFormDepartamento') },
                { path: '/departamentos/:id/editar', lazy: () => import('../funcionalidades/departamentos/PaginaFormDepartamento') },
                { path: '/pedidos', lazy: () => import('../funcionalidades/pedidos/PaginaPedidos') },
                { path: '/pedidos/nuevo', lazy: () => import('../funcionalidades/pedidos/PaginaNuevoPedido') },
                { path: '/pedidos/:id', lazy: () => import('../funcionalidades/pedidos/PaginaPedido') },
                { path: '*', element: <PaginaNoEncontrada /> },
              ],
            },
          ],
        },
      ],
    },
  ],
  { basename: import.meta.env.BASE_URL },
);

La estructura de rutas tiene tres niveles sin path, que solo agrupan:

  1. La raíz pone una pantalla de error para cualquier fallo y un hydrateFallbackElement. Este último se muestra mientras se descarga la primera página perezosa, en lugar de una pantalla en blanco.
  2. RutaProtegida deja pasar solo con sesión.
  3. Disposicion pinta el menú y la cabecera, y sus hijas aparecen en su <Outlet />. Tiene su propio errorElement: si una página falla al renderizar, el error se muestra dentro del marco y el menú sigue funcionando.

basename: import.meta.env.BASE_URL sirve para publicar la aplicación en una subcarpeta, como en GitHub Pages: basta con cambiar base en vite.config.ts y el router lo sigue solo.

La ruta protegida

src/funcionalidades/sesion/api.ts

import { api } from '../../compartido/api/cliente';
import type { RespuestaLogin } from '../../compartido/api/tipos';

export interface Credenciales {
  email: string;
  password: string;
}

export const iniciarSesion = (credenciales: Credenciales) =>
  api<RespuestaLogin>('/auth/login', { metodo: 'POST', cuerpo: credenciales });

// Solo admite rutas internas: /login?volver=https://otra-web.com no debe sacarte de aquí.
export function destinoSeguro(volver: string | null): string {
  return volver && volver.startsWith('/') && !volver.startsWith('//') ? volver : '/';
}

src/funcionalidades/sesion/RutaProtegida.tsx

import { Navigate, Outlet, useLocation } from 'react-router';
import { useSesion } from '../../compartido/estado/sesion';

// Es un componente, no un loader: al suscribirse al store, reacciona
// también cuando la sesión se cierra en mitad de una pantalla.
export function RutaProtegida() {
  const token = useSesion((s) => s.token);
  const { pathname, search } = useLocation();

  if (!token) {
    return <Navigate to={`/login?volver=${encodeURIComponent(pathname + search)}`} replace />;
  }
  return <Outlet />;
}

En el módulo 18 protegiste rutas con un loader y con middleware. Aquí se usa un componente, por un motivo concreto: la sesión puede cerrarse en mitad de una pantalla. Cuando la API responde 401, api() llama a cerrarSesion, el store cambia y RutaProtegida, que está suscrita a token, vuelve a renderizar y redirige. Un loader solo se ejecuta al navegar, y no se enteraría hasta el siguiente clic en el menú.

destinoSeguro evita una redirección abierta. Sin ella, un enlace como /login?volver=https://web-falsa.com llevaría a tu usuario, recién autenticado y confiado, a otra web. Por eso solo se aceptan rutas que empiezan por una sola / (//web-falsa.com también es una URL externa para el navegador).

La página de inicio de sesión

src/funcionalidades/sesion/PaginaLogin.tsx

import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { Navigate, useSearchParams } from 'react-router';
import { z } from 'zod';
import { useSesion } from '../../compartido/estado/sesion';
import { Boton } from '../../compartido/ui/Boton';
import { Campo } from '../../compartido/ui/Campos';
import { MensajeError } from '../../compartido/ui/Estados';
import { destinoSeguro, iniciarSesion } from './api';

const esquema = z.object({
  email: z.email('Escribe un email válido'),
  password: z.string().min(1, 'Escribe la contraseña'),
});

function PaginaLogin() {
  const [params] = useSearchParams();
  const token = useSesion((s) => s.token);
  const motivoCierre = useSesion((s) => s.motivoCierre);
  const abrirSesion = useSesion((s) => s.abrirSesion);
  const entrar = useMutation({ mutationFn: iniciarSesion, onSuccess: abrirSesion });
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm({ resolver: zodResolver(esquema), defaultValues: { email: '', password: '' } });

  // Al abrir la sesión cambia el token, y este mismo render nos lleva al destino.
  if (token) return <Navigate to={destinoSeguro(params.get('volver'))} replace />;

  return (
    <main className="flex min-h-screen items-center justify-center p-4">
      <title>Iniciar sesión · EMPRESA</title>
      <form
        noValidate
        onSubmit={handleSubmit((datos) => entrar.mutate(datos))}
        className="flex w-96 max-w-full flex-col gap-4 rounded-xl border border-borde bg-superficie p-6"
      >
        <h1 className="text-center text-2xl font-bold tracking-wide text-enlace">EMPRESA</h1>
        {motivoCierre === 'caducada' && (
          <p role="status" className="rounded-md bg-peligro-fondo p-3 text-sm text-peligro">
            Tu sesión ha caducado. Vuelve a entrar.
          </p>
        )}
        {entrar.isError && <MensajeError error={entrar.error} />}
        <Campo etiqueta="Email" type="email" autoComplete="username" error={errors.email?.message} {...register('email')} />
        <Campo
          etiqueta="Contraseña"
          type="password"
          autoComplete="current-password"
          error={errors.password?.message}
          {...register('password')}
        />
        <Boton type="submit" variante="primario" disabled={entrar.isPending}>
          {entrar.isPending ? 'Entrando...' : 'Entrar'}
        </Boton>
      </form>
    </main>
  );
}

export { PaginaLogin as Component };
  • El inicio de sesión es una mutación de TanStack Query: cambia algo en el servidor (crea una sesión) y no se guarda en caché. entrar.isPending y entrar.error dan los estados sin un useState.
  • No hay navigate() tras el éxito. onSuccess guarda la sesión; el store cambia, la página vuelve a renderizar con token y el <Navigate> del principio lleva al destino. Así el mismo código sirve si llegas a /login con la sesión ya abierta.
  • useForm no lleva tipos explícitos: los deduce de zodResolver(esquema). En los formularios con números sí harán falta, como verás.

9. Componentes compartidos

Son los ladrillos de todas las pantallas. Ninguno sabe nada de empleados ni de pedidos.

src/compartido/ui/Boton.tsx

import type { ComponentProps } from 'react';
import { Link, type LinkProps } from 'react-router';

const BASE =
  'inline-flex items-center justify-center rounded-md px-3 py-1.5 text-sm font-medium transition-colors ' +
  'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-marca ' +
  'disabled:cursor-not-allowed disabled:opacity-50';

const VARIANTES = {
  primario: 'bg-marca text-white hover:bg-indigo-900',
  secundario: 'border border-borde bg-superficie text-enlace hover:bg-fondo',
  peligro: 'bg-red-700 text-white hover:bg-red-800',
};

type Variante = keyof typeof VARIANTES;

export function Boton({ variante = 'secundario', type = 'button', ...resto }: ComponentProps<'button'> & { variante?: Variante }) {
  return <button type={type} className={`${BASE} ${VARIANTES[variante]}`} {...resto} />;
}

// Un enlace que parece un botón sigue siendo un enlace: navega, no ejecuta.
export function EnlaceBoton({ variante = 'secundario', ...resto }: LinkProps & { variante?: Variante }) {
  return <Link className={`${BASE} ${VARIANTES[variante]}`} {...resto} />;
}

EnlaceBoton existe porque “Nuevo empleado” parece un botón pero navega. Si fuera un <button> con navigate(), no podrías abrirlo en otra pestaña con el botón central del ratón, y un lector de pantalla lo anunciaría como botón. La regla es sencilla: si cambia la URL, es un enlace.

Campos de formulario y ref como prop 🆕 19.0

🆕 Desde React 19.0. En React 18.3, ref no llega a un componente función como prop. Si usas una versión anterior: envuelve Campo y Desplegable con forwardRef, como se muestra en la sección 17. Sin ese cambio, React Hook Form no recibe la referencia del <input> y el formulario se envía siempre vacío.

src/compartido/ui/Campos.tsx

import { useId, type ComponentProps, type ReactNode } from 'react';

const CLASES_CONTROL =
  'mt-1 w-full rounded-md border border-borde bg-superficie px-2.5 py-1.5 text-sm text-texto ' +
  'focus:outline-2 focus:outline-marca aria-invalid:border-peligro';

interface Comunes {
  etiqueta: string;
  error?: string;
}

function Envoltura({ id, etiqueta, error, children }: Comunes & { id: string; children: ReactNode }) {
  return (
    <div>
      <label htmlFor={id} className="block text-sm font-medium">
        {etiqueta}
      </label>
      {children}
      {error && (
        <p id={`${id}-error`} className="mt-1 text-sm text-peligro">
          {error}
        </p>
      )}
    </div>
  );
}

// `ref` llega dentro de `resto`: desde React 19 es una prop normal,
// y por eso funciona con {...register('campo')} sin forwardRef.
export function Campo({ etiqueta, error, ...resto }: ComponentProps<'input'> & Comunes) {
  const id = useId();
  return (
    <Envoltura id={id} etiqueta={etiqueta} error={error}>
      <input
        id={id}
        className={CLASES_CONTROL}
        aria-invalid={error ? true : undefined}
        aria-describedby={error ? `${id}-error` : undefined}
        {...resto}
      />
    </Envoltura>
  );
}

export function Desplegable({ etiqueta, error, children, ...resto }: ComponentProps<'select'> & Comunes) {
  const id = useId();
  return (
    <Envoltura id={id} etiqueta={etiqueta} error={error}>
      <select
        id={id}
        className={CLASES_CONTROL}
        aria-invalid={error ? true : undefined}
        aria-describedby={error ? `${id}-error` : undefined}
        {...resto}
      >
        {children}
      </select>
    </Envoltura>
  );
}

register('email') devuelve { name, onChange, onBlur, ref }. Al escribir {...register('email')} sobre <Campo>, esa ref llega a Campo dentro de resto y de ahí al <input>. React Hook Form la usa para leer el valor y para mover el foco al primer campo con error. Lo viste en el módulo 03.

useId genera un id único para enlazar la etiqueta con su campo (htmlFor), y el mensaje de error con aria-describedby. Así un lector de pantalla lee “Email, no válido, Ese email ya está en uso” al llegar al campo, como en el módulo 24. Gracias a eso, las pruebas pueden buscar los campos con getByLabelText('Email'), igual que lo haría una persona.

Estados de carga, error y búsqueda

src/compartido/ui/Estados.tsx

import { useEffect, useState, type ReactNode } from 'react';
import { textoError } from '../api/cliente';
import { Boton } from './Boton';

export function Cargando() {
  return (
    <p role="status" className="p-6 text-sm text-suave">
      Cargando...
    </p>
  );
}

export function MensajeError({ error, onReintentar }: { error: unknown; onReintentar?: () => void }) {
  return (
    <div role="alert" className="rounded-md bg-peligro-fondo p-3 text-sm text-peligro">
      <p>{textoError(error)}</p>
      {onReintentar && (
        <div className="mt-2">
          <Boton onClick={onReintentar}>Reintentar</Boton>
        </div>
      )}
    </div>
  );
}

export function Vacio({ children }: { children: ReactNode }) {
  return <p className="p-6 text-center text-sm text-suave">{children}</p>;
}

// Escribe en la URL 300 ms después de la última tecla, no en cada una.
export function Buscador({ valor, onCambiar, etiqueta }: { valor: string; onCambiar: (texto: string) => void; etiqueta: string }) {
  const [texto, setTexto] = useState(valor);

  useEffect(() => {
    if (texto === valor) return;
    const temporizador = setTimeout(() => onCambiar(texto), 300);
    return () => clearTimeout(temporizador);
  }, [texto, valor, onCambiar]);

  return (
    <input
      type="search"
      aria-label={etiqueta}
      placeholder={etiqueta}
      value={texto}
      onChange={(e) => setTexto(e.target.value)}
      className="w-64 max-w-full rounded-md border border-borde bg-superficie px-2.5 py-1.5 text-sm"
    />
  );
}

Buscador guarda el texto en un estado local y solo lo escribe en la URL 300 ms después de la última tecla. Si escribiera en cada tecla, cada letra crearía una petición y una entrada en el historial. El efecto se limpia con clearTimeout: si llega otra tecla antes de tiempo, el temporizador anterior se cancela. Es el debounce del módulo 12.

Títulos de documento 🆕 19.0

🆕 Desde React 19.0. React 18.3 no sube <title> al <head>: lo pinta dentro del <body> y el título de la pestaña no cambia. Si usas una versión anterior: sustituye la etiqueta por useEffect(() => { document.title = ... }, [titulo]).

src/compartido/ui/Encabezado.tsx

import type { ReactNode } from 'react';

// <title> dentro de un componente: React lo sube solo al <head>.
export function Encabezado({ titulo, children }: { titulo: string; children?: ReactNode }) {
  return (
    <header className="mb-5 flex flex-wrap items-center justify-between gap-3">
      <title>{`${titulo} · EMPRESA`}</title>
      <h1 className="text-2xl font-semibold text-enlace">{titulo}</h1>
      {children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
    </header>
  );
}

export function Tarjeta({ children }: { children: ReactNode }) {
  return <section className="rounded-xl border border-borde bg-superficie p-5">{children}</section>;
}

El <title> lleva un único valor con plantilla de texto, {`${titulo} · EMPRESA`}, y no {titulo} · EMPRESA. Lo segundo crea varios nodos de texto hijos, y <title> debe recibir un único texto.

Tablas, orden y paginación

src/compartido/ui/Tabla.tsx

import type { ReactNode } from 'react';
import { Boton } from './Boton';

export const CLASE_CELDA = 'border-t border-borde px-3 py-2 text-sm';

export function Tabla({ titulo, cabecera, children, atenuada = false }: { titulo: string; cabecera: ReactNode; children: ReactNode; atenuada?: boolean }) {
  return (
    <div className="overflow-x-auto rounded-xl border border-borde bg-superficie">
      <table className={`w-full text-left ${atenuada ? 'opacity-60' : ''}`}>
        <caption className="sr-only">{titulo}</caption>
        <thead>
          <tr>{cabecera}</tr>
        </thead>
        <tbody>{children}</tbody>
      </table>
    </div>
  );
}

interface PropsColumna {
  campo?: string;
  orden?: string;
  onOrdenar?: (orden: string) => void;
  children?: ReactNode;
}

// La columna activa se marca con aria-sort. Pulsar de nuevo invierte el orden.
export function Columna({ campo, orden, onOrdenar, children }: PropsColumna) {
  const clases = 'px-3 py-2 text-xs font-semibold tracking-wide text-suave uppercase';
  if (!campo || !onOrdenar) return <th scope="col" className={clases}>{children}</th>;

  const ascendente = orden === campo;
  const descendente = orden === `-${campo}`;
  return (
    <th scope="col" className={clases} aria-sort={ascendente ? 'ascending' : descendente ? 'descending' : 'none'}>
      <button type="button" className="cursor-pointer uppercase hover:text-enlace" onClick={() => onOrdenar(ascendente ? `-${campo}` : campo)}>
        {children} {ascendente ? '▲' : descendente ? '▼' : ''}
      </button>
    </th>
  );
}

export function Paginacion({ pagina, paginas, total, onCambiar }: { pagina: number; paginas: number; total: number; onCambiar: (pagina: number) => void }) {
  return (
    <nav aria-label="Paginación" className="mt-3 flex items-center justify-between gap-3 text-sm text-suave">
      <span>
        {total} {total === 1 ? 'resultado' : 'resultados'}, página {pagina} de {Math.max(paginas, 1)}
      </span>
      <span className="flex gap-2">
        <Boton disabled={pagina <= 1} onClick={() => onCambiar(pagina - 1)}>
          Anterior
        </Boton>
        <Boton disabled={pagina >= paginas} onClick={() => onCambiar(pagina + 1)}>
          Siguiente
        </Boton>
      </span>
    </nav>
  );
}
  • <caption className="sr-only"> da nombre a la tabla para los lectores de pantalla sin ocupar sitio en pantalla.
  • La cabecera ordenable es un <button> dentro del <th>, y la columna activa lleva aria-sort. Un <th onClick> no se puede usar con el teclado.
  • atenuada baja la opacidad mientras llega la página siguiente. Lo usarás con isPlaceholderData.

El diálogo de confirmación

src/compartido/ui/DialogoConfirmar.tsx

import { useEffect, useRef, type ReactNode } from 'react';
import { Boton } from './Boton';
import { MensajeError } from './Estados';

interface Props {
  titulo: string;
  children: ReactNode;
  pendiente: boolean;
  error: unknown;
  onConfirmar: () => void;
  onCancelar: () => void;
}

// <dialog> con showModal() ya bloquea el fondo, atrapa el foco y cierra con Esc.
export function DialogoConfirmar({ titulo, children, pendiente, error, onConfirmar, onCancelar }: Props) {
  const dialogo = useRef<HTMLDialogElement>(null);

  useEffect(() => {
    const nodo = dialogo.current;
    nodo?.showModal();
    return () => nodo?.close();
  }, []);

  return (
    <dialog
      ref={dialogo}
      aria-labelledby="dialogo-titulo"
      onCancel={(e) => {
        e.preventDefault();
        onCancelar();
      }}
      className="m-auto w-[28rem] max-w-[90vw] rounded-xl border border-borde bg-superficie p-5 text-texto backdrop:bg-black/50"
    >
      <h2 id="dialogo-titulo" className="mb-2 text-lg font-semibold">
        {titulo}
      </h2>
      <div className="mb-4 text-sm">{children}</div>
      {error != null && (
        <div className="mb-4">
          <MensajeError error={error} />
        </div>
      )}
      <div className="flex justify-end gap-2">
        <Boton onClick={onCancelar}>Cancelar</Boton>
        <Boton variante="peligro" disabled={pendiente} onClick={onConfirmar}>
          {pendiente ? 'Borrando...' : 'Borrar'}
        </Boton>
      </div>
    </dialog>
  );
}

El elemento nativo <dialog> con showModal() hace solo lo que antes costaba una librería: oscurece el fondo (::backdrop), atrapa el foco dentro, lo devuelve al cerrar y cierra con Esc. onCancel intercepta la tecla Esc para que sea React quien decida cerrarlo, desmontando el componente.

El diálogo recibe el error de la mutación y lo muestra dentro. Es la clave para los 409: si el borrado falla, el diálogo no se cierra, y el usuario ve el motivo en el mismo sitio donde pulsó.

Avisos, marco de la aplicación y errores de ruta

src/compartido/ui/ZonaAvisos.tsx

import { useAvisos } from '../estado/avisos';

export function ZonaAvisos() {
  const avisos = useAvisos((s) => s.avisos);

  return (
    <div aria-live="polite" className="fixed right-4 bottom-4 z-10 flex w-80 max-w-[90vw] flex-col gap-2">
      {avisos.map((aviso) => (
        <p
          key={aviso.id}
          role={aviso.tipo === 'error' ? 'alert' : 'status'}
          className={`rounded-md p-3 text-sm shadow-md ${aviso.tipo === 'error' ? 'bg-peligro-fondo text-peligro' : 'bg-exito-fondo text-exito'}`}
        >
          {aviso.texto}
        </p>
      ))}
    </div>
  );
}

src/compartido/ui/Disposicion.tsx

import { useQueryClient } from '@tanstack/react-query';
import { NavLink, Outlet } from 'react-router';
import { useSesion } from '../estado/sesion';
import { useTema } from '../estado/tema';
import { Boton } from './Boton';
import { ZonaAvisos } from './ZonaAvisos';

const ENLACES = [
  { a: '/', texto: 'Panel' },
  { a: '/empleados', texto: 'Empleados' },
  { a: '/departamentos', texto: 'Departamentos' },
  { a: '/pedidos', texto: 'Pedidos' },
];

export function Disposicion() {
  const usuario = useSesion((s) => s.usuario);
  const cerrarSesion = useSesion((s) => s.cerrarSesion);
  const oscuro = useTema((s) => s.oscuro);
  const alternarTema = useTema((s) => s.alternar);
  const queryClient = useQueryClient();

  function salir() {
    cerrarSesion();
    // La caché es de este usuario: el siguiente no debe verla.
    queryClient.clear();
  }

  return (
    <div className="flex min-h-screen flex-col md:flex-row">
      <aside className="border-b border-borde bg-superficie p-4 md:w-52 md:shrink-0 md:border-r md:border-b-0">
        <p className="mb-4 text-lg font-bold tracking-wide text-enlace">EMPRESA</p>
        <nav aria-label="Principal" className="flex gap-1 md:flex-col">
          {ENLACES.map(({ a, texto }) => (
            <NavLink
              key={a}
              to={a}
              end={a === '/'}
              className={({ isActive }) =>
                `rounded-md px-3 py-1.5 text-sm ${isActive ? 'bg-marca font-medium text-white' : 'text-texto hover:bg-fondo'}`
              }
            >
              {texto}
            </NavLink>
          ))}
        </nav>
      </aside>

      <div className="min-w-0 flex-1">
        <div className="flex items-center justify-end gap-3 border-b border-borde bg-superficie px-5 py-2 text-sm">
          <span>
            {usuario?.nombre} <span className="text-suave">({usuario?.rol})</span>
          </span>
          <Boton aria-pressed={oscuro} onClick={alternarTema}>
            Tema oscuro
          </Boton>
          <Boton onClick={salir}>Salir</Boton>
        </div>
        <main className="mx-auto max-w-5xl p-5">
          <Outlet />
        </main>
      </div>
      <ZonaAvisos />
    </div>
  );
}

Al salir se llama a queryClient.clear(). Las lecturas de esta API son públicas, pero en una aplicación real la caché puede contener datos que el siguiente usuario no debe ver.

src/compartido/ui/PaginaError.tsx

import { isRouteErrorResponse, Link, useRouteError } from 'react-router';
import { textoError } from '../api/cliente';

function Mensaje({ titulo, texto }: { titulo: string; texto: string }) {
  return (
    <div className="mx-auto max-w-lg p-8 text-center">
      <title>{`${titulo} · EMPRESA`}</title>
      <h1 className="mb-2 text-2xl font-semibold text-enlace">{titulo}</h1>
      <p className="mb-4 text-suave">{texto}</p>
      <Link to="/" className="text-enlace underline">
        Volver al panel
      </Link>
    </div>
  );
}

export function PaginaNoEncontrada() {
  return <Mensaje titulo="Página no encontrada" texto="Esa dirección no existe en la aplicación." />;
}

export function PaginaError() {
  const error = useRouteError();
  if (isRouteErrorResponse(error) && error.status === 404) return <PaginaNoEncontrada />;
  return <Mensaje titulo="Algo ha fallado" texto={textoError(error)} />;
}

Utilidades

src/compartido/formato.ts

const euros = new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' });
const fechas = new Intl.DateTimeFormat('es-ES', { dateStyle: 'medium', timeZone: 'UTC' });

export const formatearEuros = (valor: number) => euros.format(valor);
export const formatearFecha = (iso: string) => fechas.format(new Date(iso));

src/compartido/hooks/useFiltrosUrl.ts

import { useCallback } from 'react';
import { useSearchParams } from 'react-router';

// Los filtros viven en la URL: se pueden compartir y sobreviven a una recarga.
export function useFiltrosUrl(ordenInicial: string) {
  const [params, setParams] = useSearchParams();

  const cambiar = useCallback(
    (cambios: Record<string, string>) => {
      setParams(
        (anteriores) => {
          const nuevos = new URLSearchParams(anteriores);
          for (const [clave, valor] of Object.entries(cambios)) {
            if (valor) nuevos.set(clave, valor);
            else nuevos.delete(clave);
          }
          // Cualquier filtro nuevo vuelve a la primera página.
          if (!('pagina' in cambios)) nuevos.delete('pagina');
          return nuevos;
        },
        { replace: true },
      );
    },
    [setParams],
  );

  return {
    q: params.get('q') ?? '',
    orden: params.get('orden') ?? ordenInicial,
    pagina: Number(params.get('pagina') ?? 1),
    leer: (clave: string) => params.get(clave) ?? '',
    cambiar,
  };
}

Los filtros viven en la URL con useSearchParams, como en el módulo 18. Así puedes compartir un enlace a “empleados de VENTAS ordenados por salario”, y una recarga no pierde nada. Tres detalles:

  • setParams recibe una función que parte de los parámetros anteriores. Si construyeras los nuevos desde cero, cambiar el orden borraría la búsqueda.
  • replace: true sustituye la entrada del historial en lugar de añadir una. Si no, el botón Atrás recorrería cada letra que escribiste.
  • Cambiar cualquier filtro borra pagina. Si estás en la página 3 y buscas “garcia”, que tiene un solo resultado, la página 3 estaría vacía.

10. El panel

src/funcionalidades/panel/api.ts

import { queryOptions } from '@tanstack/react-query';
import { api } from '../../compartido/api/cliente';
import type { Estadisticas } from '../../compartido/api/tipos';

export const consultaEstadisticas = () =>
  queryOptions({
    queryKey: ['estadisticas'],
    queryFn: ({ signal }) => api<Estadisticas>('/estadisticas', { senal: signal }),
  });

src/funcionalidades/panel/GraficoBarras.tsx

interface Fila {
  etiqueta: string;
  valor: number;
  texto: string;
}

// Una tabla real con barras dentro: se lee con lector de pantalla y sin colores.
export function GraficoBarras({ titulo, filas }: { titulo: string; filas: Fila[] }) {
  const maximo = Math.max(...filas.map((f) => f.valor), 1);

  return (
    <figure>
      <figcaption className="mb-3 text-sm font-semibold">{titulo}</figcaption>
      <table className="w-full text-sm">
        <tbody>
          {filas.map(({ etiqueta, valor, texto }) => (
            <tr key={etiqueta} title={`${etiqueta}: ${texto}`} className="hover:bg-fondo">
              <th scope="row" className="w-32 py-1 pr-2 text-left text-xs font-normal text-suave">
                {etiqueta}
              </th>
              <td className="py-1">
                <div className="h-3 min-w-1 rounded-r bg-marca dark:bg-indigo-300" style={{ width: `${(valor / maximo) * 100}%` }} />
              </td>
              <td className="w-24 py-1 pl-2 text-right text-xs tabular-nums">{texto}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </figure>
  );
}

src/funcionalidades/panel/PaginaPanel.tsx

import { useQuery } from '@tanstack/react-query';
import { ESTADOS_PEDIDO } from '../../compartido/api/tipos';
import { formatearEuros } from '../../compartido/formato';
import { Encabezado, Tarjeta } from '../../compartido/ui/Encabezado';
import { Cargando, MensajeError } from '../../compartido/ui/Estados';
import { consultaEstadisticas } from './api';
import { GraficoBarras } from './GraficoBarras';

function Dato({ etiqueta, valor }: { etiqueta: string; valor: string | number }) {
  return (
    <div className="rounded-xl border border-borde bg-superficie p-4">
      <p className="text-xs text-suave">{etiqueta}</p>
      <p className="mt-1 text-2xl font-semibold tabular-nums">{valor}</p>
    </div>
  );
}

function PaginaPanel() {
  const { data, isPending, isError, error, refetch } = useQuery(consultaEstadisticas());

  return (
    <>
      <Encabezado titulo="Panel" />
      {isPending && <Cargando />}
      {isError && <MensajeError error={error} onReintentar={refetch} />}
      {data && (
        <>
          <div className="mb-5 grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-6">
            <Dato etiqueta="Empleados" valor={data.empleados} />
            <Dato etiqueta="Departamentos" valor={data.departamentos} />
            <Dato etiqueta="Clientes" valor={data.clientes} />
            <Dato etiqueta="Productos" valor={data.productos} />
            <Dato etiqueta="Pedidos" valor={data.pedidos} />
            <Dato etiqueta="Facturación" valor={formatearEuros(data.facturacion)} />
          </div>
          <div className="grid gap-4 lg:grid-cols-2">
            <Tarjeta>
              <GraficoBarras
                titulo="Masa salarial mensual por departamento"
                filas={data.porDepartamento.map((d) => ({
                  etiqueta: d.nombre,
                  valor: d.masaSalarial,
                  texto: formatearEuros(d.masaSalarial),
                }))}
              />
            </Tarjeta>
            <Tarjeta>
              <GraficoBarras
                titulo="Pedidos por estado"
                filas={ESTADOS_PEDIDO.map((estado) => ({
                  etiqueta: estado,
                  valor: data.porEstado[estado],
                  texto: String(data.porEstado[estado]),
                }))}
              />
            </Tarjeta>
          </div>
        </>
      )}
    </>
  );
}

export { PaginaPanel as Component };

Para dos gráficos de barras sencillos no hace falta una librería. Cada gráfico es una <table> con tres columnas: la etiqueta, la barra (un div con width en porcentaje) y el valor escrito. Esto tiene ventajas reales:

  • Es accesible sin esfuerzo. Un lector de pantalla lo recorre como una tabla, fila a fila.
  • El valor siempre se lee. La barra solo sirve para comparar de un vistazo; el número exacto está escrito al lado, y el atributo title lo repite al pasar el ratón.
  • Un solo color. Cada gráfico tiene una única serie de datos, y no hace falta leyenda: el título dice qué se mide.

Fíjate en que ESTADOS_PEDIDO fija el orden de las barras. Object.entries(data.porEstado) también funcionaría hoy, pero dependería del orden en que la API escriba las claves.


11. Departamentos: el primer CRUD

El CRUD de departamentos es el más sencillo y sirve de modelo para el de empleados.

Esquema y conversión de tipos

src/funcionalidades/departamentos/esquema.ts

import { z } from 'zod';

// Un campo numérico opcional llega como cadena: '' significa null.
export const numeroOpcional = (mensaje: string) =>
  z
    .string()
    .transform((v) => (v.trim() === '' ? null : Number(v)))
    .refine((v) => v === null || (Number.isFinite(v) && v >= 0), mensaje);

export const esquemaDepartamento = z.object({
  nombre: z.string().trim().min(1, 'El nombre es obligatorio'),
  localidad: z
    .string()
    .trim()
    .transform((v) => v || null),
  presupuesto: numeroOpcional('El presupuesto no puede ser negativo'),
});

export type EntradaDepartamento = z.input<typeof esquemaDepartamento>;
export type DatosDepartamento = z.output<typeof esquemaDepartamento>;

Un <input type="number"> entrega siempre una cadena, y el campo vacío es ''. La API espera un número o null. El esquema hace la conversión:

El usuario escribe El formulario tiene Zod entrega
(nada) '' null
1500 '1500' 1500
-3 '-3' error: “El presupuesto no puede ser negativo”

Por eso el esquema tiene dos tipos: z.input (lo que maneja el formulario, todo cadenas) y z.output (lo que recibe tu código al enviar). En el módulo 21 usaste valueAsNumber, que convierte el vacío en NaN. Con un número opcional, el esquema es más claro: '' significa “sin valor”.

⚠️ Importante: Si en defaultValues pones presupuesto: departamento.presupuesto sin convertir, el departamento 70 metería null en un campo de texto. React avisa de que un <input> pasa de no controlado a controlado, y TypeScript no lo detecta si el tipo de entrada no está bien declarado. Por eso se escribe departamento?.presupuesto?.toString() ?? ''.

Consultas y mutaciones

src/funcionalidades/departamentos/api.ts

import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../../compartido/api/cliente';
import type { Departamento, Pagina } from '../../compartido/api/tipos';
import type { DatosDepartamento } from './esquema';

const RAIZ = ['departamentos'];

export const departamentosQueries = {
  lista: () =>
    queryOptions({
      queryKey: [...RAIZ, 'lista'],
      queryFn: ({ signal }) => api<Pagina<Departamento>>('/departamentos?orden=nombre&tamano=100', { senal: signal }),
      select: (pagina) => pagina.contenido,
    }),
  detalle: (id: string) =>
    queryOptions({
      queryKey: [...RAIZ, 'detalle', id],
      queryFn: ({ signal }) => api<Departamento>(`/departamentos/${id}`, { senal: signal }),
    }),
};

export function useGuardarDepartamento(id?: string) {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (datos: DatosDepartamento) =>
      id
        ? api<Departamento>(`/departamentos/${id}`, { metodo: 'PUT', cuerpo: datos })
        : api<Departamento>('/departamentos', { metodo: 'POST', cuerpo: datos }),
    onSuccess: () =>
      Promise.all([
        queryClient.invalidateQueries({ queryKey: RAIZ }),
        queryClient.invalidateQueries({ queryKey: ['estadisticas'] }),
        // El listado de empleados incluye el nombre del departamento.
        queryClient.invalidateQueries({ queryKey: ['empleados'] }),
      ]),
  });
}

export function useBorrarDepartamento() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (id: number) => api<void>(`/departamentos/${id}`, { metodo: 'DELETE' }),
    onSuccess: () =>
      Promise.all([
        queryClient.invalidateQueries({ queryKey: RAIZ }),
        queryClient.invalidateQueries({ queryKey: ['estadisticas'] }),
      ]),
  });
}

Tras guardar un departamento se invalidan tres familias de claves: los departamentos, las estadísticas (cuentan departamentos) y los empleados, porque cada empleado del listado muestra el nombre de su departamento. Si renombras VENTAS y no invalidas empleados, el listado de empleados seguirá diciendo VENTAS durante 30 segundos. Este es el error más típico de TanStack Query: no es un fallo de la librería, es una dependencia entre datos que solo tú conoces.

El listado

src/funcionalidades/departamentos/PaginaDepartamentos.tsx

import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { Link } from 'react-router';
import type { Departamento } from '../../compartido/api/tipos';
import { useAvisos } from '../../compartido/estado/avisos';
import { useEsAdmin } from '../../compartido/estado/sesion';
import { formatearEuros } from '../../compartido/formato';
import { Boton, EnlaceBoton } from '../../compartido/ui/Boton';
import { DialogoConfirmar } from '../../compartido/ui/DialogoConfirmar';
import { Encabezado } from '../../compartido/ui/Encabezado';
import { Cargando, MensajeError } from '../../compartido/ui/Estados';
import { CLASE_CELDA, Columna, Tabla } from '../../compartido/ui/Tabla';
import { departamentosQueries, useBorrarDepartamento } from './api';

function PaginaDepartamentos() {
  const { data, isPending, isError, error, refetch } = useQuery(departamentosQueries.lista());
  const esAdmin = useEsAdmin();
  const avisar = useAvisos((s) => s.avisar);
  const borrar = useBorrarDepartamento();
  const [aBorrar, setABorrar] = useState<Departamento | null>(null);

  function cerrarDialogo() {
    setABorrar(null);
    borrar.reset();
  }

  function confirmarBorrado() {
    if (!aBorrar) return;
    borrar.mutate(aBorrar.deptId, {
      onSuccess: () => {
        avisar('exito', `Departamento ${aBorrar.nombre} borrado`);
        cerrarDialogo();
      },
    });
  }

  return (
    <>
      <Encabezado titulo="Departamentos">
        <EnlaceBoton to="/departamentos/nuevo" variante="primario">
          Nuevo departamento
        </EnlaceBoton>
      </Encabezado>

      {isPending && <Cargando />}
      {isError && <MensajeError error={error} onReintentar={refetch} />}
      {data && (
        <Tabla
          titulo="Departamentos"
          cabecera={
            <>
              <Columna>Nombre</Columna>
              <Columna>Localidad</Columna>
              <Columna>Presupuesto</Columna>
              <Columna>Acciones</Columna>
            </>
          }
        >
          {data.map((departamento) => (
            <tr key={departamento.deptId}>
              <td className={CLASE_CELDA}>{departamento.nombre}</td>
              <td className={CLASE_CELDA}>{departamento.localidad ?? '—'}</td>
              <td className={`${CLASE_CELDA} tabular-nums`}>
                {departamento.presupuesto === null ? '—' : formatearEuros(departamento.presupuesto)}
              </td>
              <td className={`${CLASE_CELDA} flex flex-wrap gap-2`}>
                <EnlaceBoton to={`/departamentos/${departamento.deptId}/editar`} aria-label={`Editar ${departamento.nombre}`}>
                  Editar
                </EnlaceBoton>
                <Link to={`/empleados?deptId=${departamento.deptId}`} className="self-center text-sm text-enlace underline">
                  Ver empleados
                </Link>
                {esAdmin && (
                  <Boton aria-label={`Borrar ${departamento.nombre}`} onClick={() => setABorrar(departamento)}>
                    Borrar
                  </Boton>
                )}
              </td>
            </tr>
          ))}
        </Tabla>
      )}

      {aBorrar && (
        <DialogoConfirmar
          titulo="Borrar departamento"
          pendiente={borrar.isPending}
          error={borrar.error}
          onConfirmar={confirmarBorrado}
          onCancelar={cerrarDialogo}
        >
          ¿Seguro que quieres borrar <strong>{aBorrar.nombre}</strong>?
        </DialogoConfirmar>
      )}
    </>
  );
}

export { PaginaDepartamentos as Component };

El flujo del borrado:

  1. El botón Borrar guarda el departamento en aBorrar, y eso abre el diálogo.
  2. Confirmar llama a borrar.mutate. Mientras tanto, borrar.isPending desactiva el botón para que un doble clic no envíe dos peticiones.
  3. Si va bien, aviso de éxito y diálogo cerrado.
  4. Si la API responde 409, borrar.error llega al diálogo y se muestra dentro.
  5. Al cancelar, borrar.reset() borra el error. Si no lo hicieras, al abrir el diálogo para otro departamento verías el error del anterior.

Diálogo de borrado con el error 409: no se puede borrar un departamento con empleados

Figura 6. Un 409 real de la API. El diálogo sigue abierto y explica por qué no se ha borrado.

Errores del servidor en el formulario

src/compartido/api/formularios.ts

import type { FieldValues, Path, UseFormSetError } from 'react-hook-form';
import { ErrorApi, textoError } from './cliente';

// Lleva cada error de un 422 a su campo. Lo que no tenga campo en este
// formulario (o cualquier otro error, como un 409) va a la raíz.
export function aplicarErrores<T extends FieldValues>(
  error: unknown,
  setError: UseFormSetError<T>,
  campos: readonly Path<T>[],
) {
  const detalles = error instanceof ErrorApi ? (error.detalles ?? {}) : {};
  const conCampo = campos.filter((campo) => detalles[campo]);

  conCampo.forEach((campo, indice) => {
    setError(campo, { type: 'servidor', message: detalles[campo] }, { shouldFocus: indice === 0 });
  });

  const sinCampo = Object.keys(detalles).filter((clave) => !conCampo.some((c) => c === clave));
  if (conCampo.length === 0 || sinCampo.length > 0) {
    const mensaje = sinCampo.length > 0 ? sinCampo.map((c) => detalles[c]).join('. ') : textoError(error);
    setError('root', { type: 'servidor', message: mensaje });
  }
}

Esta función reparte los errores de la API:

  • Si es un 422 con detalles, cada error va a su campo con setError, y el foco se mueve al primero.
  • Lo que no tiene campo en este formulario (o un 409, o un fallo de red) va a errors.root, que se muestra arriba del formulario.

Es necesaria porque la validación del cliente no puede saberlo todo. Zod comprueba que el nombre no esté vacío, pero solo la API sabe si ya existe otro departamento llamado VENTAS. Los errores de root se borran solos en el siguiente envío.

El formulario

src/funcionalidades/departamentos/PaginaFormDepartamento.tsx

import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { useNavigate, useParams } from 'react-router';
import { aplicarErrores } from '../../compartido/api/formularios';
import type { Departamento } from '../../compartido/api/tipos';
import { useAvisos } from '../../compartido/estado/avisos';
import { Boton, EnlaceBoton } from '../../compartido/ui/Boton';
import { Campo } from '../../compartido/ui/Campos';
import { Encabezado, Tarjeta } from '../../compartido/ui/Encabezado';
import { Cargando, MensajeError } from '../../compartido/ui/Estados';
import { departamentosQueries, useGuardarDepartamento } from './api';
import { esquemaDepartamento, type DatosDepartamento, type EntradaDepartamento } from './esquema';

const CAMPOS = ['nombre', 'localidad', 'presupuesto'] as const;

function Formulario({ id, departamento }: { id?: string; departamento?: Departamento }) {
  const navegar = useNavigate();
  const guardar = useGuardarDepartamento(id);
  const avisar = useAvisos((s) => s.avisar);
  const {
    register,
    handleSubmit,
    setError,
    formState: { errors, isSubmitting },
  } = useForm<EntradaDepartamento, unknown, DatosDepartamento>({
    resolver: zodResolver(esquemaDepartamento),
    defaultValues: {
      nombre: departamento?.nombre ?? '',
      localidad: departamento?.localidad ?? '',
      presupuesto: departamento?.presupuesto?.toString() ?? '',
    },
  });

  const alEnviar = handleSubmit(async (datos) => {
    try {
      await guardar.mutateAsync(datos);
      avisar('exito', `Departamento ${datos.nombre} guardado`);
      navegar('/departamentos');
    } catch (error) {
      aplicarErrores(error, setError, CAMPOS);
    }
  });

  return (
    <Tarjeta>
      <form noValidate onSubmit={alEnviar} className="flex max-w-md flex-col gap-4">
        {errors.root && <MensajeError error={new Error(errors.root.message)} />}
        <Campo etiqueta="Nombre" error={errors.nombre?.message} {...register('nombre')} />
        <Campo etiqueta="Localidad" error={errors.localidad?.message} {...register('localidad')} />
        <Campo etiqueta="Presupuesto (€)" type="number" step="any" min="0" error={errors.presupuesto?.message} {...register('presupuesto')} />
        <div className="flex gap-2">
          <Boton type="submit" variante="primario" disabled={isSubmitting}>
            {isSubmitting ? 'Guardando...' : 'Guardar'}
          </Boton>
          <EnlaceBoton to="/departamentos">Cancelar</EnlaceBoton>
        </div>
      </form>
    </Tarjeta>
  );
}

function PaginaFormDepartamento() {
  const { id } = useParams();
  const consulta = useQuery({ ...departamentosQueries.detalle(id ?? ''), enabled: id !== undefined });

  return (
    <>
      <Encabezado titulo={id ? 'Editar departamento' : 'Nuevo departamento'} />
      {id === undefined ? (
        <Formulario />
      ) : consulta.isError ? (
        <MensajeError error={consulta.error} />
      ) : consulta.data ? (
        <Formulario key={id} id={id} departamento={consulta.data} />
      ) : (
        <Cargando />
      )}
    </>
  );
}

export { PaginaFormDepartamento as Component };
  • Una sola página sirve para crear y para editar. Si la URL tiene :id, se carga el departamento.
  • useQuery lleva enabled: id !== undefined: en /departamentos/nuevo no hay nada que pedir.
  • El formulario se monta solo cuando los datos han llegado. defaultValues se lee una vez, al montar; si montaras el formulario vacío y luego llegaran los datos, se quedaría vacío.
  • key={id} fuerza un formulario nuevo si cambias de un departamento a otro sin pasar por el listado. Es la técnica de reinicio con key del módulo 05.
  • mutateAsync en lugar de mutate: devuelve una promesa, y así handleSubmit espera a la respuesta y isSubmitting es verdadero todo el tiempo.

12. Empleados: listado con filtros y formulario completo

src/funcionalidades/empleados/esquema.ts

import { z } from 'zod';
import { numeroOpcional } from '../departamentos/esquema';

const obligatorio = (mensaje: string) => z.string().trim().min(1, mensaje);

export const esquemaEmpleado = z.object({
  nombre: obligatorio('El nombre es obligatorio'),
  apellidos: obligatorio('Los apellidos son obligatorios'),
  email: z.string().trim().pipe(z.email('El email no es válido')),
  telefono: z
    .string()
    .trim()
    .transform((v) => v || null),
  oficio: obligatorio('El oficio es obligatorio'),
  salario: z.coerce.number<string>('Escribe un número').positive('El salario debe ser mayor que cero'),
  comision: numeroOpcional('La comisión no puede ser negativa'),
  deptId: numeroOpcional('Elige un departamento'),
  jefeId: numeroOpcional('Elige un jefe'),
});

export type EntradaEmpleado = z.input<typeof esquemaEmpleado>;
export type DatosEmpleado = z.output<typeof esquemaEmpleado>;

z.coerce.number<string>() convierte la cadena en número. El parámetro de tipo <string> le dice a TypeScript que la entrada es una cadena; sin él, el tipo de entrada sería unknown y defaultValues aceptaría cualquier cosa. coerce convierte '' en 0, así que un salario vacío falla con “El salario debe ser mayor que cero”, que es justo lo que queremos.

z.string().trim().pipe(z.email(...)) primero quita espacios y después valida. Si alguien pega " eva@empresa.es ", se acepta y se envía limpio.

src/funcionalidades/empleados/api.ts

import { keepPreviousData, queryOptions, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../../compartido/api/cliente';
import type { Empleado, Pagina } from '../../compartido/api/tipos';
import type { DatosEmpleado } from './esquema';

const RAIZ = ['empleados'];

export interface FiltrosEmpleados {
  q: string;
  orden: string;
  pagina: number;
  deptId: string;
}

export const empleadosQueries = {
  // Los filtros forman parte de la clave: cada combinación tiene su entrada en la caché.
  lista: (filtros: FiltrosEmpleados) =>
    queryOptions({
      queryKey: [...RAIZ, 'lista', filtros],
      queryFn: ({ signal }) => {
        const { q, orden, pagina, deptId } = filtros;
        const params = new URLSearchParams({ q, orden, pagina: String(pagina), tamano: '10' });
        if (deptId) params.set('deptId', deptId);
        return api<Pagina<Empleado>>(`/empleados?${params}`, { senal: signal });
      },
      // Al cambiar de página, la tabla anterior sigue visible hasta tener la nueva.
      placeholderData: keepPreviousData,
    }),
  opciones: () =>
    queryOptions({
      queryKey: [...RAIZ, 'opciones'],
      queryFn: ({ signal }) => api<Pagina<Empleado>>('/empleados?orden=apellidos&tamano=100', { senal: signal }),
      select: (pagina) => pagina.contenido,
    }),
  detalle: (id: string) =>
    queryOptions({
      queryKey: [...RAIZ, 'detalle', id],
      queryFn: ({ signal }) => api<Empleado>(`/empleados/${id}`, { senal: signal }),
    }),
};

function useInvalidarEmpleados() {
  const queryClient = useQueryClient();
  return () =>
    Promise.all([
      // Invalida lista, opciones y detalle: todas las claves empiezan por 'empleados'.
      queryClient.invalidateQueries({ queryKey: RAIZ }),
      queryClient.invalidateQueries({ queryKey: ['estadisticas'] }),
    ]);
}

export function useGuardarEmpleado(id?: string) {
  const invalidar = useInvalidarEmpleados();
  return useMutation({
    mutationFn: (datos: DatosEmpleado) =>
      id
        ? api<Empleado>(`/empleados/${id}`, { metodo: 'PUT', cuerpo: datos })
        : api<Empleado>('/empleados', { metodo: 'POST', cuerpo: datos }),
    onSuccess: invalidar,
  });
}

export function useBorrarEmpleado() {
  const invalidar = useInvalidarEmpleados();
  return useMutation({
    mutationFn: (id: number) => api<void>(`/empleados/${id}`, { metodo: 'DELETE' }),
    onSuccess: invalidar,
  });
}
  • Los filtros forman parte de la clave. ['empleados', 'lista', { q: 'garcia', pagina: 1, ... }] y ['empleados', 'lista', { q: '', pagina: 2, ... }] son entradas distintas. Si vuelves a una combinación que ya visitaste, aparece al instante.
  • placeholderData: keepPreviousData mantiene la tabla anterior mientras llega la nueva página, en vez de mostrar “Cargando…” y hacer saltar la pantalla. isPlaceholderData indica que lo que ves es la página anterior, y la tabla lo muestra atenuada.
  • tamano: '10' fija las páginas de 10 empleados. Los datos tienen 24, así que hay tres páginas para probar la paginación.

Listado de empleados con buscador, filtro de departamento, orden y paginación

Figura 7. El listado de empleados. Búsqueda, filtro, orden y página viven en la URL.

src/funcionalidades/empleados/PaginaEmpleados.tsx

import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { useLocation } from 'react-router';
import type { Empleado } from '../../compartido/api/tipos';
import { useAvisos } from '../../compartido/estado/avisos';
import { useEsAdmin } from '../../compartido/estado/sesion';
import { formatearEuros } from '../../compartido/formato';
import { useFiltrosUrl } from '../../compartido/hooks/useFiltrosUrl';
import { Boton, EnlaceBoton } from '../../compartido/ui/Boton';
import { DialogoConfirmar } from '../../compartido/ui/DialogoConfirmar';
import { Encabezado } from '../../compartido/ui/Encabezado';
import { Buscador, Cargando, MensajeError, Vacio } from '../../compartido/ui/Estados';
import { CLASE_CELDA, Columna, Paginacion, Tabla } from '../../compartido/ui/Tabla';
import { departamentosQueries } from '../departamentos/api';
import { empleadosQueries, useBorrarEmpleado } from './api';

function PaginaEmpleados() {
  const { q, orden, pagina, leer, cambiar } = useFiltrosUrl('apellidos');
  const deptId = leer('deptId');
  // El formulario la recibe para volver a esta misma vista al guardar.
  const volver = { busqueda: useLocation().search };
  const { data, isPending, isError, error, refetch, isPlaceholderData } = useQuery(
    empleadosQueries.lista({ q, orden, pagina, deptId }),
  );
  const { data: departamentos = [] } = useQuery(departamentosQueries.lista());
  const esAdmin = useEsAdmin();
  const avisar = useAvisos((s) => s.avisar);
  const borrar = useBorrarEmpleado();
  const [aBorrar, setABorrar] = useState<Empleado | null>(null);

  function cerrarDialogo() {
    setABorrar(null);
    borrar.reset();
  }

  function confirmarBorrado() {
    if (!aBorrar) return;
    borrar.mutate(aBorrar.empId, {
      onSuccess: () => {
        avisar('exito', `${aBorrar.nombre} ${aBorrar.apellidos} borrado`);
        cerrarDialogo();
      },
    });
  }

  const ordenar = (nuevo: string) => cambiar({ orden: nuevo });

  return (
    <>
      <Encabezado titulo="Empleados">
        <EnlaceBoton to="/empleados/nuevo" state={volver} variante="primario">
          Nuevo empleado
        </EnlaceBoton>
      </Encabezado>

      <div className="mb-3 flex flex-wrap gap-2">
        <Buscador etiqueta="Buscar empleados" valor={q} onCambiar={(texto) => cambiar({ q: texto })} />
        <select
          aria-label="Filtrar por departamento"
          value={deptId}
          onChange={(e) => cambiar({ deptId: e.target.value })}
          className="rounded-md border border-borde bg-superficie px-2 py-1.5 text-sm"
        >
          <option value="">Todos los departamentos</option>
          {departamentos.map((d) => (
            <option key={d.deptId} value={d.deptId}>
              {d.nombre}
            </option>
          ))}
        </select>
      </div>

      {isPending && <Cargando />}
      {isError && <MensajeError error={error} onReintentar={refetch} />}
      {data && data.total === 0 && <Vacio>No hay empleados con esos filtros.</Vacio>}
      {data && data.total > 0 && (
        <>
          <Tabla
            titulo="Empleados"
            atenuada={isPlaceholderData}
            cabecera={
              <>
                <Columna campo="apellidos" orden={orden} onOrdenar={ordenar}>Nombre</Columna>
                <Columna campo="oficio" orden={orden} onOrdenar={ordenar}>Oficio</Columna>
                <Columna>Departamento</Columna>
                <Columna campo="salario" orden={orden} onOrdenar={ordenar}>Salario</Columna>
                <Columna>Acciones</Columna>
              </>
            }
          >
            {data.contenido.map((empleado) => {
              const nombre = `${empleado.nombre} ${empleado.apellidos}`;
              return (
                <tr key={empleado.empId}>
                  <td className={CLASE_CELDA}>
                    {nombre}
                    <span className="block text-xs text-suave">{empleado.email}</span>
                  </td>
                  <td className={CLASE_CELDA}>{empleado.oficio}</td>
                  <td className={CLASE_CELDA}>{empleado.departamento ?? '—'}</td>
                  <td className={`${CLASE_CELDA} tabular-nums`}>{formatearEuros(empleado.salario)}</td>
                  <td className={`${CLASE_CELDA} flex gap-2`}>
                    <EnlaceBoton to={`/empleados/${empleado.empId}/editar`} state={volver} aria-label={`Editar a ${nombre}`}>
                      Editar
                    </EnlaceBoton>
                    {esAdmin && (
                      <Boton aria-label={`Borrar a ${nombre}`} onClick={() => setABorrar(empleado)}>
                        Borrar
                      </Boton>
                    )}
                  </td>
                </tr>
              );
            })}
          </Tabla>
          <Paginacion
            pagina={data.pagina}
            paginas={data.paginas}
            total={data.total}
            onCambiar={(nueva) => cambiar({ pagina: String(nueva) })}
          />
        </>
      )}

      {aBorrar && (
        <DialogoConfirmar
          titulo="Borrar empleado"
          pendiente={borrar.isPending}
          error={borrar.error}
          onConfirmar={confirmarBorrado}
          onCancelar={cerrarDialogo}
        >
          ¿Seguro que quieres borrar a <strong>{aBorrar.nombre} {aBorrar.apellidos}</strong>?
        </DialogoConfirmar>
      )}
    </>
  );
}

export { PaginaEmpleados as Component };

Los enlaces de alta y edición llevan state={volver}: el estado de navegación de React Router, que viaja con la navegación pero no aparece en la URL. El formulario lo lee con useLocation().state para volver al listado con los mismos filtros. Sin esto, al editar un empleado de la página 3 de una búsqueda volverías a la página 1 sin filtros, y tendrías que buscarlo otra vez. Si alguien entra al formulario con un enlace directo, state es null y se vuelve al listado sin filtros.

El formulario de empleado

src/funcionalidades/empleados/PaginaFormEmpleado.tsx

import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { useLocation, useNavigate, useParams } from 'react-router';
import { aplicarErrores } from '../../compartido/api/formularios';
import type { Departamento, Empleado } from '../../compartido/api/tipos';
import { useAvisos } from '../../compartido/estado/avisos';
import { Boton, EnlaceBoton } from '../../compartido/ui/Boton';
import { Campo, Desplegable } from '../../compartido/ui/Campos';
import { Encabezado, Tarjeta } from '../../compartido/ui/Encabezado';
import { Cargando, MensajeError } from '../../compartido/ui/Estados';
import { departamentosQueries } from '../departamentos/api';
import { empleadosQueries, useGuardarEmpleado } from './api';
import { esquemaEmpleado, type DatosEmpleado, type EntradaEmpleado } from './esquema';

const CAMPOS = ['nombre', 'apellidos', 'email', 'telefono', 'oficio', 'salario', 'comision', 'deptId', 'jefeId'] as const;

interface PropsFormulario {
  id?: string;
  empleado?: Empleado;
  departamentos: Departamento[];
  personas: Empleado[];
}

function Formulario({ id, empleado, departamentos, personas }: PropsFormulario) {
  const navegar = useNavigate();
  // Si venimos del listado, volvemos con sus filtros; si no, al listado sin filtros.
  const estado: unknown = useLocation().state;
  const listado = `/empleados${(estado as { busqueda?: string } | null)?.busqueda ?? ''}`;
  const guardar = useGuardarEmpleado(id);
  const avisar = useAvisos((s) => s.avisar);
  const {
    register,
    handleSubmit,
    setError,
    formState: { errors, isSubmitting },
  } = useForm<EntradaEmpleado, unknown, DatosEmpleado>({
    resolver: zodResolver(esquemaEmpleado),
    defaultValues: {
      nombre: empleado?.nombre ?? '',
      apellidos: empleado?.apellidos ?? '',
      email: empleado?.email ?? '',
      telefono: empleado?.telefono ?? '',
      oficio: empleado?.oficio ?? '',
      salario: empleado?.salario.toString() ?? '',
      comision: empleado?.comision?.toString() ?? '',
      deptId: empleado?.deptId?.toString() ?? '',
      jefeId: empleado?.jefeId?.toString() ?? '',
    },
  });

  const alEnviar = handleSubmit(async (datos) => {
    try {
      await guardar.mutateAsync(datos);
      avisar('exito', `${datos.nombre} ${datos.apellidos} guardado`);
      navegar(listado);
    } catch (error) {
      aplicarErrores(error, setError, CAMPOS);
    }
  });

  return (
    <Tarjeta>
      <form noValidate onSubmit={alEnviar} className="grid gap-4 md:grid-cols-2">
        {errors.root && (
          <div className="md:col-span-2">
            <MensajeError error={new Error(errors.root.message)} />
          </div>
        )}
        <Campo etiqueta="Nombre" error={errors.nombre?.message} {...register('nombre')} />
        <Campo etiqueta="Apellidos" error={errors.apellidos?.message} {...register('apellidos')} />
        <Campo etiqueta="Email" type="email" error={errors.email?.message} {...register('email')} />
        <Campo etiqueta="Teléfono" type="tel" error={errors.telefono?.message} {...register('telefono')} />
        <Campo etiqueta="Oficio" error={errors.oficio?.message} {...register('oficio')} />
        <Campo etiqueta="Salario (€)" type="number" step="any" min="0" error={errors.salario?.message} {...register('salario')} />
        <Campo etiqueta="Comisión (€)" type="number" step="any" min="0" error={errors.comision?.message} {...register('comision')} />
        <Desplegable etiqueta="Departamento" error={errors.deptId?.message} {...register('deptId')}>
          <option value="">Sin departamento</option>
          {departamentos.map((d) => (
            <option key={d.deptId} value={d.deptId}>
              {d.nombre}
            </option>
          ))}
        </Desplegable>
        <Desplegable etiqueta="Jefe" error={errors.jefeId?.message} {...register('jefeId')}>
          <option value="">Sin jefe</option>
          {personas
            .filter((p) => p.empId !== empleado?.empId)
            .map((p) => (
              <option key={p.empId} value={p.empId}>
                {p.apellidos}, {p.nombre}
              </option>
            ))}
        </Desplegable>
        <div className="flex gap-2 md:col-span-2">
          <Boton type="submit" variante="primario" disabled={isSubmitting}>
            {isSubmitting ? 'Guardando...' : 'Guardar'}
          </Boton>
          <EnlaceBoton to={listado}>Cancelar</EnlaceBoton>
        </div>
      </form>
    </Tarjeta>
  );
}

function PaginaFormEmpleado() {
  const { id } = useParams();
  const consulta = useQuery({ ...empleadosQueries.detalle(id ?? ''), enabled: id !== undefined });
  const departamentos = useQuery(departamentosQueries.lista());
  const personas = useQuery(empleadosQueries.opciones());

  const error = consulta.error ?? departamentos.error ?? personas.error;
  // El formulario se monta cuando todo ha llegado: register() fija el valor del
  // <select> al montar, y sin sus <option> se quedaría en la primera opción.
  const listo = (id === undefined || consulta.data) && departamentos.data && personas.data;

  return (
    <>
      <Encabezado titulo={id ? 'Editar empleado' : 'Nuevo empleado'} />
      {error ? (
        <MensajeError error={error} />
      ) : listo ? (
        <Formulario key={id} id={id} empleado={consulta.data} departamentos={departamentos.data} personas={personas.data} />
      ) : (
        <Cargando />
      )}
    </>
  );
}

export { PaginaFormEmpleado as Component };

El error más difícil de ver de este proyecto está en el comentario de listo. register() fija el valor de un <select> cuando se monta. Si el formulario se monta antes de que lleguen los departamentos, el <select> no tiene la opción 20 y el navegador muestra la primera, “Sin departamento”. Lo peor es que React Hook Form sigue teniendo internamente deptId: '20': la pantalla y los datos no coinciden. Si el usuario guarda sin tocar el desplegable, se envía 20, pero si lo toca, cambia. La solución es esperar a todas las consultas antes de montar el formulario. Las pruebas E2E comprueban que, al editar, el desplegable muestra el departamento correcto.

Formulario de empleado con el error 422 de email repetido bajo el campo Email

Figura 8. Un 422 real: el email ya existe. El error aparece bajo su campo, el campo se marca con aria-invalid y recibe el foco.


13. Pedidos: listas dinámicas, conflictos y optimismo

src/funcionalidades/pedidos/esquema.ts

import { z } from 'zod';

const lineaPedido = z.object({
  productoId: z.coerce.number<string>().min(1, 'Elige un producto'),
  cantidad: z.coerce.number<string>('Escribe un número').int('Debe ser un entero').min(1, 'Como mínimo, 1'),
});

export const esquemaPedido = z.object({
  clienteId: z.coerce.number<string>().min(1, 'Elige un cliente'),
  lineas: z
    .array(lineaPedido)
    .min(1, 'Añade al menos una línea')
    // La API comprueba el stock línea a línea: dos líneas del mismo producto
    // podrían superarlo sin que ella lo detecte. Mejor impedirlo aquí.
    .refine((lineas) => new Set(lineas.map((l) => l.productoId)).size === lineas.length, 'No repitas un producto: suma las cantidades'),
});

export type EntradaPedido = z.input<typeof esquemaPedido>;
export type DatosPedido = z.output<typeof esquemaPedido>;

La regla de no repetir productos merece una explicación. La API comprueba el stock línea a línea: si hay 9 impresoras y pides 6 en una línea y 6 en otra, cada línea por separado cabe en el stock, y el pedido se acepta con 12. Es un fallo de la API, pero la interfaz puede evitarlo. Validar en el cliente no es solo comodidad: a veces protege los datos.

src/funcionalidades/pedidos/api.ts

import { keepPreviousData, queryOptions, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../../compartido/api/cliente';
import type { Cliente, EstadoPedido, Pagina, Pedido, Producto } from '../../compartido/api/tipos';
import type { DatosPedido } from './esquema';

const RAIZ = ['pedidos'];

export interface FiltrosPedidos {
  estado: string;
  orden: string;
  pagina: number;
}

export const pedidosQueries = {
  lista: ({ estado, orden, pagina }: FiltrosPedidos) =>
    queryOptions({
      queryKey: [...RAIZ, 'lista', { estado, orden, pagina }],
      queryFn: ({ signal }) => {
        const params = new URLSearchParams({ orden, pagina: String(pagina), tamano: '10' });
        if (estado) params.set('estado', estado);
        return api<Pagina<Pedido>>(`/pedidos?${params}`, { senal: signal });
      },
      placeholderData: keepPreviousData,
    }),
  detalle: (id: string) =>
    queryOptions({
      queryKey: [...RAIZ, 'detalle', id],
      queryFn: ({ signal }) => api<Pedido>(`/pedidos/${id}`, { senal: signal }),
    }),
  clientes: () =>
    queryOptions({
      queryKey: ['clientes'],
      queryFn: ({ signal }) => api<Pagina<Cliente>>('/clientes?orden=nombre&tamano=100', { senal: signal }),
      select: (pagina) => pagina.contenido,
    }),
  productos: () =>
    queryOptions({
      queryKey: ['productos', 'disponibles'],
      queryFn: ({ signal }) => api<Pagina<Producto>>('/productos?disponibles=true&orden=nombre&tamano=100', { senal: signal }),
      select: (pagina) => pagina.contenido,
    }),
};

export function useCrearPedido() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (datos: DatosPedido) => api<Pedido>('/pedidos', { metodo: 'POST', cuerpo: datos }),
    onSuccess: () =>
      Promise.all([
        queryClient.invalidateQueries({ queryKey: RAIZ }),
        queryClient.invalidateQueries({ queryKey: ['estadisticas'] }),
      ]),
    // Tanto si sale bien (ha bajado) como con un 409 (estaba desfasado), el stock cambia.
    onSettled: () => queryClient.invalidateQueries({ queryKey: ['productos'] }),
  });
}

// Actualización optimista: la pantalla cambia antes de que responda la API,
// y si falla se deshace con la copia guardada en onMutate.
export function useCambiarEstado(id: string) {
  const queryClient = useQueryClient();
  const { queryKey } = pedidosQueries.detalle(id);

  return useMutation({
    mutationFn: (estado: EstadoPedido) => api<Pedido>(`/pedidos/${id}`, { metodo: 'PATCH', cuerpo: { estado } }),
    onMutate: async (estado) => {
      await queryClient.cancelQueries({ queryKey });
      const anterior = queryClient.getQueryData(queryKey);
      queryClient.setQueryData(queryKey, (pedido) => pedido && { ...pedido, estado });
      return { anterior };
    },
    onError: (_error, _estado, contexto) => queryClient.setQueryData(queryKey, contexto?.anterior),
    onSettled: () =>
      Promise.all([
        queryClient.invalidateQueries({ queryKey: RAIZ }),
        queryClient.invalidateQueries({ queryKey: ['estadisticas'] }),
      ]),
  });
}

useCrearPedido invalida los productos en onSettled, que se ejecuta tanto si sale bien como si falla. Si sale bien, el stock ha bajado. Si falla con un 409 de stock, es que el stock que tenía la pantalla estaba desfasado: otro usuario ha comprado mientras tanto. En los dos casos, el desplegable debe mostrar el stock nuevo.

Cambio de estado optimista

useCambiarEstado aplica el patrón optimista del módulo 19 paso a paso:

  1. onMutate cancela las peticiones en curso de ese pedido (si una respuesta antigua llegara después, pisaría el cambio), guarda una copia y escribe el estado nuevo en la caché. La pantalla cambia al instante.
  2. onError restaura la copia. El usuario ve cómo el estado vuelve atrás, y un aviso le dice por qué.
  3. onSettled invalida pedidos y estadísticas para que todo se sincronice con el servidor.

La API admite cualquier cambio de estado, incluso de ENTREGADO a PENDIENTE. La tabla SIGUIENTES de la página de detalle pone las reglas de negocio: un pedido pendiente se envía o se cancela, uno enviado se entrega, y los entregados y cancelados ya no cambian.

src/funcionalidades/pedidos/InsigniaEstado.tsx

import type { EstadoPedido } from '../../compartido/api/tipos';

const CLASES: Record<EstadoPedido, string> = {
  PENDIENTE: 'bg-amber-100 text-amber-900',
  ENVIADO: 'bg-blue-100 text-blue-900',
  ENTREGADO: 'bg-green-100 text-green-900',
  CANCELADO: 'bg-gray-200 text-gray-800',
};

// El color acompaña al texto, nunca lo sustituye.
export function InsigniaEstado({ estado }: { estado: EstadoPedido }) {
  return <span className={`rounded-full px-2 py-0.5 text-xs font-medium ${CLASES[estado]}`}>{estado}</span>;
}

src/funcionalidades/pedidos/PaginaPedidos.tsx

import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router';
import { ESTADOS_PEDIDO } from '../../compartido/api/tipos';
import { formatearEuros, formatearFecha } from '../../compartido/formato';
import { useFiltrosUrl } from '../../compartido/hooks/useFiltrosUrl';
import { EnlaceBoton } from '../../compartido/ui/Boton';
import { Encabezado } from '../../compartido/ui/Encabezado';
import { Cargando, MensajeError, Vacio } from '../../compartido/ui/Estados';
import { CLASE_CELDA, Columna, Paginacion, Tabla } from '../../compartido/ui/Tabla';
import { pedidosQueries } from './api';
import { InsigniaEstado } from './InsigniaEstado';

function PaginaPedidos() {
  const { orden, pagina, leer, cambiar } = useFiltrosUrl('-pedidoId');
  const estado = leer('estado');
  const { data, isPending, isError, error, refetch, isPlaceholderData } = useQuery(
    pedidosQueries.lista({ estado, orden, pagina }),
  );
  const ordenar = (nuevo: string) => cambiar({ orden: nuevo });

  return (
    <>
      <Encabezado titulo="Pedidos">
        <EnlaceBoton to="/pedidos/nuevo" variante="primario">
          Nuevo pedido
        </EnlaceBoton>
      </Encabezado>

      <div className="mb-3">
        <select
          aria-label="Filtrar por estado"
          value={estado}
          onChange={(e) => cambiar({ estado: e.target.value })}
          className="rounded-md border border-borde bg-superficie px-2 py-1.5 text-sm"
        >
          <option value="">Todos los estados</option>
          {ESTADOS_PEDIDO.map((e) => (
            <option key={e} value={e}>
              {e}
            </option>
          ))}
        </select>
      </div>

      {isPending && <Cargando />}
      {isError && <MensajeError error={error} onReintentar={refetch} />}
      {data && data.total === 0 && <Vacio>No hay pedidos en ese estado.</Vacio>}
      {data && data.total > 0 && (
        <>
          <Tabla
            titulo="Pedidos"
            atenuada={isPlaceholderData}
            cabecera={
              <>
                <Columna campo="pedidoId" orden={orden} onOrdenar={ordenar}>Número</Columna>
                <Columna campo="cliente" orden={orden} onOrdenar={ordenar}>Cliente</Columna>
                <Columna campo="fechaPedido" orden={orden} onOrdenar={ordenar}>Fecha</Columna>
                <Columna>Estado</Columna>
                <Columna campo="total" orden={orden} onOrdenar={ordenar}>Total</Columna>
              </>
            }
          >
            {data.contenido.map((pedido) => (
              <tr key={pedido.pedidoId}>
                <td className={CLASE_CELDA}>
                  <Link to={`/pedidos/${pedido.pedidoId}`} className="text-enlace underline">
                    {pedido.pedidoId}
                  </Link>
                </td>
                <td className={CLASE_CELDA}>{pedido.cliente}</td>
                <td className={CLASE_CELDA}>{formatearFecha(pedido.fechaPedido)}</td>
                <td className={CLASE_CELDA}>
                  <InsigniaEstado estado={pedido.estado} />
                </td>
                <td className={`${CLASE_CELDA} tabular-nums`}>{formatearEuros(pedido.total)}</td>
              </tr>
            ))}
          </Tabla>
          <Paginacion
            pagina={data.pagina}
            paginas={data.paginas}
            total={data.total}
            onCambiar={(nueva) => cambiar({ pagina: String(nueva) })}
          />
        </>
      )}
    </>
  );
}

export { PaginaPedidos as Component };

src/funcionalidades/pedidos/PaginaPedido.tsx

import { useQuery } from '@tanstack/react-query';
import { useParams } from 'react-router';
import type { EstadoPedido } from '../../compartido/api/tipos';
import { useAvisos } from '../../compartido/estado/avisos';
import { textoError } from '../../compartido/api/cliente';
import { formatearEuros, formatearFecha } from '../../compartido/formato';
import { Boton, EnlaceBoton } from '../../compartido/ui/Boton';
import { Encabezado, Tarjeta } from '../../compartido/ui/Encabezado';
import { Cargando, MensajeError } from '../../compartido/ui/Estados';
import { CLASE_CELDA, Columna, Tabla } from '../../compartido/ui/Tabla';
import { pedidosQueries, useCambiarEstado } from './api';
import { InsigniaEstado } from './InsigniaEstado';

// La API acepta cualquier cambio; las reglas del negocio las pone la interfaz.
const SIGUIENTES: Record<EstadoPedido, { estado: EstadoPedido; texto: string }[]> = {
  PENDIENTE: [
    { estado: 'ENVIADO', texto: 'Marcar como enviado' },
    { estado: 'CANCELADO', texto: 'Cancelar pedido' },
  ],
  ENVIADO: [{ estado: 'ENTREGADO', texto: 'Marcar como entregado' }],
  ENTREGADO: [],
  CANCELADO: [],
};

function PaginaPedido() {
  const { id = '' } = useParams();
  const { data: pedido, isPending, isError, error, refetch } = useQuery(pedidosQueries.detalle(id));
  const cambiar = useCambiarEstado(id);
  const avisar = useAvisos((s) => s.avisar);

  const cambiarA = (estado: EstadoPedido) =>
    cambiar.mutate(estado, {
      onError: (e) => avisar('error', textoError(e)),
    });

  return (
    <>
      <Encabezado titulo={`Pedido ${id}`}>
        <EnlaceBoton to="/pedidos">Volver a pedidos</EnlaceBoton>
      </Encabezado>
      {isPending && <Cargando />}
      {isError && <MensajeError error={error} onReintentar={refetch} />}
      {pedido && (
        <div className="flex flex-col gap-4">
          <Tarjeta>
            <dl className="grid gap-3 text-sm sm:grid-cols-4">
              <div>
                <dt className="text-suave">Cliente</dt>
                <dd>{pedido.cliente}</dd>
              </div>
              <div>
                <dt className="text-suave">Fecha</dt>
                <dd>{formatearFecha(pedido.fechaPedido)}</dd>
              </div>
              <div>
                <dt className="text-suave">Estado</dt>
                <dd data-testid="estado-pedido">
                  <InsigniaEstado estado={pedido.estado} />
                </dd>
              </div>
              <div>
                <dt className="text-suave">Total</dt>
                <dd className="font-semibold tabular-nums">{formatearEuros(pedido.total)}</dd>
              </div>
            </dl>
            {SIGUIENTES[pedido.estado].length > 0 && (
              <div className="mt-4 flex flex-wrap gap-2">
                {SIGUIENTES[pedido.estado].map(({ estado, texto }) => (
                  <Boton key={estado} disabled={cambiar.isPending} onClick={() => cambiarA(estado)}>
                    {texto}
                  </Boton>
                ))}
              </div>
            )}
          </Tarjeta>

          <Tabla
            titulo="Líneas del pedido"
            cabecera={
              <>
                <Columna>Producto</Columna>
                <Columna>Cantidad</Columna>
                <Columna>Precio</Columna>
                <Columna>Descuento</Columna>
                <Columna>Importe</Columna>
              </>
            }
          >
            {pedido.lineas.map((linea) => (
              <tr key={linea.numLinea}>
                <td className={CLASE_CELDA}>{linea.producto}</td>
                <td className={`${CLASE_CELDA} tabular-nums`}>{linea.cantidad}</td>
                <td className={`${CLASE_CELDA} tabular-nums`}>{formatearEuros(linea.precioUnitario)}</td>
                <td className={`${CLASE_CELDA} tabular-nums`}>{linea.descuento} %</td>
                <td className={`${CLASE_CELDA} tabular-nums`}>{formatearEuros(linea.importe)}</td>
              </tr>
            ))}
          </Tabla>
        </div>
      )}
    </>
  );
}

export { PaginaPedido as Component };

Detalle de un pedido con su estado, total, botones de cambio de estado y líneas

Figura 9. Detalle de un pedido. Los botones dependen del estado actual.

Nuevo pedido con líneas dinámicas

src/funcionalidades/pedidos/PaginaNuevoPedido.tsx

import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { useFieldArray, useForm, useWatch } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { aplicarErrores } from '../../compartido/api/formularios';
import type { Cliente, Producto } from '../../compartido/api/tipos';
import { useAvisos } from '../../compartido/estado/avisos';
import { formatearEuros } from '../../compartido/formato';
import { Boton, EnlaceBoton } from '../../compartido/ui/Boton';
import { Campo, Desplegable } from '../../compartido/ui/Campos';
import { Encabezado, Tarjeta } from '../../compartido/ui/Encabezado';
import { Cargando, MensajeError } from '../../compartido/ui/Estados';
import { pedidosQueries, useCrearPedido } from './api';
import { esquemaPedido, type DatosPedido, type EntradaPedido } from './esquema';

const LINEA_VACIA = { productoId: '', cantidad: '1' };

function Formulario({ clientes, productos }: { clientes: Cliente[]; productos: Producto[] }) {
  const navegar = useNavigate();
  const crear = useCrearPedido();
  const avisar = useAvisos((s) => s.avisar);
  const {
    control,
    register,
    handleSubmit,
    setError,
    formState: { errors, isSubmitting },
  } = useForm<EntradaPedido, unknown, DatosPedido>({
    resolver: zodResolver(esquemaPedido),
    defaultValues: { clienteId: '', lineas: [LINEA_VACIA] },
  });
  const { fields, append, remove } = useFieldArray({ control, name: 'lineas' });
  const lineas = useWatch({ control, name: 'lineas' });

  const total = lineas.reduce((suma, linea) => {
    const producto = productos.find((p) => p.productoId === Number(linea.productoId));
    return suma + (producto?.precio ?? 0) * (Number(linea.cantidad) || 0);
  }, 0);

  const alEnviar = handleSubmit(async (datos) => {
    try {
      const pedido = await crear.mutateAsync(datos);
      avisar('exito', `Pedido ${pedido.pedidoId} creado`);
      navegar(`/pedidos/${pedido.pedidoId}`);
    } catch (error) {
      aplicarErrores(error, setError, ['clienteId']);
    }
  });

  const errorLineas = errors.lineas?.root?.message ?? errors.lineas?.message;

  return (
    <Tarjeta>
      <form noValidate onSubmit={alEnviar} className="flex flex-col gap-4">
        {errors.root && <MensajeError error={new Error(errors.root.message)} />}
        <Desplegable etiqueta="Cliente" error={errors.clienteId?.message} {...register('clienteId')}>
          <option value="">Elige un cliente</option>
          {clientes.map((c) => (
            <option key={c.clienteId} value={c.clienteId}>
              {c.nombre} ({c.ciudad})
            </option>
          ))}
        </Desplegable>

        {fields.map((campo, indice) => (
          // campo.id, no el índice: al quitar una línea, cada fila conserva sus valores.
          <fieldset key={campo.id} className="grid items-start gap-3 rounded-md border border-borde p-3 sm:grid-cols-[1fr_7rem_auto]">
            <legend className="px-1 text-xs text-suave">Línea {indice + 1}</legend>
            <Desplegable
              etiqueta="Producto"
              error={errors.lineas?.[indice]?.productoId?.message}
              {...register(`lineas.${indice}.productoId`)}
            >
              <option value="">Elige un producto</option>
              {productos.map((p) => (
                <option key={p.productoId} value={p.productoId}>
                  {p.nombre}, {formatearEuros(p.precio)} (stock: {p.stock})
                </option>
              ))}
            </Desplegable>
            <Campo
              etiqueta="Cantidad"
              type="number"
              min="1"
              error={errors.lineas?.[indice]?.cantidad?.message}
              {...register(`lineas.${indice}.cantidad`)}
            />
            <div className="sm:pt-6">
              <Boton disabled={fields.length === 1} aria-label={`Quitar línea ${indice + 1}`} onClick={() => remove(indice)}>
                Quitar
              </Boton>
            </div>
          </fieldset>
        ))}
        {errorLineas && (
          <p role="alert" className="text-sm text-peligro">
            {errorLineas}
          </p>
        )}

        <div className="flex flex-wrap items-center justify-between gap-3">
          <Boton onClick={() => append(LINEA_VACIA)}>Añadir línea</Boton>
          <p className="text-sm">
            Total estimado: <strong className="tabular-nums">{formatearEuros(total)}</strong>
          </p>
        </div>
        <div className="flex gap-2">
          <Boton type="submit" variante="primario" disabled={isSubmitting}>
            {isSubmitting ? 'Creando...' : 'Crear pedido'}
          </Boton>
          <EnlaceBoton to="/pedidos">Cancelar</EnlaceBoton>
        </div>
      </form>
    </Tarjeta>
  );
}

function PaginaNuevoPedido() {
  const clientes = useQuery(pedidosQueries.clientes());
  const productos = useQuery(pedidosQueries.productos());
  const error = clientes.error ?? productos.error;

  return (
    <>
      <Encabezado titulo="Nuevo pedido" />
      {error ? (
        <MensajeError error={error} />
      ) : clientes.data && productos.data ? (
        <Formulario clientes={clientes.data} productos={productos.data} />
      ) : (
        <Cargando />
      )}
    </>
  );
}

export { PaginaNuevoPedido as Component };
  • useFieldArray gestiona la lista de líneas. Cada fila usa campo.id como key, un identificador que genera React Hook Form. Con el índice, al quitar la primera línea, React reutilizaría sus campos para la segunda y verías valores cruzados, como en el módulo 06.
  • useWatch se suscribe a las líneas para calcular el total en cada tecla. Es “estimado” porque el precio y el descuento definitivos los pone la API.
  • Con useFieldArray, el error de la lista entera (la regla de no repetir) llega en errors.lineas.root, y no en errors.lineas. El código mira los dos sitios.
  • El error de cliente usa role="alert" para que el lector de pantalla lo anuncie en cuanto aparece.

Formulario de nuevo pedido con el error 409 de falta de stock

Figura 10. Se piden 5000 discos y hay 70: la API responde 409 y el mensaje aparece arriba. Lo escrito en el formulario se conserva.


14. Pruebas con Vitest, Testing Library y MSW

Las pruebas de este nivel no necesitan la API: MSW intercepta las peticiones y responde lo que cada prueba necesita. Así puedes provocar un 409 o un 422 sin preparar datos, y las pruebas tardan segundos. Todo esto se explicó en el módulo 22.

src/pruebas/preparacion.ts

import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterAll, afterEach, beforeAll } from 'vitest';
import { useSesion } from '../compartido/estado/sesion';
import { servidor } from './servidor';

// jsdom no implementa estos dos métodos de <dialog>.
HTMLDialogElement.prototype.showModal ??= function (this: HTMLDialogElement) {
  this.open = true;
};
HTMLDialogElement.prototype.close ??= function (this: HTMLDialogElement) {
  this.open = false;
};

// Una petición sin manejador es un fallo de la prueba, no algo que ignorar.
beforeAll(() => servidor.listen({ onUnhandledFrame: 'error' }));
afterEach(() => {
  cleanup();
  servidor.resetHandlers();
  localStorage.clear();
  useSesion.setState({ token: null, usuario: null, expiraEl: null, motivoCierre: null });
});
afterAll(() => servidor.close());

src/pruebas/servidor.ts

import { http, HttpResponse } from 'msw/http';
import { setupServer } from 'msw/node';

export const API = 'http://localhost:3000/api';

export const paginaDe = <T,>(contenido: T[]) => ({
  contenido,
  pagina: 1,
  tamano: 20,
  total: contenido.length,
  paginas: 1,
});

export const servidor = setupServer();
export { http, HttpResponse };

src/pruebas/utilidades.tsx

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render } from '@testing-library/react';
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router';
import type { Rol } from '../compartido/api/tipos';
import { useSesion } from '../compartido/estado/sesion';

export function entrarComo(rol: Rol) {
  useSesion.setState({
    token: 'token-de-prueba',
    usuario: { email: 'prueba@empresa.es', nombre: 'Prueba', rol },
    expiraEl: Date.now() + 60_000,
  });
}

// Cada prueba tiene su caché y su router: nada se filtra de una a otra.
export function renderizarRutas(rutas: RouteObject[], inicial: string) {
  const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  const router = createMemoryRouter(rutas, { initialEntries: [inicial] });
  render(
    <QueryClientProvider client={queryClient}>
      <RouterProvider router={router} />
    </QueryClientProvider>,
  );
  return router;
}
  • onUnhandledFrame: 'error': en MSW 3 sustituye a onUnhandledRequest. Una petición que nadie esperaba hace fallar la prueba, en lugar de salir a la red.
  • El store de sesión se limpia tras cada prueba. Los stores de Zustand son módulos: viven mientras dura el fichero de pruebas, y una sesión abierta en una prueba seguiría abierta en la siguiente.
  • jsdom 30.1.3 no implementa showModal() ni close() en <dialog> (lo hemos comprobado), así que se añaden versiones mínimas. El operador ??= solo las añade si faltan: el día que jsdom las incluya, se usarán las suyas.
  • renderizarRutas crea un QueryClient sin reintentos para cada prueba. Con reintentos, una prueba de error tardaría varios segundos esperando.

src/compartido/api/cliente.test.ts

import { beforeEach, describe, expect, it } from 'vitest';
import { useSesion } from '../estado/sesion';
import { API, http, HttpResponse, servidor } from '../../pruebas/servidor';
import { entrarComo } from '../../pruebas/utilidades';
import { api, ErrorApi } from './cliente';

describe('cliente de la API', () => {
  beforeEach(() => useSesion.setState({ motivoCierre: null }));

  it('envía el token en la cabecera Authorization', async () => {
    entrarComo('USER');
    let cabecera: string | null = null;
    servidor.use(
      http.get(`${API}/prueba`, ({ request }) => {
        cabecera = request.headers.get('Authorization');
        return HttpResponse.json({ ok: true });
      }),
    );
    await api('/prueba');
    expect(cabecera).toBe('Bearer token-de-prueba');
  });

  it('convierte un 422 en ErrorApi con sus detalles', async () => {
    servidor.use(
      http.post(`${API}/prueba`, () =>
        HttpResponse.json({ status: 422, mensaje: 'Hay datos no válidos', detalles: { email: 'Ese email ya está en uso' } }, { status: 422 }),
      ),
    );
    const error = await api<never>('/prueba', { metodo: 'POST', cuerpo: {} }).catch((e: ErrorApi) => e);
    expect(error).toBeInstanceOf(ErrorApi);
    expect(error.status).toBe(422);
    expect(error.detalles?.email).toBe('Ese email ya está en uso');
  });

  it('cierra la sesión si la API rechaza el token', async () => {
    entrarComo('USER');
    servidor.use(http.get(`${API}/prueba`, () => HttpResponse.json({ mensaje: 'Debes iniciar sesión' }, { status: 401 })));
    await expect(api('/prueba')).rejects.toThrow('Debes iniciar sesión');
    expect(useSesion.getState().token).toBeNull();
    expect(useSesion.getState().motivoCierre).toBe('caducada');
  });

  it('no marca sesión caducada en un 401 sin token', async () => {
    servidor.use(http.post(`${API}/auth/login`, () => HttpResponse.json({ mensaje: 'Email o contraseña incorrectos' }, { status: 401 })));
    await expect(api('/auth/login', { metodo: 'POST', cuerpo: {} })).rejects.toThrow('Email o contraseña incorrectos');
    expect(useSesion.getState().motivoCierre).toBeNull();
  });

  it('traduce un fallo de red a status 0', async () => {
    servidor.use(http.get(`${API}/prueba`, () => HttpResponse.error()));
    const error = await api<never>('/prueba').catch((e: ErrorApi) => e);
    expect(error.status).toBe(0);
  });
});

src/funcionalidades/sesion/sesion.test.tsx

import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
import { API, http, HttpResponse, servidor } from '../../pruebas/servidor';
import { entrarComo, renderizarRutas } from '../../pruebas/utilidades';
import { destinoSeguro } from './api';
import { Component as PaginaLogin } from './PaginaLogin';
import { RutaProtegida } from './RutaProtegida';

const rutas = [
  { path: '/login', element: <PaginaLogin /> },
  {
    element: <RutaProtegida />,
    children: [
      { path: '/', element: <h1>Inicio</h1> },
      { path: '/pedidos', element: <h1>Pedidos</h1> },
    ],
  },
];

describe('sesión', () => {
  it('sin sesión, una ruta protegida lleva al login y recuerda el destino', async () => {
    const router = renderizarRutas(rutas, '/pedidos?estado=ENVIADO');
    expect(await screen.findByRole('button', { name: 'Entrar' })).toBeInTheDocument();
    expect(router.state.location.search).toBe('?volver=%2Fpedidos%3Festado%3DENVIADO');
  });

  it('con sesión, la ruta protegida se muestra', async () => {
    entrarComo('USER');
    renderizarRutas(rutas, '/pedidos');
    expect(await screen.findByRole('heading', { name: 'Pedidos' })).toBeInTheDocument();
  });

  it('al entrar, vuelve al destino recordado', async () => {
    servidor.use(
      http.post(`${API}/auth/login`, () =>
        HttpResponse.json({ token: 'abc', usuario: { email: 'a@b.es', nombre: 'Ana', rol: 'ADMIN' }, expiraEn: 3600 }),
      ),
    );
    renderizarRutas(rutas, '/login?volver=/pedidos');
    const usuario = userEvent.setup();
    await usuario.type(screen.getByLabelText('Email'), 'ana.garcia@empresa.es');
    await usuario.type(screen.getByLabelText('Contraseña'), 'secreto');
    await usuario.click(screen.getByRole('button', { name: 'Entrar' }));
    expect(await screen.findByRole('heading', { name: 'Pedidos' })).toBeInTheDocument();
  });

  it('muestra el error de una contraseña incorrecta', async () => {
    servidor.use(http.post(`${API}/auth/login`, () => HttpResponse.json({ mensaje: 'Email o contraseña incorrectos' }, { status: 401 })));
    renderizarRutas(rutas, '/login');
    const usuario = userEvent.setup();
    await usuario.type(screen.getByLabelText('Email'), 'ana.garcia@empresa.es');
    await usuario.type(screen.getByLabelText('Contraseña'), 'mal');
    await usuario.click(screen.getByRole('button', { name: 'Entrar' }));
    expect(await screen.findByRole('alert')).toHaveTextContent('Email o contraseña incorrectos');
  });

  it('solo acepta destinos internos', () => {
    expect(destinoSeguro('/pedidos')).toBe('/pedidos');
    expect(destinoSeguro('https://otra-web.com')).toBe('/');
    expect(destinoSeguro('//otra-web.com')).toBe('/');
    expect(destinoSeguro(null)).toBe('/');
  });
});

src/funcionalidades/empleados/empleados.test.tsx

import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
import { API, http, HttpResponse, paginaDe, servidor } from '../../pruebas/servidor';
import { entrarComo, renderizarRutas } from '../../pruebas/utilidades';
import { esquemaEmpleado } from './esquema';
import { Component as PaginaFormEmpleado } from './PaginaFormEmpleado';

const VALIDO = {
  nombre: ' Eva ',
  apellidos: 'Ruiz',
  email: 'eva@empresa.es',
  telefono: '',
  oficio: 'ANALISTA',
  salario: '2500',
  comision: '',
  deptId: '20',
  jefeId: '',
};

describe('esquema de empleado', () => {
  it('limpia los textos y convierte los números', () => {
    expect(esquemaEmpleado.parse(VALIDO)).toEqual({
      ...VALIDO,
      nombre: 'Eva',
      telefono: null,
      salario: 2500,
      comision: null,
      deptId: 20,
      jefeId: null,
    });
  });

  it('rechaza un salario vacío o negativo', () => {
    expect(esquemaEmpleado.safeParse({ ...VALIDO, salario: '' }).success).toBe(false);
    expect(esquemaEmpleado.safeParse({ ...VALIDO, salario: '-5' }).success).toBe(false);
  });
});

describe('formulario de empleado', () => {
  it('lleva el 422 del servidor al campo email', async () => {
    entrarComo('ADMIN');
    servidor.use(
      http.get(`${API}/departamentos`, () => HttpResponse.json(paginaDe([]))),
      http.get(`${API}/empleados`, () => HttpResponse.json(paginaDe([]))),
      http.post(`${API}/empleados`, () =>
        HttpResponse.json({ status: 422, mensaje: 'Hay datos no válidos', detalles: { email: 'Ese email ya está en uso' } }, { status: 422 }),
      ),
    );
    renderizarRutas([{ path: '/empleados/nuevo', element: <PaginaFormEmpleado /> }], '/empleados/nuevo');

    const usuario = userEvent.setup();
    await usuario.type(await screen.findByLabelText('Nombre'), 'Eva');
    await usuario.type(screen.getByLabelText('Apellidos'), 'Ruiz');
    await usuario.type(screen.getByLabelText('Email'), 'ana.garcia@empresa.es');
    await usuario.type(screen.getByLabelText('Oficio'), 'ANALISTA');
    await usuario.type(screen.getByLabelText('Salario (€)'), '2500');
    await usuario.click(screen.getByRole('button', { name: 'Guardar' }));

    expect(await screen.findByText('Ese email ya está en uso')).toBeInTheDocument();
    expect(screen.getByLabelText('Email')).toHaveAttribute('aria-invalid', 'true');
    expect(screen.getByLabelText('Email')).toHaveFocus();
  });
});

src/funcionalidades/departamentos/departamentos.test.tsx

import { screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
import { API, http, HttpResponse, paginaDe, servidor } from '../../pruebas/servidor';
import { entrarComo, renderizarRutas } from '../../pruebas/utilidades';
import { Component as PaginaDepartamentos } from './PaginaDepartamentos';

const DEPARTAMENTOS = [{ deptId: 20, nombre: 'VENTAS', localidad: 'Valencia', presupuesto: 1000 }];
const rutas = [{ path: '/departamentos', element: <PaginaDepartamentos /> }];

describe('listado de departamentos', () => {
  it('muestra el 409 dentro del diálogo y no lo cierra', async () => {
    entrarComo('ADMIN');
    servidor.use(
      http.get(`${API}/departamentos`, () => HttpResponse.json(paginaDe(DEPARTAMENTOS))),
      http.delete(`${API}/departamentos/20`, () =>
        HttpResponse.json({ status: 409, mensaje: 'No se puede borrar un departamento con empleados' }, { status: 409 }),
      ),
    );
    renderizarRutas(rutas, '/departamentos');

    const usuario = userEvent.setup();
    await usuario.click(await screen.findByRole('button', { name: 'Borrar VENTAS' }));
    const dialogo = screen.getByRole('dialog', { name: 'Borrar departamento' });
    await usuario.click(within(dialogo).getByRole('button', { name: 'Borrar' }));

    expect(await within(dialogo).findByRole('alert')).toHaveTextContent('No se puede borrar un departamento con empleados');
  });

  it('un usuario sin rol ADMIN no ve el botón de borrar', async () => {
    entrarComo('USER');
    servidor.use(http.get(`${API}/departamentos`, () => HttpResponse.json(paginaDe(DEPARTAMENTOS))));
    renderizarRutas(rutas, '/departamentos');

    expect(await screen.findByText('VENTAS')).toBeInTheDocument();
    expect(screen.queryByRole('button', { name: 'Borrar VENTAS' })).not.toBeInTheDocument();
  });
});

src/funcionalidades/pedidos/pedidos.test.tsx

import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it } from 'vitest';
import { API, http, HttpResponse, paginaDe, servidor } from '../../pruebas/servidor';
import { entrarComo, renderizarRutas } from '../../pruebas/utilidades';
import { esquemaPedido } from './esquema';
import { Component as PaginaNuevoPedido } from './PaginaNuevoPedido';

const rutas = [{ path: '/pedidos/nuevo', element: <PaginaNuevoPedido /> }];

describe('esquema de pedido', () => {
  it('rechaza dos líneas del mismo producto', () => {
    const resultado = esquemaPedido.safeParse({
      clienteId: '1001',
      lineas: [
        { productoId: '1', cantidad: '1' },
        { productoId: '1', cantidad: '2' },
      ],
    });
    expect(resultado.error?.issues[0].message).toBe('No repitas un producto: suma las cantidades');
  });
});

describe('nuevo pedido', () => {
  beforeEach(() => {
    entrarComo('USER');
    servidor.use(
      http.get(`${API}/clientes`, () => HttpResponse.json(paginaDe([{ clienteId: 1001, nombre: 'Talleres Martín', ciudad: 'Valencia' }]))),
      http.get(`${API}/productos`, () => HttpResponse.json(paginaDe([{ productoId: 1, nombre: 'Portátil', precio: 100, stock: 3 }]))),
    );
  });

  it('calcula el total y muestra el 409 de falta de stock', async () => {
    servidor.use(
      http.post(`${API}/pedidos`, () =>
        HttpResponse.json({ status: 409, mensaje: 'No hay stock suficiente de Portátil' }, { status: 409 }),
      ),
    );
    renderizarRutas(rutas, '/pedidos/nuevo');

    const usuario = userEvent.setup();
    await usuario.selectOptions(await screen.findByLabelText('Cliente'), '1001');
    await usuario.selectOptions(screen.getByLabelText('Producto'), '1');
    const cantidad = screen.getByLabelText('Cantidad');
    await usuario.clear(cantidad);
    await usuario.type(cantidad, '9');
    expect(screen.getByText(/900,00/)).toBeInTheDocument();

    await usuario.click(screen.getByRole('button', { name: 'Crear pedido' }));
    expect(await screen.findByRole('alert')).toHaveTextContent('No hay stock suficiente de Portátil');
  });

  it('exige elegir cliente y producto antes de llamar a la API', async () => {
    renderizarRutas(rutas, '/pedidos/nuevo');
    await userEvent.click(await screen.findByRole('button', { name: 'Crear pedido' }));
    expect(await screen.findByText('Elige un cliente', { selector: 'p' })).toBeInTheDocument();
    expect(screen.getByText('Elige un producto', { selector: 'p' })).toBeInTheDocument();
  });
});
npm test
 Test Files  5 passed (5)
      Tests  18 passed (18)

Las pruebas importan las páginas con su nombre de exportación, Component, y las montan en un router de memoria con solo las rutas que necesitan. Fíjate en lo que comprueban: lo que ve el usuario (un texto, un rol, un atributo aria-invalid), nunca el estado interno de un componente.

Extremo a extremo con Playwright

Las pruebas E2E recorren la aplicación en un Chromium real contra la API real. Son más lentas, pero comprueban lo que las anteriores no pueden: que el build funciona, que la API devuelve lo que creías y que todas las piezas encajan.

playwright.config.ts

import { defineConfig, devices } from '@playwright/test';

// Puertos por defecto: la web en 5173 y la API en 3000.
const puerto = process.env.E2E_PUERTO ?? '5173';
const apiUrl = process.env.E2E_API_URL ?? 'http://localhost:3000/api';

export default defineConfig({
  testDir: './e2e',
  // Comparten la misma API, y algunas pruebas cuentan registros.
  workers: 1,
  reporter: 'list',
  use: {
    baseURL: `http://127.0.0.1:${puerto}`,
    locale: 'es-ES',
    trace: 'retain-on-failure',
  },
  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
  webServer: {
    command: `npm run dev -- --host 127.0.0.1 --port ${puerto} --strictPort`,
    url: `http://127.0.0.1:${puerto}`,
    // Nunca reutilizamos un servidor ya abierto: podría apuntar a otra API.
    reuseExistingServer: false,
    env: { VITE_API_URL: apiUrl },
  },
});

e2e/flujo.spec.ts

import { expect, test, type Page } from '@playwright/test';

const API = process.env.E2E_API_URL ?? 'http://localhost:3000/api';
const unico = Date.now();

async function entrar(page: Page, email = 'ana.garcia@empresa.es', password = 'secreto') {
  await page.getByLabel('Email').fill(email);
  await page.getByLabel('Contraseña').fill(password);
  await page.getByRole('button', { name: 'Entrar' }).click();
}

// Antes de nada, una petición real: si la API no contesta, mejor saberlo ya.
test.beforeAll(async ({ request }) => {
  const respuesta = await request.get(API);
  expect(respuesta.ok(), `La API no responde en ${API}`).toBe(true);
  expect((await respuesta.json()).nombre).toBe('API EMPRESA');
});

// Cualquier error de JavaScript o de React en la consola hace fallar la prueba.
// Las respuestas 4xx esperadas se registran como recursos fallidos: esas no cuentan.
test.beforeEach(async ({ page }) => {
  const errores: string[] = [];
  page.on('pageerror', (e) => errores.push(e.message));
  page.on('console', (m) => {
    if (m.type() === 'error' && !m.text().includes('Failed to load resource')) errores.push(m.text());
  });
  // @ts-expect-error guardamos la lista en la página para leerla al acabar
  page.errores = errores;
});

test.afterEach(async ({ page }) => {
  // @ts-expect-error la lista se guardó en beforeEach
  expect(page.errores).toEqual([]);
});

test('una ruta protegida lleva al login y vuelve al destino', async ({ page }) => {
  await page.goto('/pedidos?estado=ENVIADO');
  await expect(page).toHaveURL(/\/login\?volver=/);

  await entrar(page, 'ana.garcia@empresa.es', 'incorrecta');
  await expect(page.getByRole('alert')).toHaveText('Email o contraseña incorrectos');

  await entrar(page);
  await expect(page).toHaveURL(/\/pedidos\?estado=ENVIADO$/);
  await expect(page.getByRole('heading', { name: 'Pedidos' })).toBeVisible();

  // La sesión sobrevive a una recarga, y Salir la cierra.
  await page.reload();
  await expect(page.getByText('Ana García')).toBeVisible();
  await page.getByRole('button', { name: 'Salir' }).click();
  await expect(page).toHaveURL(/\/login/);
});

test('el panel muestra las estadísticas de la API', async ({ page, request }) => {
  const datos = await (await request.get(`${API}/estadisticas`)).json();
  await page.goto('/login');
  await entrar(page);
  await expect(page).toHaveTitle('Panel · EMPRESA');
  await expect(page.getByRole('main').getByText('Empleados', { exact: true }).locator('..')).toContainText(String(datos.empleados));
  await expect(page.getByRole('figure').first()).toContainText('Masa salarial mensual por departamento');
  await expect(page.getByRole('row', { name: /PENDIENTE/ })).toContainText(String(datos.porEstado.PENDIENTE));
});

test('CRUD de empleados, con 422 y 409', async ({ page }) => {
  await page.goto('/login?volver=/empleados');
  await entrar(page);

  // Búsqueda y filtro en la URL
  await page.getByLabel('Buscar empleados').fill('garcia');
  await expect(page).toHaveURL(/q=garcia/);
  await expect(page.getByRole('cell', { name: /ana\.garcia@empresa\.es/ })).toBeVisible();
  await page.getByLabel('Buscar empleados').fill('');
  // La búsqueda llega a la URL 300 ms después de la última tecla
  await expect(page).toHaveURL(/\/empleados$/);

  // Alta con validación en cliente
  await page.getByRole('link', { name: 'Nuevo empleado' }).click();
  await page.getByRole('button', { name: 'Guardar' }).click();
  await expect(page.getByText('El nombre es obligatorio')).toBeVisible();

  // 422: el email ya existe, y solo el servidor puede saberlo
  await page.getByLabel('Nombre').fill('Eva');
  await page.getByLabel('Apellidos').fill(`Prueba ${unico}`);
  await page.getByLabel('Email').fill('ana.garcia@empresa.es');
  await page.getByLabel('Oficio').fill('ANALISTA');
  await page.getByLabel('Salario (€)').fill('2500');
  await page.getByLabel('Departamento').selectOption({ label: 'VENTAS' });
  await page.getByRole('button', { name: 'Guardar' }).click();
  await expect(page.getByText('Ese email ya está en uso')).toBeVisible();
  await expect(page.getByLabel('Email')).toHaveAttribute('aria-invalid', 'true');

  await page.getByLabel('Email').fill(`eva.${unico}@empresa.es`);
  await page.getByRole('button', { name: 'Guardar' }).click();
  await expect(page).toHaveURL(/\/empleados$/);
  await expect(page.getByText(`Eva Prueba ${unico} guardado`)).toBeVisible();

  // Modificación: el departamento se conserva al abrir el formulario
  await page.getByLabel('Buscar empleados').fill(String(unico));
  const nombre = `Eva Prueba ${unico}`;
  await page.getByRole('link', { name: `Editar a ${nombre}` }).click();
  await expect(page.getByLabel('Departamento').locator('option:checked')).toHaveText('VENTAS');
  await page.getByLabel('Salario (€)').fill('2750');
  await page.getByRole('button', { name: 'Guardar' }).click();
  // Volvemos al listado con la búsqueda que teníamos
  await expect(page).toHaveURL(new RegExp(`q=${unico}`));
  await expect(page.getByRole('row', { name: nombre })).toContainText('2750,00');

  // Baja
  await page.getByRole('button', { name: `Borrar a ${nombre}` }).click();
  await page.getByRole('dialog').getByRole('button', { name: 'Borrar' }).click();
  await expect(page.getByText('No hay empleados con esos filtros.')).toBeVisible();

  // 409: Ana tiene subordinados
  await page.getByLabel('Buscar empleados').fill('ana.garcia');
  await page.getByRole('button', { name: 'Borrar a Ana García López' }).click();
  const dialogo = page.getByRole('dialog');
  await dialogo.getByRole('button', { name: 'Borrar' }).click();
  await expect(dialogo.getByRole('alert')).toHaveText('No se puede borrar a un empleado que tiene subordinados');
  await dialogo.getByRole('button', { name: 'Cancelar' }).click();
  await expect(dialogo).toBeHidden();
});

test('CRUD de departamentos, con 422 y 409', async ({ page }) => {
  await page.goto('/login?volver=/departamentos');
  await entrar(page);

  // 422: nombre repetido
  await page.getByRole('link', { name: 'Nuevo departamento' }).click();
  await page.getByLabel('Nombre').fill('ventas');
  await page.getByRole('button', { name: 'Guardar' }).click();
  await expect(page.getByText('Ya existe un departamento con ese nombre')).toBeVisible();

  const nombre = `PRUEBAS ${unico}`;
  await page.getByLabel('Nombre').fill(nombre);
  await page.getByLabel('Localidad').fill('Cuenca');
  await page.getByLabel('Presupuesto (€)').fill('1000');
  await page.getByRole('button', { name: 'Guardar' }).click();
  await expect(page.getByRole('row', { name: new RegExp(nombre) })).toContainText('Cuenca');

  // Modificación
  await page.getByRole('link', { name: `Editar ${nombre}` }).click();
  await expect(page.getByLabel('Localidad')).toHaveValue('Cuenca');
  await page.getByLabel('Localidad').fill('Teruel');
  await page.getByRole('button', { name: 'Guardar' }).click();
  await expect(page.getByRole('row', { name: new RegExp(nombre) })).toContainText('Teruel');

  // Baja
  await page.getByRole('button', { name: `Borrar ${nombre}` }).click();
  await page.getByRole('dialog').getByRole('button', { name: 'Borrar' }).click();
  await expect(page.getByRole('cell', { name: nombre, exact: true })).toBeHidden();

  // 409: VENTAS tiene empleados
  await page.getByRole('button', { name: 'Borrar VENTAS' }).click();
  const dialogo = page.getByRole('dialog');
  await dialogo.getByRole('button', { name: 'Borrar' }).click();
  await expect(dialogo.getByRole('alert')).toHaveText('No se puede borrar un departamento con empleados');
});

test('pedidos: 409 de stock, alta y cambio de estado', async ({ page }) => {
  await page.goto('/login?volver=/pedidos/nuevo');
  await entrar(page);

  // Validación en cliente: dos líneas del mismo producto
  await page.getByLabel('Cliente').selectOption({ index: 1 });
  const producto = page.getByLabel('Producto');
  await producto.selectOption({ index: 1 });
  await page.getByRole('button', { name: 'Añadir línea' }).click();
  await producto.nth(1).selectOption({ index: 1 });
  await page.getByRole('button', { name: 'Crear pedido' }).click();
  await expect(page.getByText('No repitas un producto: suma las cantidades')).toBeVisible();
  await page.getByRole('button', { name: 'Quitar línea 2' }).click();

  // 409: más cantidad que stock
  await page.getByLabel('Cantidad').fill('99999');
  await page.getByRole('button', { name: 'Crear pedido' }).click();
  await expect(page.getByRole('alert')).toContainText('No hay stock suficiente de');

  // Alta correcta
  await page.getByLabel('Cantidad').fill('1');
  await page.getByRole('button', { name: 'Crear pedido' }).click();
  await expect(page).toHaveURL(/\/pedidos\/\d+$/);
  const estado = page.getByTestId('estado-pedido');
  await expect(estado).toHaveText('PENDIENTE');

  // Cambios de estado
  await page.getByRole('button', { name: 'Marcar como enviado' }).click();
  await expect(estado).toHaveText('ENVIADO');
  await page.reload();
  await expect(estado).toHaveText('ENVIADO');
  await page.getByRole('button', { name: 'Marcar como entregado' }).click();
  await expect(estado).toHaveText('ENTREGADO');
  await expect(page.getByRole('button', { name: /Marcar/ })).toHaveCount(0);

  // El listado filtrado y el panel se han actualizado
  const numero = page.url().split('/').pop()!;
  await page.getByRole('link', { name: 'Volver a pedidos' }).click();
  await page.getByLabel('Filtrar por estado').selectOption('ENTREGADO');
  await expect(page.getByRole('link', { name: numero, exact: true })).toBeVisible();
});

test('los datos con campos nulos se muestran y se guardan', async ({ page, request }) => {
  const { contenido } = await (await request.get(`${API}/empleados?tamano=100`)).json();
  const sinDatos = contenido.find((e: { deptId: number | null; telefono: string | null }) => e.deptId === null || e.telefono === null);
  await page.goto('/login?volver=/departamentos');
  await entrar(page);

  // INVESTIGACIÓN tiene presupuesto null: se ve un guion y se guarda sin convertirlo en 0
  await expect(page.getByRole('row', { name: /INVESTIGACIÓN/ })).toContainText('—');
  await page.getByRole('link', { name: 'Editar INVESTIGACIÓN' }).click();
  await expect(page.getByLabel('Presupuesto (€)')).toHaveValue('');
  await page.getByRole('button', { name: 'Guardar' }).click();
  await expect(page.getByText('Departamento INVESTIGACIÓN guardado')).toBeVisible();
  expect((await (await request.get(`${API}/departamentos/70`)).json()).presupuesto).toBeNull();

  // Un empleado con teléfono o departamento null se edita y los conserva
  await page.goto(`/empleados/${sinDatos.empId}/editar`);
  await expect(page.getByLabel('Nombre')).toHaveValue(sinDatos.nombre);
  await page.getByRole('button', { name: 'Guardar' }).click();
  await expect(page).toHaveURL(/\/empleados$/);
  const guardado = await (await request.get(`${API}/empleados/${sinDatos.empId}`)).json();
  expect([guardado.deptId, guardado.telefono]).toEqual([sinDatos.deptId, sinDatos.telefono]);
});

test('el rol USER no puede borrar y la sesión rechazada lleva al login', async ({ page }) => {
  await page.goto('/login?volver=/departamentos');
  await entrar(page, 'luis.martinez@empresa.es');
  await expect(page.getByText('(USER)')).toBeVisible();
  await expect(page.getByRole('row', { name: /VENTAS/ })).toBeVisible();
  await expect(page.getByRole('button', { name: /^Borrar/ })).toHaveCount(0);

  // Simulamos un token caducado y escribimos: la API responde 401
  await page.getByRole('link', { name: 'Nuevo departamento' }).click();
  await page.evaluate(() => {
    const clave = 'empresa-sesion';
    const guardado = JSON.parse(localStorage.getItem(clave)!);
    guardado.state.token = 'token.no.valido';
    localStorage.setItem(clave, JSON.stringify(guardado));
  });
  await page.reload();
  await page.getByLabel('Nombre').fill(`CADUCADO ${unico}`);
  await page.getByRole('button', { name: 'Guardar' }).click();
  await expect(page).toHaveURL(/\/login\?volver=%2Fdepartamentos%2Fnuevo/);
  await expect(page.getByText('Tu sesión ha caducado. Vuelve a entrar.')).toBeVisible();
});

Algunas decisiones de estas pruebas:

  • beforeAll hace una petición real a la API antes de nada. Si la API no está arrancada, la prueba falla al momento con “La API no responde en …”, y no con un toBeVisible agotado 5 segundos después.
  • reuseExistingServer: false: si ya tienes npm run dev abierto en ese puerto, Playwright se niega a arrancar. Reutilizarlo sería cómodo, pero ese servidor podría apuntar a otra API.
  • Cualquier error en consola hace fallar la prueba. Así se detectan avisos de React como una key repetida o una prop mal escrita. Se ignoran los “Failed to load resource”, que son los 4xx esperados.
  • unico = Date.now() hace únicos los nombres creados. Puedes repetir las pruebas sin reiniciar la API.
  • workers: 1: las pruebas comparten la API y algunas cuentan registros. En paralelo se pisarían.

Con la API arrancada y npm run dev cerrado:

npm run test:e2e
Running 7 tests using 1 worker
  ok 1 [chromium] › e2e\flujo.spec.ts:36:1 › una ruta protegida lleva al login y vuelve al destino (5.4s)
  ok 2 [chromium] › e2e\flujo.spec.ts:54:1 › el panel muestra las estadísticas de la API (3.0s)
  ok 3 [chromium] › e2e\flujo.spec.ts:64:1 › CRUD de empleados, con 422 y 409 (9.0s)
  ok 4 [chromium] › e2e\flujo.spec.ts:123:1 › CRUD de departamentos, con 422 y 409 (5.1s)
  ok 5 [chromium] › e2e\flujo.spec.ts:159:1 › pedidos: 409 de stock, alta y cambio de estado (6.1s)
  ok 6 [chromium] › e2e\flujo.spec.ts:201:1 › los datos con campos nulos se muestran y se guardan (5.4s)
  ok 7 [chromium] › e2e\flujo.spec.ts:224:1 › el rol USER no puede borrar y la sesión rechazada lleva al login (4.9s)
  7 passed (54.3s)

Si tu API está en otro puerto, indícalo: E2E_API_URL=http://localhost:4000/api npm run test:e2e (en PowerShell, $env:E2E_API_URL='http://localhost:4000/api'; npm run test:e2e).

💡 Consejo: Mientras escribíamos estas pruebas, una falló de una forma instructiva. La prueba vaciaba el buscador y pulsaba “Nuevo empleado” en el mismo instante. Como el buscador espera 300 ms antes de escribir en la URL, la página se desmontaba antes, el temporizador se cancelaba y la URL conservaba q=garcia. No era un fallo de la aplicación: es lo que pasaría con un usuario igual de rápido. La prueba ahora espera a que la URL cambie: await expect(page).toHaveURL(/\/empleados$/).


15. Build de producción

npm run lint
npm run build
npm run preview

npm run build comprueba primero los tipos (tsc -b) y, si no hay errores, genera dist. Esta es la salida real, recortada:

dist/index.html                                   0.45 kB │ gzip:   0.30 kB
dist/assets/index-Iv4aRVfK.css                   15.75 kB │ gzip:   4.34 kB
dist/assets/PaginaLogin-DNdx8oSJ.js               1.70 kB │ gzip:   0.94 kB
dist/assets/PaginaDepartamentos-Bclda3We.js       2.11 kB │ gzip:   0.93 kB
dist/assets/PaginaPanel-Dz9dpmcJ.js               2.42 kB │ gzip:   0.99 kB
dist/assets/PaginaPedidos-XJ-VNP8C.js             2.47 kB │ gzip:   0.97 kB
dist/assets/PaginaPedido-Cl5---_t.js              2.74 kB │ gzip:   1.01 kB
dist/assets/PaginaEmpleados-BTa3T-vB.js           3.31 kB │ gzip:   1.39 kB
dist/assets/PaginaNuevoPedido-BDHV5HAH.js         3.46 kB │ gzip:   1.56 kB
dist/assets/PaginaFormEmpleado-CJSgUMyJ.js        3.54 kB │ gzip:   1.54 kB
dist/assets/schemas-BJyX8LS1.js                 128.32 kB │ gzip:  38.86 kB
dist/assets/index-Djy6ze3s.js                   353.20 kB │ gzip: 111.68 kB
✓ built in 3.44s

Así se lee:

  • Cada página tiene su fichero. Es el efecto de lazy en el router: quien solo mira el panel no descarga el formulario de pedidos.
  • index (353 kB, 112 kB comprimido) contiene React, React Router, TanStack Query y Zustand: lo que necesita cualquier pantalla.
  • schemas (128 kB) es Zod, y solo lo descargan las páginas con formulario. Vite y Rolldown lo han separado solos porque varias páginas perezosas lo comparten.

npm run preview sirve dist en http://localhost:4173. Las capturas de este módulo se han hecho así, sobre el build de producción. Para publicarlo en un servidor real, recuerda dos cosas del módulo 25: el servidor debe devolver index.html para cualquier ruta (si no, recargar /empleados da un 404), y VITE_API_URL se fija al compilar.


16. Errores frecuentes y depuración

Síntoma Causa Solución
“No se puede conectar con la API” en todas las pantallas La API no está arrancada, o está en otro puerto Comprueba con curl http://localhost:3000/api; si usas otro puerto, crea .env.local con VITE_API_URL y reinicia npm run dev
Cambias .env.local y no pasa nada Vite lee las variables al arrancar Reinicia npm run dev; para producción, repite el build
Tras guardar, el listado muestra datos viejos Falta invalidar una clave que depende de lo guardado Revisa qué otras pantallas muestran ese dato e invalida su raíz
El desplegable de edición muestra “Sin departamento” El formulario se montó antes de llegar las opciones Monta el formulario cuando todas las consultas tengan datos
El formulario se envía vacío Campo no pasa la ref al <input> (o estás en React 18 sin forwardRef) Comprueba que {...resto} llega al <input>; en React 18, forwardRef
Al recargar, me echa al login La sesión guardada había caducado, o borraste localStorage Es lo esperado: el token dura una hora
Un 401 al guardar me lleva al login El token ya no es válido (caducado, o reiniciaste la API más de una hora después) Vuelve a entrar; el aviso “Tu sesión ha caducado” lo explica
“Solo un administrador puede hacer esto” Has entrado como luis.martinez, rol USER, y has llamado a la API a mano Los borrados exigen ADMIN; la interfaz oculta el botón, la API lo impide
npm run test:e2e dice “La API no responde” La API no está arrancada o está en otro puerto Arráncala, o usa E2E_API_URL
npm run test:e2e dice que el puerto 5173 está en uso Tienes npm run dev abierto Ciérralo, o usa E2E_PUERTO=5180
npm install falla con ERESOLVE y react-router Tu React es anterior a 19.2.7 Sección 17

Para depurar, tres herramientas que ya conoces:

  • La pestaña Red del navegador. Mira la petición, su cabecera Authorization y la respuesta exacta. Casi todos los errores de datos se ven ahí.
  • React DevTools. En la pestaña Components, busca RutaProtegida o cualquier página y mira sus hooks.
  • Las DevTools de TanStack Query (@tanstack/react-query-devtools, módulo 19). No se incluyen en el proyecto para mantenerlo pequeño, pero añadir <ReactQueryDevtools /> dentro de QueryClientProvider te muestra cada clave, su estado y cuándo caduca.

17. Si tu proyecto usa React 19.1 o anterior 🔄 19.2

🔄 Cambia en React 19.2. React Router 8 exige React 19.2.7 o superior y Node 22.22 o superior. Si usas una versión anterior: cambia react-router por la versión 7.18.5; con React 19.0 o 19.1 no hay que tocar nada más, y con React 18.3 hay que añadir forwardRef a los dos campos de formulario y sustituir <title> por document.title.

React no tiene versiones LTS, como viste en el módulo 00: cada equipo se queda en la versión que le conviene. Si tu proyecto sigue en React 19.1 o en 18.3, puedes construir esta misma aplicación. Lo hemos comprobado descomprimiendo empresa-react.zip, cambiando las versiones en package.json y ejecutando npm install, el build, las 18 pruebas de Vitest y las 7 de Playwright contra la API real.

Versión de React Qué cambiar Resultado comprobado
19.3, o 19.2.7 y posteriores Nada Todo pasa
19.2.0 a 19.2.6 Sube a React 19.2.9, o usa react-router@7 No comprobado aparte: misma regla de dependencias que 19.1
19.1 y 19.0 react-router 7.18.5 19.1.10: build correcto, 18 de 18 en Vitest y 7 de 7 en Playwright
18.3 react-router 7.18.5, forwardRef y document.title Sin los cambios fallan 4 pruebas de Vitest y todas las de Playwright; con ellos, todo pasa

Paso 1: las versiones en package.json

Cambia estas líneas y ejecuta npm install. Con React 19.1:

"react": "19.1.10",
"react-dom": "19.1.10",
"react-router": "^7.18.5",
"@types/react": "~19.1.0",
"@types/react-dom": "~19.1.0",

Con React 18.3, "react": "18.3.1", "react-dom": "18.3.1" y los tipos "^18.3.0". Cambia la versión de React y la de React Router a la vez. Si solo bajas React, npm encuentra que react-router 8 exige react >= 19.2.7 y se niega a instalar con ERESOLVE, como viste en el módulo 18. Las demás dependencias (TanStack Query, Zustand, React Hook Form, Testing Library) admiten React 18 o superior y no cambian.

React Router 7 admite las mismas importaciones que usa este proyecto, incluido react-router/dom, y el modo Datos con lazy funciona igual. Puedes borrar el campo engines de package.json o bajarlo a Node 20.19, que es lo que exigen Vite 8 y React Router 7. Vitest 5 sigue pidiendo Node 22.12 o superior.

Con React 19.1.10 y React Router 7.18.5 no hay que tocar ni una línea de código: ref como prop y <title> existen desde React 19.0.

Paso 2, solo para React 18.3: forwardRef en los campos

En React 18, ref no es una prop: React la aparta antes de llamar a tu componente. register('email') devuelve una ref, pero esa ref nunca llega al <input>, y React Hook Form no puede leer lo que escribes. El proyecto compila sin errores, porque los tipos de React 18 también aceptan ref en ComponentProps<'input'>, pero en ejecución el formulario no ve lo que escribes y la validación no lo deja enviar. Al ejecutar las pruebas en React 18.3.1 sin cambios, el inicio de sesión nunca llegó a la API: fallaron las cuatro pruebas de Vitest que envían un formulario (login correcto, contraseña incorrecta, 422 de empleado y 409 de pedido) y las siete de Playwright.

La solución es envolver los dos componentes en forwardRef:

import { forwardRef, useId, type ComponentPropsWithoutRef, type ReactNode } from 'react';

export const Campo = forwardRef<HTMLInputElement, ComponentPropsWithoutRef<'input'> & Comunes>(
  function Campo({ etiqueta, error, ...resto }, ref) {
    const id = useId();
    return (
      <Envoltura id={id} etiqueta={etiqueta} error={error}>
        <input
          ref={ref}
          id={id}
          className={CLASES_CONTROL}
          aria-invalid={error ? true : undefined}
          aria-describedby={error ? `${id}-error` : undefined}
          {...resto}
        />
      </Envoltura>
    );
  },
);

Desplegable cambia igual, con HTMLSelectElement y ComponentPropsWithoutRef<'select'>. Se usa ComponentPropsWithoutRef porque la ref ya llega como segundo argumento. El resto del proyecto no cambia: quien usa <Campo {...register('email')} /> no nota la diferencia.

Paso 3, solo para React 18.3: el título del documento

React 18 no sube <title> al <head>. Lo pinta donde está, dentro del <body>, y el título de la pestaña sigue siendo “EMPRESA”. No da error, pero la prueba E2E del panel falla al comprobar toHaveTitle('Panel · EMPRESA'). Crea un hook:

import { useEffect } from 'react';

export function useTitulo(titulo: string) {
  useEffect(() => {
    document.title = `${titulo} · EMPRESA`;
  }, [titulo]);
}

Y en Encabezado, PaginaLogin y PaginaError, quita la etiqueta <title> y llama a useTitulo(titulo) al principio del componente (useTitulo('Iniciar sesión') en el login).

Con estos dos cambios, el proyecto sobre React 18.3.1 y React Router 7.18.5 compila, pasa las 18 pruebas de Vitest y las 7 de Playwright.

💡 Consejo: Si vas a mantener una aplicación en React 18 durante un tiempo, escribe ya los componentes con forwardRef y useTitulo: también funcionan en React 19. Cuando subas de versión, volver a ref como prop y a <title> es opcional y puedes hacerlo poco a poco.


18. Ejercicios

Ejercicio 1: Cerrar la sesión a la hora exacta

Ahora, una sesión caducada solo se detecta cuando la API rechaza una petición, o al recargar. Escribe un hook useCaducidad() que cierre la sesión con el motivo 'caducada' justo cuando llegue expiraEl, y úsalo en RutaProtegida. Escribe una prueba con temporizadores falsos.

Ver solución
import { useEffect } from 'react';
import { useSesion } from '../../compartido/estado/sesion';

export function useCaducidad() {
  const expiraEl = useSesion((s) => s.expiraEl);
  const cerrarSesion = useSesion((s) => s.cerrarSesion);

  useEffect(() => {
    if (!expiraEl) return;
    const temporizador = setTimeout(() => cerrarSesion('caducada'), Math.max(0, expiraEl - Date.now()));
    return () => clearTimeout(temporizador);
  }, [expiraEl, cerrarSesion]);
}

En RutaProtegida, llama a useCaducidad() en la primera línea, antes del if (!token): los hooks no pueden ir después de un return condicional.

it('cierra la sesión cuando llega la hora de caducidad', () => {
  vi.useFakeTimers();
  useSesion.setState({ token: 't', usuario: { email: 'a@b.es', nombre: 'A', rol: 'USER' }, expiraEl: Date.now() + 5000 });
  renderHook(() => useCaducidad());
  act(() => vi.advanceTimersByTime(4999));
  expect(useSesion.getState().token).toBe('t');
  act(() => vi.advanceTimersByTime(1));
  expect(useSesion.getState()).toMatchObject({ token: null, motivoCierre: 'caducada' });
  vi.useRealTimers();
});
  • El efecto depende de expiraEl: si inicias otra sesión, el temporizador anterior se limpia y se crea otro.
  • Math.max(0, ...) cubre una sesión que ya ha caducado: el temporizador se dispara en el siguiente ciclo.
  • vi.useFakeTimers() congela también Date.now(), así que la cuenta es exacta. Al cerrarse la sesión, RutaProtegida redirige sola, como con el 401.

Ejercicio 2: Filtrar departamentos sin pedirlos de nuevo

Añade un campo de búsqueda al listado de departamentos. Como son pocos y ya están en caché, filtra en el cliente, sin distinguir mayúsculas ni tildes, por nombre o localidad. Escribe la función de filtrado como función pura y pruébala.

Ver solución
const normalizar = (texto: string) => texto.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase();

export function filtrarDepartamentos(departamentos: Departamento[], filtro: string) {
  const buscado = normalizar(filtro.trim());
  if (!buscado) return departamentos;
  return departamentos.filter((d) => normalizar(`${d.nombre} ${d.localidad ?? ''}`).includes(buscado));
}

En la página, un estado local y el valor derivado en el render, sin efectos ni estado duplicado:

const [filtro, setFiltro] = useState('');
const visibles = data ? filtrarDepartamentos(data, filtro) : [];
// ...
<input type="search" aria-label="Buscar departamentos" value={filtro} onChange={(e) => setFiltro(e.target.value)} />

Y en la tabla, visibles.map(...) en lugar de data.map(...).

it('filtra sin distinguir tildes ni mayúsculas', () => {
  const lista = [
    { deptId: 30, nombre: 'INFORMÁTICA', localidad: 'Valencia', presupuesto: 1 },
    { deptId: 70, nombre: 'INVESTIGACIÓN', localidad: 'Bilbao', presupuesto: null },
  ];
  expect(filtrarDepartamentos(lista, 'informatica').map((d) => d.deptId)).toEqual([30]);
  expect(filtrarDepartamentos(lista, 'bilbao').map((d) => d.deptId)).toEqual([70]);
  expect(filtrarDepartamentos(lista, '  ')).toHaveLength(2);
});

normalize('NFD') separa cada letra de su tilde (Á pasa a A + tilde) y la expresión \p{Diacritic} borra las tildes. Es lo mismo que hace la API con su parámetro q. localidad ?? '' evita que un departamento sin localidad se convierta en el texto "null" y coincida al buscar “nu”.

Ejercicio 3: Precargar la página siguiente

Cuando estás en la página 1 de empleados, lo más probable es que pulses Siguiente. Escribe un hook usePrecargaSiguiente(filtros, paginas) que pida la página siguiente en segundo plano, para que aparezca al instante. Comprueba con MSW que se piden las páginas 1 y 2.

Ver solución
export function usePrecargaSiguiente(filtros: FiltrosEmpleados, paginas: number | undefined) {
  const queryClient = useQueryClient();

  useEffect(() => {
    if (paginas === undefined || filtros.pagina >= paginas) return;
    void queryClient.prefetchQuery(empleadosQueries.lista({ ...filtros, pagina: filtros.pagina + 1 }));
  }, [queryClient, filtros, paginas]);
}

En PaginaEmpleados, junto al useQuery:

const filtros = useMemo(() => ({ q, orden, pagina, deptId }), [q, orden, pagina, deptId]);
const { data, ... } = useQuery(empleadosQueries.lista(filtros));
usePrecargaSiguiente(filtros, data?.paginas);

useMemo hace falta porque filtros es una dependencia del efecto. Un objeto nuevo en cada render lanzaría el efecto en cada render; prefetchQuery no repetiría la petición si los datos están frescos, pero es trabajo inútil. Con React Compiler, del módulo 14, te lo ahorrarías.

La prueba cuenta qué páginas se piden:

it('precarga la página siguiente', async () => {
  const pedidas: string[] = [];
  servidor.use(
    http.get(`${API}/empleados`, ({ request }) => {
      const pagina = new URL(request.url).searchParams.get('pagina')!;
      pedidas.push(pagina);
      return HttpResponse.json({ ...paginaDe([]), pagina: Number(pagina), paginas: 3 });
    }),
  );
  const filtros = { q: '', orden: 'apellidos', pagina: 1, deptId: '' };
  function Prueba() {
    const { data } = useQuery(empleadosQueries.lista(filtros));
    usePrecargaSiguiente(filtros, data?.paginas);
    return <p>{data ? 'listo' : '...'}</p>;
  }
  render(
    <QueryClientProvider client={new QueryClient()}>
      <Prueba />
    </QueryClientProvider>,
  );
  await screen.findByText('listo');
  await vi.waitFor(() => expect(pedidas).toEqual(['1', '2']));
});

prefetchQuery guarda el resultado en la caché con la misma clave que usará useQuery al pulsar Siguiente. Como esos datos están frescos durante 30 segundos (staleTime), la página 2 se pinta sin petición.

Ejercicio 4: Confirmar antes de cancelar un pedido

Cancelar un pedido no se puede deshacer en la interfaz. Haz que el botón “Cancelar pedido” abra DialogoConfirmar antes de enviar el cambio. Adapta el diálogo para que el texto del botón de confirmar sea configurable (“Borrar” no tiene sentido aquí).

Ver solución

En DialogoConfirmar, una prop opcional con su valor por defecto, para no romper los usos actuales:

interface Props {
  // ...
  textoConfirmar?: string;
}

export function DialogoConfirmar({ titulo, children, pendiente, error, onConfirmar, onCancelar, textoConfirmar = 'Borrar' }: Props) {
  // ...
  <Boton variante="peligro" disabled={pendiente} onClick={onConfirmar}>
    {pendiente ? 'Enviando...' : textoConfirmar}
  </Boton>

En PaginaPedido, un estado que dice si el diálogo está abierto:

const [confirmarCancelacion, setConfirmarCancelacion] = useState(false);

// en el map de SIGUIENTES:
<Boton
  key={estado}
  disabled={cambiar.isPending}
  onClick={() => (estado === 'CANCELADO' ? setConfirmarCancelacion(true) : cambiarA(estado))}
>
  {texto}
</Boton>

// al final del componente:
{confirmarCancelacion && (
  <DialogoConfirmar
    titulo="Cancelar pedido"
    textoConfirmar="Cancelar pedido"
    pendiente={cambiar.isPending}
    error={null}
    onConfirmar={() => {
      setConfirmarCancelacion(false);
      cambiarA('CANCELADO');
    }}
    onCancelar={() => setConfirmarCancelacion(false)}
  >
    El stock de sus productos no se repone. ¿Seguro que quieres cancelar el pedido {id}?
  </DialogoConfirmar>
)}

El diálogo se cierra antes de enviar porque el cambio es optimista: el estado ya aparece como CANCELADO, y si la API falla, el aviso de error y la vuelta atrás lo explican. Pasa error={null} porque aquí el error se comunica con el aviso, no dentro del diálogo.

El texto del diálogo cuenta algo importante: la API no devuelve el stock al cancelar. Es una regla del negocio que el usuario debe conocer antes de pulsar.

Ejercicio 5: Probar que el optimismo se deshace

Escribe una prueba de Vitest para PaginaPedido: el pedido 5001 está PENDIENTE, pulsas “Marcar como enviado”, la pantalla muestra ENVIADO al instante, la API responde 500 tras 100 ms y la pantalla vuelve a PENDIENTE con un aviso de error.

Ver solución
it('la actualización optimista se deshace si la API falla', async () => {
  entrarComo('USER');
  const pedido = { pedidoId: 5001, clienteId: 1001, cliente: 'Talleres', fechaPedido: '2026-01-10', fechaEnvio: null, estado: 'PENDIENTE', lineas: [], total: 0 };
  servidor.use(
    http.get(`${API}/pedidos/5001`, () => HttpResponse.json(pedido)),
    http.patch(`${API}/pedidos/5001`, async () => {
      await new Promise((r) => setTimeout(r, 100));
      return HttpResponse.json({ status: 500, mensaje: 'Error interno del servidor' }, { status: 500 });
    }),
  );
  renderizarRutas([{ path: '/pedidos/:id', element: <PaginaPedido /> }], '/pedidos/5001');

  await userEvent.click(await screen.findByRole('button', { name: 'Marcar como enviado' }));
  expect(screen.getByTestId('estado-pedido')).toHaveTextContent('ENVIADO');
  await vi.waitFor(() => expect(screen.getByTestId('estado-pedido')).toHaveTextContent('PENDIENTE'));
  expect(useAvisos.getState().avisos.at(-1)).toMatchObject({ tipo: 'error', texto: 'Error interno del servidor' });
});
  • La primera comprobación es síncrona, sin findBy: justo después del clic ya debe verse ENVIADO. Si el optimismo no funcionara, fallaría aquí.
  • Los 100 ms de espera en el manejador de MSW dan tiempo a ver el estado intermedio. Sin la espera, la respuesta podría llegar antes de la comprobación.
  • El aviso se comprueba en el store y no en pantalla, porque la prueba no monta ZonaAvisos. Leer el store directamente con getState() es legítimo para un efecto que no es visual en esta pantalla.

Ejercicio 6 (propuesto): CRUD de productos

Añade la sección Productos con listado (filtro por categoría con /api/categorias, y casilla “solo disponibles”), alta, edición y borrado. La API responde 409 si el producto aparece en algún pedido, con el mensaje “descatalógalo en lugar de borrarlo”: en ese caso, ofrece en el propio diálogo un botón para marcarlo como descatalogado con un PUT. Recuerda invalidar ['productos'], porque el formulario de pedidos usa esa clave.

Ejercicio 7 (propuesto): Ficha de cliente

Crea /clientes/:id con los datos del cliente y su historial de pedidos (/api/clientes/:id/pedidos), y enlázala desde el listado y el detalle de pedidos. Muestra el total facturado al cliente sin contar los pedidos cancelados, y compáralo con su limiteCredito.

Ejercicio 8 (propuesto): Validación con avisos tempranos de stock

En el nuevo pedido, muestra un aviso bajo la cantidad si supera el stock que dice el desplegable, pero sin impedir el envío: el stock puede haber cambiado, y quien decide es la API. Explica en un comentario por qué no es una regla de Zod.

Ejercicio 9 (propuesto): Publicar en GitHub Pages

Publica la aplicación en GitHub Pages siguiendo el módulo 25. Necesitarás base en vite.config.ts (el router ya usa BASE_URL), una copia de index.html como 404.html para que funcionen las rutas al recargar, y una API accesible desde Internet. Comprueba qué pasa con CORS y con VITE_API_URL.

Ejercicio 10 (propuesto): React Compiler

Activa React Compiler 1.0 como en el módulo 14, pasa todas las pruebas y busca con React DevTools qué componentes aparecen marcados como optimizados. ¿Sobra algún useCallback del proyecto? ¿Y el useMemo del ejercicio 3?


Resumen

Concepto API Idea clave
Organización por funcionalidades funcionalidades/, compartido/ Cada funcionalidad tiene su api.ts; lo compartido no conoce las funcionalidades
Estado de servidor useQuery, queryOptions, useMutation Todo dato de la API vive en TanStack Query, nunca en un store
Estado de cliente create, persist, getState Sesión, tema y avisos en Zustand; getState() lo lee fuera de React
Cliente HTTP único api(), ErrorApi Todas las respuestas se convierten en datos o en un error con status
Sesión caducada 401 con token → cerrarSesion El store cambia y la ruta protegida redirige sola
Ruta protegida <Navigate>, useLocation Un componente suscrito al store reacciona en cualquier momento
Destino tras el login ?volver=, destinoSeguro Solo rutas internas: evita redirecciones abiertas
Carga perezosa lazy, export { X as Component } Un fichero JavaScript por página
Filtros en la URL useSearchParams, replace Se comparten, sobreviven a una recarga y vuelven a la página 1
Paginación sin saltos placeholderData: keepPreviousData La tabla anterior se atenúa hasta que llega la nueva
Invalidación invalidateQueries({ queryKey }) Invalida también lo que muestra el dato cambiado
Optimismo onMutate, onError, onSettled Copia, cambia, deshace si falla, sincroniza siempre
Esquemas con conversión z.input, z.output, z.coerce El formulario maneja cadenas; tu código recibe números o null
Errores 422 setError, aplicarErrores Cada error del servidor bajo su campo, con foco
Conflictos 409 mutation.error en el diálogo El usuario ve el motivo donde actuó y no pierde lo escrito
Listas dinámicas useFieldArray, useWatch campo.id como key; errores de la lista en .root
ref como prop ComponentProps<'input'> 🆕 19.0 register() funciona sobre componentes propios sin forwardRef
Título de documento <title> 🆕 19.0 React lo sube al <head>
Modo oscuro @theme inline, @custom-variant Variables CSS: ningún componente necesita dark:
Pruebas Vitest, MSW 3, Testing Library Errores de la API simulados en segundos y sin datos de prueba
E2E Playwright, webServer Comprueba antes la API y falla con cualquier error de consola
React anterior a 19.2.7 react-router@7 El proyecto funciona igual; en 18.3, forwardRef y document.title