Cliente web de la Warehouse API construido con Vue 3 + TypeScript + Vite + Tailwind CSS, usando bun como gestor de paquetes.
Su propósito es probar los endpoints de la API desde una UI real: login con JWT y los cuatro módulos de negocio —Usuarios, Productos, Almacenes e Inventario— con listado paginado, filtros y alta/edición/baja lógica.
- bun 1.3 o superior (también vale
npm/pnpm, pero elbun.lockestá generado con bun) - La API arrancada en
http://localhost:5259(ver Warehouse API)
bun install # instala dependencias
bun run dev # arranca Vite en http://localhost:5173En otra terminal, la API:
cd ../Warehouse-API
dotnet run --project src/ApiAbrir http://localhost:5173 e iniciar sesión con un usuario de la API. Si el puerto está ocupado, Vite prueba con el siguiente (5174, 5175…) y lo indica en la terminal.
| Comando | Qué hace |
|---|---|
bun run dev |
Servidor de desarrollo con hot reload |
bun run build |
Compila tipos (vue-tsc) y genera dist/ |
bun run preview |
Sirve dist/ en local para revisar el build |
En desarrollo no hace falta configurar CORS en la API: vite.config.ts reenvía /api al puerto 5259.
server: {
proxy: {
"/api": { target: "http://localhost:5259", changeOrigin: true },
},
},Por eso src/api/http.ts usa baseURL: "/api". Para apuntar a otro origen (producción, otro entorno) se define VITE_API_BASE_URL en un fichero .env — ver .env.example.
Separación en tres capas, sin framework de MVVM: el patrón es una convención de organización.
src/
├── api/ # Model — cliente HTTP y un módulo por recurso
│ ├── http.ts # instancia axios, interceptores, tokens, ApiError
│ ├── paged.ts # fetchAllPages: recorre todas las páginas (selects)
│ ├── auth.ts # /api/Auth
│ ├── users.ts # /api/User
│ ├── products.ts # /api/Product
│ ├── warehouses.ts # /api/Warehouse
│ └── inventory.ts # /api/InventoryItem
├── types/ # Contratos espejo de la API (DTOs y ViewModels)
├── composables/
│ └── usePagedList.ts # paginado compartido por los listados
├── stores/ # ViewModel — estado y acciones (Pinia)
│ ├── auth.ts # sesión: login, refresh programado, logout
│ ├── users.ts # listado: filtros, paginación, CRUD
│ ├── products.ts # ídem
│ ├── warehouses.ts # ídem
│ └── inventory.ts # listado + movimientos + opciones de los selects
├── views/ # View — una página por ruta
├── components/
│ ├── ui/ # 8 componentes reutilizables (ver abajo)
│ └── common/ # HeaderComponent
├── layouts/ # Esqueleto con <RouterView />
└── router/ # Rutas + guards de sesión
| MVVM | Aquí |
|---|---|
| Model | api/ + types/: la verdad vive en el servidor, el cliente solo tiene el cliente HTTP y los contratos espejo |
| View | el <template> de cada .vue |
| ViewModel | stores/ (Pinia) y el <script setup>: exponen estado reactivo y acciones, sin lógica de negocio en la vista |
Regla práctica: las vistas no llaman a axios directamente (usan los módulos de api/) y la API no se conoce en ningún sitio fuera de api/ y types/.
| Componente | Qué resuelve |
|---|---|
PageHeader |
título, descripción y acciones a la derecha |
Alert |
error/aviso/éxito/información con role="alert" |
DataTable |
columnas declaradas + slots cell-<key>, con loading y vacío dentro |
FormField |
etiqueta, pista y errores de validación; el control va en el slot |
Pagination |
controlada; emite change con la página objetivo |
LoadingState |
spinner de lista |
EmptyState |
lista vacía, con slot action |
ConfirmDialog |
confirmación de acciones destructivas, teletransportada al body |
DataTable y FormField son genéricos: conservan el tipo de la fila y del campo.
POST /api/Auth/logindevuelve access token + refresh token.- Se guardan en
sessionStorage(warehouse.accessToken,warehouse.refreshToken): sobreviven a recargar la pestaña y desaparecen al cerrarla. - Un interceptor monta
Authorization: Beareren cada petición. - El store programa un refresh con 30 segundos de margen antes de que venza el access token.
- Al recargar la página, los guards esperan a
ensureRestored(), que canjea el refresh token antes de decidir si la ruta es accesible. - Un 401 (token caducado o revocado) limpia la sesión y manda al
/login. En el login, un 401 significa credenciales incorrectas y no navega a ninguna parte. POST /api/Auth/revokecierra la sesión en el servidor (es idempotente).
| Ruta | Acceso |
|---|---|
/ |
pública |
/login |
solo sin sesión (guestOnly) |
/users, /users/new, /users/:id/edit |
requieren sesión |
/products, /products/new, /products/:id/edit |
requieren sesión |
/warehouses, /warehouses/new, /warehouses/:id/edit |
requieren sesión |
/inventory, /inventory/movement |
requieren sesión |
Todos comparten el mismo patrón: types/ → api/ → stores/ → views/, con el paginado extraído a usePagedList y los filtros propios de cada módulo en su store.
| Módulo | Listado | Escritura |
|---|---|---|
| Usuarios | búsqueda, estado, orden | alta, edición (contraseña opcional), baja lógica |
| Productos | búsqueda por nombre o SKU, estado, orden | alta, edición, baja lógica |
| Almacenes | búsqueda por nombre o ubicación, estado, orden | alta, edición, baja lógica |
| Inventario | filtro por almacén y producto | movimientos: entrada (restock) y salida (consume) |
InventoryItem es la tabla puente almacén–producto. No hay PUT ni DELETE: la cantidad no se edita a mano, solo se mueve.
POST /api/InventoryItem/restock— sumaquantity; si el par no existe, crea la fichaPOST /api/InventoryItem/consume— restaquantity; 409 si no hay saldo, 404 si no hay ficha
El base path es
/api/InventoryItem, no/api/Inventory: lo decide el nombre de la claseInventoryItemController, porque[Route("api/[controller]")]sustituye[controller]por el nombre de la clase sin el sufijoController.
InventoryMovementView no navega al listado al terminar a propósito: un albarán suele llevar varios movimientos seguidos sobre el mismo par, así que se queda mostrando las existencias resultantes con el almacén y el producto ya elegidos.
src/style.css declara en @layer components las clases que antes estaban repetidas en cada vista:
btn-primary · btn-secondary · btn-danger · btn-ghost · btn-sm · field · field-invalid
Al ir en @layer, cualquier utilidad de Tailwind escrita en el template sigue ganando, así que una vista puede sobreescribir sin pelearse con la especificidad.
La paleta se declara en @theme (Tailwind v4): morado (primary, el de .NET) para acciones y foco, verde (accent, #42b883, el de Vue) para acentos, verde oscuro (success) para confirmaciones, más slate y blanco. Sin esas variables no existirían las clases bg-primary-600 ni text-accent-500.
Rojo y ámbar se conservan para error y aviso: son colores semánticos, y cambiarlos por los de marca confundiría en vez de aclarar.
Tres casos en los que "" no es lo mismo que omitir el campo, y los tres se resuelven igual: el campo viaja solo si tiene contenido.
Contraseña en la edición — PUT /api/User/{id} rechaza password: "" con un 400 (MinLength(8)):
await usersApi.update(id, {
name,
email,
...(password ? { password } : {}), // si está vacío, no viaja
});Descripción en productos y ubicación en almacenes — el servicio hace dto.X directamente sobre la entidad, así que "" se guarda como cadena vacía y null queda de verdad sin valor. Comprobado contra el servidor: omitir da "location": null y enviar "" da "location": "".
const payload = {
name: name.trim(),
...(location.trim() ? { location: location.trim() } : {}),
};Todos los fallos HTTP se convierten en ApiError (src/api/http.ts) con:
status: código HTTP (0= sin conexión con la API)code: código de negocio de ProblemDetails (validation_error,not_found,insufficient_stock…)field: campo al que apunta el error, cuando la API lo sabe de uno solo (SKU, correo o nombre duplicado)fieldErrors: mensajes de validación agrupados por campo, que la vista pinta debajo de cada input
La API tiene dos formatos de error por campo y toApiError los unifica en fieldErrors:
| Formato | Cuándo | Ejemplo |
|---|---|---|
errors |
validación de DataAnnotations, varios campos a la vez | {"Quantity": ["La cantidad debe ser al menos 1."]} |
field |
un solo campo, lanzado como ArgumentException o DomainException |
SKU, correo o nombre duplicado |
Sin esa normalización, un duplicado solo llegaría como mensaje general y la vista no sabría debajo de qué input pintarlo.
Los códigos de negocio propios de Inventario (insufficient_stock, no_stock_record, inactive_warehouse…) llegan en inglés desde el servicio y InventoryMovementView los traduce; los de validación sí vienen ya en español en errors.