Compare commits
8 Commits
bcecdbb184
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 800ec60a53 | |||
| 3cccf758a8 | |||
| dea268d975 | |||
| c43f45eca1 | |||
| 3e576426e1 | |||
| fe1ef7b70d | |||
| 0f2ea59754 | |||
| 44fe7527be |
@@ -8,6 +8,10 @@ astro dev --background
|
||||
|
||||
Manage the background server with `astro dev stop`, `astro dev status`, and `astro dev logs`.
|
||||
|
||||
## Testing
|
||||
|
||||
Todo testing manual en el navegador (probar un flujo, verificar un fix visual, QA de una feature) se hace con la skill **agent-browser**, no con curl ni asunciones. Usar el bypass temporal `?preview=alumno|admin` del middleware (solo activo en `DEV`) para simular sesión sin hacer login real, y revertirlo antes de terminar.
|
||||
|
||||
## Documentation
|
||||
|
||||
Full documentation: https://docs.astro.build
|
||||
@@ -240,3 +244,38 @@ Fases 3, 4/5 y 6 tocan carpetas de rutas distintas (`src/pages/alumno/*`, `src/p
|
||||
- **Aprendizajes registrados**: (a) los agentes fallando por límite de sesión son un modo de degradación normal cuando el trabajo por agente es grande — la mitigación es dividir mejor los tracks (agentes P y H fueron los más grandes, ambos cortaron); alternativa: preparar prompts para que cada agente escriba archivos incrementalmente y el orquestador pueda completar los huecos con menos contexto; (b) Docker Compose lee `.env` **implícitamente** para expansión de `${VAR}` en el YAML pero NO lee `env_file` para expansión — para vars en `build.args`, o bien renombras `.env.production` a `.env`, o exportas al shell antes del compose (elegimos lo segundo, menos disruptivo con la config existente); (c) `Storage RLS` con path por carpeta = uid es el patrón limpio para "cada quien sube la suya" — `(storage.foldername(name))[1] = auth.uid()::text` en el `using`/`with check`, no necesita RPC ni endpoint intermedio; (d) para RPCs con fallback lógico (como `crear_solicitud` autocompletando el tutor), poner la lógica en la BD y no en el endpoint es más robusto porque el mismo comportamiento aplica si algún día se llama la RPC desde otro cliente; (e) `CLAUDE.md` es symlink a `AGENTS.md` (aprendido en la sesión anterior, sigue vigente — editar el path real).
|
||||
- **Deliberadamente NO tocado**: horarios académicos automáticos (fuera de alcance explícito — usuario eligió textarea manual); notificaciones al maestro cuando se aprueba un vale (backlog); comprimir avatares antes de subir (si pesan mucho se agrega después); PWA y browser notifications (backlog viejo).
|
||||
- **Deploy completado**: **sistema en producción con perfil + onboarding + home reformulado + maestros CRUD + upload arreglado + 5 bugs mobile arreglados en https://prestamos.buglabs.dev**.
|
||||
|
||||
- **2026-08-25 — v1.6: rol docente + combobox maestros/tutores + sonido notif + fecha 1 día + wrap admin + fix self-heal profile**. Iteración operativa reportada por usuarios reales del laboratorio, más un bug crítico de login descubierto la misma sesión.
|
||||
- **Bug crítico previo (self-heal)**: usuarios `@uabc.edu.mx` haciendo login vía OAuth entraban con `user` pero **sin fila en `prestamos.profiles`** — la app los trataba como "no logueado". Root cause: coexisten 2 triggers `AFTER INSERT` en `auth.users`: `on_auth_user_created` (del proyecto vecino que usa `public.profiles`) y `prestamos_on_auth_user_created` (nuestro). El del vecino se ejecuta primero por orden alfabético y por razón no diagnosticada el nuestro no dispara consistentemente en producción — verificado que la función `prestamos.handle_new_user()` funciona bien manualmente (insert vía backfill regresó 8 filas de 8 users). Fix aplicado: (a) migración 0005 con policy `profiles_insert_self` (with check `id = auth.uid() and rol = 'alumno'` — impide auto-promoción a admin); (b) middleware.ts con self-heal: si `user` existe y `!profile` y email `@uabc.edu.mx`, hace `upsert` al vuelo con nombre desde `user_metadata.full_name`; (c) backfill manual en prod para los 8 users existentes. Aprendizaje: cuando 2 apps comparten una instancia de Supabase, cualquier trigger `AFTER INSERT` en `auth.users` es riesgoso — mejor no depender del trigger y hacer self-heal server-side desde el middleware. En 0006 se amplió la policy a `rol in ('alumno','docente')`.
|
||||
- **Decisiones acordadas con el usuario antes de tocar código**: (1) lista de maestros = admin la va agregando manualmente desde `/admin/maestros` (no seed inicial masivo — la lista suele ser estable); (2) separar tutores de maestros = **flag `es_tutor` en la misma tabla** — todos los maestros aparecen en el combobox del checkout; solo los marcados con `es_tutor=true` aparecen en el select de tutor del perfil (mismo CRUD, un checkbox extra); (3) docente = **`profiles.matricula` reusada con label dinámico** "Matrícula" para alumno / "Número de empleado" para docente (cero columna nueva, cero migración de datos); (4) errores sha512/CORS de `beacon.min.js` = **el usuario los desactiva en Cloudflare dashboard** (Analytics & Logs → Web Analytics → toggle off para el dominio), no requiere código.
|
||||
- **Migración `0006_docente_maestros_v16.sql`** aplicada limpia (`BEGIN`/`COMMIT` propios, mismo patrón que 0001-0005): `profiles.rol` check ahora acepta `'alumno'|'docente'|'admin'`; `maestros.es_tutor boolean not null default false` con `update ... where nombre='Sin especificar'` para marcarlo como tutor fallback (no romper flows existentes); policy `profiles_insert_self` reescrita ahora permite `rol in ('alumno','docente')`; RPC `crear_solicitud` **cambio de firma** de `(text, text, jsonb)` a `(int, text, jsonb)` — recibe `p_maestro_id` en vez de texto libre, y la lógica es: si rol='docente' usa el propio `profiles.nombre` como `maestro_responsable`; si alumno con `p_maestro_id` valida y guarda ese nombre; si alumno sin `p_maestro_id` cae al `tutor_id` del perfil; si tampoco hay tutor, raise `maestro_responsable_requerido`. El drop de la firma vieja es explícito con `drop function if exists prestamos.crear_solicitud(text, text, jsonb)`.
|
||||
- **Ejecución**: 1 agente Explore para background (falló por límite en la ronda anterior — esta vez no hizo falta explorar), 1 agente en paralelo para el track más grande (perfil + onboarding + `es_tutor` en MaestroForm/endpoints/maestros.astro), y el orquestador hizo el resto directo en el hilo principal (endpoint `/api/solicitudes` con `maestro_id`, `catalogo.astro` con SSR de maestros + `esDocente`, `SolicitudCart.tsx` con `<select>` combobox + guard docente + `maxLength=250` en notas, wrap `break-words` en 4 vistas admin, sonido en `BadgeSolicitudes.tsx`, mover audio a `public/audio/`, fecha default `enDias(1)` en `AccionesSolicitud.tsx`). El agente completó su track limpio, cero conflictos con lo que hice.
|
||||
- **UX del combobox maestros**: el `<option value="">` inicial muestra "— Usar mi tutor ({tutorNombre}) —" (o "— Elige un maestro —" si no tiene tutor). El fallback backend sigue activo: si el alumno no elige nadie, la RPC usa el `tutor_id` del perfil. Si escoge otro, guarda ese nombre. Doble defensa contra "diferencia de escritura entre humanos" (la razón que dio el usuario para pedir el combobox).
|
||||
- **UX del docente**: en el checkout, el bloque de maestro simplemente NO se renderiza (`{!esDocente && ...}`); el `perfilCompleto` es `!!matricula` (sin tutor). En perfil/onboarding, semestre y tutor no aparecen; label matrícula → "Número de empleado"; badge del header muestra "Docente". Registro de docente: el admin promueve manualmente vía SQL o desde Studio (`update prestamos.profiles set rol='docente' where email=...`), igual patrón que admin. No hay UI para auto-registrarse como docente — evita abuso.
|
||||
- **Sonido de notificación**: archivo movido de `src/audio/` (Astro no sirve archivos de `src/` estáticamente) a `public/audio/sonido_notificacion.mp3` — accesible en `/audio/sonido_notificacion.mp3` (verificado 200 `audio/mpeg` post-deploy). `BadgeSolicitudes.tsx` en el handler INSERT: `new Audio('/audio/sonido_notificacion.mp3').play().catch(() => {})` — el catch cubre la política de autoplay (muchos navegadores bloquean sonido sin interacción previa; como el admin ya interactuó al hacer login, en la práctica funciona).
|
||||
- **Fecha default 1 día**: cambio de una línea en `AccionesSolicitud.tsx:44` (`useState(enDias(7))` → `useState(enDias(1))`). El admin puede cambiarla si quiere; el default solo era muy generoso.
|
||||
- **Fix del desbordamiento**: `maxLength={500}` → `250` en el textarea del motivo en `SolicitudCart.tsx`, mismo cap aplicado en el backend (`.slice(0, 250)` en `/api/solicitudes/index.ts` como defensa). Wrap con `break-words` (Tailwind = `overflow-wrap: break-word`) agregado en cada renglón donde aparece `Maestro:` o `Motivo:` en las 3 vistas admin (index/activos/historial, tanto tabla desktop como cards mobile) y en el `<dd>` de `VerDetalles.tsx`. El `notas` de `VerDetalles` ya tenía `whitespace-pre-wrap` — quedó intacto.
|
||||
- **Verificación**: `npm run build` limpio (1.52s). Deploy: 2 commits (migración + código), push con token efímero, `ssh buglabs '... set -a && . .env.production && set +a && docker compose up -d --build'` (mismo patrón que 0004). Container healthy en 23s. Smoke test post-deploy: `/login` → 200; `/audio/sonido_notificacion.mp3` → 200 con `content-type: audio/mpeg` (audio sirve correctamente); BD verifica `profiles_rol_check` incluye docente, 3 maestros existentes con `es_tutor` booleano correcto.
|
||||
- **Deploy completado**: **v1.6 en producción en https://prestamos.buglabs.dev**. El usuario se encarga de: (a) desactivar Cloudflare Web Analytics para el dominio (bug de sha512/CORS); (b) marcar como docente vía SQL a las cuentas que corresponda.
|
||||
|
||||
- **2026-08-27 — v1.7: overlay de carga, lista negra de cuentas, carrito persistente + fix definitivo del upload**. 3 features + 1 cambio UX + 2 bugs residuales. Fase 1 con Bash/grep + logs de Storage en prod (sin Explore agents — bug root cause obvio en los logs), Fase 2 sin Plan agent (contexto claro), 2 preguntas al usuario (modelo de baneos, estrategia fix upload), plan escrito y aprobado, ejecución con migración yo + 3 agentes paralelos.
|
||||
- **Bug del upload — root cause definitivo (via logs storage de prod)**: `"role":"anon"` + `"error":"new row violates row-level security policy"` (código 42501). Causa real: `browserClient()` en `src/lib/supabase.ts` usa `createBrowserClient` de `@supabase/ssr` — en el browser NO puede leer las cookies de auth porque están seteadas con `httpOnly: true` (correcto por seguridad, JS del browser no las puede leer nunca). Al hacer `.storage.from(bucket).upload()`, la request sale con `Authorization: Bearer <anon_key>` (sin JWT del user), Storage la evalúa como `role: 'anon'`, y RLS rechaza. **Los fixes previos (Dockerfile ARG PUBLIC_*, guard typeof process en supabase.ts) NO tocaban este bug** — solo aseguraban que la URL del Supabase estuviera horneada en el bundle, pero el bundle nunca pudo autenticar contra Storage. Fix elegido: **endpoints proxy server-side** que reciben multipart, validan la sesión con la cookie httpOnly (server sí la lee via `serverClient(cookies)`), y suben con `serviceClient()` (bypass RLS). Cookies siguen httpOnly — cero riesgo XSS.
|
||||
- **Decisiones tomadas con el usuario**: (1) baneos = **tabla separada** `prestamos.baneos(profile_id, razon, banned_at, banned_by, expires_at, unbanned_at, unbanned_by)` — historial completo + soporte para baneos temporales (aunque UI solo expone permanentes esta ronda); (2) fix upload = endpoints proxy; (3) overlay de carga literal (blur + spinner) según petición del usuario — no ClientRouter (evita side effects en islands y Realtime).
|
||||
- **Migración `0007_baneos.sql`** aplicada limpia: nueva tabla `baneos` con partial unique index `where unbanned_at is null` (garantiza máximo 1 baneo activo por profile), RLS `baneos_admin_all` (admin CRUD) + `baneos_read_self` (user ve los suyos para /banned), helper SQL `prestamos.is_banned(uid) returns boolean` `security definer stable` que retorna true si existe baneo activo no expirado — invocable por el middleware vía `.rpc('is_banned', {p_uid})`.
|
||||
- **3 agentes paralelos** con contratos aislados — **los 3 completos limpios en un pase** (a diferencia de v1.6 donde P y H cortaron por límite de sesión):
|
||||
- **B · Baneos + panel + middleware guard** (9 archivos): `banned.astro` (pantalla completa patrón /403, muestra razón + fecha + expires + quién baneó + botón signout), `POST /api/admin/baneos` (guards admin + no-self-ban + razón 5-500 chars + expires_at futuro opcional, 23505 → 409), `POST /api/admin/baneos/[id]/desbanear` (update idempotente con `where unbanned_at is null`, 404 si ya inactivo), `BanearForm.tsx` (dialog con textarea razón + contador; deshabilitado con tooltip si adminSelf), `DesbanearButton.tsx` (window.confirm simple), `/admin/usuarios.astro` (2 queries paralelas profiles + baneos activos, cruce en memoria, tabla desktop + cards mobile), middleware con nuevo array `BANNED_ALLOWED = ['/banned', '/api/auth/signout']` para escapar del rewrite y evitar loop, RPC `is_banned` solo se llama si `profile && rol !== 'admin'` (evita costo en admin/no-login), nav admin `+ Usuarios`.
|
||||
- **U · Uploads proxy** (4 archivos): `POST /api/upload/material-foto/[id].ts` (guard admin, valida mime `/^image\//` + `size <= 2MB`, path `${id}/${Date.now()}.${ext}`, sube con `serviceClient().storage.from('materiales-fotos').upload({upsert:true, contentType})`, actualiza `materiales.imagen_path` con locals.supabase), `POST /api/upload/avatar.ts` (guard user autenticado, path `${uid}/${Date.now()}.${ext}` al bucket `avatares`, actualiza `profiles.foto_path`), refactor `MaterialForm.tsx` `uploadFotoFor` para fetch multipart al endpoint (eliminó import de browserClient y del PATCH cliente del imagen_path — el endpoint ya lo persiste, evita doble escritura), refactor `PerfilForm.tsx` mismo patrón. Decisión no obvia: `uploadFotoFor` mantiene el patrón toast+return-null (no throw) del original — preserva la UX de "material creado sin foto" si el upload falla, en vez de bloquear la creación completa. Anotado en comentario: el "Quitar foto" en edit-mode nunca se propagaba a la BD (bug preexistente fuera de scope).
|
||||
- **X · UX chico** (3 archivos): `Layout.astro` con `<div id="page-loader" hidden>` + estilos `position:fixed backdrop-filter:blur(8px)` + spinner mono animado (respeta prefers-reduced-motion) + script inline que captura clicks en `<a href>` y submits de `<form>` mismo origen (guardas: modifier keys, target=_blank, anchor#, javascript:/mailto:/tel:, cross-origin, defaultPrevented — este último es clave: los forms fetch-managed llaman preventDefault en su onSubmit React, así que el overlay no se dispara falsamente para ellos), `pageshow` limpia el overlay por bfcache. `SolicitudCart.tsx` con useEffect hidratar+persistir `labre:cart:v1` en localStorage + guard `cartHydrated` (evita que la primera pasada del effect pise el localStorage antes de leerlo) + botón Vaciar en footer del checkout con `window.confirm` (solo dentro del bloque perfilCompleto). `admin/inventario/index.astro` eliminado el `<script>` inline de auto-submit debounced que se había agregado en v1.5.
|
||||
- **Verificación combinada**: `npm run build` limpio (2.74s en dev; 2.64s en el agente B). Sin conflictos de merge. Smoke test público post-deploy: `/login` → 200, `/banned` → 302 (protegido, redirige a login sin sesión), `/admin/usuarios` → 302 (protegido), `/api/upload/avatar` → 302 (middleware protegiendo API antes del handler). Container healthy en ~90s.
|
||||
- **Aprendizajes registrados**: (a) los "fixes" del bug de upload en sesiones previas (Dockerfile ARG, guard typeof process) NUNCA fueron el fix real — solo eran precondiciones necesarias. El bug real requiere abandonar la idea de que el `browserClient` pueda hablar directo con Storage cuando las cookies son httpOnly. Diagnóstico definitivo llegó por leer logs de `supabase-storage` container donde el error 42501/anon estaba explícito; no era necesario reproducir en browser. (b) Contratos de agentes aislados por CARPETA (no por archivo) escalan mucho mejor — 3 agentes editaron 3 conjuntos disjuntos de rutas/componentes/endpoints sin overhead de coordinación. (c) El uso de `preventDefault` como señal semántica funciona bien: cualquier form que llama `preventDefault()` en su `onSubmit` React no dispara el overlay global, sin necesidad de opt-out explícito por form. (d) `partial unique index` `where unbanned_at is null` es el patrón limpio para "máximo un baneo activo por profile" — evita constraint compleja y da el error 23505 traducible.
|
||||
- **Deliberadamente NO tocado**: (i) super admin (mencionado por usuario como consideración futura); (ii) baneo automático por rate limiting; (iii) UI para expires_at en baneos (columna existe, se puede exponer si se pide); (iv) compresión de imágenes cliente-side (si las fotos que sube el admin regularmente pasan de 2MB se agrega); (v) el bug preexistente de "Quitar foto" en MaterialForm que no propagaba null a la BD.
|
||||
- **Deploy completado**: 2 commits (migración + código), push con token efímero de Gitea, `set -a && . .env.production && set +a && docker compose up -d --build` en buglabs (patrón del build args). Container healthy. **v1.7 en producción en https://prestamos.buglabs.dev**. El usuario puede empezar a: (a) crear/actualizar fotos de materiales y perfil (bug arreglado); (b) banear cuentas problemáticas desde `/admin/usuarios`.
|
||||
|
||||
- **2026-08-26 — Primera ronda de QA manual con agent-browser (post v1.6)**. Se siguió el proceso de `CLAUDE.md`/`AGENTS.md`: bypass temporal `?preview=alumno|admin|docente` en el middleware (solo `import.meta.env.DEV`), revertido al terminar (`git diff src/middleware.ts` queda limpio).
|
||||
- **Bug crítico encontrado y arreglado — `process is not defined` rompía la hidratación de 3 formularios en producción**: `src/lib/supabase.ts` leía `process.env.X ?? import.meta.env.X` (orden fijado en la sesión del 24-ago para el bug de upload en Docker). En el browser `process` no existe como global — evaluar `process.env` revienta con `ReferenceError` **antes** de que el `??` pueda caer al fallback. Como el módulo se evalúa completo al importarse (aunque solo se use `browserClient`), esto tumbaba la hidratación de **`PerfilForm.tsx`** (usado en `/perfil` y `/onboarding`) y de **`MaterialForm.tsx`** (admin, alta/edición de material) — los tres quedaban sin JS: los botones "Guardar" hacían un submit nativo del `<form>` (sin `action`, sin querystring) que caía en `/login` en vez de llamar al endpoint. Confirmado con red real: antes del fix, `PATCH /api/profile` nunca se disparaba (submit nativo); con el fix, sí, y persiste correctamente. Fix: guard `typeof process !== 'undefined'` antes de leer `process.env` en las 3 constantes de `supabase.ts` — mantiene la prioridad process→import.meta.env para el server (necesaria por el fix de Docker de esa sesión) sin tocar `process` en el bundle de browser. `avatar.ts` y `materialImg.ts` ya tenían el orden inverso (`import.meta.env` primero) por eso nunca mostraron el bug. **Pendiente: este fix vive solo en el working tree, no se ha commiteado ni desplegado** — el bug sigue viivo en producción hasta que se despliegue.
|
||||
- **Hallazgo operativo, no bug de código — no había ningún perfil `rol='admin'`** en la base de datos de producción al momento de probar (los 10 perfiles reales eran todos `alumno`/`docente`). Bloqueaba por completo `/admin/*`. Con confirmación del usuario, se promovió `amado.garcia.ramirez@uabc.edu.mx` a `admin` vía `PATCH` directo a PostgREST con `service_role`. No se investigó la causa de cómo se quedó sin admin (posble que nunca se re-promovió tras alguna migración, o que el admin real usaba otro correo ya no presente) — si vuelve a pasar, vale la pena revisar.
|
||||
- **Incidente de infraestructura durante la sesión**: el túnel Cloudflare de buglabs cayó a mitad de las pruebas (error 1033, "tunnel not connected") — tumbó `supabase.buglabs.dev`, `prestamos.buglabs.dev` y el SSH a buglabs simultáneamente (los tres pasan por el mismo túnel). Se detectó por una request de ~14s seguida de fallos consistentes, confirmado con `curl` externo a los 3 hosts. El usuario lo resolvió revisando el servidor físicamente; no se necesitó ninguna acción de este lado. Aprendizaje: si `supabase.buglabs.dev` empieza a fallar con 530/1033 en medio de una sesión de dev, sospechar del túnel completo antes de asumir un bug de código — afecta a la vez prod, dev local (comparten DB) y el acceso SSH.
|
||||
- **Error propio durante el QA — datos de prueba escritos sobre perfiles reales**: para probar el guardado de `/perfil` se necesitó una cookie "sticky" (`preview_rol`) además del querystring, porque los `fetch()` que dispara el propio formulario no llevan `?preview=`. La query de impersonación (`.eq('rol','alumno').limit(1)`, sin `order by`) no es determinística entre requests — dos escrituras de prueba consecutivas cayeron en **dos alumnos reales distintos** (Saul Guzman Garcia y Sergio Paolo Piñuelas Manzo), y un intento de "limpiar" con valores `null` cayó en un **tercer** alumno (Eduardo Avitia Castro) que no había sido tocado antes. Se revirtieron a `null` los dos perfiles que sí se escribieron con data de prueba (`QATEST0001`/sem 3/tutor 1); el tercero (Eduardo) ya estaba en `null` cuando se le escribió `null` encima, así que lo más probable es que no se perdiera nada real — coincide con el patrón de otro alumno real (Romell) que también tiene el perfil sin llenar. Aprendizaje para la próxima vez que se necesite probar una escritura contra un bypass basado en rol: **fijar el id exacto del perfil de prueba primero** (`select id from profiles where email = '...'`) en vez de un `.limit(1)` sin orden, precisamente para que esto no vuelva a pasar.
|
||||
- **Verificado sin hallazgos**: alumno (home, catálogo con buscador+carrito+checkout, guard de perfil incompleto, mis-préstamos), docente (catálogo sin campo de maestro, perfil con label "Número de empleado"), admin (panel con KPIs y actividad reciente, las 3 vistas de solicitudes con wrap correcto incluso con un vale real lleno de emoji spam de un alumno, `VerDetalles` con historial de audit_log correcto, grid de inventario con fotos/placeholder, categorías, maestros con columna `es_tutor`, estadísticas con las 3 gráficas de recharts, export CSV). Un falso positivo: el donut de "Distribución de estados" salía en blanco en un screenshot `--full` (full-page stitched) de agent-browser pero pintaba bien en un screenshot de viewport normal — confirmado que es un artefacto de la herramienta de captura, no un bug de la app (el SVG/paths están completos y correctos en el DOM).
|
||||
- **Nota de producto, no bug**: en `/admin/maestros`, de los 3 maestros solo "Sin especificar" tiene `es_tutor=true` — por eso el combobox de tutor en `/perfil` solo ofrece esa opción. Si se quiere que los alumnos puedan elegir a "Maria Angelica" o "Monica Cristina" como tutor real, hay que marcarles el checkbox "Es tutor" desde el CRUD.
|
||||
- **Pendiente de decisión del usuario**: si desplegar el fix de `process is not defined` a producción ahora (bug activo en prod: nadie puede guardar su perfil ni el admin editar materiales) — commit + push + `docker compose up -d --build` en buglabs, mismo patrón de siempre.
|
||||
|
||||
Binary file not shown.
@@ -38,6 +38,8 @@ export default function BadgeSolicitudes() {
|
||||
() => {
|
||||
setBadge(readBadge() + 1);
|
||||
toast({ title: 'Nueva solicitud pendiente', kind: 'info' });
|
||||
// Autoplay puede fallar (política del navegador antes de interacción); ignorar.
|
||||
new Audio('/audio/sonido_notificacion.mp3').play().catch(() => {});
|
||||
},
|
||||
)
|
||||
.on(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { browserClient } from '@/lib/supabase';
|
||||
import { imgUrl } from '@/lib/materialImg';
|
||||
import { toast } from '@/lib/toast';
|
||||
import UnidadesManager from './UnidadesManager';
|
||||
@@ -25,8 +24,6 @@ type Props = {
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
const BUCKET = 'materiales-fotos';
|
||||
|
||||
export default function MaterialForm({ mode, material, categorias, compact }: Props) {
|
||||
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
||||
const firstFieldRef = useRef<HTMLInputElement | null>(null);
|
||||
@@ -99,18 +96,16 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr
|
||||
if (error) errorRef.current?.focus();
|
||||
}, [error]);
|
||||
|
||||
async function uploadFotoFor(id: number): Promise<string | null> {
|
||||
if (!file) return null;
|
||||
const ext = (file.name.split('.').pop() ?? 'jpg').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
const path = `${id}/${Date.now()}.${ext || 'jpg'}`;
|
||||
const { error: upErr } = await browserClient()
|
||||
.storage.from(BUCKET)
|
||||
.upload(path, file, { upsert: true, contentType: file.type || undefined });
|
||||
if (upErr) {
|
||||
toast({ kind: 'error', title: 'La foto no se subió', description: 'El material se guardó sin foto.' });
|
||||
async function uploadFotoFor(materialId: number, f: File): Promise<string | null> {
|
||||
const fd = new FormData();
|
||||
fd.append('file', f);
|
||||
const res = await fetch(`/api/upload/material-foto/${materialId}`, { method: 'POST', body: fd });
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
toast({ kind: 'error', title: 'La foto no se subió', description: json?.error ?? 'El material se guardó sin foto.' });
|
||||
return null;
|
||||
}
|
||||
return path;
|
||||
return json.path as string;
|
||||
}
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
@@ -151,26 +146,14 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(json?.error ?? 'No se pudo crear');
|
||||
|
||||
// 2) subir foto (si hay) y PATCH imagen_path
|
||||
// 2) subir foto (si hay) — el endpoint ya persiste imagen_path
|
||||
if (file && json.id) {
|
||||
const path = await uploadFotoFor(json.id);
|
||||
if (path) {
|
||||
const p = await fetch(`/api/admin/materiales/${json.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ imagen_path: path }),
|
||||
});
|
||||
if (!p.ok) {
|
||||
toast({ kind: 'error', title: 'Foto subida pero no vinculada', description: 'Recarga y vuelve a intentar.' });
|
||||
}
|
||||
}
|
||||
await uploadFotoFor(json.id, file);
|
||||
}
|
||||
} else {
|
||||
// edit — sube nueva foto primero (si hay) y luego PATCH con el path incluido
|
||||
let newImagenPath: string | null | undefined = undefined;
|
||||
// edit — sube nueva foto primero (si hay); el endpoint persiste imagen_path
|
||||
if (file && material) {
|
||||
const path = await uploadFotoFor(material.id);
|
||||
if (path) newImagenPath = path;
|
||||
await uploadFotoFor(material.id, file);
|
||||
}
|
||||
const patch: Record<string, unknown> = {
|
||||
nombre: nombreT,
|
||||
@@ -181,7 +164,7 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr
|
||||
trackeado_por_unidad: trackeado,
|
||||
};
|
||||
if (!trackeado) patch.cantidad_total = cantidad;
|
||||
if (newImagenPath !== undefined) patch.imagen_path = newImagenPath;
|
||||
// ponytail: si el user quitó la foto (imagenPath=null y sin file), no lo PATCHeamos aquí — ese flow ya está fuera de scope de este fix
|
||||
const res = await fetch(`/api/admin/materiales/${material!.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
mode: 'create' | 'edit';
|
||||
maestro?: { id: number; nombre: string; activo: boolean };
|
||||
maestro?: { id: number; nombre: string; activo: boolean; es_tutor: boolean };
|
||||
};
|
||||
|
||||
export default function MaestroForm({ mode, maestro }: Props) {
|
||||
@@ -11,6 +11,7 @@ export default function MaestroForm({ mode, maestro }: Props) {
|
||||
const errorRef = useRef<HTMLParagraphElement | null>(null);
|
||||
const [nombre, setNombre] = useState(maestro?.nombre ?? '');
|
||||
const [activo, setActivo] = useState(maestro?.activo ?? true);
|
||||
const [esTutor, setEsTutor] = useState(maestro?.es_tutor ?? false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -21,6 +22,7 @@ export default function MaestroForm({ mode, maestro }: Props) {
|
||||
if (mode === 'create') {
|
||||
setNombre('');
|
||||
setActivo(true);
|
||||
setEsTutor(false);
|
||||
}
|
||||
dialogRef.current?.showModal();
|
||||
queueMicrotask(() => firstFieldRef.current?.focus());
|
||||
@@ -60,7 +62,7 @@ export default function MaestroForm({ mode, maestro }: Props) {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ nombre: trimmed, activo }),
|
||||
body: JSON.stringify({ nombre: trimmed, activo, es_tutor: esTutor }),
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(json?.error ?? 'No se pudo guardar');
|
||||
@@ -126,7 +128,17 @@ export default function MaestroForm({ mode, maestro }: Props) {
|
||||
checked={activo}
|
||||
onChange={(e) => setActivo(e.target.checked)}
|
||||
/>
|
||||
Activo (visible en la lista de tutores)
|
||||
Activo (visible en el catálogo de maestros)
|
||||
</label>
|
||||
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
checked={esTutor}
|
||||
onChange={(e) => setEsTutor(e.target.checked)}
|
||||
/>
|
||||
<span>Es tutor <span className="opacity-70">(aparece en la lista de tutores del perfil de alumnos)</span></span>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -41,7 +41,7 @@ function useDialog() {
|
||||
|
||||
function AprobarDialog({ prestamo }: { prestamo: Prestamo }) {
|
||||
const dlg = useDialog();
|
||||
const [fecha, setFecha] = useState(enDias(7));
|
||||
const [fecha, setFecha] = useState(enDias(1));
|
||||
const [notas, setNotas] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -197,7 +197,7 @@ export default function VerDetalles({ prestamoId }: { prestamoId: number }) {
|
||||
<dt className="opacity-70">Alumno</dt>
|
||||
<dd className="col-span-2">{data.prestamo.alumno?.nombre ?? data.prestamo.alumno?.email}</dd>
|
||||
<dt className="opacity-70">Maestro</dt>
|
||||
<dd className="col-span-2">{data.prestamo.maestro_responsable}</dd>
|
||||
<dd className="col-span-2 break-words">{data.prestamo.maestro_responsable}</dd>
|
||||
<dt className="opacity-70">Materiales</dt>
|
||||
<dd className="col-span-2">
|
||||
<ul className="list-disc pl-4 space-y-2">
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { toastAfterReload } from '@/lib/toast';
|
||||
|
||||
type Props = {
|
||||
profile: { id: string; nombre?: string | null; email: string };
|
||||
adminSelf: boolean;
|
||||
};
|
||||
|
||||
export default function BanearForm({ profile, adminSelf }: Props) {
|
||||
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
||||
const firstFieldRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const errorRef = useRef<HTMLParagraphElement | null>(null);
|
||||
const [razon, setRazon] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const titleId = `banear-${profile.id}`;
|
||||
const label = profile.nombre?.trim() || profile.email;
|
||||
|
||||
const open = () => {
|
||||
if (adminSelf) return;
|
||||
setError(null);
|
||||
setRazon('');
|
||||
dialogRef.current?.showModal();
|
||||
queueMicrotask(() => firstFieldRef.current?.focus());
|
||||
};
|
||||
const close = () => {
|
||||
if (loading) return;
|
||||
dialogRef.current?.close();
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (error) errorRef.current?.focus();
|
||||
}, [error]);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (loading) return;
|
||||
const trimmed = razon.trim();
|
||||
if (trimmed.length < 5) {
|
||||
setError('La razón debe tener al menos 5 caracteres');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch('/api/admin/baneos', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ profile_id: profile.id, razon: trimmed }),
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(json?.error ?? 'No se pudo banear');
|
||||
toastAfterReload({
|
||||
title: 'Usuario baneado',
|
||||
description: label,
|
||||
kind: 'success',
|
||||
});
|
||||
dialogRef.current?.close();
|
||||
location.reload();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error inesperado');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
minHeight: '36px',
|
||||
paddingBlock: '0.25rem',
|
||||
color: adminSelf ? undefined : 'var(--color-danger-text)',
|
||||
opacity: adminSelf ? 0.5 : 1,
|
||||
cursor: adminSelf ? 'not-allowed' : undefined,
|
||||
}}
|
||||
onClick={open}
|
||||
disabled={adminSelf}
|
||||
title={adminSelf ? 'No puedes banearte a ti mismo' : undefined}
|
||||
aria-label={adminSelf ? 'No puedes banearte a ti mismo' : `Banear a ${label}`}
|
||||
>
|
||||
Banear
|
||||
</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" autoComplete="off">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h2 id={titleId} className="text-lg font-semibold">
|
||||
Banear a {label}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
aria-label="Cerrar"
|
||||
className="rounded-lg p-1 hover:bg-black/5 transition-colors leading-none text-xl"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm" style={{ color: 'var(--color-pencil)' }}>
|
||||
El usuario no podrá acceder al sistema hasta ser desbaneado.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="label" htmlFor={`${titleId}-razon`}>
|
||||
Razón del baneo
|
||||
</label>
|
||||
<textarea
|
||||
ref={firstFieldRef}
|
||||
id={`${titleId}-razon`}
|
||||
className="input"
|
||||
rows={4}
|
||||
value={razon}
|
||||
onChange={(e) => setRazon(e.target.value)}
|
||||
required
|
||||
minLength={5}
|
||||
maxLength={500}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? `${titleId}-err` : undefined}
|
||||
placeholder="Explica el motivo — el usuario lo verá en su pantalla de bloqueo."
|
||||
/>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--color-pencil)' }}>
|
||||
{razon.length}/500
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
ref={errorRef}
|
||||
id={`${titleId}-err`}
|
||||
role="alert"
|
||||
tabIndex={-1}
|
||||
className="text-sm"
|
||||
style={{ color: 'var(--color-danger-text)' }}
|
||||
>
|
||||
{error}
|
||||
</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"
|
||||
style={{ background: 'var(--color-danger)', color: 'var(--color-ink)' }}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Baneando…' : 'Banear'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { toastAfterReload } from '@/lib/toast';
|
||||
|
||||
type Props = {
|
||||
baneoId: number;
|
||||
nombre: string;
|
||||
};
|
||||
|
||||
export default function DesbanearButton({ baneoId, nombre }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const errorDialogRef = useRef<HTMLDialogElement | null>(null);
|
||||
const titleId = `desbanear-err-${baneoId}`;
|
||||
|
||||
const submit = async () => {
|
||||
if (loading) return;
|
||||
if (!window.confirm(`¿Desbanear a ${nombre}? Recuperará acceso inmediato.`)) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/baneos/${baneoId}/desbanear`, { method: 'POST' });
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(json?.error ?? 'No se pudo desbanear');
|
||||
toastAfterReload({
|
||||
title: 'Usuario desbaneado',
|
||||
description: nombre,
|
||||
kind: 'success',
|
||||
});
|
||||
location.reload();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error inesperado');
|
||||
errorDialogRef.current?.showModal();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||
onClick={submit}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Desbaneando…' : 'Desbanear'}
|
||||
</button>
|
||||
|
||||
<dialog
|
||||
ref={errorDialogRef}
|
||||
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,26rem)]"
|
||||
>
|
||||
<div className="p-5 sm:p-6 flex flex-col gap-4">
|
||||
<h2 id={titleId} className="text-lg font-semibold">
|
||||
No se pudo desbanear
|
||||
</h2>
|
||||
<p className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
|
||||
{error}
|
||||
</p>
|
||||
<div className="flex justify-end pt-2">
|
||||
<button type="button" className="btn btn-primary" onClick={() => errorDialogRef.current?.close()}>
|
||||
Entendido
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { toast, toastAfterReload } from '@/lib/toast';
|
||||
import { imgUrl } from '@/lib/materialImg';
|
||||
import AgregarMaterial from './AgregarMaterial';
|
||||
|
||||
type Material = {
|
||||
@@ -9,9 +10,18 @@ type Material = {
|
||||
cantidad_disponible: number;
|
||||
cantidad_total: number;
|
||||
numero_inventario: string | null;
|
||||
imagen_path: string | null;
|
||||
categoria: { id: number; nombre: string } | null;
|
||||
};
|
||||
|
||||
// El catálogo sólo lista materiales con estado 'disponible' (ver catalogo.astro),
|
||||
// así que el chip siempre coincide con el mismo estilo que usa el inventario del admin.
|
||||
const DISPONIBLE_BADGE_STYLE = {
|
||||
background: 'color-mix(in oklab, var(--color-positive) 18%, white)',
|
||||
color: 'var(--color-ink)',
|
||||
border: '1.5px solid var(--color-positive)',
|
||||
} as const;
|
||||
|
||||
type CartItem = {
|
||||
material_id: number;
|
||||
nombre: string;
|
||||
@@ -39,19 +49,26 @@ export function useCart() {
|
||||
|
||||
const clamp = (n: number, max: number) => Math.max(1, Math.min(max, n));
|
||||
|
||||
type MaestroOpt = { id: number; nombre: string };
|
||||
|
||||
export default function CartProvider({
|
||||
materiales,
|
||||
tutorNombre = null,
|
||||
perfilCompleto = true,
|
||||
esDocente = false,
|
||||
maestros = [],
|
||||
}: {
|
||||
materiales: Material[];
|
||||
tutorNombre?: string | null;
|
||||
perfilCompleto?: boolean;
|
||||
esDocente?: boolean;
|
||||
maestros?: MaestroOpt[];
|
||||
}) {
|
||||
const [cart, setCart] = useState<Map<number, CartItem>>(new Map());
|
||||
const cartHydrated = useRef(false);
|
||||
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
||||
const firstFieldRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const [maestroResponsable, setMaestroResponsable] = useState('');
|
||||
const firstFieldRef = useRef<HTMLSelectElement | null>(null);
|
||||
const [maestroId, setMaestroId] = useState<string>('');
|
||||
const [notas, setNotas] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -124,6 +141,43 @@ export default function CartProvider({
|
||||
dialogRef.current?.close();
|
||||
};
|
||||
|
||||
// Hidrata carrito desde localStorage al montar
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
const raw = window.localStorage.getItem('labre:cart:v1');
|
||||
if (raw) {
|
||||
const arr = JSON.parse(raw);
|
||||
if (Array.isArray(arr)) {
|
||||
const next = new Map<number, CartItem>();
|
||||
for (const i of arr) {
|
||||
if (i && typeof i.material_id === 'number' && typeof i.nombre === 'string') {
|
||||
next.set(i.material_id, {
|
||||
material_id: i.material_id,
|
||||
nombre: i.nombre,
|
||||
cantidad: clamp(Number(i.cantidad) || 1, Number(i.cantidad_disponible) || 1),
|
||||
cantidad_disponible: Number(i.cantidad_disponible) || 1,
|
||||
descripcion: typeof i.descripcion === 'string' ? i.descripcion : '',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (next.size > 0) setCart(next);
|
||||
}
|
||||
}
|
||||
} catch { /* localStorage bloqueado o JSON inválido */ }
|
||||
cartHydrated.current = true;
|
||||
}, []);
|
||||
|
||||
// Persiste carrito a localStorage cuando cambia (después de la hidratación inicial)
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (!cartHydrated.current) return;
|
||||
try {
|
||||
if (cart.size === 0) window.localStorage.removeItem('labre:cart:v1');
|
||||
else window.localStorage.setItem('labre:cart:v1', JSON.stringify(Array.from(cart.values())));
|
||||
} catch { /* localStorage bloqueado */ }
|
||||
}, [cart]);
|
||||
|
||||
// Cerrar al click en backdrop
|
||||
useEffect(() => {
|
||||
const dlg = dialogRef.current;
|
||||
@@ -141,11 +195,12 @@ export default function CartProvider({
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const maestroIdNum = maestroId ? Number(maestroId) : null;
|
||||
const res = await fetch('/api/solicitudes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
maestro_responsable: maestroResponsable,
|
||||
maestro_id: esDocente ? null : maestroIdNum,
|
||||
notas: notas || undefined,
|
||||
items: items.map((i) => ({
|
||||
material_id: i.material_id,
|
||||
@@ -159,7 +214,7 @@ export default function CartProvider({
|
||||
throw new Error(json?.error ?? 'No se pudo enviar la solicitud');
|
||||
}
|
||||
setOk(true);
|
||||
const usedFallback = maestroResponsable.trim() === '' && !!tutorNombre;
|
||||
const usedFallback = !esDocente && !maestroIdNum && !!tutorNombre;
|
||||
toastAfterReload({
|
||||
kind: 'success',
|
||||
title: 'Solicitud enviada',
|
||||
@@ -184,30 +239,41 @@ export default function CartProvider({
|
||||
return (
|
||||
<CartContext.Provider value={value}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{materiales.map((m) => (
|
||||
{materiales.map((m) => {
|
||||
const src = imgUrl(m.imagen_path);
|
||||
return (
|
||||
<article
|
||||
key={m.id}
|
||||
className="card flex flex-col gap-3"
|
||||
className="card p-0 flex flex-col overflow-hidden"
|
||||
data-cat={m.categoria?.id ?? 'sin'}
|
||||
data-nombre={m.nombre}
|
||||
data-numero-inventario={m.numero_inventario ?? ''}
|
||||
>
|
||||
<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)',
|
||||
}}
|
||||
<div
|
||||
className="relative aspect-square w-full bg-[color:var(--color-chalk)] grid place-items-center"
|
||||
style={{ borderBottom: '2px solid var(--color-ink)' }}
|
||||
>
|
||||
{m.categoria.nombre}
|
||||
</span>
|
||||
{src ? (
|
||||
<img src={src} alt={m.nombre} className="w-full h-full object-cover" loading="lazy" />
|
||||
) : (
|
||||
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="var(--color-ink)" strokeWidth={1.5} aria-hidden="true">
|
||||
<rect x="3" y="4" width="18" height="16" rx="1" />
|
||||
<path d="M3 16l5-5 4 4 3-3 6 6" />
|
||||
<circle cx="8" cy="9" r="1.5" />
|
||||
</svg>
|
||||
)}
|
||||
<span
|
||||
className="absolute bottom-1.5 right-1.5 text-xs px-2 py-0.5 rounded-[2px] whitespace-nowrap shadow-[var(--shadow-hard-sm)]"
|
||||
style={DISPONIBLE_BADGE_STYLE}
|
||||
>
|
||||
Disponible
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4 flex flex-col gap-3 flex-1">
|
||||
<h3 className="font-semibold leading-snug">{m.nombre}</h3>
|
||||
{m.categoria && <p className="text-xs opacity-70 -mt-1">{m.categoria.nombre}</p>}
|
||||
|
||||
{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">
|
||||
@@ -222,8 +288,10 @@ export default function CartProvider({
|
||||
<div className="mt-auto">
|
||||
<AgregarMaterial material={{ id: m.id, nombre: m.nombre, cantidad_disponible: m.cantidad_disponible }} />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{items.length > 0 && (
|
||||
@@ -317,30 +385,31 @@ export default function CartProvider({
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!esDocente && (
|
||||
<div>
|
||||
<label className="label" htmlFor="maestro-responsable">Maestro responsable</label>
|
||||
<textarea
|
||||
<label className="label" htmlFor="maestro-select">Maestro responsable</label>
|
||||
<select
|
||||
ref={firstFieldRef}
|
||||
id="maestro-responsable"
|
||||
id="maestro-select"
|
||||
className="input"
|
||||
rows={2}
|
||||
maxLength={200}
|
||||
value={maestroResponsable}
|
||||
onChange={(e) => setMaestroResponsable(e.target.value)}
|
||||
placeholder={tutorNombre ?? 'Escribe el nombre del maestro responsable'}
|
||||
value={maestroId}
|
||||
onChange={(e) => setMaestroId(e.target.value)}
|
||||
aria-describedby="maestro-hint"
|
||||
style={{ minHeight: '64px' }}
|
||||
/>
|
||||
{tutorNombre ? (
|
||||
>
|
||||
<option value="">
|
||||
{tutorNombre ? `— Usar mi tutor (${tutorNombre}) —` : '— Elige un maestro —'}
|
||||
</option>
|
||||
{maestros.map((m) => (
|
||||
<option key={m.id} value={String(m.id)}>{m.nombre}</option>
|
||||
))}
|
||||
</select>
|
||||
<p id="maestro-hint" className="text-xs opacity-70 mt-1">
|
||||
Si dejas esto vacío, se usará tu tutor: <strong>{tutorNombre}</strong>.
|
||||
{tutorNombre
|
||||
? <>Si no eliges nadie se usará tu tutor: <strong>{tutorNombre}</strong>.</>
|
||||
: 'Debes elegir un maestro; no tienes tutor guardado.'}
|
||||
</p>
|
||||
) : (
|
||||
<p id="maestro-hint" className="text-xs opacity-60 mt-1">
|
||||
Debes escribir un maestro; no tienes tutor guardado.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="label" htmlFor="notas-cart">Motivo del préstamo</label>
|
||||
@@ -348,7 +417,7 @@ export default function CartProvider({
|
||||
id="notas-cart"
|
||||
className="input"
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
maxLength={100}
|
||||
value={notas}
|
||||
onChange={(e) => setNotas(e.target.value)}
|
||||
placeholder="¿Para qué clase o proyecto lo necesitas?"
|
||||
@@ -356,7 +425,7 @@ export default function CartProvider({
|
||||
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)
|
||||
Ejemplo: Clase de Electrónica Analógica · Prof. Gómez. (Opcional, máx. 100 caracteres)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -377,7 +446,16 @@ export default function CartProvider({
|
||||
<a href="/perfil" className="btn btn-primary">Completar perfil</a>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<div className="flex gap-2 justify-between items-center pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => { if (window.confirm('¿Vaciar todo el carrito?')) setCart(new Map()); }}
|
||||
disabled={loading || items.length === 0}
|
||||
>
|
||||
Vaciar
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" className="btn btn-ghost" onClick={closeCheckout} disabled={loading}>
|
||||
Cancelar
|
||||
</button>
|
||||
@@ -385,6 +463,7 @@ export default function CartProvider({
|
||||
{loading ? 'Enviando…' : 'Enviar solicitud'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { browserClient } from '@/lib/supabase';
|
||||
import { avatarInfo } from '@/lib/avatar';
|
||||
import { toast, toastAfterReload } from '@/lib/toast';
|
||||
import Avatar from './Avatar';
|
||||
@@ -10,6 +9,7 @@ type Props = {
|
||||
userId: string;
|
||||
email: string;
|
||||
nombre: string | null;
|
||||
rol: 'alumno' | 'docente' | 'admin';
|
||||
initialProfile: {
|
||||
matricula: string | null;
|
||||
semestre: string | null;
|
||||
@@ -21,18 +21,20 @@ type Props = {
|
||||
mode?: 'edit' | 'onboarding';
|
||||
};
|
||||
|
||||
const BUCKET = 'avatares';
|
||||
const SEM_OPTS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'];
|
||||
|
||||
export default function PerfilForm({
|
||||
userId,
|
||||
email,
|
||||
nombre,
|
||||
rol,
|
||||
initialProfile,
|
||||
maestros,
|
||||
googleAvatarUrl,
|
||||
mode = 'edit',
|
||||
}: Props) {
|
||||
const soloAlumno = rol === 'alumno';
|
||||
const matriculaLabel = rol === 'docente' ? 'Número de empleado' : 'Matrícula';
|
||||
const [matricula, setMatricula] = useState(initialProfile.matricula ?? '');
|
||||
const [semestre, setSemestre] = useState(initialProfile.semestre ?? '');
|
||||
const [tutorId, setTutorId] = useState<string>(
|
||||
@@ -65,18 +67,16 @@ export default function PerfilForm({
|
||||
? { ...info, src: previewUrl }
|
||||
: info;
|
||||
|
||||
async function uploadAvatar(): Promise<string | null> {
|
||||
if (!file) return null;
|
||||
const ext = (file.name.split('.').pop() ?? 'jpg').toLowerCase().replace(/[^a-z0-9]/g, '') || 'jpg';
|
||||
const path = `${userId}/${Date.now()}.${ext}`;
|
||||
const { error: upErr } = await browserClient()
|
||||
.storage.from(BUCKET)
|
||||
.upload(path, file, { upsert: true, contentType: file.type || undefined });
|
||||
if (upErr) {
|
||||
toast({ kind: 'error', title: 'La foto no se subió', description: upErr.message });
|
||||
return null;
|
||||
async function uploadAvatar(f: File): Promise<boolean> {
|
||||
const fd = new FormData();
|
||||
fd.append('file', f);
|
||||
const res = await fetch('/api/upload/avatar', { method: 'POST', body: fd });
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
toast({ kind: 'error', title: 'La foto no se subió', description: json?.error ?? 'Intenta de nuevo.' });
|
||||
return false;
|
||||
}
|
||||
return path;
|
||||
return true;
|
||||
}
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
@@ -85,16 +85,18 @@ export default function PerfilForm({
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
let nuevoPath: string | null | undefined = undefined;
|
||||
// La foto se sube por su propio endpoint (que ya persiste foto_path).
|
||||
// El PATCH /api/profile solo maneja matricula/semestre/tutor.
|
||||
if (file) {
|
||||
nuevoPath = await uploadAvatar();
|
||||
await uploadAvatar(file);
|
||||
}
|
||||
const body: Record<string, string | number | null> = {
|
||||
matricula: matricula.trim() || null,
|
||||
semestre: semestre || null,
|
||||
tutor_id: tutorId ? Number(tutorId) : null,
|
||||
};
|
||||
if (nuevoPath !== undefined) body.foto_path = nuevoPath;
|
||||
if (soloAlumno) {
|
||||
body.semestre = semestre || null;
|
||||
body.tutor_id = tutorId ? Number(tutorId) : null;
|
||||
}
|
||||
|
||||
const res = await fetch('/api/profile', {
|
||||
method: 'PATCH',
|
||||
@@ -154,7 +156,7 @@ export default function PerfilForm({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label" htmlFor="p-mat">Matrícula</label>
|
||||
<label className="label" htmlFor="p-mat">{matriculaLabel}</label>
|
||||
<input
|
||||
id="p-mat"
|
||||
className="input"
|
||||
@@ -167,6 +169,7 @@ export default function PerfilForm({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{soloAlumno && (
|
||||
<div>
|
||||
<label className="label" htmlFor="p-sem">Semestre</label>
|
||||
<select
|
||||
@@ -181,7 +184,9 @@ export default function PerfilForm({
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{soloAlumno && (
|
||||
<div>
|
||||
<label className="label" htmlFor="p-tutor">Tutor</label>
|
||||
<select
|
||||
@@ -197,6 +202,7 @@ export default function PerfilForm({
|
||||
</select>
|
||||
<p className="text-xs opacity-70 mt-1">Se rellenará por defecto al crear vales.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
|
||||
|
||||
Vendored
+1
-1
@@ -7,7 +7,7 @@ export type Profile = {
|
||||
email: string;
|
||||
nombre: string | null;
|
||||
matricula: string | null;
|
||||
rol: 'alumno' | 'admin';
|
||||
rol: 'alumno' | 'docente' | 'admin';
|
||||
semestre: string | null;
|
||||
tutor_id: number | null;
|
||||
foto_path: string | null;
|
||||
|
||||
@@ -57,6 +57,7 @@ const ICONS = {
|
||||
chart: 'M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z',
|
||||
logout: 'M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75',
|
||||
dashboard: 'M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5m.75-9l3-3 2.148 2.148A12.061 12.061 0 0116.5 7.605',
|
||||
users: 'M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z',
|
||||
};
|
||||
|
||||
const alumnoNav: NavItem[] = [
|
||||
@@ -70,6 +71,7 @@ const adminNav: NavItem[] = [
|
||||
{ href: '/admin/inventario', label: 'Inventario', icon: 'box' },
|
||||
{ href: '/admin/estadisticas', label: 'Estadísticas', icon: 'stats' },
|
||||
{ href: '/admin/reportes', label: 'Reportes', icon: 'chart' },
|
||||
{ href: '/admin/usuarios', label: 'Usuarios', icon: 'users' },
|
||||
];
|
||||
const nav = isAdmin ? adminNav : alumnoNav;
|
||||
|
||||
|
||||
@@ -38,5 +38,70 @@ const { title = 'Sistema de Préstamos — Laboratorio UABC' } = Astro.props;
|
||||
Saltar al contenido
|
||||
</a>
|
||||
<slot />
|
||||
<div id="page-loader" hidden aria-hidden="true" aria-live="polite">
|
||||
<div class="page-loader-inner">
|
||||
<svg class="page-loader-spinner" viewBox="0 0 24 24" width="40" height="40" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<path d="M12 3a9 9 0 1 0 9 9" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<style is:global>
|
||||
#page-loader {
|
||||
position: fixed; inset: 0; z-index: 9999;
|
||||
background: color-mix(in oklab, var(--color-surface) 60%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
display: grid; place-items: center;
|
||||
}
|
||||
#page-loader[hidden] { display: none; }
|
||||
.page-loader-spinner {
|
||||
color: var(--color-ink);
|
||||
animation: labre-spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes labre-spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.page-loader-spinner { animation: none; }
|
||||
}
|
||||
</style>
|
||||
<script is:inline>
|
||||
(() => {
|
||||
const overlay = document.getElementById('page-loader');
|
||||
if (!overlay) return;
|
||||
const show = () => { overlay.hidden = false; };
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
const a = (e.target instanceof Element) ? e.target.closest('a[href]') : null;
|
||||
if (!a) return;
|
||||
if (e.defaultPrevented) return;
|
||||
if (e.button !== 0) return;
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
|
||||
if (a.target && a.target !== '_self') return;
|
||||
const href = a.getAttribute('href') || '';
|
||||
if (!href || href.startsWith('#') || href.startsWith('javascript:') || href.startsWith('mailto:') || href.startsWith('tel:')) return;
|
||||
try {
|
||||
const url = new URL(a.href, location.href);
|
||||
if (url.origin !== location.origin) return;
|
||||
if (url.pathname === location.pathname && url.search === location.search) return;
|
||||
} catch { return; }
|
||||
show();
|
||||
}, true);
|
||||
|
||||
document.addEventListener('submit', (e) => {
|
||||
if (e.defaultPrevented) return;
|
||||
const form = e.target;
|
||||
if (!(form instanceof HTMLFormElement)) return;
|
||||
try {
|
||||
const action = form.getAttribute('action');
|
||||
if (action) {
|
||||
const url = new URL(action, location.href);
|
||||
if (url.origin !== location.origin) return;
|
||||
}
|
||||
} catch { return; }
|
||||
show();
|
||||
}, true);
|
||||
|
||||
window.addEventListener('pageshow', () => { overlay.hidden = true; });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+7
-3
@@ -4,9 +4,13 @@ import type { AstroCookies } from 'astro';
|
||||
|
||||
const SCHEMA = 'prestamos';
|
||||
|
||||
const SUPABASE_URL = process.env.PUBLIC_SUPABASE_URL ?? import.meta.env.PUBLIC_SUPABASE_URL;
|
||||
const SUPABASE_ANON_KEY = process.env.PUBLIC_SUPABASE_ANON_KEY ?? import.meta.env.PUBLIC_SUPABASE_ANON_KEY;
|
||||
const SUPABASE_SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY ?? import.meta.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
// `process` no existe en el bundle de browser (islands con client:load que
|
||||
// importan browserClient) — hay que checar typeof antes de leerlo o revienta
|
||||
// la hidratación con "process is not defined" en vez de caer al fallback.
|
||||
const hasProcess = typeof process !== 'undefined';
|
||||
const SUPABASE_URL = (hasProcess ? process.env.PUBLIC_SUPABASE_URL : undefined) ?? import.meta.env.PUBLIC_SUPABASE_URL;
|
||||
const SUPABASE_ANON_KEY = (hasProcess ? process.env.PUBLIC_SUPABASE_ANON_KEY : undefined) ?? import.meta.env.PUBLIC_SUPABASE_ANON_KEY;
|
||||
const SUPABASE_SERVICE_KEY = (hasProcess ? process.env.SUPABASE_SERVICE_ROLE_KEY : undefined) ?? import.meta.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
|
||||
// ponytail: solo para preview local sin login. Bypass RLS con service_role.
|
||||
// Su uso queda tras un flag de DEV en el middleware.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const truncate = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '…' : s);
|
||||
@@ -3,6 +3,8 @@ import { serverClient } from '@/lib/supabase';
|
||||
|
||||
const UABC_DOMAIN = '@uabc.edu.mx';
|
||||
const PUBLIC_ROUTES = ['/login', '/api/auth/signin', '/api/auth/callback', '/api/auth/signout'];
|
||||
// Rutas siempre accesibles para un user baneado (sino, loop de rewrite)
|
||||
const BANNED_ALLOWED = ['/banned', '/api/auth/signout'];
|
||||
|
||||
export const onRequest = defineMiddleware(async (context, next) => {
|
||||
const supabase = serverClient(context.cookies);
|
||||
@@ -56,6 +58,15 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||
return context.redirect('/');
|
||||
}
|
||||
|
||||
// Ban guard: si el user está baneado (y no es admin), toda ruta cae a /banned
|
||||
// salvo la propia /banned y el signout. RPC is_banned respeta expires_at.
|
||||
if (user && context.locals.profile && context.locals.profile.rol !== 'admin') {
|
||||
const { data: banned } = await supabase.rpc('is_banned', { p_uid: user.id });
|
||||
if (banned === true && !BANNED_ALLOWED.includes(pathname)) {
|
||||
return context.rewrite('/banned');
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/admin') && context.locals.profile?.rol !== 'admin') {
|
||||
return context.rewrite('/403');
|
||||
}
|
||||
|
||||
@@ -109,21 +109,6 @@ const estadoBadge = (e: Material['estado']) => {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script is:inline>
|
||||
(() => {
|
||||
const form = document.getElementById('inv-filter-form');
|
||||
if (!form) return;
|
||||
const q = form.querySelector('input[name="q"]');
|
||||
const selects = form.querySelectorAll('select');
|
||||
let t;
|
||||
q && q.addEventListener('input', () => {
|
||||
clearTimeout(t);
|
||||
t = setTimeout(() => form.submit(), 250);
|
||||
});
|
||||
selects.forEach((s) => s.addEventListener('change', () => form.submit()));
|
||||
})();
|
||||
</script>
|
||||
|
||||
{matError && (
|
||||
<div class="card mb-6" role="alert" style="border-color: color-mix(in oklab, var(--color-danger) 30%, transparent);">
|
||||
<p class="text-sm" style="color: var(--color-danger-text);">
|
||||
@@ -151,7 +136,7 @@ const estadoBadge = (e: Material['estado']) => {
|
||||
return (
|
||||
<article class="card p-0 flex flex-col overflow-hidden">
|
||||
<div
|
||||
class="aspect-square w-full bg-[color:var(--color-chalk)] grid place-items-center"
|
||||
class="relative aspect-square w-full bg-[color:var(--color-chalk)] grid place-items-center"
|
||||
style="border-bottom: 2px solid var(--color-ink);"
|
||||
>
|
||||
{src ? (
|
||||
@@ -163,14 +148,12 @@ const estadoBadge = (e: Material['estado']) => {
|
||||
<circle cx="8" cy="9" r="1.5" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div class="p-3 flex flex-col gap-2 flex-1">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<h3 class="uppercase font-semibold text-sm leading-snug line-clamp-2">{m.nombre}</h3>
|
||||
<span class="text-xs px-2 py-0.5 rounded-[2px] whitespace-nowrap shrink-0" style={badge.style}>
|
||||
<span class="absolute bottom-1.5 right-1.5 text-xs px-2 py-0.5 rounded-[2px] whitespace-nowrap shadow-[var(--shadow-hard-sm)]" style={badge.style}>
|
||||
{badge.label}
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-3 flex flex-col gap-2 flex-1">
|
||||
<h3 class="uppercase font-semibold text-sm leading-snug line-clamp-2">{m.nombre}</h3>
|
||||
{m.categoria && (
|
||||
<p class="text-xs opacity-70 -mt-1">{m.categoria.nombre}</p>
|
||||
)}
|
||||
|
||||
@@ -7,18 +7,20 @@ type MaestroRow = {
|
||||
id: number;
|
||||
nombre: string;
|
||||
activo: boolean;
|
||||
es_tutor: boolean;
|
||||
alumnos: { count: number }[];
|
||||
};
|
||||
|
||||
const { data, error } = await Astro.locals.supabase
|
||||
.from('maestros')
|
||||
.select('id, nombre, activo, alumnos:profiles!tutor_id(count)')
|
||||
.select('id, nombre, activo, es_tutor, alumnos:profiles!tutor_id(count)')
|
||||
.order('nombre');
|
||||
|
||||
const maestros = ((data ?? []) as unknown as MaestroRow[]).map((m) => ({
|
||||
id: m.id,
|
||||
nombre: m.nombre,
|
||||
activo: m.activo,
|
||||
es_tutor: m.es_tutor,
|
||||
count: m.alumnos?.[0]?.count ?? 0,
|
||||
}));
|
||||
---
|
||||
@@ -67,6 +69,7 @@ const maestros = ((data ?? []) as unknown as MaestroRow[]).map((m) => ({
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">Nombre</th>
|
||||
<th class="px-4 py-3 font-medium">Estado</th>
|
||||
<th class="px-4 py-3 font-medium text-center">Tutor</th>
|
||||
<th class="px-4 py-3 font-medium text-right"># Alumnos</th>
|
||||
<th class="px-4 py-3 font-medium text-right">Acciones</th>
|
||||
</tr>
|
||||
@@ -82,12 +85,21 @@ const maestros = ((data ?? []) as unknown as MaestroRow[]).map((m) => ({
|
||||
<span class="text-xs uppercase tracking-wide opacity-60">Inactivo</span>
|
||||
)}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
{m.es_tutor ? (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-label="Sí" role="img" style="display:inline-block; color: var(--color-primary);">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
) : (
|
||||
<span class="opacity-40" aria-label="No">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right" style="font-variant-numeric: tabular-nums;">
|
||||
{m.count > 0 ? m.count : <span class="opacity-60">0</span>}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex gap-2 justify-end">
|
||||
<MaestroForm mode="edit" maestro={{ id: m.id, nombre: m.nombre, activo: m.activo }} client:load />
|
||||
<MaestroForm mode="edit" maestro={{ id: m.id, nombre: m.nombre, activo: m.activo, es_tutor: m.es_tutor }} client:load />
|
||||
<EliminarMaestro id={m.id} nombre={m.nombre} alumnosCount={m.count} client:load />
|
||||
</div>
|
||||
</td>
|
||||
@@ -103,11 +115,11 @@ const maestros = ((data ?? []) as unknown as MaestroRow[]).map((m) => ({
|
||||
<div>
|
||||
<h3 class="font-semibold">{m.nombre}</h3>
|
||||
<p class="text-xs opacity-70 mt-1">
|
||||
{m.activo ? 'Activo' : 'Inactivo'} · {m.count} alumno{m.count === 1 ? '' : 's'}
|
||||
{m.activo ? 'Activo' : 'Inactivo'} · {m.es_tutor ? 'Tutor' : 'No tutor'} · {m.count} alumno{m.count === 1 ? '' : 's'}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 shrink-0">
|
||||
<MaestroForm mode="edit" maestro={{ id: m.id, nombre: m.nombre, activo: m.activo }} client:load />
|
||||
<MaestroForm mode="edit" maestro={{ id: m.id, nombre: m.nombre, activo: m.activo, es_tutor: m.es_tutor }} client:load />
|
||||
<EliminarMaestro id={m.id} nombre={m.nombre} alumnosCount={m.count} client:load />
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import AppLayout from '@/layouts/AppLayout.astro';
|
||||
import MarcarDevuelto from '@/components/admin/solicitudes/MarcarDevuelto.tsx';
|
||||
import VerDetalles from '@/components/admin/solicitudes/VerDetalles.tsx';
|
||||
import { truncate } from '@/lib/text';
|
||||
|
||||
const supabase = Astro.locals.supabase;
|
||||
const { data, error } = await supabase
|
||||
@@ -94,7 +95,7 @@ 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>
|
||||
<div class="text-xs opacity-70 mt-1 break-words">Maestro: {r.maestro_responsable}</div>
|
||||
</td>
|
||||
<td class="p-3">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
@@ -107,7 +108,7 @@ const tabs = [
|
||||
</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}
|
||||
<span class="opacity-60">Motivo:</span> <span class="break-words">{truncate(r.notas, 50)}</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
@@ -150,7 +151,7 @@ const tabs = [
|
||||
)}
|
||||
</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="text-xs opacity-70 mb-2 break-words">Maestro: {r.maestro_responsable}</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{(r.items ?? []).map((i: any) => (
|
||||
<div class="text-sm">
|
||||
@@ -162,7 +163,7 @@ const tabs = [
|
||||
<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);">
|
||||
<span class="opacity-60">Motivo:</span> {r.notas}
|
||||
<span class="opacity-60">Motivo:</span> <span class="break-words">{truncate(r.notas, 50)}</span>
|
||||
</p>
|
||||
)}
|
||||
<div class="mt-3 flex gap-2 flex-wrap">
|
||||
|
||||
@@ -115,7 +115,7 @@ 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>
|
||||
<div class="text-xs opacity-70 mt-1 break-words">Maestro: {r.maestro_responsable}</div>
|
||||
</td>
|
||||
<td class="p-3">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
@@ -144,7 +144,7 @@ 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-xs opacity-70">Maestro: {r.maestro_responsable}</div>
|
||||
<div class="text-xs opacity-70 break-words">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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import AppLayout from '@/layouts/AppLayout.astro';
|
||||
import AccionesSolicitud from '@/components/admin/solicitudes/AccionesSolicitud.tsx';
|
||||
import { truncate } from '@/lib/text';
|
||||
|
||||
const supabase = Astro.locals.supabase;
|
||||
const { data, error } = await supabase
|
||||
@@ -84,7 +85,7 @@ 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>
|
||||
<div class="text-xs opacity-70 mt-1 break-words">Maestro: {r.maestro_responsable}</div>
|
||||
</td>
|
||||
<td class="p-3">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
@@ -99,7 +100,7 @@ const tabs = [
|
||||
</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}
|
||||
<span class="opacity-60">Motivo:</span> <span class="break-words">{truncate(r.notas, 50)}</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
@@ -124,7 +125,7 @@ const tabs = [
|
||||
<div class="text-xs opacity-70">{fmtFecha.format(new Date(r.fecha_solicitud))}</div>
|
||||
</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="text-xs opacity-70 mb-2 break-words">Maestro: {r.maestro_responsable}</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{(r.items ?? []).map((i: any) => (
|
||||
<div class="text-sm">
|
||||
@@ -135,7 +136,7 @@ const tabs = [
|
||||
</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}
|
||||
<span class="opacity-60">Motivo:</span> <span class="break-words">{truncate(r.notas, 50)}</span>
|
||||
</p>
|
||||
)}
|
||||
<div class="mt-3">
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
---
|
||||
import AppLayout from '@/layouts/AppLayout.astro';
|
||||
import BanearForm from '@/components/admin/usuarios/BanearForm.tsx';
|
||||
import DesbanearButton from '@/components/admin/usuarios/DesbanearButton.tsx';
|
||||
|
||||
type ProfileRow = {
|
||||
id: string;
|
||||
nombre: string | null;
|
||||
email: string;
|
||||
matricula: string | null;
|
||||
rol: 'alumno' | 'docente' | 'admin';
|
||||
};
|
||||
|
||||
type BaneoRow = {
|
||||
id: number;
|
||||
profile_id: string;
|
||||
razon: string;
|
||||
banned_at: string;
|
||||
};
|
||||
|
||||
const meId = Astro.locals.user?.id ?? null;
|
||||
|
||||
const [{ data: profiles, error: perr }, { data: baneos }] = await Promise.all([
|
||||
Astro.locals.supabase
|
||||
.from('profiles')
|
||||
.select('id, nombre, email, matricula, rol')
|
||||
.order('nombre'),
|
||||
Astro.locals.supabase
|
||||
.from('baneos')
|
||||
.select('id, profile_id, razon, banned_at')
|
||||
.is('unbanned_at', null),
|
||||
]);
|
||||
|
||||
const baneosByProfile = new Map<string, BaneoRow>();
|
||||
for (const b of (baneos ?? []) as BaneoRow[]) baneosByProfile.set(b.profile_id, b);
|
||||
|
||||
const usuarios = ((profiles ?? []) as ProfileRow[]).map((p) => ({
|
||||
...p,
|
||||
baneo: baneosByProfile.get(p.id) ?? null,
|
||||
}));
|
||||
|
||||
const rolLabel = (r: ProfileRow['rol']) =>
|
||||
r === 'admin' ? 'Admin' : r === 'docente' ? 'Docente' : 'Alumno';
|
||||
|
||||
const fmtDate = (iso: string) =>
|
||||
new Intl.DateTimeFormat('es-MX', {
|
||||
dateStyle: 'medium',
|
||||
timeZone: 'America/Tijuana',
|
||||
}).format(new Date(iso));
|
||||
|
||||
const trunc = (s: string, n = 80) => (s.length > n ? s.slice(0, n - 1) + '…' : s);
|
||||
---
|
||||
<AppLayout title="Usuarios — Admin">
|
||||
<div class="max-w-5xl">
|
||||
<header class="mb-6">
|
||||
<h1 class="text-2xl md:text-3xl font-semibold">Usuarios</h1>
|
||||
<p class="text-sm opacity-70 mt-1">Perfiles de todos los usuarios. Banea a quien haga mal uso del sistema.</p>
|
||||
</header>
|
||||
|
||||
{perr && (
|
||||
<div class="card mb-6" role="alert" style="border-color: color-mix(in oklab, var(--color-danger) 30%, transparent);">
|
||||
<p class="text-sm" style="color: var(--color-danger-text);">
|
||||
No se pudieron cargar los usuarios. Recarga la página.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!perr && usuarios.length === 0 && (
|
||||
<div class="card text-center">
|
||||
<h2 class="text-lg font-semibold mb-1">Sin usuarios aún</h2>
|
||||
<p class="text-sm opacity-70">Se listarán aquí en cuanto alguien inicie sesión.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!perr && usuarios.length > 0 && (
|
||||
<>
|
||||
{/* Desktop */}
|
||||
<div class="hidden md:block card p-0 overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">Nombre</th>
|
||||
<th class="px-4 py-3 font-medium">Email</th>
|
||||
<th class="px-4 py-3 font-medium">Rol</th>
|
||||
<th class="px-4 py-3 font-medium">Estado</th>
|
||||
<th class="px-4 py-3 font-medium text-right">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{usuarios.map((u) => {
|
||||
const isSelf = u.id === meId;
|
||||
return (
|
||||
<tr class="border-t align-top" style="border-color: color-mix(in oklab, var(--color-ink) 8%, transparent);">
|
||||
<td class="px-4 py-3">
|
||||
<div class="font-medium break-words">{u.nombre ?? '—'}</div>
|
||||
{u.matricula && <div class="text-xs opacity-60 mt-0.5" style="font-variant-numeric: tabular-nums;">{u.matricula}</div>}
|
||||
</td>
|
||||
<td class="px-4 py-3 break-all text-xs">{u.email}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="text-xs uppercase tracking-wide px-2 py-0.5 rounded-[2px]"
|
||||
style={`border: 1.5px solid var(--color-ink); ${u.rol === 'admin' ? 'background: var(--color-secondary);' : ''}`}
|
||||
>{rolLabel(u.rol)}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
{u.baneo ? (
|
||||
<div class="flex flex-col gap-1">
|
||||
<span
|
||||
class="text-xs uppercase tracking-wide px-2 py-0.5 rounded-[2px] w-fit"
|
||||
style="background: var(--color-danger); color: var(--color-ink); border: 1.5px solid var(--color-ink);"
|
||||
>Baneado</span>
|
||||
<span class="text-xs opacity-70 break-words">{trunc(u.baneo.razon)}</span>
|
||||
<span class="text-[11px] opacity-50">desde {fmtDate(u.baneo.banned_at)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span class="text-xs uppercase tracking-wide" style="color: var(--color-primary);">Activo</span>
|
||||
)}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex gap-2 justify-end">
|
||||
{u.baneo ? (
|
||||
<DesbanearButton baneoId={u.baneo.id} nombre={u.nombre ?? u.email} client:load />
|
||||
) : (
|
||||
<BanearForm
|
||||
profile={{ id: u.id, nombre: u.nombre, email: u.email }}
|
||||
adminSelf={isSelf}
|
||||
client:load
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile */}
|
||||
<div class="md:hidden flex flex-col gap-3">
|
||||
{usuarios.map((u) => {
|
||||
const isSelf = u.id === meId;
|
||||
return (
|
||||
<article class="card flex flex-col gap-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h3 class="font-semibold break-words">{u.nombre ?? '—'}</h3>
|
||||
<p class="text-xs opacity-70 break-all">{u.email}</p>
|
||||
<p class="text-xs opacity-60 mt-1">
|
||||
{rolLabel(u.rol)}{u.matricula ? ` · ${u.matricula}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
{u.baneo ? (
|
||||
<span
|
||||
class="text-xs uppercase tracking-wide px-2 py-0.5 rounded-[2px] shrink-0"
|
||||
style="background: var(--color-danger); color: var(--color-ink); border: 1.5px solid var(--color-ink);"
|
||||
>Baneado</span>
|
||||
) : (
|
||||
<span class="text-xs uppercase tracking-wide shrink-0" style="color: var(--color-primary);">Activo</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{u.baneo && (
|
||||
<div class="text-xs opacity-80 break-words">
|
||||
<strong>Razón:</strong> {trunc(u.baneo.razon, 120)}
|
||||
<div class="opacity-60 mt-1">desde {fmtDate(u.baneo.banned_at)}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="flex gap-2 justify-end">
|
||||
{u.baneo ? (
|
||||
<DesbanearButton baneoId={u.baneo.id} nombre={u.nombre ?? u.email} client:load />
|
||||
) : (
|
||||
<BanearForm
|
||||
profile={{ id: u.id, nombre: u.nombre, email: u.email }}
|
||||
adminSelf={isSelf}
|
||||
client:load
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AppLayout>
|
||||
@@ -8,6 +8,8 @@ const qInitial = Astro.url.searchParams.get('q') ?? '';
|
||||
|
||||
const supabase = Astro.locals.supabase;
|
||||
const userId = Astro.locals.user?.id;
|
||||
const rol = Astro.locals.profile?.rol ?? 'alumno';
|
||||
const esDocente = rol === 'docente';
|
||||
|
||||
// Trae matricula + tutor_id directo (el middleware sólo hidrata columnas antiguas).
|
||||
let matricula: string | null = null;
|
||||
@@ -27,7 +29,21 @@ if (tutorId) {
|
||||
const { data } = await supabase.from('maestros').select('nombre').eq('id', tutorId).maybeSingle();
|
||||
tutorNombre = data?.nombre ?? null;
|
||||
}
|
||||
const perfilCompleto = !!(matricula && tutorId);
|
||||
|
||||
// Docente: solo requiere matrícula (número de empleado). Alumno: matrícula + tutor.
|
||||
const perfilCompleto = esDocente ? !!matricula : !!(matricula && tutorId);
|
||||
|
||||
// Lista completa de maestros activos para el combobox del checkout (solo alumno lo usa).
|
||||
type MaestroOpt = { id: number; nombre: string };
|
||||
let maestros: MaestroOpt[] = [];
|
||||
if (!esDocente) {
|
||||
const { data: ms } = await supabase
|
||||
.from('maestros')
|
||||
.select('id, nombre')
|
||||
.eq('activo', true)
|
||||
.order('nombre');
|
||||
maestros = (ms ?? []) as MaestroOpt[];
|
||||
}
|
||||
|
||||
type Material = {
|
||||
id: number;
|
||||
@@ -95,7 +111,7 @@ const categoriasFiltro = grupoList
|
||||
Ningún material en esta categoría. Prueba con otra.
|
||||
</p>
|
||||
|
||||
<CartProvider materiales={materiales} tutorNombre={tutorNombre} perfilCompleto={perfilCompleto} client:load />
|
||||
<CartProvider materiales={materiales} tutorNombre={tutorNombre} perfilCompleto={perfilCompleto} esDocente={esDocente} maestros={maestros} client:load />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const POST: APIRoute = async ({ locals, params }) => {
|
||||
if (locals.profile?.rol !== 'admin') {
|
||||
return Response.json({ error: 'No autorizado' }, { status: 403 });
|
||||
}
|
||||
|
||||
const id = Number(params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return Response.json({ error: 'ID inválido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data, error } = await locals.supabase
|
||||
.from('baneos')
|
||||
.update({ unbanned_at: new Date().toISOString(), unbanned_by: locals.user!.id })
|
||||
.eq('id', id)
|
||||
.is('unbanned_at', null)
|
||||
.select('id');
|
||||
|
||||
if (error) {
|
||||
return Response.json({ error: 'No se pudo desbanear' }, { status: 500 });
|
||||
}
|
||||
if (!data || data.length === 0) {
|
||||
return Response.json({ error: 'Baneo no encontrado o ya inactivo' }, { status: 404 });
|
||||
}
|
||||
|
||||
return Response.json({ ok: true });
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
if (locals.profile?.rol !== 'admin') {
|
||||
return Response.json({ error: 'No autorizado' }, { status: 403 });
|
||||
}
|
||||
|
||||
let body: { profile_id?: unknown; razon?: unknown; expires_at?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: 'JSON inválido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const profile_id = typeof body.profile_id === 'string' ? body.profile_id.trim() : '';
|
||||
if (!profile_id) {
|
||||
return Response.json({ error: 'profile_id es obligatorio' }, { status: 400 });
|
||||
}
|
||||
if (profile_id === locals.user!.id) {
|
||||
return Response.json({ error: 'No puedes banearte a ti mismo' }, { status: 400 });
|
||||
}
|
||||
|
||||
const razon = typeof body.razon === 'string' ? body.razon.trim().slice(0, 500) : '';
|
||||
if (razon.length < 5) {
|
||||
return Response.json({ error: 'La razón debe tener al menos 5 caracteres' }, { status: 400 });
|
||||
}
|
||||
|
||||
let expires_at: string | null = null;
|
||||
if (body.expires_at != null && body.expires_at !== '') {
|
||||
if (typeof body.expires_at !== 'string') {
|
||||
return Response.json({ error: 'expires_at debe ser string ISO' }, { status: 400 });
|
||||
}
|
||||
const d = new Date(body.expires_at);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return Response.json({ error: 'expires_at inválido' }, { status: 400 });
|
||||
}
|
||||
if (d <= new Date()) {
|
||||
return Response.json({ error: 'expires_at debe ser futuro' }, { status: 400 });
|
||||
}
|
||||
expires_at = d.toISOString();
|
||||
}
|
||||
|
||||
const { data, error } = await locals.supabase
|
||||
.from('baneos')
|
||||
.insert({
|
||||
profile_id,
|
||||
razon,
|
||||
expires_at,
|
||||
banned_by: locals.user!.id,
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
if ((error as { code?: string }).code === '23505') {
|
||||
return Response.json(
|
||||
{ error: 'El usuario ya tiene un baneo activo — desbaneálo primero' },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
return Response.json({ error: 'No se pudo crear el baneo' }, { status: 500 });
|
||||
}
|
||||
|
||||
return Response.json({ id: data.id }, { status: 201 });
|
||||
};
|
||||
@@ -15,7 +15,7 @@ export const PATCH: APIRoute = async ({ request, locals, params }) => {
|
||||
const id = parseId(params.id);
|
||||
if (!id) return Response.json({ error: 'ID inválido' }, { status: 400 });
|
||||
|
||||
let body: { nombre?: unknown; activo?: unknown };
|
||||
let body: { nombre?: unknown; activo?: unknown; es_tutor?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
@@ -29,6 +29,7 @@ export const PATCH: APIRoute = async ({ request, locals, params }) => {
|
||||
patch.nombre = nombre;
|
||||
}
|
||||
if (typeof body.activo === 'boolean') patch.activo = body.activo;
|
||||
if (typeof body.es_tutor === 'boolean') patch.es_tutor = body.es_tutor;
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
return Response.json({ error: 'Nada para actualizar' }, { status: 400 });
|
||||
|
||||
@@ -7,7 +7,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
return Response.json({ error: 'No autorizado' }, { status: 403 });
|
||||
}
|
||||
|
||||
let body: { nombre?: unknown; activo?: unknown };
|
||||
let body: { nombre?: unknown; activo?: unknown; es_tutor?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
@@ -19,10 +19,11 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
return Response.json({ error: 'El nombre es obligatorio' }, { status: 400 });
|
||||
}
|
||||
const activo = typeof body.activo === 'boolean' ? body.activo : true;
|
||||
const es_tutor = typeof body.es_tutor === 'boolean' ? body.es_tutor : false;
|
||||
|
||||
const { data, error } = await locals.supabase
|
||||
.from('maestros')
|
||||
.insert({ nombre, activo })
|
||||
.insert({ nombre, activo, es_tutor })
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
|
||||
@@ -8,6 +8,12 @@ function statusForError(message: string): { status: number; error: string } {
|
||||
if (message.startsWith('no_autenticado')) {
|
||||
return { status: 401, error: 'No autenticado' };
|
||||
}
|
||||
if (message.startsWith('perfil_no_existe')) {
|
||||
return { status: 400, error: 'Tu perfil no existe. Recarga la página.' };
|
||||
}
|
||||
if (message.startsWith('maestro_no_valido') || message.startsWith('tutor_no_valido')) {
|
||||
return { status: 400, error: 'El maestro seleccionado no es válido' };
|
||||
}
|
||||
if (
|
||||
message.startsWith('maestro_responsable_requerido') ||
|
||||
message.startsWith('items_requeridos') ||
|
||||
@@ -24,6 +30,9 @@ function statusForError(message: string): { status: number; error: string } {
|
||||
if (message.startsWith('stock_insuficiente')) {
|
||||
return { status: 409, error: 'Sin stock suficiente para uno de los materiales' };
|
||||
}
|
||||
if (message.startsWith('sin_unidad_disponible')) {
|
||||
return { status: 409, error: 'Sin unidades disponibles para uno de los materiales' };
|
||||
}
|
||||
return { status: 500, error: 'No se pudo registrar la solicitud' };
|
||||
}
|
||||
|
||||
@@ -32,19 +41,23 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
return Response.json({ error: 'No autenticado' }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: { maestro_responsable?: unknown; notas?: unknown; items?: unknown };
|
||||
let body: { maestro_id?: 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;
|
||||
// maestro_id: number opcional. Si docente o si alumno sin elección, va null
|
||||
// y la RPC decide (docente usa su propio nombre; alumno cae al tutor).
|
||||
const rawMaestro = body.maestro_id;
|
||||
const maestro_id =
|
||||
typeof rawMaestro === 'number' && Number.isInteger(rawMaestro) && rawMaestro > 0
|
||||
? rawMaestro
|
||||
: null;
|
||||
|
||||
const notas = typeof body.notas === 'string' && body.notas.trim() ? body.notas.trim().slice(0, 100) : 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 });
|
||||
}
|
||||
@@ -64,7 +77,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
}
|
||||
|
||||
const { data, error } = await locals.supabase.rpc('crear_solicitud', {
|
||||
p_maestro_responsable: maestro_responsable,
|
||||
p_maestro_id: maestro_id,
|
||||
p_notas: notas,
|
||||
p_items: items,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { serviceClient } from '@/lib/supabase';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const BUCKET = 'avatares';
|
||||
const MAX_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
const extFrom = (file: File): string => {
|
||||
const fromName = file.name?.split('.').pop()?.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
if (fromName) return fromName;
|
||||
const fromMime = file.type?.split('/')[1]?.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
return fromMime || 'jpg';
|
||||
};
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
const user = locals.user;
|
||||
if (!user) return Response.json({ error: 'no autorizado' }, { status: 401 });
|
||||
|
||||
let form: FormData;
|
||||
try {
|
||||
form = await request.formData();
|
||||
} catch {
|
||||
return Response.json({ error: 'form-data inválido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const file = form.get('file');
|
||||
if (!(file instanceof File)) {
|
||||
return Response.json({ error: 'Archivo faltante' }, { status: 400 });
|
||||
}
|
||||
if (!/^image\//.test(file.type)) {
|
||||
return Response.json({ error: 'Solo se permiten imágenes' }, { status: 400 });
|
||||
}
|
||||
if (file.size > MAX_BYTES) {
|
||||
return Response.json({ error: 'La imagen supera 2 MB' }, { status: 400 });
|
||||
}
|
||||
|
||||
const path = `${user.id}/${Date.now()}.${extFrom(file)}`;
|
||||
|
||||
const { error: upErr } = await serviceClient()
|
||||
.storage.from(BUCKET)
|
||||
.upload(path, file, { upsert: true, contentType: file.type });
|
||||
|
||||
if (upErr) {
|
||||
return Response.json({ error: upErr.message || 'No se pudo subir' }, { status: 500 });
|
||||
}
|
||||
|
||||
const { error: patchErr } = await locals.supabase
|
||||
.from('profiles')
|
||||
.update({ foto_path: path })
|
||||
.eq('id', user.id);
|
||||
|
||||
if (patchErr) {
|
||||
return Response.json({ error: 'Foto subida pero no vinculada' }, { status: 500 });
|
||||
}
|
||||
|
||||
return Response.json({ ok: true, path });
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { serviceClient } from '@/lib/supabase';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const BUCKET = 'materiales-fotos';
|
||||
const MAX_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
const parseId = (raw: string | undefined) => {
|
||||
const id = Number(raw);
|
||||
return Number.isInteger(id) && id > 0 ? id : null;
|
||||
};
|
||||
|
||||
const extFrom = (file: File): string => {
|
||||
const fromName = file.name?.split('.').pop()?.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
if (fromName) return fromName;
|
||||
const fromMime = file.type?.split('/')[1]?.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
return fromMime || 'jpg';
|
||||
};
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals, params }) => {
|
||||
if (locals.profile?.rol !== 'admin') {
|
||||
return Response.json({ error: 'No autorizado' }, { status: 403 });
|
||||
}
|
||||
|
||||
const materialId = parseId(params.id);
|
||||
if (!materialId) return Response.json({ error: 'ID inválido' }, { status: 400 });
|
||||
|
||||
let form: FormData;
|
||||
try {
|
||||
form = await request.formData();
|
||||
} catch {
|
||||
return Response.json({ error: 'form-data inválido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const file = form.get('file');
|
||||
if (!(file instanceof File)) {
|
||||
return Response.json({ error: 'Archivo faltante' }, { status: 400 });
|
||||
}
|
||||
if (!/^image\//.test(file.type)) {
|
||||
return Response.json({ error: 'Solo se permiten imágenes' }, { status: 400 });
|
||||
}
|
||||
if (file.size > MAX_BYTES) {
|
||||
return Response.json({ error: 'La imagen supera 2 MB' }, { status: 400 });
|
||||
}
|
||||
|
||||
const path = `${materialId}/${Date.now()}.${extFrom(file)}`;
|
||||
|
||||
const { error: upErr } = await serviceClient()
|
||||
.storage.from(BUCKET)
|
||||
.upload(path, file, { upsert: true, contentType: file.type });
|
||||
|
||||
if (upErr) {
|
||||
return Response.json({ error: upErr.message || 'No se pudo subir' }, { status: 500 });
|
||||
}
|
||||
|
||||
const { error: patchErr } = await locals.supabase
|
||||
.from('materiales')
|
||||
.update({ imagen_path: path })
|
||||
.eq('id', materialId);
|
||||
|
||||
if (patchErr) {
|
||||
return Response.json({ error: 'Foto subida pero no vinculada' }, { status: 500 });
|
||||
}
|
||||
|
||||
return Response.json({ ok: true, path });
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
import Layout from '@/layouts/Layout.astro';
|
||||
import BrandMark from '@/components/BrandMark.astro';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
if (!user) return Astro.redirect('/login');
|
||||
|
||||
type BanRow = {
|
||||
razon: string;
|
||||
banned_at: string;
|
||||
expires_at: string | null;
|
||||
banned_by: { nombre: string | null } | null;
|
||||
};
|
||||
|
||||
const { data } = await Astro.locals.supabase
|
||||
.from('baneos')
|
||||
.select('razon, banned_at, expires_at, banned_by:profiles!banned_by(nombre)')
|
||||
.eq('profile_id', user.id)
|
||||
.is('unbanned_at', null)
|
||||
.maybeSingle();
|
||||
|
||||
const ban = data as BanRow | null;
|
||||
if (!ban) return Astro.redirect('/');
|
||||
|
||||
const fmtDate = (iso: string) =>
|
||||
new Intl.DateTimeFormat('es-MX', {
|
||||
dateStyle: 'long',
|
||||
timeStyle: 'short',
|
||||
timeZone: 'America/Tijuana',
|
||||
}).format(new Date(iso));
|
||||
|
||||
const bannedAtStr = fmtDate(ban.banned_at);
|
||||
const expiresStr = ban.expires_at ? fmtDate(ban.expires_at) : null;
|
||||
const byName = ban.banned_by?.nombre ?? null;
|
||||
|
||||
Astro.response.status = 403;
|
||||
---
|
||||
<Layout title="Cuenta bloqueada — LabPréstamos">
|
||||
<main id="main" class="min-h-screen grid place-items-center p-4">
|
||||
<div class="w-full max-w-lg">
|
||||
<div class="text-center mb-6">
|
||||
<div class="inline-grid place-items-center w-16 h-16 rounded-[2px] p-4 mb-4" style="background: var(--color-danger); border: 2px solid var(--color-ink); box-shadow: var(--shadow-hard);">
|
||||
<BrandMark class="w-full h-full" style="color: var(--color-ink);" />
|
||||
</div>
|
||||
<h1 class="text-2xl md:text-3xl font-semibold uppercase tracking-wide">Cuenta bloqueada</h1>
|
||||
<p class="text-sm mt-2" style="color: var(--color-pencil);">Tu acceso al sistema de préstamos fue suspendido.</p>
|
||||
</div>
|
||||
|
||||
<div class="card-raised">
|
||||
<dl class="flex flex-col gap-4 text-sm">
|
||||
<div>
|
||||
<dt class="text-xs uppercase tracking-wide mb-1" style="color: var(--color-pencil);">Motivo</dt>
|
||||
<dd class="whitespace-pre-wrap break-words">{ban.razon}</dd>
|
||||
</div>
|
||||
<div class="grid sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<dt class="text-xs uppercase tracking-wide mb-1" style="color: var(--color-pencil);">Fecha del bloqueo</dt>
|
||||
<dd>{bannedAtStr}</dd>
|
||||
</div>
|
||||
{expiresStr && (
|
||||
<div>
|
||||
<dt class="text-xs uppercase tracking-wide mb-1" style="color: var(--color-pencil);">Expira</dt>
|
||||
<dd>{expiresStr}</dd>
|
||||
</div>
|
||||
)}
|
||||
{byName && (
|
||||
<div>
|
||||
<dt class="text-xs uppercase tracking-wide mb-1" style="color: var(--color-pencil);">Bloqueado por</dt>
|
||||
<dd>{byName}</dd>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p class="text-sm mt-6" style="color: var(--color-pencil);">
|
||||
Si crees que esto es un error, contacta al Laboratorio de Sistemas Computacionales.
|
||||
</p>
|
||||
|
||||
<form method="POST" action="/api/auth/signout" class="mt-6">
|
||||
<button type="submit" class="btn btn-primary w-full">Cerrar sesión</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</Layout>
|
||||
@@ -15,10 +15,15 @@ const { data: maestrosData } = await Astro.locals.supabase
|
||||
.from('maestros')
|
||||
.select('id, nombre')
|
||||
.eq('activo', true)
|
||||
.eq('es_tutor', true)
|
||||
.order('nombre');
|
||||
const maestros = (maestrosData ?? []) as { id: number; nombre: string }[];
|
||||
|
||||
const primerNombre = (profile.nombre ?? profile.email.split('@')[0]).split(/\s+/)[0];
|
||||
const copyIntro =
|
||||
profile.rol === 'docente'
|
||||
? 'Completa tu perfil como docente. Solo tarda un momento.'
|
||||
: 'Completa tu perfil para crear vales de préstamo. Puedes omitirlo, pero será requerido antes de solicitar material.';
|
||||
---
|
||||
<Layout title="Bienvenido — LabPréstamos">
|
||||
<main id="main" class="min-h-screen grid place-items-center p-4">
|
||||
@@ -31,7 +36,7 @@ const primerNombre = (profile.nombre ?? profile.email.split('@')[0]).split(/\s+/
|
||||
Bienvenido, {primerNombre}
|
||||
</h1>
|
||||
<p class="text-sm mt-2" style="color: var(--color-pencil);">
|
||||
Completa tu perfil para crear vales de préstamo. Puedes omitirlo, pero será requerido antes de solicitar material.
|
||||
{copyIntro}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -42,6 +47,7 @@ const primerNombre = (profile.nombre ?? profile.email.split('@')[0]).split(/\s+/
|
||||
userId={user.id}
|
||||
email={profile.email}
|
||||
nombre={profile.nombre}
|
||||
rol={profile.rol}
|
||||
initialProfile={{
|
||||
matricula: profile.matricula,
|
||||
semestre: profile.semestre,
|
||||
|
||||
@@ -16,9 +16,13 @@ const { data: maestrosData } = await Astro.locals.supabase
|
||||
.from('maestros')
|
||||
.select('id, nombre')
|
||||
.eq('activo', true)
|
||||
.eq('es_tutor', true)
|
||||
.order('nombre');
|
||||
const maestros = (maestrosData ?? []) as { id: number; nombre: string }[];
|
||||
|
||||
const rolLabel =
|
||||
profile.rol === 'admin' ? 'Administrador' : profile.rol === 'docente' ? 'Docente' : 'Alumno';
|
||||
|
||||
const info = avatarInfo({
|
||||
email: profile.email,
|
||||
nombre: profile.nombre,
|
||||
@@ -36,7 +40,7 @@ const info = avatarInfo({
|
||||
</h1>
|
||||
<p class="text-sm truncate" style="color: var(--color-pencil);">{profile.email}</p>
|
||||
<span class="badge mt-2" style={`background: ${profile.rol === 'admin' ? 'var(--color-secondary)' : 'var(--color-chalk)'};`}>
|
||||
{profile.rol}
|
||||
{rolLabel}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
@@ -48,6 +52,7 @@ const info = avatarInfo({
|
||||
userId={user.id}
|
||||
email={profile.email}
|
||||
nombre={profile.nombre}
|
||||
rol={profile.rol}
|
||||
initialProfile={{
|
||||
matricula: profile.matricula,
|
||||
semestre: profile.semestre,
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
-- 0006_docente_maestros_v16.sql
|
||||
-- v1.6:
|
||||
-- A) profiles.rol acepta 'docente' (alumno|docente|admin)
|
||||
-- B) maestros.es_tutor bool (subset de maestros que son elegibles como tutor)
|
||||
-- C) RPC crear_solicitud reescrita: recibe p_maestro_id int; resuelve nombre
|
||||
-- y lo guarda en maestro_responsable (snapshot). Docente no requiere
|
||||
-- maestro (se guarda su propio nombre como responsable). Fallback al
|
||||
-- tutor guardado si p_maestro_id es null y el user es alumno.
|
||||
|
||||
begin;
|
||||
|
||||
-- A) Rol docente ----------------------------------------------------------
|
||||
alter table prestamos.profiles drop constraint if exists profiles_rol_check;
|
||||
alter table prestamos.profiles
|
||||
add constraint profiles_rol_check check (rol in ('alumno','docente','admin'));
|
||||
|
||||
-- B) Flag es_tutor --------------------------------------------------------
|
||||
alter table prestamos.maestros
|
||||
add column if not exists es_tutor boolean not null default false;
|
||||
|
||||
-- 'Sin especificar' queda como fallback de tutor para no romper flows
|
||||
-- previos (no es un maestro real; se puede desmarcar despues desde /admin/maestros).
|
||||
update prestamos.maestros set es_tutor = true where nombre = 'Sin especificar';
|
||||
|
||||
-- Self-heal policy insert amplia el check a los 3 roles no-admin.
|
||||
-- Sigue prohibiendo auto-promocion a admin.
|
||||
drop policy if exists profiles_insert_self on prestamos.profiles;
|
||||
create policy profiles_insert_self on prestamos.profiles
|
||||
for insert to authenticated
|
||||
with check (id = auth.uid() and rol in ('alumno','docente'));
|
||||
|
||||
-- C) RPC crear_solicitud v2: recibe maestro_id ----------------------------
|
||||
-- Cambio de firma: antes (text, text, jsonb) -> ahora (int, text, jsonb).
|
||||
-- Drop de la anterior para no dejar overload muerto.
|
||||
drop function if exists prestamos.crear_solicitud(text, text, jsonb);
|
||||
|
||||
create or replace function prestamos.crear_solicitud(
|
||||
p_maestro_id int,
|
||||
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;
|
||||
v_trackeado boolean;
|
||||
v_unidad_id bigint;
|
||||
v_asignadas int;
|
||||
v_maestro text;
|
||||
v_rol text;
|
||||
v_nombre_propio text;
|
||||
v_tutor_id int;
|
||||
begin
|
||||
if v_alumno is null then
|
||||
raise exception 'no_autenticado';
|
||||
end if;
|
||||
|
||||
select rol, nombre, tutor_id
|
||||
into v_rol, v_nombre_propio, v_tutor_id
|
||||
from prestamos.profiles where id = v_alumno;
|
||||
|
||||
if v_rol is null then
|
||||
raise exception 'perfil_no_existe';
|
||||
end if;
|
||||
|
||||
if v_rol = 'docente' then
|
||||
-- Docente: no elige maestro, se registra su propio nombre como responsable.
|
||||
v_maestro := coalesce(nullif(trim(v_nombre_propio), ''), '(Docente)');
|
||||
elsif p_maestro_id is not null then
|
||||
select nombre into v_maestro
|
||||
from prestamos.maestros
|
||||
where id = p_maestro_id and activo;
|
||||
if v_maestro is null then
|
||||
raise exception 'maestro_no_valido';
|
||||
end if;
|
||||
else
|
||||
-- Alumno sin selección: cae al tutor guardado.
|
||||
if v_tutor_id is null then
|
||||
raise exception 'maestro_responsable_requerido';
|
||||
end if;
|
||||
select nombre into v_maestro
|
||||
from prestamos.maestros where id = v_tutor_id;
|
||||
if v_maestro is null then
|
||||
raise exception 'tutor_no_valido';
|
||||
end if;
|
||||
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 por orden ascendente de material_id, evita deadlocks entre alumnos
|
||||
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', v_maestro, 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, trackeado_por_unidad
|
||||
into v_disponible, v_estado_mat, v_trackeado
|
||||
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;
|
||||
|
||||
if v_trackeado then
|
||||
v_asignadas := 0;
|
||||
while v_asignadas < v_cantidad loop
|
||||
select id into v_unidad_id
|
||||
from prestamos.material_unidades
|
||||
where material_id = v_material_id and estado = 'disponible'
|
||||
order by id
|
||||
for update skip locked
|
||||
limit 1;
|
||||
|
||||
if v_unidad_id is null then
|
||||
raise exception 'sin_unidad_disponible: %', v_material_id;
|
||||
end if;
|
||||
|
||||
insert into prestamos.solicitud_items
|
||||
(solicitud_id, material_id, cantidad, descripcion, material_unidad_id)
|
||||
values (v_solicitud_id, v_material_id, 1, v_descripcion, v_unidad_id);
|
||||
|
||||
update prestamos.material_unidades
|
||||
set estado = 'prestado'
|
||||
where id = v_unidad_id;
|
||||
|
||||
v_asignadas := v_asignadas + 1;
|
||||
end loop;
|
||||
else
|
||||
insert into prestamos.solicitud_items (solicitud_id, material_id, cantidad, descripcion)
|
||||
values (v_solicitud_id, v_material_id, v_cantidad, v_descripcion);
|
||||
end if;
|
||||
end loop;
|
||||
|
||||
return v_solicitud_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function prestamos.crear_solicitud(int, text, jsonb) to authenticated;
|
||||
|
||||
commit;
|
||||
@@ -0,0 +1,63 @@
|
||||
-- 0007_baneos.sql
|
||||
-- Lista negra de cuentas: tabla prestamos.baneos con historial
|
||||
-- (soporta baneos permanentes y temporales via expires_at, aunque la UI
|
||||
-- de esta ronda solo expone permanentes). Helper is_banned() para middleware.
|
||||
|
||||
begin;
|
||||
|
||||
create table if not exists prestamos.baneos (
|
||||
id bigserial primary key,
|
||||
profile_id uuid not null references prestamos.profiles(id) on delete cascade,
|
||||
razon text not null,
|
||||
banned_at timestamptz not null default now(),
|
||||
banned_by uuid references prestamos.profiles(id),
|
||||
expires_at timestamptz,
|
||||
unbanned_at timestamptz,
|
||||
unbanned_by uuid references prestamos.profiles(id)
|
||||
);
|
||||
|
||||
-- Un solo baneo activo por profile (unbanned_at IS NULL) — partial unique index
|
||||
create unique index if not exists baneos_profile_activo_uniq
|
||||
on prestamos.baneos (profile_id)
|
||||
where unbanned_at is null;
|
||||
|
||||
create index if not exists baneos_banned_at_idx
|
||||
on prestamos.baneos (banned_at desc);
|
||||
|
||||
alter table prestamos.baneos enable row level security;
|
||||
|
||||
-- Admin lee/escribe todo
|
||||
drop policy if exists baneos_admin_all on prestamos.baneos;
|
||||
create policy baneos_admin_all on prestamos.baneos
|
||||
for all to authenticated
|
||||
using (prestamos.is_admin())
|
||||
with check (prestamos.is_admin());
|
||||
|
||||
-- User autenticado lee los suyos (para /banned mostrar la razón)
|
||||
drop policy if exists baneos_read_self on prestamos.baneos;
|
||||
create policy baneos_read_self on prestamos.baneos
|
||||
for select to authenticated
|
||||
using (profile_id = auth.uid());
|
||||
|
||||
grant select, insert, update, delete on prestamos.baneos to authenticated, service_role;
|
||||
grant usage, select on prestamos.baneos_id_seq to authenticated, service_role;
|
||||
|
||||
-- Helper: baneo activo (no desbaneado y no expirado)
|
||||
create or replace function prestamos.is_banned(p_uid uuid)
|
||||
returns boolean
|
||||
language sql
|
||||
stable
|
||||
security definer
|
||||
set search_path = prestamos, pg_temp
|
||||
as $$
|
||||
select exists (
|
||||
select 1 from prestamos.baneos
|
||||
where profile_id = p_uid
|
||||
and unbanned_at is null
|
||||
and (expires_at is null or expires_at > now())
|
||||
);
|
||||
$$;
|
||||
|
||||
grant execute on function prestamos.is_banned(uuid) to anon, authenticated;
|
||||
|
||||
commit;
|
||||
Reference in New Issue
Block a user