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