Rediseño: vale de préstamo multi-ítem, fiel al formulario de papel
El vale físico del LSC permite pedir varios materiales en un solo trámite; el sistema modelaba 1 solicitud = 1 material. Migra prestamos.prestamos -> solicitudes (cabecera) + solicitud_items (renglones), vía RENAME + backfill (preserva ids/historial real). - RPC prestamos.crear_solicitud: transaccional, lockea materiales por fila, arregla la race condition del insert directo anterior. El alumno ya no inserta directo (RLS lo bloquea). - sync_stock/log_estado reescritos para iterar renglones por vale. - maestro_responsable (por vale) y profiles.semestre (perfil, nullable, sin UI todavía — onboarding queda para después). - Alumno: carrito (SolicitudCart/AgregarMaterial) reemplaza el modal de solicitud único por material. - Admin: 3 vistas de solicitudes, reportes y CSV export listan renglones por vale; api/admin/prestamos -> api/admin/solicitudes. De paso corrige un bug preexistente en detalles.ts (audit_log nunca se ordenaba por la columna correcta). Verificado: build limpio, ciclo RPC+triggers probado en una transacción revertida en prod (sin residuo), 9 vistas SSR smoke-testeadas contra el schema real. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -49,14 +49,19 @@ Sistema web para gestionar préstamos de material del laboratorio. Dos roles: **
|
||||
| Subdominio | **prestamos.buglabs.dev** | Se añade al mismo túnel Cloudflare (`3de17b3c-...`) junto a supabase/git/status/genqbar. |
|
||||
| Exportar reportes | CSV nativo (sin librería) para v1 | Cero dependencias nuevas; se sube a PDF/Excel solo si se pide explícitamente. |
|
||||
|
||||
## Modelo de datos propuesto (schema `prestamos`)
|
||||
## Modelo de datos (schema `prestamos`)
|
||||
|
||||
- `profiles` — id (=auth.users.id), email, nombre, matricula, rol (`alumno`|`admin`), created_at
|
||||
Rediseñado 2026-08-17 para reflejar el vale de préstamo físico del LSC (un trámite agrupa varios materiales, no uno solo — ver Bitácora del 2026-08-17 para el detalle completo).
|
||||
|
||||
- `profiles` — id (=auth.users.id), email, nombre, matricula, semestre (nullable, sin UI aún — ver backlog), rol (`alumno`|`admin`), created_at
|
||||
- `categorias` — id, nombre
|
||||
- `materiales` — id, nombre, categoria_id, descripcion, cantidad_total, cantidad_disponible, numero_inventario, estado (`disponible`|`mantenimiento`|`baja`)
|
||||
- `prestamos` — id, alumno_id, material_id, cantidad, estado (`pendiente`|`aprobado`|`rechazado`|`activo`|`devuelto`|`vencido`), fecha_solicitud, fecha_aprobacion, fecha_devolucion_estimada, fecha_devolucion_real, aprobado_por, notas
|
||||
- `solicitudes` (cabecera del vale, antes se llamaba `prestamos`) — id, alumno_id, maestro_responsable, estado (`pendiente`|`aprobado`|`rechazado`|`activo`|`devuelto`|`vencido`), fecha_solicitud, fecha_aprobacion, fecha_devolucion_estimada, fecha_devolucion_real, aprobado_por, notas (motivo del préstamo)
|
||||
- `solicitud_items` (renglones del vale) — id, solicitud_id, material_id, cantidad, descripcion (nota libre por renglón)
|
||||
|
||||
RLS: alumno solo ve/edita sus propios préstamos; admin ve y gestiona todo. Enforced con `auth.uid()` contra `profiles.id` y policies por rol.
|
||||
Alta de solicitudes: solo vía RPC `prestamos.crear_solicitud(maestro_responsable, notas, items jsonb)` (transaccional, valida stock con lock por fila) — el alumno ya no puede insertar directo en `solicitudes` (RLS lo bloquea).
|
||||
|
||||
RLS: alumno solo ve/edita sus propias solicitudes/renglones; admin ve y gestiona todo. Enforced con `auth.uid()` contra `profiles.id` y policies por rol.
|
||||
|
||||
## Fases de implementación
|
||||
|
||||
@@ -187,3 +192,25 @@ Fases 3, 4/5 y 6 tocan carpetas de rutas distintas (`src/pages/alumno/*`, `src/p
|
||||
- **Revisión de cierre y documentación**: sin subagentes `impeccable-finish-reviewer`/`impeccable-documenter` instalados en este entorno, ambos pases corrieron in-thread siguiendo `reference/degraded/finish-reviewer.md` y `reference/degraded/documenter.md` — disclosure explícito de la sustitución. `DESIGN.md` reescrito completo desde el mundo construido (ya no describe "La Bitácora Digital" verde institucional). `PRODUCT.md` → Brand Commitments actualizado con nota de reemplazo fechada y los valores activos.
|
||||
- **Deliberadamente NO tocado**: arquitectura de layout (sidebar desktop + dock mobile se mantienen, solo cambió su piel), copy/contenido, lógica de negocio, el escudo oficial UABC (sigue sin fabricarse, por instrucción explícita en `PRODUCT.md`).
|
||||
- Estado: cambios completos y verificados localmente, **sin commitear ni desplegar** — el usuario ya tiene un deploy previo (UABC verde) en producción; este reemplazo de identidad es mucho más grande y espera confirmación explícita antes de `git push` + deploy.
|
||||
|
||||
- **2026-08-17 — Corrección: colores de identidad UABC restaurados dentro del sistema MotherDuck**. El usuario señaló correctamente que el rediseño anterior había sustituido también los colores de marca (verde/dorado UABC) por la paleta literal del brief de MotherDuck (azul cielo/naranja) — un exceso: adoptar un *lenguaje* de diseño (sombra dura, radio 2px, tipografía mono, canvas crema) no implica adoptar los *colores de marca* de otra empresa.
|
||||
- Fix: `--color-primary` volvió a `#00723F` (verde institucional), `--color-secondary` a `#DD971A` (dorado UABC), en `global.css`. Todo lo demás del sistema neobrutalista (sombra `-6px 6px 0 0`, radio 2px, JetBrains Mono, canvas crema `#f4efea`) se mantuvo intacto — la corrección fue puntual, no una reversión del trabajo de la sesión anterior.
|
||||
- **Problema de contraste no trivial que surgió del cambio**: verde institucional (`#00723F`) es mucho más oscuro que el azul cielo del brief original (`#6fc2ff`). La regla que se había fijado ("texto siempre carbón, nunca blanco, sobre cualquier relleno de color") dejó de sostenerse — carbón sobre verde oscuro da ~2:1 de contraste (falla AA). Se verificó por cálculo de luminancia relativa que verde necesita texto blanco (~6.2:1) y dorado necesita texto carbón (~4.9:1; blanco sobre dorado falla en ~2.5:1) — son casos opuestos, no intercambiables.
|
||||
- Se corrigieron 8 puntos que asumían texto carbón sobre fondo primario sólido: `.btn-primary` en `global.css`, el chip del `BrandMark` en sidebar/header móvil/login/404, el ítem de nav activo (sidebar y dock móvil), el chip activo de `FiltroCategorias`, el badge de conteo en `admin/solicitudes/index.astro`, y el ícono "G" del botón de Google en login — todos a texto/ícono blanco. `.btn-secondary` volvió a ser un relleno dorado sólido (como el sistema UABC original) en vez del botón "outline blanco" que había impuesto la regla literal de MotherDuck ("Sky Crayon es el único color de relleno") — esa regla ya no aplica: dorado es un segundo color de marca real de UABC, no un antojo decorativo.
|
||||
- `DESIGN.md` reescrito: la "Charcoal Text Rule" se reemplazó por la "Text-on-Fill Rule" (el color de texto se decide por contraste real contra cada relleno, no por hábito); toda la prosa que nombraba "Sky Crayon"/"Duck Bill Orange" se corrigió a "Verde Institucional"/"Dorado UABC". `.impeccable/design.json` actualizado en paralelo (colorMeta, CSS de los componentes de ejemplo, narrative). `PRODUCT.md` → Brand Commitments corregido con nota fechada explicando el error y la corrección.
|
||||
- Verificación: `npm run build` limpio, detector mecánico sin hallazgos, ronda de screenshots (login, panel admin, catálogo, solicitudes, reportes) confirmando visualmente verde/dorado UABC correctos con buen contraste en cada superficie. Middleware de preview revertido de nuevo tras las capturas.
|
||||
- Aprendizaje para futuras adopciones de brief externo: "usa el estilo de X" nunca implica "usa los colores de marca de X" a menos que se diga explícitamente — la mecánica (sombra, radio, tipografía, densidad) y la identidad de color son ejes independientes, y hay que preguntar o inferir con cuidado cuál se está pidiendo.
|
||||
|
||||
- **2026-08-17 — Rediseño de fondo: vale de préstamo multi-ítem (fiel al formulario de papel)**. El usuario mostró la hoja física "Vale de Préstamo" del LSC (UABC) — permite pedir varios materiales en un solo trámite (Laptop, Proyector, Impresora, Switch, Mouse, Teclado, Extensión, Adaptador, Regleta, Bocinas, Herramienta, Otros ×3), cada renglón con cantidad y descripción, más Nombre/Matrícula/Semestre del alumno, Maestro Responsable y Fecha/Hora. El sistema modelaba "1 solicitud = 1 material" — no coincidía. Se rediseñó a "1 vale = N renglones", con decisiones acordadas con el usuario: (1) multi-ítem fiel al papel; (2) el catálogo de materiales/categorías **no se toca** (se mantiene individual con número de inventario y stock, tal como estaba); (3) `semestre` es dato del perfil, se llena una sola vez — pero la UI de onboarding para llenarlo queda **explícitamente fuera de esta ronda** (backlog futuro); (4) `maestro_responsable` es dato por vale, se captura en cada solicitud.
|
||||
- **Migración `supabase/migrations/0002_solicitudes_multi_item.sql`** aplicada en producción (sin downtime de BD — todo `ALTER`/rename de metadatos + backfill de 5 filas reales): `prestamos.prestamos` → `RENAME TO solicitudes` (preserva ids/FKs/policies existentes intactos); nueva tabla `solicitud_items(solicitud_id, material_id, cantidad, descripcion)` poblada por backfill 1:1 desde las filas legacy; `maestro_responsable` agregado a `solicitudes` (backfill legacy con placeholder, luego `not null`); `semestre` agregado a `profiles` (nullable); `audit_log.prestamo_id` renombrado a `solicitud_id`. Los triggers `sync_stock()`/`log_estado()` se reescribieron para iterar `solicitud_items` de la solicitud afectada en vez de leer `material_id`/`cantidad` de una sola fila. Nueva función RPC `prestamos.crear_solicitud(maestro_responsable, notas, items jsonb)` — `security definer`, transaccional, lockea (`for update`) las filas de `materiales` involucradas en orden `material_id asc` (evita deadlocks), valida cada renglón (material existe, disponible, stock suficiente) y de paso arregla la race condition que tenía el endpoint viejo (select + insert sin lock). El alumno ya no puede insertar directo en `solicitudes` vía supabase-js — se eliminó esa policy RLS; todo pasa por el RPC.
|
||||
- **Recomendación de dry-run no se pudo seguir literalmente**: el archivo de migración trae su propio `begin;`/`commit;` (mismo patrón que `0001_init.sql`), así que correrlo por `psql` lo aplicó y comprometió de una sola pasada sin ventana para `ROLLBACK` manual — no hubo error, así que no fue necesario, pero es un aprendizaje para la próxima migración: si se quiere un dry-run real hay que envolver el `psql -f archivo.sql` en un `BEGIN`/`ROLLBACK` externo, o quitarle el `begin;`/`commit;` propio al archivo antes de probarlo.
|
||||
- **Sin ambiente de staging** (dev local y producción comparten el mismo Postgres de buglabs) — se le preguntó al usuario cómo manejar la ventana de riesgo antes de tocar la BD; eligió "migrar ya y trabajar sin pausas hasta el deploy completo" en vez de escribir todo el código a ciegas primero. Aplicó bien: la migración no rompió nada porque el trabajo de código se hizo inmediatamente después, en un solo tramo.
|
||||
- **4 tracks de código** (3 en paralelo vía Agent tool tras la migración + RPC, 1 trivial hecho directo sin agente por ser 3 líneas):
|
||||
- **Track A (alumno)** — `src/components/alumno/{SolicitudCart,AgregarMaterial}.tsx` (nuevos, reemplazan `SolicitarModal.tsx`, borrado), `catalogo.astro`, `mis-prestamos.astro`. Decisión de diseño no trivial: Astro hidrata cada `client:*` como raíz de React independiente, así que un `CartProvider` y N `AgregarMaterial` como islands separados NO comparten Context. Solución: `CartProvider` recibe el array completo de `materiales` como prop y renderiza él mismo todo el grid de cards dentro de un único árbol React (`client:load`), con `AgregarMaterial` como hijo normal (no island) leyendo `useCart()`. Carrito con botón flotante "Ver solicitud (N)" + `<dialog>` de checkout (maestro responsable, motivo opcional, renglones editables con cantidad/descripción/quitar) → `POST /api/solicitudes`.
|
||||
- **Track B (admin solicitudes)** — 11 archivos: 3 vistas de listado, `AccionesSolicitud`/`MarcarDevuelto`/`VerDetalles`, los 4 endpoints de acción, `admin/index.astro`. La columna "Cantidad" separada de las tablas se eliminó en las 3 vistas — la cantidad ahora es por renglón y se muestra apilada dentro de la celda "Material". Se movió `src/pages/api/admin/prestamos/[id]/` → `src/pages/api/admin/solicitudes/[id]/` (`git mv`, coherencia de nombres). De paso se arregló un bug preexistente en `detalles.ts`: el fetch de `audit_log` filtraba por `prestamo_id` (ahora `solicitud_id`) y ordenaba por `created_at` (la columna real siempre fue `at`) — el historial de cambios en "Ver detalles" nunca había funcionado en producción por ese typo.
|
||||
- **Track C (reportes)** — `reportes.astro` + `export.ts`. El filtro `material_id` pasó a usar `solicitud_items!inner(...)` + `.eq('items.material_id', id)` (trade-off consciente: con el filtro activo, el array de `items` embebido queda acotado solo al renglón que matchea, evita una segunda query). El CSV cambió de "una fila por vale" a "**una fila por renglón**" (estándar para exports de línea de pedido, filtrable/pivoteable en Excel) — se agregaron columnas `Maestro responsable` y `Descripción`.
|
||||
- **Track D (trivial)** — `admin/inventario/index.astro`: 3 queries de KPIs del día renombradas de `.from('prestamos')` a `.from('solicitudes')`. Hecho directo, sin agente (ponytail: no vale la pena un agente completo para 3 líneas).
|
||||
- **Verificación**: `npm run build` limpio en cada track y en el árbol final combinado. Lógica de negocio crítica (RPC + triggers de stock + audit_log + RLS) probada **dentro de una transacción `BEGIN`/`ROLLBACK` directo en `psql`** simulando `auth.uid()` vía `request.jwt.claim.sub` (como lo hace PostgREST) — creó una solicitud real con 2 materiales distintos, confirmó que aprobar descuenta stock de ambos, que devolver lo repone, que el audit log registra ambas transiciones con `solicitud_id` correcto — y al hacer `ROLLBACK` no quedó ningún residuo en producción (verificado con conteo antes/después). Las 9 páginas SSR tocadas (catálogo, mis-préstamos, 3 vistas de solicitudes, panel admin, reportes ×2, inventario) se probaron con `curl` contra el dev server reiniciado, usando un bypass temporal `?preview=alumno|admin` en el middleware (mismo patrón ya usado en sesiones anteriores, revertido inmediatamente después — diff de `middleware.ts` quedó limpio) — las 9 devolvieron 200 sin errores en los logs del dev server, incluyendo el filtro `material_id` con el join `!inner` y el CSV export (confirmó que la data legacy migrada aparece correctamente con el placeholder de `maestro_responsable`).
|
||||
- **Backlog futuro explícito, no incluido en esta ronda**: pantalla de onboarding/completar perfil post-login que pida `semestre` (y posibles otros datos) una sola vez a usuarios nuevos. La columna ya existe en `profiles`, solo falta el endpoint `PATCH` (la policy `profiles_update_self` de `0001_init.sql` ya lo permite) y la UI.
|
||||
- **Edge case menor, no bloqueante, anotado para si se vuelve a tocar este código**: la RPC `crear_solicitud` no dedupe `material_id` repetidos dentro del mismo array de `items` — si dos renglones apuntan al mismo material con cantidades que combinadas exceden el stock, cada uno se valida contra el disponible total de forma independiente (no acumulativa) al momento de crear la solicitud. En la práctica no ocurre porque el carrito del alumno (`SolicitudCart.tsx`) dedupea por `material_id` incrementando cantidad en vez de crear renglones duplicados; y aunque ocurriera, el trigger `sync_stock` al aprobar sí acumula correctamente sobre la fila real de `materiales`, así que el peor caso es que la aprobación falle por el `check (cantidad_disponible >= 0)` en vez de fallar silenciosamente.
|
||||
- Estado: código completo, migración ya viva en producción, build y smoke test verificados. Falta el paso final: commit + deploy (`git push` + `ssh buglabs 'cd ~/labre-web && git pull && docker compose up -d --build'`) — pendiente de confirmación explícita del usuario antes de ejecutarlo.
|
||||
|
||||
Reference in New Issue
Block a user