diff --git a/AGENTS.md b/AGENTS.md index 497a7d0..d181b19 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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)" + `` 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. diff --git a/src/components/admin/solicitudes/AccionesSolicitud.tsx b/src/components/admin/solicitudes/AccionesSolicitud.tsx index 45ce091..8ca17bb 100644 --- a/src/components/admin/solicitudes/AccionesSolicitud.tsx +++ b/src/components/admin/solicitudes/AccionesSolicitud.tsx @@ -1,15 +1,17 @@ import { useEffect, useRef, useState } from 'react'; import { toast, toastAfterReload } from '@/lib/toast'; +type Item = { cantidad: number; material: { nombre: string; cantidad_disponible: number } | null }; type Prestamo = { id: number; - cantidad: number; - material: { nombre: string; cantidad_disponible: number } | null; + items: Item[]; + maestro_responsable: string; alumno: { nombre: string | null; email: string } | null; }; const hoy = () => new Date().toISOString().split('T')[0]; const enDias = (d: number) => new Date(Date.now() + d * 86400000).toISOString().split('T')[0]; +const resumenNombres = (items: Item[]) => items.map((i) => i.material?.nombre ?? 'Material').join(', '); export default function AccionesSolicitud({ prestamo }: { prestamo: Prestamo }) { return ( @@ -43,7 +45,7 @@ function AprobarDialog({ prestamo }: { prestamo: Prestamo }) { const [notas, setNotas] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const stockInsuficiente = (prestamo.material?.cantidad_disponible ?? 0) < prestamo.cantidad; + const stockInsuficiente = prestamo.items.some((i) => (i.material?.cantidad_disponible ?? 0) < i.cantidad); const submit = async (e: React.FormEvent) => { e.preventDefault(); @@ -54,7 +56,7 @@ function AprobarDialog({ prestamo }: { prestamo: Prestamo }) { } setLoading(true); try { - const res = await fetch(`/api/admin/prestamos/${prestamo.id}/aprobar`, { + const res = await fetch(`/api/admin/solicitudes/${prestamo.id}/aprobar`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ fecha_devolucion_estimada: fecha, notas: notas.trim() || undefined }), @@ -66,7 +68,7 @@ function AprobarDialog({ prestamo }: { prestamo: Prestamo }) { toastAfterReload({ kind: 'success', title: 'Solicitud aprobada', - description: `${prestamo.material?.nombre ?? 'Material'} · ${prestamo.alumno?.nombre ?? prestamo.alumno?.email ?? ''}`, + description: `${resumenNombres(prestamo.items)} · ${prestamo.alumno?.nombre ?? prestamo.alumno?.email ?? ''}`, }); location.reload(); } catch (err: any) { @@ -84,12 +86,21 @@ function AprobarDialog({ prestamo }: { prestamo: Prestamo }) {

Aprobar solicitud

- {prestamo.alumno?.nombre ?? prestamo.alumno?.email} solicita{' '} - {prestamo.cantidad} de {prestamo.material?.nombre ?? '—'}. + {prestamo.alumno?.nombre ?? prestamo.alumno?.email} solicita, para {prestamo.maestro_responsable}:

+
    + {prestamo.items.map((i, idx) => ( +
  • + {i.cantidad} de {i.material?.nombre ?? '—'} + {(i.material?.cantidad_disponible ?? 0) < i.cantidad && ( + — stock insuficiente ({i.material?.cantidad_disponible ?? 0} disp.) + )} +
  • + ))} +
{stockInsuficiente && (
- Stock disponible ({prestamo.material?.cantidad_disponible ?? 0}) menor a lo solicitado. + Uno o más materiales no tienen stock suficiente.
)}
@@ -146,7 +157,7 @@ function RechazarDialog({ prestamo }: { prestamo: Prestamo }) { } setLoading(true); try { - const res = await fetch(`/api/admin/prestamos/${prestamo.id}/rechazar`, { + const res = await fetch(`/api/admin/solicitudes/${prestamo.id}/rechazar`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ motivo: motivo.trim() }), @@ -158,7 +169,7 @@ function RechazarDialog({ prestamo }: { prestamo: Prestamo }) { toastAfterReload({ kind: 'info', title: 'Solicitud rechazada', - description: `${prestamo.material?.nombre ?? 'Material'} · ${prestamo.alumno?.nombre ?? prestamo.alumno?.email ?? ''}`, + description: `${resumenNombres(prestamo.items)} · ${prestamo.alumno?.nombre ?? prestamo.alumno?.email ?? ''}`, }); location.reload(); } catch (err: any) { @@ -181,6 +192,9 @@ function RechazarDialog({ prestamo }: { prestamo: Prestamo }) {

Rechazar solicitud

+

+ {resumenNombres(prestamo.items)} · {prestamo.alumno?.nombre ?? prestamo.alumno?.email} +

Explica al alumno por qué se rechaza. Este texto queda registrado.

diff --git a/src/components/admin/solicitudes/MarcarDevuelto.tsx b/src/components/admin/solicitudes/MarcarDevuelto.tsx index d460a27..a1811ce 100644 --- a/src/components/admin/solicitudes/MarcarDevuelto.tsx +++ b/src/components/admin/solicitudes/MarcarDevuelto.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { toast, toastAfterReload } from '@/lib/toast'; -export default function MarcarDevuelto({ prestamoId, nombreMaterial }: { prestamoId: number; nombreMaterial: string }) { +export default function MarcarDevuelto({ prestamoId, resumenMateriales }: { prestamoId: number; resumenMateriales: string }) { const ref = useRef(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -19,7 +19,7 @@ export default function MarcarDevuelto({ prestamoId, nombreMaterial }: { prestam setError(null); setLoading(true); try { - const res = await fetch(`/api/admin/prestamos/${prestamoId}/devolver`, { method: 'POST' }); + const res = await fetch(`/api/admin/solicitudes/${prestamoId}/devolver`, { method: 'POST' }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body?.error ?? `Error ${res.status}`); @@ -27,7 +27,7 @@ export default function MarcarDevuelto({ prestamoId, nombreMaterial }: { prestam toastAfterReload({ kind: 'success', title: 'Devolución registrada', - description: `${nombreMaterial} regresa al inventario`, + description: `${resumenMateriales} regresa al inventario`, }); location.reload(); } catch (err: any) { @@ -47,7 +47,7 @@ export default function MarcarDevuelto({ prestamoId, nombreMaterial }: { prestam

Confirmar devolución

- ¿Confirmas la devolución de {nombreMaterial}? El material regresará al inventario disponible. + ¿Confirmas la devolución de {resumenMateriales}? El material regresará al inventario disponible.

{error && (
diff --git a/src/components/admin/solicitudes/VerDetalles.tsx b/src/components/admin/solicitudes/VerDetalles.tsx index 6dee821..054c4b0 100644 --- a/src/components/admin/solicitudes/VerDetalles.tsx +++ b/src/components/admin/solicitudes/VerDetalles.tsx @@ -1,17 +1,22 @@ import { useEffect, useRef, useState } from 'react'; +type Item = { + cantidad: number; + descripcion: string | null; + material: { nombre: string; numero_inventario: string | null } | null; +}; type Detalles = { prestamo: { id: number; - cantidad: number; estado: string; fecha_solicitud: string; fecha_aprobacion: string | null; fecha_devolucion_estimada: string | null; fecha_devolucion_real: string | null; notas: string | null; + maestro_responsable: string; alumno: { nombre: string | null; email: string; matricula: string | null } | null; - material: { nombre: string; numero_inventario: string | null } | null; + items: Item[]; }; audit_log: Array>; }; @@ -38,7 +43,7 @@ export default function VerDetalles({ prestamoId }: { prestamoId: number }) { setLoading(true); setError(null); try { - const res = await fetch(`/api/admin/prestamos/${prestamoId}/detalles`); + const res = await fetch(`/api/admin/solicitudes/${prestamoId}/detalles`); if (!res.ok) throw new Error(`Error ${res.status}`); setData(await res.json()); } catch (err: any) { @@ -74,10 +79,20 @@ export default function VerDetalles({ prestamoId }: { prestamoId: number }) {
Alumno
{data.prestamo.alumno?.nombre ?? data.prestamo.alumno?.email}
-
Material
-
{data.prestamo.material?.nombre} · Inv. {data.prestamo.material?.numero_inventario ?? '—'}
-
Cantidad
-
{data.prestamo.cantidad}
+
Maestro
+
{data.prestamo.maestro_responsable}
+
Materiales
+
+
    + {data.prestamo.items.map((i, idx) => ( +
  • + {i.cantidad}× {i.material?.nombre ?? '—'}{' '} + · Inv. {i.material?.numero_inventario ?? '—'} + {i.descripcion &&
    {i.descripcion}
    } +
  • + ))} +
+
Solicitado
{fmt.format(new Date(data.prestamo.fecha_solicitud))}
{data.prestamo.fecha_aprobacion && (<>
Aprobado
{fmt.format(new Date(data.prestamo.fecha_aprobacion))}
)} @@ -91,7 +106,7 @@ export default function VerDetalles({ prestamoId }: { prestamoId: number }) { ) : (
    {data.audit_log.map((e, i) => { - const fecha = e.created_at ?? e.fecha ?? e.timestamp; + const fecha = e.at ?? e.created_at ?? e.fecha ?? e.timestamp; return (
  • {e.accion ?? 'cambio'} diff --git a/src/components/alumno/AgregarMaterial.tsx b/src/components/alumno/AgregarMaterial.tsx new file mode 100644 index 0000000..b455007 --- /dev/null +++ b/src/components/alumno/AgregarMaterial.tsx @@ -0,0 +1,37 @@ +import { useCart } from './SolicitudCart'; +import { toast } from '@/lib/toast'; + +type Props = { + material: { id: number; nombre: string; cantidad_disponible: number }; +}; + +export default function AgregarMaterial({ material }: Props) { + const { addItem, has } = useCart(); + const sinStock = material.cantidad_disponible === 0; + const enCarrito = has(material.id); + + if (enCarrito) { + return ( + + ); + } + + return ( + + ); +} diff --git a/src/components/alumno/SolicitarModal.tsx b/src/components/alumno/SolicitarModal.tsx deleted file mode 100644 index 8992bcf..0000000 --- a/src/components/alumno/SolicitarModal.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { toast, toastAfterReload } from '@/lib/toast'; - -type Props = { - material: { - id: number; - nombre: string; - cantidad_disponible: number; - }; -}; - -export default function SolicitarModal({ material }: Props) { - const dialogRef = useRef(null); - const firstFieldRef = useRef(null); - const [cantidad, setCantidad] = useState(1); - const [notas, setNotas] = useState(''); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [ok, setOk] = useState(false); - - const titleId = `solicitar-title-${material.id}`; - - const open = () => { - setError(null); - setOk(false); - setCantidad(1); - setNotas(''); - dialogRef.current?.showModal(); - queueMicrotask(() => firstFieldRef.current?.focus()); - }; - - const close = () => { - if (loading) return; - dialogRef.current?.close(); - }; - - // Cerrar al click en backdrop - useEffect(() => { - const dlg = dialogRef.current; - if (!dlg) return; - const onClick = (e: MouseEvent) => { - if (e.target === dlg) close(); - }; - dlg.addEventListener('click', onClick); - return () => dlg.removeEventListener('click', onClick); - }); - - const submit = async (e: React.FormEvent) => { - e.preventDefault(); - if (loading) return; - setLoading(true); - setError(null); - try { - const res = await fetch('/api/prestamos', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ material_id: material.id, cantidad, notas }), - }); - const json = await res.json().catch(() => ({})); - if (!res.ok) { - throw new Error(json?.error ?? 'No se pudo enviar la solicitud'); - } - setOk(true); - toastAfterReload({ - kind: 'success', - title: 'Solicitud enviada', - description: `${material.nombre} · pendiente de aprobación`, - }); - setTimeout(() => { - dialogRef.current?.close(); - location.reload(); - }, 800); - } catch (err) { - const msg = err instanceof Error ? err.message : 'Error inesperado'; - setError(msg); - toast({ kind: 'error', title: 'No se pudo enviar', description: msg }); - } finally { - setLoading(false); - } - }; - - const disabled = material.cantidad_disponible === 0; - - return ( - <> - - - - -
    -

    - Solicitar: {material.nombre} -

    - -
    - -

    - Disponibles: {material.cantidad_disponible} -

    - -
    - - setCantidad(Math.max(1, Math.min(material.cantidad_disponible, Number(e.target.value) || 1)))} - style={{ fontVariantNumeric: 'tabular-nums' }} - /> -
    - -
    - -