Compare commits

...

2 Commits

Author SHA1 Message Date
LakG 81a5ea016e 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>
2026-08-17 08:37:44 -07:00
LakG 4005008872 Fix: texto blanco sobre verde institucional en elementos rellenos
El verde UABC (#00723F) es demasiado oscuro para texto carbón (~2:1,
falla AA). Corrige BrandMark, nav activo (sidebar/dock), chip de
FiltroCategorias y botón de Google en login a texto/ícono blanco
(~6.2:1). Actualiza DESIGN.md/PRODUCT.md y design.json acorde.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 08:35:28 -07:00
31 changed files with 1068 additions and 480 deletions
+20 -20
View File
@@ -1,6 +1,6 @@
{
"schemaVersion": 2,
"generatedAt": "2026-08-16T00:00:00.000Z",
"generatedAt": "2026-08-17T00:00:00.000Z",
"title": "Design System: LabPréstamos",
"extensions": {
"colorMeta": {
@@ -9,8 +9,8 @@
"chalk": { "role": "neutral", "displayName": "Chalk Gray", "canonical": "#f8f8f7" },
"ink": { "role": "neutral", "displayName": "Charcoal Ink", "canonical": "#383838" },
"pencil": { "role": "neutral", "displayName": "Pencil Gray", "canonical": "#a1a1a1" },
"primary": { "role": "primary", "displayName": "Sky Crayon", "canonical": "#6fc2ff" },
"secondary": { "role": "secondary", "displayName": "Duck Bill Orange", "canonical": "#ff9538" },
"primary": { "role": "primary", "displayName": "Verde Institucional", "canonical": "#00723F" },
"secondary": { "role": "secondary", "displayName": "Dorado UABC", "canonical": "#DD971A" },
"danger": { "role": "danger", "displayName": "Coral Sketch (fill)", "canonical": "#f38e84" },
"danger-text": { "role": "danger", "displayName": "Coral Sketch (text, AA-darkened)", "canonical": "#a83224" },
"positive": { "role": "success", "displayName": "Mint Sketch", "canonical": "#38c1b0" }
@@ -35,17 +35,17 @@
"name": "Primary Button",
"kind": "button",
"refersTo": "button-primary",
"description": "The system's default call to action.",
"description": "The system's default call to action. Verde Institucional is dark, so this is the one fill that takes white text.",
"html": "<button class=\"ds-btn ds-btn-primary\">Solicitar</button>",
"css": ".ds-btn { display:inline-flex; align-items:center; justify-content:center; gap:8px; min-height:44px; padding:10px 16px; border-radius:2px; border:2px solid #383838; font-family:'JetBrains Mono Variable',monospace; letter-spacing:0.02em; font-weight:500; box-shadow:-6px 6px 0 0 #383838; transition:transform 100ms ease-out; } .ds-btn:hover, .ds-btn:focus-visible { transform:translate(-6px,6px); box-shadow:0 0 0 0 transparent; } .ds-btn-primary { background:#6fc2ff; color:#383838; }"
"css": ".ds-btn { display:inline-flex; align-items:center; justify-content:center; gap:8px; min-height:44px; padding:10px 16px; border-radius:2px; border:2px solid #383838; font-family:'JetBrains Mono Variable',monospace; letter-spacing:0.02em; font-weight:500; box-shadow:-6px 6px 0 0 #383838; transition:transform 100ms ease-out; } .ds-btn:hover, .ds-btn:focus-visible { transform:translate(-6px,6px); box-shadow:0 0 0 0 transparent; } .ds-btn-primary { background:#00723F; color:#ffffff; }"
},
{
"name": "Secondary Button",
"kind": "button",
"refersTo": "button-secondary",
"description": "White-filled, charcoal-outlined — never a second brand color.",
"description": "Dorado UABC fill, charcoal text (light enough to clear AA with charcoal, unlike white-on-gold).",
"html": "<button class=\"ds-btn ds-btn-secondary\">Exportar CSV</button>",
"css": ".ds-btn-secondary { background:#ffffff; color:#383838; }"
"css": ".ds-btn-secondary { background:#DD971A; color:#383838; }"
},
{
"name": "Ghost Button",
@@ -77,13 +77,13 @@
"refersTo": "input",
"description": "Flat, bordered, no shadow, no glow focus.",
"html": "<input class=\"ds-input\" type=\"text\" placeholder=\"Nombre o Nº inventario…\" />",
"css": ".ds-input { width:100%; min-height:44px; padding:10px 12px; border-radius:2px; border:2px solid #383838; background:#ffffff; font-family:'JetBrains Mono Variable',monospace; color:#383838; } .ds-input:focus { outline:none; border-color:#6fc2ff; }"
"css": ".ds-input { width:100%; min-height:44px; padding:10px 12px; border-radius:2px; border:2px solid #383838; background:#ffffff; font-family:'JetBrains Mono Variable',monospace; color:#383838; } .ds-input:focus { outline:none; border-color:#00723F; }"
},
{
"name": "Status Badge",
"kind": "chip",
"refersTo": "badge",
"description": "Tinted fill, semantic-color border, always charcoal text.",
"description": "Tinted fill, semantic-color border, charcoal text (tints are always light enough for it).",
"html": "<span class=\"ds-badge ds-badge-positive\">Disponible</span>",
"css": ".ds-badge { display:inline-flex; align-items:center; border-radius:2px; padding:2px 8px; font-family:'JetBrains Mono Variable',monospace; font-size:0.75rem; font-weight:600; text-transform:uppercase; letter-spacing:0.03em; color:#383838; } .ds-badge-positive { background:color-mix(in oklab, #38c1b0 18%, white); border:1.5px solid #38c1b0; } .ds-badge-danger { background:color-mix(in oklab, #f38e84 18%, white); border:1.5px solid #f38e84; }"
},
@@ -91,34 +91,34 @@
"name": "Nav Item (desktop sidebar)",
"kind": "nav",
"refersTo": "nav-item",
"description": "Uppercase mono label; active = solid Sky Crayon chip with charcoal border.",
"description": "Uppercase mono label; active = solid Verde Institucional chip with white text and charcoal border.",
"html": "<a class=\"ds-nav-item ds-nav-item-active\" href=\"#\">Catálogo</a>",
"css": ".ds-nav-item { display:flex; align-items:center; gap:12px; padding:10px 12px; border-radius:2px; border:2px solid transparent; font-family:'JetBrains Mono Variable',monospace; text-transform:uppercase; letter-spacing:0.02em; font-size:0.875rem; color:#383838; } .ds-nav-item-active { background:#6fc2ff; border-color:#383838; } .ds-nav-item:not(.ds-nav-item-active):hover { background:#f8f8f7; }"
"css": ".ds-nav-item { display:flex; align-items:center; gap:12px; padding:10px 12px; border-radius:2px; border:2px solid transparent; font-family:'JetBrains Mono Variable',monospace; text-transform:uppercase; letter-spacing:0.02em; font-size:0.875rem; color:#383838; } .ds-nav-item-active { background:#00723F; color:#ffffff; border-color:#383838; } .ds-nav-item:not(.ds-nav-item-active):hover { background:#f8f8f7; }"
}
],
"narrative": {
"northStar": "The Equipment Tag on a Terminal Readout",
"overview": "LabPréstamos was rebuilt against a fully pinned external brief (MotherDuck's crayon-terminal-on-cream neobrutalism), translated from a marketing site's vocabulary into an operational lab tool's. The identity keeps the source system's mechanics intact — a warm cream canvas, monospace type standing in for a terminal readout, hard 2px corners, and a flat charcoal-offset shadow that never blurs — while dropping what only belonged to marketing (hand-drawn duck illustrations, scrolling marquees, testimonial cards). What replaces the duck is the product's own mark: a loan tag with a punch-hole, echoing the paper ticket this system replaced. Depth here is binary, not a hierarchy: an element either sits flat on the page or it's a discrete raised object that presses into its own shadow on hover. Color plays a stricter role than in the source brief: Sky Crayon blue is reserved for actionable, filled controls; Mint Sketch was pulled from the source's decorative rainbow set and fixed as the positive-status color; Coral Sketch was fixed as the sole danger color, split into a light fill and a separately darkened AA-compliant text variant.",
"overview": "LabPréstamos runs the mechanics of a fully pinned external brief (MotherDuck's crayon-terminal-on-cream neobrutalism) — cream canvas, monospace type, hard 2px corners, a flat charcoal-offset shadow that never blurs — while carrying UABC's own institutional green and gold as its identity colors instead of the source brief's blue and orange. The first pass used the source's literal palette wholesale; that was corrected once the user flagged it — adopting a design language is not the same as adopting someone else's brand colors, and UABC's green/gold predate this redesign and outrank it. Depth is binary: an element either sits flat on the page or is a discrete raised object that presses into its own shadow on hover. Because Verde Institucional is dark and saturated (unlike the source brief's light blue), it is the one fill in the system that takes white text rather than charcoal — every other fill (Dorado, the status tints, the danger fill) stays with charcoal text because that's what actually clears AA contrast.",
"keyCharacteristics": [
"Cream page canvas, white card surfaces, charcoal (never pure black) as the universal line, border, and text color",
"Cream page canvas, white card surfaces, charcoal (never pure black) as the universal line and border color",
"Every corner is 2px — no pills, no circles, no rounded-full anywhere",
"One shadow, one behavior: -6px 6px 0 0 charcoal, hard-edged, flattens into the element on hover/focus",
"JetBrains Mono Variable everywhere, 0.02em tracking, uppercase on structural labels only",
"Status pills are tinted fills with a matching-color border and always-charcoal text",
"UABC Verde Institucional carries every filled action (white text); Dorado UABC carries the secondary action and a handful of accents (charcoal text)",
"Dense, repeated surfaces stay flat and bordered; only discrete, single-instance elements carry the shadow"
],
"rules": [
{ "name": "The Charcoal Text Rule", "body": "No filled or tinted surface ever carries white or colored text — text is always Charcoal Ink. Color differentiates status and action through background fill and border only.", "section": "colors" },
{ "name": "The Text-on-Fill Rule", "body": "Text/icon color on any filled surface is whichever of Charcoal Ink or white actually clears AA contrast against that fill — never a blanket choice. White on Verde Institucional (dark); Charcoal Ink everywhere else.", "section": "colors" },
{ "name": "The One Shadow Rule", "body": "-6px 6px 0 0 var(--color-ink) is the only shadow value in the system. It never blurs, never softens, never gets a second 'subtle' variant.", "section": "colors" },
{ "name": "The Tabular Numbers Rule", "body": "Any figure representing a count carries font-variant-numeric: tabular-nums.", "section": "typography" },
{ "name": "The Press Rule", "body": "Every raised element's hover state is the same transform: translate exactly the shadow's own offset, so the shadow disappears under the element. No brightness filter, no color darken.", "section": "elevation" },
{ "name": "The Tinted-Fill-Bordered Rule", "body": "Badge background = the semantic color mixed 18-25% into white; border = the semantic color at full strength; text = Charcoal Ink always.", "section": "components" }
{ "name": "The Tinted-Fill-Bordered Rule", "body": "Badge background = the semantic color mixed 18-25% into white; border = the semantic color at full strength; text = Charcoal Ink (tints are always light enough for it).", "section": "components" }
],
"dos": [
"Do use the exact shadow -6px 6px 0 0 var(--color-ink) for every raised element, and the exact press transform translate(-6px, 6px) on hover/focus.",
"Do keep every radius at 2px — buttons, cards, inputs, dialogs, badges, chips.",
"Do keep text Charcoal Ink on every fill, tint, or border color.",
"Do reserve Sky Crayon for actionable, filled controls; use Positive/Danger/Secondary for status.",
"Do check contrast per fill before choosing text color — don't assume charcoal always works.",
"Do keep Verde Institucional for actionable, filled controls; use Positive/Danger/Secondary for status.",
"Do keep dense/repeated surfaces flat and bordered; reserve the hard shadow for discrete, singular surfaces.",
"Do draw every icon as an SVG — never a unicode glyph or emoji.",
"Do apply font-variant-numeric: tabular-nums to every numeric quantity, stock figure, or date.",
@@ -127,8 +127,8 @@
"donts": [
"Don't use blur, glass, or a soft ambient shadow anywhere.",
"Don't introduce a radius above 2px, or a pill/circle shape, anywhere.",
"Don't use a second filled brand color for secondary actions.",
"Don't reuse Sky Crayon for status pills.",
"Don't adopt a borrowed style system's literal brand colors — take its shape/shadow/type mechanics, keep the project's own identity colors in them.",
"Don't reuse Verde Institucional for status pills.",
"Don't introduce a second typeface.",
"Don't stand a unicode glyph in for an icon."
]
+31 -4
View File
@@ -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.
+31 -30
View File
@@ -9,10 +9,10 @@ colors:
ink: "#383838"
pencil: "#a1a1a1"
graphite: "#818181"
primary: "#6fc2ff"
primary-hover: "#56b4f7"
secondary: "#ff9538"
secondary-hover: "#ef8526"
primary: "#00723F"
primary-hover: "#005f34"
secondary: "#DD971A"
secondary-hover: "#c78614"
danger: "#f38e84"
danger-hover: "#ec7469"
danger-text: "#a83224"
@@ -41,11 +41,11 @@ spacing:
components:
button-primary:
backgroundColor: "{colors.primary}"
textColor: "{colors.ink}"
textColor: "#ffffff"
rounded: "{rounded.control}"
padding: "10px 16px"
button-secondary:
backgroundColor: "{colors.frost}"
backgroundColor: "{colors.secondary}"
textColor: "{colors.ink}"
rounded: "{rounded.control}"
padding: "10px 16px"
@@ -72,43 +72,44 @@ components:
**Creative North Star: "The Equipment Tag on a Terminal Readout"**
LabPréstamos was rebuilt against a fully pinned external brief (MotherDuck's crayon-terminal-on-cream neobrutalism), translated from a marketing site's vocabulary into an operational lab tool's. The identity keeps the source system's mechanics intact — a warm cream canvas, monospace type standing in for a terminal readout, hard 2px corners, and a flat charcoal-offset shadow that never blurs — while dropping what only belonged to marketing (hand-drawn duck illustrations, scrolling marquees, testimonial cards). What replaces the duck is the product's own mark: a loan tag with a punch-hole, echoing the paper ticket this system replaced.
LabPréstamos runs the *mechanics* of a fully pinned external brief (MotherDuck's crayon-terminal-on-cream neobrutalism) — a warm cream canvas, monospace type standing in for a terminal readout, hard 2px corners, and a flat charcoal-offset shadow that never blurs — while carrying UABC's own institutional green and gold as its identity colors instead of the source brief's blue and orange. The first pass at this system used the source's literal palette wholesale; that was corrected once the user flagged it — adopting a design *language* is not the same as adopting someone else's brand colors, and UABC's green/gold predate this redesign and outrank it. What replaces MotherDuck's duck mascot is the product's own mark: a loan tag with a punch-hole, echoing the paper ticket this system replaced.
Depth here is binary, not a hierarchy: an element either sits flat on the page (bordered, no shadow — tables, list rows, inputs, the nav) or it's a discrete raised object that "presses" into its own shadow on hover (buttons, standalone cards, modals, the login card). There is no soft, no blur, no ambient glow anywhere in the system — every visual weight decision reads as a hard yes/no.
Color plays a stricter role here than in the source brief. Sky Crayon blue is reserved for actionable, filled controls — never for status. A teal (Mint Sketch) was pulled out of the source's decorative rainbow set and fixed as the system's "positive/available" status color, because an operational tool needs that distinction and a marketing site never did. Coral Sketch was likewise fixed as the sole danger color, with a separately darkened text variant (`--color-danger-text`, `#a83224`) for body copy, because the source coral fails AA contrast on white at normal text weight — the fill stays light and loud, the text goes dark and readable.
Color plays a stricter role here than in the source brief. Verde Institucional is reserved for actionable, filled controls — never for status. A teal (Mint Sketch, from the source brief's decorative rainbow set) is fixed as the system's "positive/available" status color, kept distinct from the primary action color because an operational tool needs that distinction and a marketing site never did. Coral Sketch is fixed as the sole danger color, with a separately darkened text variant (`--color-danger-text`, `#a83224`) for body copy, because the coral fill fails AA contrast on white at normal text weight — the fill stays light and loud, the text goes dark and readable. Because Verde Institucional is a dark, saturated green (unlike the source brief's light Sky Crayon), it is the one fill in the system that takes white text rather than charcoal — see the Text-on-Fill Rule below.
**Key Characteristics:**
- Cream page canvas, white card surfaces, charcoal (`#383838`, never pure black) as the universal line, border, and text color
- Cream page canvas, white card surfaces, charcoal (`#383838`, never pure black) as the universal line and border color
- Every corner is 2px — buttons, cards, inputs, dialogs, badges, the brand mark's own chip. No pills, no circles, no `rounded-full` anywhere in the system
- One shadow, one behavior: `-6px 6px 0 0 var(--color-ink)`, hard-edged, zero blur, on every button, standalone card, toast, and modal — and it flattens into the element on hover/focus (`translate(-6px, 6px)`)
- JetBrains Mono Variable everywhere, 0.02em tracking, uppercase treatment on nav labels, button labels, section labels, and headlines
- Status pills and count badges are tinted fills with a matching-color 1.5px border and always-charcoal text — color differentiates via background and border, never via colored type
- UABC Verde Institucional carries every filled action (white text); UABC Dorado carries the secondary action and a handful of accents (charcoal text)
- Status pills and count badges are tinted or solid fills with a matching-color border — text stays whichever of charcoal/white actually clears contrast against that fill, never picked by habit
- Dense, repeated surfaces (tables, list-item cards, form inputs) stay flat and bordered; only discrete, single-instance elements carry the shadow
## Colors
The palette is inherited near-verbatim from the pinned MotherDuck brief; only the semantic *assignment* changes to fit an operational tool instead of a marketing site.
The shape/shadow/type system is inherited from the pinned MotherDuck brief; the color *values* are UABC's own institutional palette, carried into that system's roles.
### Primary
- **Sky Crayon** (`#6fc2ff`): the system's only filled-action color. Primary buttons, active nav state, focused/selected chips, "Solicitar"/"Aprobar"/"Filtrar"/"Continuar con Google." Text on it is always Charcoal Ink, never white — matching every other filled control in the system.
- **Verde Institucional** (`#00723F`): the system's primary filled-action color — unchanged from the project's pre-redesign identity. Primary buttons, active nav state, focused/selected chips, "Solicitar"/"Aprobar"/"Filtrar"/"Continuar con Google," the brand mark's chip fill. Dark enough that it always pairs with **white** text/icons, not charcoal — the one fill in the system where that's true.
### Secondary
- **Duck Bill Orange** (`#ff9538`): sparse accent, repurposed from the source's "illustration/marquee" role (this app has neither) into the operational tool's low-emphasis-but-real accent — "Exportar CSV," "Mantenimiento" status tint, the "En préstamo" KPI tone.
- **Dorado UABC** (`#DD971A`): the institutional gold, carried over unchanged. Secondary buttons ("Exportar CSV," "Marcar devuelto"), "Mantenimiento" status tint, the "En préstamo" KPI tone. Light enough to pair with charcoal text (passes AA; white on gold does not).
### Neutral
- **Cream Paper** (`#f4efea`): the page canvas — every screen's base layer.
- **Frost White** (`#ffffff`): card, dialog, sidebar, and nav surfaces — the raised layer on cream.
- **Chalk Gray** (`#f8f8f7`): hover wash on flat (non-shadowed) interactive rows, and the neutral badge fill for "Baja"/inactive states.
- **Charcoal Ink** (`#383838`): the universal line — every border, every default text color, the shadow color itself. Never pure black.
- **Charcoal Ink** (`#383838`): the universal line — every border, most default text, the shadow color itself. Never pure black.
- **Pencil Gray** (`#a1a1a1`): secondary/muted text (labels under headings, metadata, roles) — a real token, not an opacity trick.
### Status colors (extension beyond the source brief)
- **Mint Sketch → Positive** (`#38c1b0`): "disponible," "aprobado," "devuelto en préstamo" status. Pulled out of the source's decorative rainbow-button palette because the operational tool needs a status color distinct from the primary action color; the source brief, built for a marketing site, never had this problem.
- **Mint Sketch → Positive** (`#38c1b0`): "disponible," "aprobado," "devuelto" status. Pulled out of the source brief's decorative rainbow-button palette because the operational tool needs a status color distinct from the primary action color; the source brief, built for a marketing site, never had this problem.
- **Coral Sketch → Danger** (`#f38e84` fill / `#a83224` text): "rechazado," "vencido," destructive actions. Split into two values because the fill color (used for badge backgrounds, borders, button fills) is deliberately light and loud, while body text needs the darker `-text` variant to clear 4.5:1 contrast on white.
### Named Rules
**The Charcoal Text Rule.** No filled or tinted surface ever carries white or colored text — text is always Charcoal Ink, full stop. Color differentiates status and action through background fill and border only. This is a deliberate tightening of the source brief, which used white nav-button text as an exception; LabPréstamos has no exception.
**The Text-on-Fill Rule.** Text and icon color on any filled surface is whichever of Charcoal Ink or white actually clears AA contrast against that specific fill — never a blanket choice. In practice: white on Verde Institucional (dark), Charcoal Ink on everything else (Dorado, the status tints, the danger fill), because that's what the numbers say for each color. Never pick by habit; check the fill.
**The One Shadow Rule.** `-6px 6px 0 0 var(--color-ink)` is the only shadow value in the system. It never blurs, never softens, never gets a second variant for "subtle" elevation — an element either has this exact shadow or it has none.
@@ -151,15 +152,15 @@ One radius for the entire system: **2px** (`--radius-card`, and Tailwind's `roun
### Buttons
- **Shape:** 2px radius, 2px Charcoal Ink border, 44px minimum height, `touch-action: manipulation`.
- **Primary:** Sky Crayon fill, Charcoal Ink text, hard shadow. The default call to action.
- **Secondary:** Frost White fill (not a second brand color — the source brief is explicit that Sky Crayon is the sole filled-action color), Charcoal Ink border and text, hard shadow.
- **Primary:** Verde Institucional fill, **white** text, hard shadow. The default call to action.
- **Secondary:** Dorado UABC fill, Charcoal Ink text, hard shadow. "Exportar CSV," "Marcar devuelto."
- **Ghost:** transparent fill, Charcoal Ink text, a lighter border (35% ink-mix), no shadow. Used for "Cancelar"/"Limpiar."
- **Hover / Focus / Active:** see Elevation → The Press Rule.
### Badges / Status Pills
- **Style:** 2px radius, 1.5px border in the semantic color, uppercase text at 0.03em tracking, always Charcoal Ink.
- **The Tinted-Fill-Bordered Rule.** Background = the semantic color mixed 1825% into white; border = the semantic color at full strength; text = Charcoal Ink always. Count badges (the "N pendientes" chip beside a heading) use a solid semantic fill instead of a tint, bordered in Charcoal Ink — the one place a badge goes solid.
- **Semantic mapping:** disponible / aprobado / devuelto → Positive (mint). mantenimiento / en préstamo (KPI) → Secondary (orange). rechazado / vencido → Danger (coral). baja / neutral counts → Chalk Gray with a graphite border.
- **Style:** 2px radius, 1.5px border in the semantic color, uppercase text at 0.03em tracking.
- **The Tinted-Fill-Bordered Rule.** Background = the semantic color mixed 1825% into white; border = the semantic color at full strength; text = Charcoal Ink (the tinted backgrounds are always light enough for it). Count badges (the "N pendientes" chip beside a heading) use a **solid** semantic fill instead of a tint, bordered in Charcoal Ink — text on those follows the Text-on-Fill Rule (white on a solid Verde Institucional count badge, Charcoal Ink on a solid Positive/mint one).
- **Semantic mapping:** disponible / aprobado / devuelto → Positive (mint). mantenimiento / en préstamo (KPI) → Secondary (Dorado). rechazado / vencido → Danger (coral). baja / neutral counts → Chalk Gray with a graphite border.
### Cards / Containers
- **Corner:** 2px.
@@ -170,18 +171,18 @@ One radius for the entire system: **2px** (`--radius-card`, and Tailwind's `roun
### Inputs / Fields
- **Style:** Frost White background, 2px Charcoal Ink border, 2px radius, 44px minimum height. No shadow — inputs are always flat, matching the source brief's own Text Input spec.
- **Focus:** border color shifts to Sky Crayon; no glow, no ring.
- **Focus:** border color shifts to Verde Institucional; no glow, no ring.
- **Label:** `.label` — uppercase, 0.75rem, 0.05em tracking, Charcoal Ink.
- **Error:** inline `role="alert"` text in Danger-text below the field.
### Navigation
- **Desktop sidebar:** Frost White panel (not a colored panel — a deliberate departure from the prior UABC-green sidebar, matching the source brief's flat white nav bar), 2px Charcoal Ink right border. Active item = solid Sky Crayon chip with a 2px Charcoal border; inactive = transparent with a Chalk Gray hover wash. Uppercase labels throughout. Collapsible, same mechanism as before (localStorage + blocking inline script, no flash).
- **Mobile nav:** a flush, full-width bottom bar (not a floating pill), Frost White, 2px Charcoal top border, no shadow — flat, matching the source brief's "nav buttons are flat" rule. Active icon = a 2px-radius Sky Crayon square with Charcoal border.
- **Brand mark chip:** on colored (Sky Crayon) or panel backgrounds, a 2px-radius chip with a 2px Charcoal border; on the white sidebar/mobile header it's a translucent white wash instead, avoiding a third solid color competing with the mark itself.
- **Desktop sidebar:** Frost White panel (a deliberate departure from the pre-redesign solid-green sidebar, matching the source brief's flat white nav-bar structure — the *color* identity moved to the active-state chip instead of the whole panel), 2px Charcoal Ink right border. Active item = solid Verde Institucional chip with white text and a 2px Charcoal border; inactive = transparent with a Chalk Gray hover wash. Uppercase labels throughout. Collapsible, same mechanism as before (localStorage + blocking inline script, no flash).
- **Mobile nav:** a flush, full-width bottom bar (not a floating pill), Frost White, 2px Charcoal top border, no shadow — flat, matching the source brief's "nav buttons are flat" rule. Active icon = a 2px-radius Verde Institucional square with white icon and Charcoal border.
- **Brand mark chip:** a Verde Institucional fill with a white glyph and a 2px Charcoal border, used identically on the sidebar header, mobile header, login screen, and 404 page. The 403 page uses the same chip shape in the Danger color instead.
### Toasts
- Frost White, 2px Charcoal Ink border, hard shadow, 2px radius (down from the prior system's blurred glass `rounded-2xl` treatment — MotherDuck bans blur and ambient shadow outright).
- A 2px-bordered color chip (36px) leads each toast: Positive for success, Danger for error, Secondary for info — Charcoal Ink stroke icon, not white.
- A 2px-bordered color chip (36px) leads each toast: Positive for success, Danger for error, Secondary (Dorado) for info — Charcoal Ink stroke icon (all three chip colors clear contrast with charcoal).
- Entrance: 260ms translate+fade, same timing as before; still respects `prefers-reduced-motion`; still survives `location.reload()` via the sessionStorage queue.
### Modals
@@ -193,8 +194,8 @@ One radius for the entire system: **2px** (`--radius-card`, and Tailwind's `roun
### Do:
- **Do** use the exact shadow `-6px 6px 0 0 var(--color-ink)` (`--shadow-hard`) for every raised element, and the exact press transform `translate(-6px, 6px)` on its hover/focus state.
- **Do** keep every radius at 2px — buttons, cards, inputs, dialogs, badges, chips. No exceptions, no emphasis tier.
- **Do** keep text Charcoal Ink on every fill, tint, or border color. Never white, never the semantic color itself.
- **Do** reserve Sky Crayon for actionable, filled controls; use Positive (mint), Danger (coral), or Secondary (orange) for status and non-primary accents.
- **Do** check contrast per fill before choosing text color (Text-on-Fill Rule) — don't assume charcoal always works.
- **Do** keep Verde Institucional for actionable, filled controls; use Positive (mint), Danger (coral), or Secondary (Dorado) for status and non-primary accents.
- **Do** keep dense/repeated surfaces (tables, list cards, inputs, nav) flat and bordered; reserve the hard shadow for discrete, singular, actionable surfaces.
- **Do** draw every icon as an SVG in the shared heroicons-outline language (or the `BrandMark` glyph for identity) — never a unicode glyph or emoji standing in for one.
- **Do** apply `font-variant-numeric: tabular-nums` to every numeric quantity, stock figure, or date.
@@ -203,7 +204,7 @@ One radius for the entire system: **2px** (`--radius-card`, and Tailwind's `roun
### Don't:
- **Don't** use blur, glass, or a soft ambient shadow anywhere — every shadow is the one hard-edged offset value, or there is no shadow.
- **Don't** introduce a radius above 2px, or a pill/circle shape, anywhere in the UI.
- **Don't** use a second filled brand color for "secondary" actions — Secondary buttons are white-filled and charcoal-outlined, never a second solid color.
- **Don't** reuse Sky Crayon for status pills — status has its own colors (Positive/Danger/Secondary); Sky Crayon means "you can act on this."
- **Don't** adopt a borrowed style system's literal brand colors — take its shape/shadow/type mechanics, keep the project's own identity colors in them. This system exists because that line was crossed once and had to be corrected.
- **Don't** reuse Verde Institucional for status pills — status has its own colors (Positive/Danger/Secondary); Verde Institucional means "you can act on this."
- **Don't** introduce a second typeface. JetBrains Mono Variable carries every role through weight, size, and case.
- **Don't** stand a unicode glyph in for an icon.
+2 -2
View File
@@ -41,8 +41,8 @@ Herramienta interna de un solo laboratorio (Laboratorio de Sistemas Computaciona
## Brand Commitments
- **2026-08-16: identidad visual reemplazada por decisión explícita del usuario.** La paleta institucional UABC verde/dorada (60/30/10) fue retirada del sistema de diseño; se conserva únicamente como posible insumo para una futura marca oficial si el usuario la reintroduce. El sistema actual sigue un brief externo fijado por el usuario (estilo MotherDuck: "crayon-coded terminal on cream paper", neobrutalista) — ver `DESIGN.md` para los tokens completos y su justificación de por qué cada valor se adaptó a una herramienta operativa en vez de un sitio de marketing.
- Paleta activa: canvas crema `#f4efea`, superficie blanca `#ffffff`, tinta carbón `#383838` (nunca negro puro), acción primaria azul cielo `#6fc2ff`, acento secundario naranja `#ff9538`, positivo/disponible menta `#38c1b0`, peligro coral `#f38e84` (texto `#a83224` para contraste AA).
- **2026-08-16/17: mecánica visual reemplazada (MotherDuck), colores de identidad UABC conservados.** El sistema de diseño adoptó la mecánica neobrutalista de un brief externo fijado por el usuario (estilo MotherDuck: "crayon-coded terminal on cream paper" — tipografía mono, radio 2px, sombra dura única, canvas crema) — ver `DESIGN.md` para los tokens completos. La primera pasada también reemplazó los colores de acción por la paleta literal del brief (azul/naranja); el usuario corrigió esto el 2026-08-17: **la paleta institucional UABC (verde `#00723F` primario, dorado `#DD971A` secundario) es la identidad de color activa**, llevada dentro del lenguaje visual nuevo — no reemplazada por él. Verde institucional es lo bastante oscuro para requerir texto blanco (no carbón) en los rellenos, a diferencia del azul claro del brief original.
- Paleta activa: canvas crema `#f4efea`, superficie blanca `#ffffff`, tinta carbón `#383838` (nunca negro puro), acción primaria verde institucional `#00723F` (texto blanco), acento secundario dorado UABC `#DD971A` (texto carbón), positivo/disponible menta `#38c1b0`, peligro coral `#f38e84` (texto `#a83224` para contraste AA).
- Tipografía: JetBrains Mono Variable (sustituto abierto de Aeonik Mono, la fuente con licencia que nombra el brief original), monoespaciada en todo el sistema, tracking 0.02em.
- Radios de esquina: 2px en todo — sin píldoras, sin círculos, sin `rounded-full`. Sombra dura única `-6px 6px 0 0` en tinta carbón sobre elementos elevados; superficies densas/repetidas quedan planas con borde.
- La identidad visual usa un glifo propio de producto (`BrandMark`, una ficha/etiqueta con perforación — evoca la ficha de préstamo física) como wordmark gráfico y favicon; no es ni pretende ser el escudo/sello oficial de UABC o del laboratorio. **Existe un logo institucional oficial** pendiente de integrar — el usuario lo proporcionará como asset; hasta entonces no inventar ni aproximar ese escudo.
@@ -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<string | null>(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 }) {
<form onSubmit={submit} className="p-6">
<h2 className="text-lg font-semibold">Aprobar solicitud</h2>
<p className="text-sm opacity-70 mt-1">
{prestamo.alumno?.nombre ?? prestamo.alumno?.email} solicita{' '}
<strong>{prestamo.cantidad}</strong> de <strong>{prestamo.material?.nombre ?? '—'}</strong>.
{prestamo.alumno?.nombre ?? prestamo.alumno?.email} solicita, para {prestamo.maestro_responsable}:
</p>
<ul className="text-sm mt-1 list-disc pl-5">
{prestamo.items.map((i, idx) => (
<li key={idx}>
<strong>{i.cantidad}</strong> de <strong>{i.material?.nombre ?? '—'}</strong>
{(i.material?.cantidad_disponible ?? 0) < i.cantidad && (
<span style={{ color: 'var(--color-danger-text)' }}> stock insuficiente ({i.material?.cantidad_disponible ?? 0} disp.)</span>
)}
</li>
))}
</ul>
{stockInsuficiente && (
<div role="alert" className="mt-3 rounded-lg p-2 text-sm" style={{ background: 'color-mix(in oklab, var(--color-danger) 12%, transparent)', color: 'var(--color-danger-text)' }}>
Stock disponible ({prestamo.material?.cantidad_disponible ?? 0}) menor a lo solicitado.
Uno o más materiales no tienen stock suficiente.
</div>
)}
<div className="mt-4">
@@ -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 }) {
<dialog ref={dlg.ref} className="rounded-lg p-0 max-w-md w-[calc(100%-2rem)] bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40">
<form onSubmit={submit} className="p-6">
<h2 className="text-lg font-semibold">Rechazar solicitud</h2>
<p className="text-sm opacity-70 mt-1">
{resumenNombres(prestamo.items)} · {prestamo.alumno?.nombre ?? prestamo.alumno?.email}
</p>
<p className="text-sm opacity-70 mt-1">
Explica al alumno por qué se rechaza. Este texto queda registrado.
</p>
@@ -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<HTMLDialogElement>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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
<form onSubmit={submit} className="p-6">
<h2 className="text-lg font-semibold">Confirmar devolución</h2>
<p className="text-sm opacity-80 mt-2">
¿Confirmas la devolución de <strong>{nombreMaterial}</strong>? El material regresará al inventario disponible.
¿Confirmas la devolución de <strong>{resumenMateriales}</strong>? El material regresará al inventario disponible.
</p>
{error && (
<div role="alert" className="mt-3 rounded-lg p-2 text-sm" style={{ background: 'color-mix(in oklab, var(--color-danger) 12%, transparent)', color: 'var(--color-danger-text)' }}>
@@ -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<Record<string, any>>;
};
@@ -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 }) {
<dl className="mt-4 grid grid-cols-3 gap-y-2 text-sm">
<dt className="opacity-70">Alumno</dt>
<dd className="col-span-2">{data.prestamo.alumno?.nombre ?? data.prestamo.alumno?.email}</dd>
<dt className="opacity-70">Material</dt>
<dd className="col-span-2">{data.prestamo.material?.nombre} <span className="opacity-70">· Inv. {data.prestamo.material?.numero_inventario ?? '—'}</span></dd>
<dt className="opacity-70">Cantidad</dt>
<dd className="col-span-2" style={{ fontVariantNumeric: 'tabular-nums' }}>{data.prestamo.cantidad}</dd>
<dt className="opacity-70">Maestro</dt>
<dd className="col-span-2">{data.prestamo.maestro_responsable}</dd>
<dt className="opacity-70">Materiales</dt>
<dd className="col-span-2">
<ul className="list-disc pl-4">
{data.prestamo.items.map((i, idx) => (
<li key={idx}>
<strong style={{ fontVariantNumeric: 'tabular-nums' }}>{i.cantidad}</strong>× {i.material?.nombre ?? '—'}{' '}
<span className="opacity-70">· Inv. {i.material?.numero_inventario ?? '—'}</span>
{i.descripcion && <div className="text-xs opacity-70">{i.descripcion}</div>}
</li>
))}
</ul>
</dd>
<dt className="opacity-70">Solicitado</dt>
<dd className="col-span-2">{fmt.format(new Date(data.prestamo.fecha_solicitud))}</dd>
{data.prestamo.fecha_aprobacion && (<><dt className="opacity-70">Aprobado</dt><dd className="col-span-2">{fmt.format(new Date(data.prestamo.fecha_aprobacion))}</dd></>)}
@@ -91,7 +106,7 @@ export default function VerDetalles({ prestamoId }: { prestamoId: number }) {
) : (
<ul className="text-sm divide-y" style={{ borderColor: 'color-mix(in oklab, var(--color-ink) 10%, transparent)' }}>
{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 (
<li key={i} className="py-2 flex items-baseline gap-2 flex-wrap">
<span className="font-medium">{e.accion ?? 'cambio'}</span>
+37
View File
@@ -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 (
<button type="button" className="btn btn-secondary w-full" disabled>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M4.5 12.75l6 6 9-13.5" />
</svg>
En tu solicitud
</button>
);
}
return (
<button
type="button"
className="btn btn-primary w-full"
disabled={sinStock}
onClick={() => {
addItem(material);
toast({ kind: 'success', title: 'Agregado a tu solicitud', description: material.nombre });
}}
>
{sinStock ? 'Sin stock' : 'Agregar'}
</button>
);
}
+1 -1
View File
@@ -46,7 +46,7 @@ export default function FiltroCategorias({ categorias }: Props) {
const chipStyle = (v: string): React.CSSProperties =>
v === active
? { background: 'var(--color-primary)', color: 'var(--color-ink)', minHeight: '40px', paddingBlock: '0.375rem' }
? { background: 'var(--color-primary)', color: 'white', minHeight: '40px', paddingBlock: '0.375rem' }
: { minHeight: '40px', paddingBlock: '0.375rem' };
return (
-180
View File
@@ -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<HTMLDialogElement | null>(null);
const firstFieldRef = useRef<HTMLInputElement | null>(null);
const [cantidad, setCantidad] = useState<number>(1);
const [notas, setNotas] = useState<string>('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<>
<button
type="button"
className="btn btn-primary w-full"
onClick={open}
disabled={disabled}
>
{disabled ? 'Sin stock' : 'Solicitar'}
</button>
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(92vw,28rem)]"
>
<form onSubmit={submit} className="p-5 sm:p-6 flex flex-col gap-4">
<div className="flex items-start justify-between gap-3">
<h2 id={titleId} className="text-lg font-semibold">
Solicitar: {material.nombre}
</h2>
<button
type="button"
onClick={close}
aria-label="Cerrar"
className="rounded-lg p-1.5 hover:bg-black/5 transition-colors"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<p className="text-sm opacity-70">
Disponibles: <span style={{ fontVariantNumeric: 'tabular-nums' }}>{material.cantidad_disponible}</span>
</p>
<div>
<label className="label" htmlFor={`cant-${material.id}`}>Cantidad</label>
<input
ref={firstFieldRef}
id={`cant-${material.id}`}
className="input"
type="number"
min={1}
max={material.cantidad_disponible}
value={cantidad}
required
inputMode="numeric"
onChange={(e) => setCantidad(Math.max(1, Math.min(material.cantidad_disponible, Number(e.target.value) || 1)))}
style={{ fontVariantNumeric: 'tabular-nums' }}
/>
</div>
<div>
<label className="label" htmlFor={`notas-${material.id}`}>Motivo del préstamo</label>
<textarea
id={`notas-${material.id}`}
className="input"
rows={3}
maxLength={500}
value={notas}
required
onChange={(e) => setNotas(e.target.value)}
placeholder="¿Para qué clase o proyecto lo necesitas?"
style={{ minHeight: '96px' }}
aria-describedby={`notas-hint-${material.id}`}
/>
<p id={`notas-hint-${material.id}`} className="text-xs opacity-60 mt-1">
Ejemplo: Clase de Electrónica Analógica · Prof. Gómez · práctica 3.
</p>
</div>
{error && (
<p role="alert" className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
{error} · Ajusta la cantidad o intenta de nuevo.
</p>
)}
{ok && (
<p role="status" className="text-sm" style={{ color: 'var(--color-positive)' }}>
Solicitud enviada. Actualizando
</p>
)}
<div className="flex gap-2 justify-end pt-2">
<button type="button" className="btn btn-ghost" onClick={close} disabled={loading}>
Cancelar
</button>
<button type="submit" className="btn btn-primary" disabled={loading || ok}>
{loading ? 'Enviando…' : 'Enviar solicitud'}
</button>
</div>
</form>
</dialog>
</>
);
}
+358
View File
@@ -0,0 +1,358 @@
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { toast, toastAfterReload } from '@/lib/toast';
import AgregarMaterial from './AgregarMaterial';
type Material = {
id: number;
nombre: string;
descripcion: string | null;
cantidad_disponible: number;
cantidad_total: number;
numero_inventario: string | null;
categoria: { id: number; nombre: string } | null;
};
type CartItem = {
material_id: number;
nombre: string;
cantidad: number;
cantidad_disponible: number;
descripcion: string;
};
type CartContextValue = {
items: CartItem[];
addItem: (material: { id: number; nombre: string; cantidad_disponible: number }) => void;
updateCantidad: (material_id: number, n: number) => void;
updateDescripcion: (material_id: number, texto: string) => void;
removeItem: (material_id: number) => void;
has: (material_id: number) => boolean;
};
const CartContext = createContext<CartContextValue | null>(null);
export function useCart() {
const ctx = useContext(CartContext);
if (!ctx) throw new Error('useCart debe usarse dentro de CartProvider');
return ctx;
}
const clamp = (n: number, max: number) => Math.max(1, Math.min(max, n));
export default function CartProvider({ materiales }: { materiales: Material[] }) {
const [cart, setCart] = useState<Map<number, CartItem>>(new Map());
const dialogRef = useRef<HTMLDialogElement | null>(null);
const firstFieldRef = useRef<HTMLInputElement | null>(null);
const [maestroResponsable, setMaestroResponsable] = useState('');
const [notas, setNotas] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [ok, setOk] = useState(false);
const items = useMemo(() => Array.from(cart.values()), [cart]);
const addItem: CartContextValue['addItem'] = (material) => {
setCart((prev) => {
const next = new Map(prev);
const existing = next.get(material.id);
if (existing) {
next.set(material.id, { ...existing, cantidad: clamp(existing.cantidad + 1, material.cantidad_disponible) });
} else {
next.set(material.id, {
material_id: material.id,
nombre: material.nombre,
cantidad: 1,
cantidad_disponible: material.cantidad_disponible,
descripcion: '',
});
}
return next;
});
};
const updateCantidad: CartContextValue['updateCantidad'] = (material_id, n) => {
setCart((prev) => {
const existing = prev.get(material_id);
if (!existing) return prev;
const next = new Map(prev);
next.set(material_id, { ...existing, cantidad: clamp(n, existing.cantidad_disponible) });
return next;
});
};
const updateDescripcion: CartContextValue['updateDescripcion'] = (material_id, texto) => {
setCart((prev) => {
const existing = prev.get(material_id);
if (!existing) return prev;
const next = new Map(prev);
next.set(material_id, { ...existing, descripcion: texto });
return next;
});
};
const removeItem: CartContextValue['removeItem'] = (material_id) => {
setCart((prev) => {
const next = new Map(prev);
next.delete(material_id);
return next;
});
};
const has: CartContextValue['has'] = (material_id) => cart.has(material_id);
const value: CartContextValue = { items, addItem, updateCantidad, updateDescripcion, removeItem, has };
const titleId = 'checkout-title';
const openCheckout = () => {
setError(null);
setOk(false);
dialogRef.current?.showModal();
queueMicrotask(() => firstFieldRef.current?.focus());
};
const closeCheckout = () => {
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) closeCheckout();
};
dlg.addEventListener('click', onClick);
return () => dlg.removeEventListener('click', onClick);
});
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (loading || items.length === 0) return;
setLoading(true);
setError(null);
try {
const res = await fetch('/api/solicitudes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
maestro_responsable: maestroResponsable,
notas: notas || undefined,
items: items.map((i) => ({
material_id: i.material_id,
cantidad: i.cantidad,
descripcion: i.descripcion || undefined,
})),
}),
});
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: `${items.length} material${items.length === 1 ? '' : 'es'} · pendiente de aprobación`,
});
setTimeout(() => {
dialogRef.current?.close();
setCart(new Map());
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);
}
};
return (
<CartContext.Provider value={value}>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{materiales.map((m) => (
<article key={m.id} className="card flex flex-col gap-3" data-cat={m.categoria?.id ?? 'sin'}>
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold leading-snug">{m.nombre}</h3>
{m.categoria && (
<span
className="text-xs px-2 py-0.5 rounded-[2px] whitespace-nowrap uppercase tracking-wide"
style={{
background: 'color-mix(in oklab, var(--color-primary) 25%, white)',
color: 'var(--color-ink)',
border: '1.5px solid var(--color-primary)',
}}
>
{m.categoria.nombre}
</span>
)}
</div>
{m.descripcion && <p className="text-sm opacity-75 line-clamp-2">{m.descripcion}</p>}
<dl className="text-xs opacity-70 grid grid-cols-2 gap-1">
<dt className="opacity-60">Inventario</dt>
<dd className="text-right font-mono">{m.numero_inventario ?? '—'}</dd>
<dt className="opacity-60">Disponibles</dt>
<dd className="text-right" style={{ fontVariantNumeric: 'tabular-nums' }}>
{m.cantidad_disponible} / {m.cantidad_total}
</dd>
</dl>
<div className="mt-auto">
<AgregarMaterial material={{ id: m.id, nombre: m.nombre, cantidad_disponible: m.cantidad_disponible }} />
</div>
</article>
))}
</div>
{items.length > 0 && (
<button
type="button"
className="btn btn-primary fixed bottom-24 right-4 md:bottom-6 md:right-6 z-40"
style={{ boxShadow: 'var(--shadow-hard)' }}
onClick={openCheckout}
>
Ver solicitud ({items.length})
</button>
)}
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(92vw,32rem)]"
>
<form onSubmit={submit} className="p-5 sm:p-6 flex flex-col gap-4">
<div className="flex items-start justify-between gap-3">
<h2 id={titleId} className="text-lg font-semibold">
Tu solicitud
</h2>
<button
type="button"
onClick={closeCheckout}
aria-label="Cerrar"
className="rounded-lg p-1.5 hover:bg-black/5 transition-colors"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{items.length === 0 ? (
<p className="text-sm opacity-70">Tu solicitud está vacía. Cierra esta ventana y agrega material del catálogo.</p>
) : (
<ul className="flex flex-col gap-3 max-h-64 overflow-y-auto -mx-1 px-1">
{items.map((item) => (
<li
key={item.material_id}
className="flex items-start gap-2 pb-3"
style={{ borderBottom: '1.5px solid color-mix(in oklab, var(--color-ink) 15%, transparent)' }}
>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{item.nombre}</p>
<div className="flex items-center gap-2 mt-1.5">
<label className="sr-only" htmlFor={`cant-cart-${item.material_id}`}>
Cantidad de {item.nombre}
</label>
<input
id={`cant-cart-${item.material_id}`}
className="input"
type="number"
min={1}
max={item.cantidad_disponible}
value={item.cantidad}
inputMode="numeric"
onChange={(e) => updateCantidad(item.material_id, Number(e.target.value) || 1)}
style={{ width: '4.5rem', minHeight: '36px', paddingBlock: '0.25rem', fontVariantNumeric: 'tabular-nums' }}
/>
<span className="text-xs opacity-60">de {item.cantidad_disponible}</span>
</div>
<label className="sr-only" htmlFor={`desc-cart-${item.material_id}`}>
Nota para {item.nombre}
</label>
<input
id={`desc-cart-${item.material_id}`}
className="input mt-2"
type="text"
maxLength={200}
value={item.descripcion}
onChange={(e) => updateDescripcion(item.material_id, e.target.value)}
placeholder="Nota opcional: color, con webcam…"
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
/>
</div>
<button
type="button"
onClick={() => removeItem(item.material_id)}
aria-label={`Quitar ${item.nombre}`}
className="rounded-lg p-1.5 hover:bg-black/5 transition-colors shrink-0"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</li>
))}
</ul>
)}
<div>
<label className="label" htmlFor="maestro-responsable">Maestro responsable</label>
<input
ref={firstFieldRef}
id="maestro-responsable"
className="input"
type="text"
required
maxLength={200}
value={maestroResponsable}
onChange={(e) => setMaestroResponsable(e.target.value)}
/>
</div>
<div>
<label className="label" htmlFor="notas-cart">Motivo del préstamo</label>
<textarea
id="notas-cart"
className="input"
rows={3}
maxLength={500}
value={notas}
onChange={(e) => setNotas(e.target.value)}
placeholder="¿Para qué clase o proyecto lo necesitas?"
style={{ minHeight: '96px' }}
aria-describedby="notas-cart-hint"
/>
<p id="notas-cart-hint" className="text-xs opacity-60 mt-1">
Ejemplo: Clase de Electrónica Analógica · Prof. Gómez · práctica 3. (Opcional)
</p>
</div>
{error && (
<p role="alert" className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
{error} · Ajusta la solicitud e intenta de nuevo.
</p>
)}
{ok && (
<p role="status" className="text-sm" style={{ color: 'var(--color-positive)' }}>
Solicitud enviada. Actualizando
</p>
)}
<div className="flex gap-2 justify-end pt-2">
<button type="button" className="btn btn-ghost" onClick={closeCheckout} disabled={loading}>
Cancelar
</button>
<button type="submit" className="btn btn-primary" disabled={loading || ok || items.length === 0}>
{loading ? 'Enviando…' : 'Enviar solicitud'}
</button>
</div>
</form>
</dialog>
</CartContext.Provider>
);
}
+3 -3
View File
@@ -64,7 +64,7 @@ const activeCheck = (item: NavItem) => {
<aside id="app-sidebar" class="hidden md:flex md:fixed md:top-0 md:left-0 md:w-64 md:h-screen bg-white text-[color:var(--color-ink)] md:flex-col md:z-40 sidebar-panel transition-transform duration-200 ease-out" style="border-right: 2px solid var(--color-ink);">
<a href="/" class="p-6 flex items-center gap-3 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--color-ink)]" aria-label="LabPréstamos — Inicio" style="border-bottom: 2px solid var(--color-ink);">
<div class="w-9 h-9 rounded-[2px] bg-[color:var(--color-primary)] grid place-items-center p-2" style="border: 2px solid var(--color-ink);" aria-hidden="true">
<BrandMark class="w-full h-full" />
<BrandMark class="w-full h-full text-white" />
</div>
<div>
<div class="font-semibold leading-tight uppercase tracking-wide text-sm">LabPréstamos</div>
@@ -77,7 +77,7 @@ const activeCheck = (item: NavItem) => {
href={item.href}
class:list={[
'flex items-center gap-3 px-3 py-2.5 rounded-[2px] text-sm font-medium uppercase tracking-wide whitespace-nowrap transition-colors',
activeCheck(item) ? 'bg-[color:var(--color-primary)]' : 'hover:bg-[color:var(--color-chalk)]'
activeCheck(item) ? 'bg-[color:var(--color-primary)] text-white' : 'hover:bg-[color:var(--color-chalk)]'
]}
style={activeCheck(item) ? 'border: 2px solid var(--color-ink);' : 'border: 2px solid transparent;'}
aria-current={activeCheck(item) ? 'page' : undefined}
@@ -154,7 +154,7 @@ const activeCheck = (item: NavItem) => {
<span
class:list={[
'grid place-items-center rounded-[2px] w-11 h-11 transition-[background-color,transform] duration-100 motion-safe:active:scale-95',
active ? 'bg-[color:var(--color-primary)]' : ''
active ? 'bg-[color:var(--color-primary)] text-white' : ''
]}
style={active ? 'border: 2px solid var(--color-ink);' : ''}
>
+1 -1
View File
@@ -8,7 +8,7 @@ Astro.response.status = 404;
<main id="main" class="min-h-screen grid place-items-center p-4">
<div class="w-full max-w-md text-center">
<div class="inline-grid place-items-center w-16 h-16 rounded-[2px] bg-[color:var(--color-primary)] mb-4 p-4" style="border: 2px solid var(--color-ink); box-shadow: var(--shadow-hard);">
<BrandMark class="w-full h-full" />
<BrandMark class="w-full h-full text-white" />
</div>
<h1 class="text-2xl font-semibold uppercase tracking-wide">Página no encontrada</h1>
<p class="text-sm mt-2" style="color: var(--color-pencil);">La dirección no existe o cambió de lugar.</p>
+12 -8
View File
@@ -5,13 +5,13 @@ const supabase = Astro.locals.supabase;
const today = new Date().toISOString().split('T')[0];
const [pend, activos, vencidos, agotados, ultimas] = await Promise.all([
supabase.from('prestamos').select('*', { count: 'exact', head: true }).eq('estado', 'pendiente'),
supabase.from('prestamos').select('*', { count: 'exact', head: true }).in('estado', ['aprobado', 'activo']),
supabase.from('prestamos').select('*', { count: 'exact', head: true }).in('estado', ['aprobado', 'activo']).lt('fecha_devolucion_estimada', today),
supabase.from('solicitudes').select('*', { count: 'exact', head: true }).eq('estado', 'pendiente'),
supabase.from('solicitudes').select('*', { count: 'exact', head: true }).in('estado', ['aprobado', 'activo']),
supabase.from('solicitudes').select('*', { count: 'exact', head: true }).in('estado', ['aprobado', 'activo']).lt('fecha_devolucion_estimada', today),
supabase.from('materiales').select('*', { count: 'exact', head: true }).eq('cantidad_disponible', 0),
supabase
.from('prestamos')
.select('id, cantidad, fecha_solicitud, estado, alumno:profiles!alumno_id(nombre, email), material:materiales!material_id(nombre)')
.from('solicitudes')
.select('id, fecha_solicitud, estado, alumno:profiles!alumno_id(nombre, email), items:solicitud_items(cantidad, material:materiales(nombre))')
.order('fecha_solicitud', { ascending: false })
.limit(5),
]);
@@ -67,7 +67,6 @@ const estadoLabel: Record<string, string> = {
<tr>
<th class="p-3 font-medium">Alumno</th>
<th class="p-3 font-medium">Material</th>
<th class="p-3 font-medium">Cant.</th>
<th class="p-3 font-medium">Estado</th>
<th class="p-3 font-medium">Solicitado</th>
</tr>
@@ -76,8 +75,13 @@ const estadoLabel: Record<string, string> = {
{ultimas.data.map((r: any) => (
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);">
<td class="p-3">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</td>
<td class="p-3">{r.material?.nombre ?? '—'}</td>
<td class="p-3" style="font-variant-numeric: tabular-nums;">{r.cantidad}</td>
<td class="p-3">
<div class="flex flex-col gap-0.5">
{(r.items ?? []).map((i: any) => (
<div>{i.cantidad}× {i.material?.nombre ?? '—'}</div>
))}
</div>
</td>
<td class="p-3">{estadoLabel[r.estado] ?? r.estado}</td>
<td class="p-3 opacity-80">{fmtFecha.format(new Date(r.fecha_solicitud))}</td>
</tr>
+3 -3
View File
@@ -30,12 +30,12 @@ const [
{ count: nEnPrestamo },
{ count: nVencidos },
] = await Promise.all([
supabase.from('prestamos').select('id', countHead).gte('fecha_solicitud', startISO).lt('fecha_solicitud', endISO),
supabase.from('solicitudes').select('id', countHead).gte('fecha_solicitud', startISO).lt('fecha_solicitud', endISO),
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'aprobado').gte('at', startISO).lt('at', endISO),
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'rechazado').gte('at', startISO).lt('at', endISO),
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'devuelto').gte('at', startISO).lt('at', endISO),
supabase.from('prestamos').select('id', countHead).in('estado', ['aprobado', 'activo']),
supabase.from('prestamos').select('id', countHead).in('estado', ['aprobado', 'activo']).lt('fecha_devolucion_estimada', dia.isoDate),
supabase.from('solicitudes').select('id', countHead).in('estado', ['aprobado', 'activo']),
supabase.from('solicitudes').select('id', countHead).in('estado', ['aprobado', 'activo']).lt('fecha_devolucion_estimada', dia.isoDate),
]);
const kpis = [
+45 -23
View File
@@ -63,12 +63,16 @@ let total = 0;
let hitLimit = false;
if (vista === 'historial') {
const itemsSelect = materialId
? 'items:solicitud_items!inner(cantidad, descripcion, material:materiales(nombre, numero_inventario))'
: 'items:solicitud_items(cantidad, descripcion, material:materiales(nombre, numero_inventario))';
let q = supabase
.from('prestamos')
.from('solicitudes')
.select(`
id, cantidad, fecha_solicitud, fecha_aprobacion, fecha_devolucion_estimada, fecha_devolucion_real, estado,
id, maestro_responsable, fecha_solicitud, fecha_aprobacion, fecha_devolucion_estimada, fecha_devolucion_real, estado,
alumno:profiles!alumno_id(nombre, email, matricula),
material:materiales!material_id(nombre, numero_inventario)
${itemsSelect}
`)
.order('fecha_solicitud', { ascending: false })
// ponytail: 500, paginar cuando reportes rutinarios pasen de eso
@@ -78,7 +82,7 @@ if (vista === 'historial') {
// ponytail: bound de día en UTC; cambiar a bounds tz-aware si el reporte cruza medianoche PST/PDT
if (hasta) q = q.lte('fecha_solicitud', `${hasta}T23:59:59.999Z`);
if (estado && estado !== 'all') q = q.eq('estado', estado);
if (materialId) q = q.eq('material_id', materialId);
if (materialId) q = q.eq('items.material_id', materialId);
if (alumnoId) q = q.eq('alumno_id', alumnoId);
const { data, error } = await q;
@@ -92,11 +96,11 @@ if (vista === 'historial') {
let vencidos: any[] = [];
if (vista === 'vencidos') {
const { data, error } = await supabase
.from('prestamos')
.from('solicitudes')
.select(`
id, cantidad, fecha_devolucion_estimada,
id, fecha_devolucion_estimada,
alumno:profiles!alumno_id(nombre, email, matricula),
material:materiales!material_id(nombre, numero_inventario)
items:solicitud_items(cantidad, material:materiales(nombre, numero_inventario))
`)
.in('estado', ['aprobado', 'activo'])
.lt('fecha_devolucion_estimada', today)
@@ -228,8 +232,8 @@ const exportFilename = `reporte-${today}.csv`;
<tr>
<th class="p-3 font-medium">Fecha solicitud</th>
<th class="p-3 font-medium">Alumno</th>
<th class="p-3 font-medium">Maestro responsable</th>
<th class="p-3 font-medium">Material</th>
<th class="p-3 font-medium">Cant.</th>
<th class="p-3 font-medium">Estado</th>
<th class="p-3 font-medium">Fecha devolución</th>
</tr>
@@ -242,13 +246,17 @@ const exportFilename = `reporte-${today}.csv`;
<div class="font-medium">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</div>
{r.alumno?.matricula && <div class="text-xs opacity-70">{r.alumno.matricula}</div>}
</td>
<td class="p-3 opacity-90">{r.maestro_responsable ?? '—'}</td>
<td class="p-3">
<div>{r.material?.nombre ?? '—'}</div>
{r.material?.numero_inventario && (
<div class="text-xs opacity-70">{r.material.numero_inventario}</div>
)}
{(r.items ?? []).map((i: any) => (
<div>
<span style="font-variant-numeric: tabular-nums;">{i.cantidad}×</span> {i.material?.nombre ?? '—'}
{i.material?.numero_inventario && (
<span class="text-xs opacity-70"> ({i.material.numero_inventario})</span>
)}
</div>
))}
</td>
<td class="p-3" style="font-variant-numeric: tabular-nums;">{r.cantidad}</td>
<td class="p-3">{estadoLabel[r.estado] ?? r.estado}</td>
<td class="p-3 opacity-90 whitespace-nowrap">
{fmt(r.fecha_devolucion_real ?? r.fecha_devolucion_estimada)}
@@ -278,13 +286,19 @@ const exportFilename = `reporte-${today}.csv`;
<span class="text-xs opacity-70">{fmt(r.fecha_solicitud)}</span>
<span class="text-xs font-medium">{estadoLabel[r.estado] ?? r.estado}</span>
</div>
<div class="mt-1 font-medium">{r.material?.nombre ?? '—'}</div>
<div class="mt-1 space-y-0.5">
{(r.items ?? []).map((i: any) => (
<div class="font-medium">
<span style="font-variant-numeric: tabular-nums;">{i.cantidad}×</span> {i.material?.nombre ?? '—'}
</div>
))}
</div>
<div class="text-sm opacity-80">
{r.alumno?.nombre ?? r.alumno?.email ?? '—'}
{r.alumno?.matricula && <span class="opacity-60"> · {r.alumno.matricula}</span>}
</div>
<div class="mt-2 flex items-center justify-between text-sm">
<span style="font-variant-numeric: tabular-nums;">Cant. {r.cantidad}</span>
<div class="text-xs opacity-70">Maestro: {r.maestro_responsable ?? '—'}</div>
<div class="mt-2 flex items-center justify-end text-sm">
<span class="opacity-80">
Dev.: {fmt(r.fecha_devolucion_real ?? r.fecha_devolucion_estimada)}
</span>
@@ -313,7 +327,6 @@ const exportFilename = `reporte-${today}.csv`;
<tr>
<th class="p-3 font-medium">Alumno</th>
<th class="p-3 font-medium">Material</th>
<th class="p-3 font-medium">Cant.</th>
<th class="p-3 font-medium">Debía devolver el</th>
<th class="p-3 font-medium">Días de atraso</th>
</tr>
@@ -331,12 +344,15 @@ const exportFilename = `reporte-${today}.csv`;
{r.alumno?.matricula && <div class="text-xs opacity-70">{r.alumno.matricula}</div>}
</td>
<td class="p-3">
<div>{r.material?.nombre ?? '—'}</div>
{r.material?.numero_inventario && (
<div class="text-xs opacity-70">{r.material.numero_inventario}</div>
)}
{(r.items ?? []).map((i: any) => (
<div>
<span style="font-variant-numeric: tabular-nums;">{i.cantidad}×</span> {i.material?.nombre ?? '—'}
{i.material?.numero_inventario && (
<span class="text-xs opacity-70"> ({i.material.numero_inventario})</span>
)}
</div>
))}
</td>
<td class="p-3" style="font-variant-numeric: tabular-nums;">{r.cantidad}</td>
<td class="p-3 whitespace-nowrap">{fmt(r.fecha_devolucion_estimada)}</td>
<td class="p-3 font-medium" style={`font-variant-numeric: tabular-nums; color: var(--color-danger-text);`}>
{dias} {dias === 1 ? 'día' : 'días'}
@@ -354,7 +370,13 @@ const exportFilename = `reporte-${today}.csv`;
const dias = diasAtraso(r.fecha_devolucion_estimada);
return (
<li class="card" style="background: color-mix(in oklab, var(--color-danger) 8%, transparent);">
<div class="font-medium">{r.material?.nombre ?? '—'}</div>
<div class="space-y-0.5">
{(r.items ?? []).map((i: any) => (
<div class="font-medium">
<span style="font-variant-numeric: tabular-nums;">{i.cantidad}×</span> {i.material?.nombre ?? '—'}
</div>
))}
</div>
<div class="text-sm opacity-80">
{r.alumno?.nombre ?? r.alumno?.email ?? '—'}
{r.alumno?.matricula && <span class="opacity-60"> · {r.alumno.matricula}</span>}
+28 -11
View File
@@ -5,9 +5,9 @@ import VerDetalles from '@/components/admin/solicitudes/VerDetalles.tsx';
const supabase = Astro.locals.supabase;
const { data, error } = await supabase
.from('prestamos')
.from('solicitudes')
.select(
'id, cantidad, fecha_solicitud, fecha_aprobacion, fecha_devolucion_estimada, notas, alumno:profiles!alumno_id(id, nombre, email, matricula), material:materiales!material_id(id, nombre, numero_inventario, cantidad_disponible)'
'id, fecha_solicitud, fecha_aprobacion, fecha_devolucion_estimada, notas, maestro_responsable, alumno:profiles!alumno_id(id, nombre, email, matricula), items:solicitud_items(cantidad, descripcion, material:materiales(id, nombre, numero_inventario, cantidad_disponible))'
)
.in('estado', ['aprobado', 'activo'])
.order('fecha_devolucion_estimada', { ascending: true });
@@ -16,6 +16,11 @@ const filas = data ?? [];
const fmtFecha = new Intl.DateTimeFormat('es-MX', { dateStyle: 'medium' });
const today = new Date().toISOString().split('T')[0];
const isVencido = (f?: string | null) => !!f && f < today;
const resumenMateriales = (items: any[]) => {
const nombres = (items ?? []).map((i) => i.material?.nombre ?? '—');
if (nombres.length <= 2) return nombres.join(', ') || '—';
return `${nombres.slice(0, 2).join(', ')} +${nombres.length - 2} más`;
};
const path = Astro.url.pathname;
const tabs = [
@@ -73,7 +78,6 @@ const tabs = [
<tr>
<th class="p-3 font-medium">Alumno</th>
<th class="p-3 font-medium">Material</th>
<th class="p-3 font-medium">Cant.</th>
<th class="p-3 font-medium">Aprobado</th>
<th class="p-3 font-medium">Devolver antes de</th>
<th class="p-3 font-medium text-right">Acciones</th>
@@ -90,17 +94,23 @@ const tabs = [
<td class="p-3">
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-xs opacity-70 mt-1">Maestro: {r.maestro_responsable}</div>
</td>
<td class="p-3">
<div>{r.material?.nombre ?? '—'}</div>
<div class="text-xs opacity-70">Inv. {r.material?.numero_inventario ?? '—'}</div>
<div class="flex flex-col gap-1.5">
{(r.items ?? []).map((i: any) => (
<div>
<div>{i.cantidad}× {i.material?.nombre ?? '—'}</div>
<div class="text-xs opacity-70">Inv. {i.material?.numero_inventario ?? '—'}</div>
</div>
))}
</div>
{r.notas && (
<div class="text-xs mt-1.5 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);">
<span class="opacity-60">Motivo:</span> {r.notas}
</div>
)}
</td>
<td class="p-3" style="font-variant-numeric: tabular-nums;">{r.cantidad}</td>
<td class="p-3 opacity-80">{r.fecha_aprobacion ? fmtFecha.format(new Date(r.fecha_aprobacion)) : '—'}</td>
<td class="p-3">
<span style="font-variant-numeric: tabular-nums;">
@@ -118,7 +128,7 @@ const tabs = [
<td class="p-3">
<div class="flex justify-end gap-2 flex-wrap">
<VerDetalles client:load prestamoId={r.id} />
<MarcarDevuelto client:load prestamoId={r.id} nombreMaterial={r.material?.nombre ?? '—'} />
<MarcarDevuelto client:load prestamoId={r.id} resumenMateriales={resumenMateriales(r.items)} />
</div>
</td>
</tr>
@@ -139,9 +149,16 @@ const tabs = [
<span class="inline-flex rounded-[2px] px-2 py-0.5 text-xs font-semibold" style="background: var(--color-danger); color: var(--color-ink); border: 1.5px solid var(--color-ink);">Vencido</span>
)}
</div>
<div class="text-xs opacity-70 mb-2">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-sm">{r.material?.nombre ?? '—'} <span class="opacity-70">· Inv. {r.material?.numero_inventario ?? '—'}</span></div>
<div class="text-sm mt-1">Cantidad: <strong style="font-variant-numeric: tabular-nums;">{r.cantidad}</strong></div>
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-xs opacity-70 mb-2">Maestro: {r.maestro_responsable}</div>
<div class="flex flex-col gap-1.5">
{(r.items ?? []).map((i: any) => (
<div class="text-sm">
<strong style="font-variant-numeric: tabular-nums;">{i.cantidad}×</strong> {i.material?.nombre ?? '—'}
<span class="opacity-70"> · Inv. {i.material?.numero_inventario ?? '—'}</span>
</div>
))}
</div>
<div class="text-sm mt-1 opacity-80">Devolver antes de <span style="font-variant-numeric: tabular-nums;">{r.fecha_devolucion_estimada ? fmtFecha.format(new Date(r.fecha_devolucion_estimada + 'T00:00:00')) : '—'}</span></div>
{r.notas && (
<p class="text-sm mt-2 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);">
@@ -150,7 +167,7 @@ const tabs = [
)}
<div class="mt-3 flex gap-2 flex-wrap">
<VerDetalles client:load prestamoId={r.id} />
<MarcarDevuelto client:load prestamoId={r.id} nombreMaterial={r.material?.nombre ?? '—'} />
<MarcarDevuelto client:load prestamoId={r.id} resumenMateriales={resumenMateriales(r.items)} />
</div>
</li>
);
+17 -7
View File
@@ -11,9 +11,9 @@ const estados = estadoFiltro ? [estadoFiltro] : Array.from(ALL);
// ponytail: limit 200, paginar cuando pase de 500 filas
const { data, error } = await supabase
.from('prestamos')
.from('solicitudes')
.select(
'id, cantidad, fecha_solicitud, fecha_devolucion_real, estado, notas, alumno:profiles!alumno_id(id, nombre, email, matricula), material:materiales!material_id(id, nombre, numero_inventario)'
'id, fecha_solicitud, fecha_devolucion_real, estado, notas, maestro_responsable, alumno:profiles!alumno_id(id, nombre, email, matricula), items:solicitud_items(cantidad, descripcion, material:materiales(id, nombre, numero_inventario))'
)
.in('estado', estados)
.order('fecha_solicitud', { ascending: false })
@@ -104,7 +104,6 @@ const estadoLabel: Record<string, string> = {
<tr>
<th class="p-3 font-medium">Alumno</th>
<th class="p-3 font-medium">Material</th>
<th class="p-3 font-medium">Cant.</th>
<th class="p-3 font-medium">Estado</th>
<th class="p-3 font-medium">Solicitado</th>
<th class="p-3 font-medium">Devuelto</th>
@@ -116,12 +115,18 @@ const estadoLabel: Record<string, string> = {
<td class="p-3">
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-xs opacity-70 mt-1">Maestro: {r.maestro_responsable}</div>
</td>
<td class="p-3">
<div>{r.material?.nombre ?? '—'}</div>
<div class="text-xs opacity-70">Inv. {r.material?.numero_inventario ?? '—'}</div>
<div class="flex flex-col gap-1.5">
{(r.items ?? []).map((i: any) => (
<div>
<div>{i.cantidad}× {i.material?.nombre ?? '—'}</div>
<div class="text-xs opacity-70">Inv. {i.material?.numero_inventario ?? '—'}</div>
</div>
))}
</div>
</td>
<td class="p-3" style="font-variant-numeric: tabular-nums;">{r.cantidad}</td>
<td class="p-3">{estadoLabel[r.estado] ?? r.estado}</td>
<td class="p-3 opacity-80">{fmtFecha.format(new Date(r.fecha_solicitud))}</td>
<td class="p-3 opacity-80">{r.fecha_devolucion_real ? fmtFecha.format(new Date(r.fecha_devolucion_real)) : '—'}</td>
@@ -139,7 +144,12 @@ const estadoLabel: Record<string, string> = {
<span class="text-xs rounded-[2px] px-2 py-0.5" style="background: color-mix(in oklab, var(--color-ink) 10%, transparent);">{estadoLabel[r.estado] ?? r.estado}</span>
</div>
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-sm mt-2">{r.material?.nombre ?? '—'} · Cantidad <strong style="font-variant-numeric: tabular-nums;">{r.cantidad}</strong></div>
<div class="text-xs opacity-70">Maestro: {r.maestro_responsable}</div>
<div class="flex flex-col gap-1 mt-2">
{(r.items ?? []).map((i: any) => (
<div class="text-sm">{i.cantidad}× {i.material?.nombre ?? '—'}</div>
))}
</div>
<div class="text-xs opacity-70 mt-2">Solicitado {fmtFecha.format(new Date(r.fecha_solicitud))}</div>
{r.fecha_devolucion_real && <div class="text-xs opacity-70">Devuelto {fmtFecha.format(new Date(r.fecha_devolucion_real))}</div>}
</li>
+23 -11
View File
@@ -4,9 +4,9 @@ import AccionesSolicitud from '@/components/admin/solicitudes/AccionesSolicitud.
const supabase = Astro.locals.supabase;
const { data, error } = await supabase
.from('prestamos')
.from('solicitudes')
.select(
'id, cantidad, fecha_solicitud, notas, alumno:profiles!alumno_id(id, nombre, email, matricula), material:materiales!material_id(id, nombre, numero_inventario, cantidad_disponible)'
'id, fecha_solicitud, notas, maestro_responsable, alumno:profiles!alumno_id(id, nombre, email, matricula), items:solicitud_items(cantidad, descripcion, material:materiales(id, nombre, numero_inventario, cantidad_disponible))'
)
.eq('estado', 'pendiente')
.order('fecha_solicitud', { ascending: true });
@@ -27,7 +27,7 @@ const tabs = [
<h1 class="text-2xl md:text-3xl font-semibold">Solicitudes pendientes</h1>
<span
class="inline-flex items-center rounded-[2px] px-2.5 py-0.5 text-sm font-semibold"
style="background: var(--color-primary); color: var(--color-ink); border: 1.5px solid var(--color-ink); font-variant-numeric: tabular-nums;"
style="background: var(--color-primary); color: white; border: 1.5px solid var(--color-ink); font-variant-numeric: tabular-nums;"
aria-label={`${filas.length} pendientes`}
>
{filas.length}
@@ -74,7 +74,6 @@ const tabs = [
<tr>
<th class="p-3 font-medium">Alumno</th>
<th class="p-3 font-medium">Material</th>
<th class="p-3 font-medium">Cantidad</th>
<th class="p-3 font-medium">Solicitado</th>
<th class="p-3 font-medium text-right">Acciones</th>
</tr>
@@ -85,11 +84,18 @@ const tabs = [
<td class="p-3">
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-xs opacity-70 mt-1">Maestro: {r.maestro_responsable}</div>
</td>
<td class="p-3">
<div>{r.material?.nombre ?? '—'}</div>
<div class="text-xs opacity-70">
Inv. {r.material?.numero_inventario ?? '—'} · disp. <span style="font-variant-numeric: tabular-nums;">{r.material?.cantidad_disponible ?? 0}</span>
<div class="flex flex-col gap-1.5">
{(r.items ?? []).map((i: any) => (
<div>
<div>{i.cantidad}× {i.material?.nombre ?? '—'}</div>
<div class="text-xs opacity-70">
Inv. {i.material?.numero_inventario ?? '—'} · disp. <span style="font-variant-numeric: tabular-nums;">{i.material?.cantidad_disponible ?? 0}</span>
</div>
</div>
))}
</div>
{r.notas && (
<div class="text-xs mt-1.5 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);">
@@ -97,7 +103,6 @@ const tabs = [
</div>
)}
</td>
<td class="p-3" style="font-variant-numeric: tabular-nums;">{r.cantidad}</td>
<td class="p-3 opacity-80">{fmtFecha.format(new Date(r.fecha_solicitud))}</td>
<td class="p-3">
<div class="flex justify-end">
@@ -118,9 +123,16 @@ const tabs = [
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
<div class="text-xs opacity-70">{fmtFecha.format(new Date(r.fecha_solicitud))}</div>
</div>
<div class="text-xs opacity-70 mb-2">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-sm">{r.material?.nombre ?? '—'} <span class="opacity-70">· Inv. {r.material?.numero_inventario ?? '—'}</span></div>
<div class="text-sm mt-1">Cantidad: <strong style="font-variant-numeric: tabular-nums;">{r.cantidad}</strong> <span class="opacity-70">· disp. {r.material?.cantidad_disponible ?? 0}</span></div>
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-xs opacity-70 mb-2">Maestro: {r.maestro_responsable}</div>
<div class="flex flex-col gap-1.5">
{(r.items ?? []).map((i: any) => (
<div class="text-sm">
<strong style="font-variant-numeric: tabular-nums;">{i.cantidad}×</strong> {i.material?.nombre ?? '—'}
<span class="opacity-70"> · Inv. {i.material?.numero_inventario ?? '—'} · disp. {i.material?.cantidad_disponible ?? 0}</span>
</div>
))}
</div>
{r.notas && (
<p class="text-sm mt-2 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);">
<span class="opacity-60">Motivo:</span> {r.notas}
+2 -38
View File
@@ -1,6 +1,6 @@
---
import AppLayout from '@/layouts/AppLayout.astro';
import SolicitarModal from '@/components/alumno/SolicitarModal.tsx';
import CartProvider from '@/components/alumno/SolicitudCart.tsx';
import FiltroCategorias from '@/components/alumno/FiltroCategorias.tsx';
type Material = {
@@ -67,43 +67,7 @@ const categoriasFiltro = grupoList
Ningún material en esta categoría. Prueba con otra.
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{materiales.map((m) => (
<article
class="card flex flex-col gap-3"
data-cat={m.categoria?.id ?? 'sin'}
>
<div class="flex items-start justify-between gap-2">
<h3 class="font-semibold leading-snug">{m.nombre}</h3>
{m.categoria && (
<span class="text-xs px-2 py-0.5 rounded-[2px] whitespace-nowrap uppercase tracking-wide" style="background: color-mix(in oklab, var(--color-primary) 25%, white); color: var(--color-ink); border: 1.5px solid var(--color-primary);">
{m.categoria.nombre}
</span>
)}
</div>
{m.descripcion && (
<p class="text-sm opacity-75 line-clamp-2">{m.descripcion}</p>
)}
<dl class="text-xs opacity-70 grid grid-cols-2 gap-1">
<dt class="opacity-60">Inventario</dt>
<dd class="text-right font-mono">{m.numero_inventario ?? '—'}</dd>
<dt class="opacity-60">Disponibles</dt>
<dd class="text-right" style="font-variant-numeric: tabular-nums;">
{m.cantidad_disponible} / {m.cantidad_total}
</dd>
</dl>
<div class="mt-auto">
<SolicitarModal
material={{ id: m.id, nombre: m.nombre, cantidad_disponible: m.cantidad_disponible }}
client:load
/>
</div>
</article>
))}
</div>
<CartProvider materiales={materiales} client:load />
</>
)}
</div>
+44 -22
View File
@@ -3,27 +3,33 @@ import AppLayout from '@/layouts/AppLayout.astro';
type Estado = 'pendiente' | 'aprobado' | 'rechazado' | 'activo' | 'devuelto' | 'vencido';
type Prestamo = {
id: number;
type Item = {
cantidad: number;
descripcion: string | null;
material: { id: number; nombre: string; numero_inventario: string | null } | null;
};
type Solicitud = {
id: number;
estado: Estado;
fecha_solicitud: string;
fecha_aprobacion: string | null;
fecha_devolucion_estimada: string | null;
fecha_devolucion_real: string | null;
notas: string | null;
material: { id: number; nombre: string; numero_inventario: string | null } | null;
maestro_responsable: string;
items: Item[];
};
const user = Astro.locals.user!;
const { data, error } = await Astro.locals.supabase
.from('prestamos')
.select('id, cantidad, estado, fecha_solicitud, fecha_aprobacion, fecha_devolucion_estimada, fecha_devolucion_real, notas, material:materiales(id, nombre, numero_inventario)')
.from('solicitudes')
.select('id, estado, fecha_solicitud, fecha_aprobacion, fecha_devolucion_estimada, fecha_devolucion_real, notas, maestro_responsable, items:solicitud_items(cantidad, descripcion, material:materiales(id, nombre, numero_inventario))')
.eq('alumno_id', user.id)
.order('fecha_solicitud', { ascending: false });
const prestamos = (data ?? []) as unknown as Prestamo[];
const prestamos = (data ?? []) as unknown as Solicitud[];
const activosSet = new Set<Estado>(['pendiente', 'aprobado', 'activo']);
const activos = prestamos.filter((p) => activosSet.has(p.estado));
@@ -87,19 +93,27 @@ const badgeStyle = (e: Estado) =>
{activos.map((p) => (
<li class="card">
<div class="flex items-start justify-between gap-3 flex-wrap">
<div class="min-w-0">
<h3 class="font-semibold leading-snug truncate">{p.material?.nombre ?? 'Material eliminado'}</h3>
<p class="text-xs opacity-60 font-mono mt-0.5">
{p.material?.numero_inventario ?? '—'}
</p>
</div>
<ul class="min-w-0 space-y-1">
{p.items.map((it) => (
<li class="leading-snug">
<span class="font-semibold" style="font-variant-numeric: tabular-nums;">{it.cantidad}×</span>{' '}
<span class="font-semibold">{it.material?.nombre ?? 'Material eliminado'}</span>
{it.material?.numero_inventario && (
<span class="text-xs opacity-60 font-mono ml-1.5">{it.material.numero_inventario}</span>
)}
{it.descripcion && (
<p class="text-xs opacity-60 ml-0.5">{it.descripcion}</p>
)}
</li>
))}
</ul>
<span class="text-xs font-medium px-2 py-1 rounded-[2px] whitespace-nowrap" style={badgeStyle(p.estado)}>
{estadoLabel[p.estado]}
</span>
</div>
<dl class="mt-3 grid grid-cols-2 gap-y-1 text-sm">
<dt class="opacity-60">Cantidad</dt>
<dd class="text-right" style="font-variant-numeric: tabular-nums;">{p.cantidad}</dd>
<dt class="opacity-60">Maestro responsable</dt>
<dd class="text-right truncate">{p.maestro_responsable}</dd>
<dt class="opacity-60">Solicitado</dt>
<dd class="text-right">{fmt(p.fecha_solicitud)}</dd>
{p.fecha_aprobacion && (
@@ -141,19 +155,27 @@ const badgeStyle = (e: Estado) =>
{historial.map((p) => (
<li class="card" style="opacity: 0.92;">
<div class="flex items-start justify-between gap-3 flex-wrap">
<div class="min-w-0">
<h3 class="font-semibold leading-snug truncate">{p.material?.nombre ?? 'Material eliminado'}</h3>
<p class="text-xs opacity-60 font-mono mt-0.5">
{p.material?.numero_inventario ?? '—'}
</p>
</div>
<ul class="min-w-0 space-y-1">
{p.items.map((it) => (
<li class="leading-snug">
<span class="font-semibold" style="font-variant-numeric: tabular-nums;">{it.cantidad}×</span>{' '}
<span class="font-semibold">{it.material?.nombre ?? 'Material eliminado'}</span>
{it.material?.numero_inventario && (
<span class="text-xs opacity-60 font-mono ml-1.5">{it.material.numero_inventario}</span>
)}
{it.descripcion && (
<p class="text-xs opacity-60 ml-0.5">{it.descripcion}</p>
)}
</li>
))}
</ul>
<span class="text-xs font-medium px-2 py-1 rounded-[2px] whitespace-nowrap" style={badgeStyle(p.estado)}>
{estadoLabel[p.estado]}
</span>
</div>
<dl class="mt-3 grid grid-cols-2 gap-y-1 text-sm">
<dt class="opacity-60">Cantidad</dt>
<dd class="text-right" style="font-variant-numeric: tabular-nums;">{p.cantidad}</dd>
<dt class="opacity-60">Maestro responsable</dt>
<dd class="text-right truncate">{p.maestro_responsable}</dd>
<dt class="opacity-60">Solicitado</dt>
<dd class="text-right">{fmt(p.fecha_solicitud)}</dd>
{p.fecha_devolucion_real && (
+28 -18
View File
@@ -7,8 +7,10 @@ const HEADERS = [
'Alumno',
'Matrícula',
'Email',
'Maestro responsable',
'Material',
'Nº inventario',
'Descripción',
'Cantidad',
'Estado',
'Fecha aprobación',
@@ -40,12 +42,16 @@ export const GET: APIRoute = async ({ locals, url }) => {
const materialId = sp.get('material_id');
const alumnoId = sp.get('alumno_id');
const itemsSelect = materialId
? 'items:solicitud_items!inner(cantidad, descripcion, material:materiales(nombre, numero_inventario))'
: 'items:solicitud_items(cantidad, descripcion, material:materiales(nombre, numero_inventario))';
let query = locals.supabase
.from('prestamos')
.from('solicitudes')
.select(`
id, cantidad, fecha_solicitud, fecha_aprobacion, fecha_devolucion_estimada, fecha_devolucion_real, estado, notas,
id, maestro_responsable, fecha_solicitud, fecha_aprobacion, fecha_devolucion_estimada, fecha_devolucion_real, estado, notas,
alumno:profiles!alumno_id(nombre, email, matricula),
material:materiales!material_id(nombre, numero_inventario)
${itemsSelect}
`)
.order('fecha_solicitud', { ascending: false })
// ponytail: 5000 evita OOM en export ad-hoc; paginar/streamear si un solo reporte lo excede rutinariamente
@@ -54,26 +60,30 @@ export const GET: APIRoute = async ({ locals, url }) => {
if (desde) query = query.gte('fecha_solicitud', desde);
if (hasta) query = query.lte('fecha_solicitud', `${hasta}T23:59:59.999Z`);
if (estado && estado !== 'all') query = query.eq('estado', estado);
if (materialId) query = query.eq('material_id', materialId);
if (materialId) query = query.eq('items.material_id', materialId);
if (alumnoId) query = query.eq('alumno_id', alumnoId);
const { data, error } = await query;
if (error) return new Response(error.message, { status: 500 });
const rows = (data ?? []).map((r: any) => [
isoDate(r.fecha_solicitud),
r.alumno?.nombre ?? '',
r.alumno?.matricula ?? '',
r.alumno?.email ?? '',
r.material?.nombre ?? '',
r.material?.numero_inventario ?? '',
r.cantidad,
r.estado,
isoDate(r.fecha_aprobacion),
isoDate(r.fecha_devolucion_estimada),
isoDate(r.fecha_devolucion_real),
r.notas ?? '',
]);
const rows = (data ?? []).flatMap((r: any) =>
(r.items ?? []).map((item: any) => [
isoDate(r.fecha_solicitud),
r.alumno?.nombre ?? '',
r.alumno?.matricula ?? '',
r.alumno?.email ?? '',
r.maestro_responsable ?? '',
item.material?.nombre ?? '',
item.material?.numero_inventario ?? '',
item.descripcion ?? '',
item.cantidad,
r.estado,
isoDate(r.fecha_aprobacion),
isoDate(r.fecha_devolucion_estimada),
isoDate(r.fecha_devolucion_real),
r.notas ?? '',
])
);
const lines = [HEADERS, ...rows].map((row) => row.map(csvCell).join(','));
// BOM para que Excel abra UTF-8 sin romper acentos
@@ -31,7 +31,7 @@ export const POST: APIRoute = async ({ params, request, locals }) => {
if (notas) update.notas = notas;
const { data, error } = await locals.supabase
.from('prestamos')
.from('solicitudes')
.update(update)
.eq('id', id)
.eq('estado', 'pendiente')
@@ -11,19 +11,18 @@ export const GET: APIRoute = async ({ params, locals }) => {
if (!Number.isInteger(id) || id <= 0) return json({ error: 'id inválido' }, 400);
const prestamoQ = locals.supabase
.from('prestamos')
.from('solicitudes')
.select(
'id, cantidad, estado, fecha_solicitud, fecha_aprobacion, fecha_devolucion_estimada, fecha_devolucion_real, notas, alumno:profiles!alumno_id(id, nombre, email, matricula), material:materiales!material_id(id, nombre, numero_inventario)'
'id, estado, fecha_solicitud, fecha_aprobacion, fecha_devolucion_estimada, fecha_devolucion_real, notas, maestro_responsable, alumno:profiles!alumno_id(id, nombre, email, matricula), items:solicitud_items(cantidad, descripcion, material:materiales(id, nombre, numero_inventario))'
)
.eq('id', id)
.maybeSingle();
// Nombre y orden asumidos del audit_log: select('*') para no romper si el esquema difiere.
const logQ = locals.supabase
.from('audit_log')
.select('*')
.eq('prestamo_id', id)
.order('created_at', { ascending: false })
.eq('solicitud_id', id)
.order('at', { ascending: false })
.limit(20);
const [{ data: prestamo, error: pErr }, { data: audit_log, error: lErr }] = await Promise.all([prestamoQ, logQ]);
@@ -11,7 +11,7 @@ export const POST: APIRoute = async ({ params, locals }) => {
if (!Number.isInteger(id) || id <= 0) return json({ error: 'id inválido' }, 400);
const { data, error } = await locals.supabase
.from('prestamos')
.from('solicitudes')
.update({ estado: 'devuelto', fecha_devolucion_real: new Date().toISOString() })
.eq('id', id)
.in('estado', ['aprobado', 'activo'])
@@ -17,7 +17,7 @@ export const POST: APIRoute = async ({ params, request, locals }) => {
if (motivo.length < 5) return json({ error: 'el motivo debe tener al menos 5 caracteres' }, 400);
const { data, error } = await locals.supabase
.from('prestamos')
.from('solicitudes')
.update({ estado: 'rechazado', notas: motivo, aprobado_por: locals.user.id })
.eq('id', id)
.eq('estado', 'pendiente')
-58
View File
@@ -1,58 +0,0 @@
import type { APIRoute } from 'astro';
export const prerender = false;
export const POST: APIRoute = async ({ request, locals }) => {
const user = locals.user;
if (!user) {
return Response.json({ error: 'No autenticado' }, { status: 401 });
}
let body: { material_id?: unknown; cantidad?: unknown; notas?: unknown };
try {
body = await request.json();
} catch {
return Response.json({ error: 'JSON inválido' }, { status: 400 });
}
const material_id = Number(body.material_id);
const cantidad = Number(body.cantidad);
const notas = typeof body.notas === 'string' && body.notas.trim() ? body.notas.trim() : null;
if (!Number.isInteger(material_id) || material_id <= 0) {
return Response.json({ error: 'material_id requerido' }, { status: 400 });
}
if (!Number.isInteger(cantidad) || cantidad <= 0) {
return Response.json({ error: 'cantidad debe ser un entero mayor que cero' }, { status: 400 });
}
const supabase = locals.supabase;
const { data: material, error: matErr } = await supabase
.from('materiales')
.select('id, cantidad_disponible, estado')
.eq('id', material_id)
.maybeSingle();
if (matErr) {
return Response.json({ error: 'No se pudo verificar el material' }, { status: 500 });
}
if (!material || material.estado !== 'disponible') {
return Response.json({ error: 'Material no disponible' }, { status: 404 });
}
if (material.cantidad_disponible < cantidad) {
return Response.json({ error: 'Sin stock suficiente' }, { status: 409 });
}
const { data, error } = await supabase
.from('prestamos')
.insert({ alumno_id: user.id, material_id, cantidad, notas })
.select('id')
.single();
if (error) {
return Response.json({ error: 'No se pudo registrar la solicitud' }, { status: 500 });
}
return Response.json({ id: data.id }, { status: 201 });
};
+78
View File
@@ -0,0 +1,78 @@
import type { APIRoute } from 'astro';
export const prerender = false;
type ItemInput = { material_id?: unknown; cantidad?: unknown; descripcion?: unknown };
function statusForError(message: string): { status: number; error: string } {
if (message.startsWith('no_autenticado')) {
return { status: 401, error: 'No autenticado' };
}
if (
message.startsWith('maestro_responsable_requerido') ||
message.startsWith('items_requeridos') ||
message.startsWith('item_invalido')
) {
return { status: 400, error: 'Solicitud inválida' };
}
if (message.startsWith('material_no_existe')) {
return { status: 409, error: 'Uno de los materiales ya no existe' };
}
if (message.startsWith('material_no_disponible')) {
return { status: 409, error: 'Uno de los materiales ya no está disponible' };
}
if (message.startsWith('stock_insuficiente')) {
return { status: 409, error: 'Sin stock suficiente para uno de los materiales' };
}
return { status: 500, error: 'No se pudo registrar la solicitud' };
}
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) {
return Response.json({ error: 'No autenticado' }, { status: 401 });
}
let body: { maestro_responsable?: unknown; notas?: unknown; items?: unknown };
try {
body = await request.json();
} catch {
return Response.json({ error: 'JSON inválido' }, { status: 400 });
}
const maestro_responsable = typeof body.maestro_responsable === 'string' ? body.maestro_responsable.trim() : '';
const notas = typeof body.notas === 'string' && body.notas.trim() ? body.notas.trim() : null;
if (!maestro_responsable) {
return Response.json({ error: 'Maestro responsable requerido' }, { status: 400 });
}
if (!Array.isArray(body.items) || body.items.length === 0) {
return Response.json({ error: 'Agrega al menos un material' }, { status: 400 });
}
const items: Array<{ material_id: number; cantidad: number; descripcion: string | null }> = [];
for (const raw of body.items as ItemInput[]) {
const material_id = Number(raw.material_id);
const cantidad = Number(raw.cantidad);
if (!Number.isInteger(material_id) || material_id <= 0) {
return Response.json({ error: 'material_id inválido en un renglón' }, { status: 400 });
}
if (!Number.isInteger(cantidad) || cantidad <= 0) {
return Response.json({ error: 'cantidad inválida en un renglón' }, { status: 400 });
}
const descripcion = typeof raw.descripcion === 'string' && raw.descripcion.trim() ? raw.descripcion.trim() : null;
items.push({ material_id, cantidad, descripcion });
}
const { data, error } = await locals.supabase.rpc('crear_solicitud', {
p_maestro_responsable: maestro_responsable,
p_notas: notas,
p_items: items,
});
if (error) {
const { status, error: message } = statusForError(error.message ?? '');
return Response.json({ error: message }, { status });
}
return Response.json({ id: data }, { status: 201 });
};
+2 -2
View File
@@ -17,7 +17,7 @@ const googleAuthUrl = '/api/auth/signin';
<div class="w-full max-w-md">
<div class="text-center mb-8">
<div class="inline-grid place-items-center w-16 h-16 rounded-[2px] bg-[color:var(--color-primary)] p-4 mb-4 motion-safe:animate-[fadein_.4s_ease-out]" style="border: 2px solid var(--color-ink); box-shadow: var(--shadow-hard);">
<BrandMark class="w-full h-full" />
<BrandMark class="w-full h-full text-white" />
</div>
<h1 class="text-2xl md:text-3xl font-semibold uppercase tracking-wide">Sistema de Préstamos</h1>
<p class="text-sm mt-2" style="color: var(--color-pencil);">Laboratorio de Sistemas Computacionales · UABC</p>
@@ -29,7 +29,7 @@ const googleAuthUrl = '/api/auth/signin';
<a href={googleAuthUrl} class="btn btn-primary w-full">
<svg width="18" height="18" viewBox="0 0 48 48" aria-hidden="true" focusable="false">
<path fill="var(--color-ink)" d="M44.5 20H24v8.5h11.7C34.4 33.4 29.7 36 24 36c-6.6 0-12-5.4-12-12s5.4-12 12-12c2.9 0 5.5 1 7.6 2.7l6.2-6.2C33.8 5.1 29.2 3 24 3 12.4 3 3 12.4 3 24s9.4 21 21 21c11 0 20-8 20-21 0-1.4-.1-2.7-.5-4z"/>
<path fill="white" d="M44.5 20H24v8.5h11.7C34.4 33.4 29.7 36 24 36c-6.6 0-12-5.4-12-12s5.4-12 12-12c2.9 0 5.5 1 7.6 2.7l6.2-6.2C33.8 5.1 29.2 3 24 3 12.4 3 3 12.4 3 24s9.4 21 21 21c11 0 20-8 20-21 0-1.4-.1-2.7-.5-4z"/>
</svg>
Continuar con Google
</a>
+9 -8
View File
@@ -2,7 +2,7 @@
@import "@fontsource-variable/jetbrains-mono";
@theme {
/* MotherDuck-derived palette, remapped to LabPréstamos' semantics */
/* MotherDuck-derived system (shadow/radius/type mechanics); identity colors are UABC's own */
--color-cream: #f4efea;
--color-frost: #ffffff;
--color-chalk: #f8f8f7;
@@ -12,10 +12,11 @@
--color-pencil: #a1a1a1;
--color-graphite: #818181;
--color-primary: #6fc2ff;
--color-primary-hover: #56b4f7;
--color-secondary: #ff9538;
--color-secondary-hover: #ef8526;
/* UABC institutional green/gold — dark enough that primary needs white text, not charcoal */
--color-primary: #00723F;
--color-primary-hover: #005f34;
--color-secondary: #DD971A;
--color-secondary-hover: #c78614;
--color-danger: #f38e84;
--color-danger-hover: #ec7469;
/* AA-contrast text variant of Coral Sketch — the fill color reads ~2:1 on white, too light for body text */
@@ -57,7 +58,7 @@
}
::selection {
background: var(--color-primary);
color: var(--color-ink);
color: white;
}
/* Tabular data keeps real square corners even where a nested control forgets to ask */
table {
@@ -88,10 +89,10 @@
}
.btn-primary {
background: var(--color-primary);
color: var(--color-ink);
color: white;
}
.btn-secondary {
background: var(--color-frost);
background: var(--color-secondary);
color: var(--color-ink);
}
.btn-ghost {
@@ -0,0 +1,235 @@
-- 0002_solicitudes_multi_item.sql
-- Rediseno a "vale multi-item": prestamos.prestamos -> prestamos.solicitudes (cabecera)
-- + prestamos.solicitud_items (renglones). Fiel al vale de papel del LSC:
-- un tramite agrupa varios materiales (cada uno con cantidad + descripcion),
-- mas maestro_responsable a nivel de vale y semestre a nivel de perfil.
begin;
-- ============================================================
-- 1) Cabecera: renombrar prestamos -> solicitudes (preserva ids/FKs/policies)
-- ============================================================
alter table prestamos.prestamos rename to solicitudes;
alter index prestamos.prestamos_alumno_idx rename to solicitudes_alumno_idx;
alter index prestamos.prestamos_estado_idx rename to solicitudes_estado_idx;
-- ============================================================
-- 2) Renglones: solicitud_items, poblados desde las columnas legacy
-- ============================================================
create table prestamos.solicitud_items (
id bigserial primary key,
solicitud_id bigint not null references prestamos.solicitudes(id) on delete cascade,
material_id int not null references prestamos.materiales(id),
cantidad int not null check (cantidad > 0),
descripcion text
);
create index solicitud_items_solicitud_idx on prestamos.solicitud_items(solicitud_id);
create index solicitud_items_material_idx on prestamos.solicitud_items(material_id);
-- backfill: cada solicitud legacy (1 material) -> 1 renglon
insert into prestamos.solicitud_items (solicitud_id, material_id, cantidad)
select id, material_id, cantidad from prestamos.solicitudes;
-- material_id/cantidad ya viven en solicitud_items; fuera de la cabecera
drop index if exists prestamos.prestamos_material_idx;
alter table prestamos.solicitudes drop column material_id;
alter table prestamos.solicitudes drop column cantidad;
-- ============================================================
-- 3) maestro_responsable (dato por-vale, requerido; backfill legacy)
-- ============================================================
alter table prestamos.solicitudes add column maestro_responsable text;
update prestamos.solicitudes
set maestro_responsable = 'Sin especificar (registro previo a este campo)'
where maestro_responsable is null;
alter table prestamos.solicitudes alter column maestro_responsable set not null;
-- ============================================================
-- 4) semestre en profiles (dato del alumno; nullable; SIN UI esta ronda)
-- ============================================================
alter table prestamos.profiles add column semestre text;
-- ============================================================
-- 5) audit_log: prestamo_id -> solicitud_id (mismo objeto FK, solo renombre)
-- ============================================================
alter table prestamos.audit_log rename column prestamo_id to solicitud_id;
alter index prestamos.audit_prestamo_idx rename to audit_solicitud_idx;
-- ============================================================
-- 6) sync_stock(): itera solicitud_items de la solicitud afectada.
-- Mismo trigger prestamos_sync_stock (sobrevivio el RENAME de tabla),
-- solo se reemplaza el cuerpo de la funcion.
-- ============================================================
create or replace function prestamos.sync_stock()
returns trigger
language plpgsql
security definer
set search_path = prestamos, pg_temp
as $$
declare
item record;
begin
-- transiciones que sacan stock
if new.estado in ('aprobado','activo') and old.estado not in ('aprobado','activo') then
for item in
select material_id, cantidad from prestamos.solicitud_items
where solicitud_id = new.id order by material_id
loop
update prestamos.materiales
set cantidad_disponible = cantidad_disponible - item.cantidad
where id = item.material_id;
end loop;
end if;
-- transiciones que devuelven stock
if new.estado in ('devuelto','rechazado') and old.estado in ('aprobado','activo') then
for item in
select material_id, cantidad from prestamos.solicitud_items
where solicitud_id = new.id order by material_id
loop
update prestamos.materiales
set cantidad_disponible = cantidad_disponible + item.cantidad
where id = item.material_id;
end loop;
end if;
return new;
end;
$$;
-- ============================================================
-- 7) log_estado(): usa solicitud_id. Mismo trigger prestamos_log_estado.
-- ============================================================
create or replace function prestamos.log_estado()
returns trigger
language plpgsql
security definer
set search_path = prestamos, pg_temp
as $$
begin
if new.estado is distinct from old.estado then
insert into prestamos.audit_log (solicitud_id, actor_id, accion, estado_anterior, estado_nuevo)
values (new.id, auth.uid(), 'cambio_estado', old.estado, new.estado);
end if;
return new;
end;
$$;
-- ============================================================
-- 8) RPC: crear_solicitud - reemplaza el insert directo del alumno.
-- Transaccion unica (todo o nada) + lock por fila de material para
-- consistencia entre solicitudes concurrentes. El stock real se
-- descuenta recien al aprobar (sync_stock), igual que hoy; este lock
-- da atomicidad transaccional del alta multi-item.
-- ============================================================
create or replace function prestamos.crear_solicitud(
p_maestro_responsable text,
p_notas text,
p_items jsonb
) returns bigint
language plpgsql
security definer
set search_path = prestamos, pg_temp
as $$
declare
v_alumno uuid := auth.uid();
v_solicitud_id bigint;
v_item jsonb;
v_material_id int;
v_cantidad int;
v_descripcion text;
v_disponible int;
v_estado_mat text;
begin
if v_alumno is null then
raise exception 'no_autenticado';
end if;
if p_maestro_responsable is null or length(trim(p_maestro_responsable)) = 0 then
raise exception 'maestro_responsable_requerido';
end if;
if p_items is null or jsonb_typeof(p_items) <> 'array' or jsonb_array_length(p_items) = 0 then
raise exception 'items_requeridos';
end if;
-- lock consistente (orden ascendente) de las filas de material involucradas,
-- evita deadlocks si dos alumnos solicitan materiales solapados a la vez
for v_material_id in
select distinct (elem->>'material_id')::int
from jsonb_array_elements(p_items) elem
order by 1
loop
perform 1 from prestamos.materiales where id = v_material_id for update;
end loop;
insert into prestamos.solicitudes (alumno_id, estado, maestro_responsable, notas)
values (v_alumno, 'pendiente', trim(p_maestro_responsable), nullif(trim(coalesce(p_notas, '')), ''))
returning id into v_solicitud_id;
for v_item in select * from jsonb_array_elements(p_items)
loop
v_material_id := (v_item->>'material_id')::int;
v_cantidad := (v_item->>'cantidad')::int;
v_descripcion := nullif(trim(coalesce(v_item->>'descripcion', '')), '');
if v_material_id is null or v_cantidad is null or v_cantidad <= 0 then
raise exception 'item_invalido';
end if;
select cantidad_disponible, estado into v_disponible, v_estado_mat
from prestamos.materiales where id = v_material_id;
if v_estado_mat is null then
raise exception 'material_no_existe: %', v_material_id;
end if;
if v_estado_mat <> 'disponible' then
raise exception 'material_no_disponible: %', v_material_id;
end if;
if v_disponible < v_cantidad then
raise exception 'stock_insuficiente: %', v_material_id;
end if;
insert into prestamos.solicitud_items (solicitud_id, material_id, cantidad, descripcion)
values (v_solicitud_id, v_material_id, v_cantidad, v_descripcion);
end loop;
return v_solicitud_id;
end;
$$;
grant execute on function prestamos.crear_solicitud(text, text, jsonb) to authenticated;
-- ============================================================
-- 9) RLS: solicitudes / solicitud_items
-- ============================================================
alter table prestamos.solicitud_items enable row level security;
-- El insert directo del alumno ya no existe: crear_solicitud es SECURITY DEFINER
-- y hace bypass de RLS. Sin policy de insert, RLS bloquea cualquier insert
-- directo via supabase-js aunque el GRANT de tabla siga vigente.
drop policy if exists prestamos_insert_own on prestamos.solicitudes;
-- solicitudes_select_own_or_admin / solicitudes_update_admin: los nombres de
-- policy no cambiaron con el RENAME de tabla (siguen siendo
-- prestamos_select_own_or_admin / prestamos_update_admin), su USING solo
-- referencia alumno_id/is_admin() y sigue funcionando sin tocarlas.
create policy solicitud_items_select_own_or_admin on prestamos.solicitud_items
for select to authenticated
using (
exists (
select 1 from prestamos.solicitudes s
where s.id = solicitud_items.solicitud_id
and (s.alumno_id = auth.uid() or prestamos.is_admin())
)
);
-- ============================================================
-- 10) Grants (explicitos, mismo patron que las demas tablas)
-- ============================================================
grant select, insert, update, delete on prestamos.solicitud_items to authenticated, service_role;
grant usage, select on prestamos.solicitud_items_id_seq to authenticated, service_role;
commit;