Compare commits
14 Commits
544bbf38a9
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 800ec60a53 | |||
| 3cccf758a8 | |||
| dea268d975 | |||
| c43f45eca1 | |||
| 3e576426e1 | |||
| fe1ef7b70d | |||
| 0f2ea59754 | |||
| 44fe7527be | |||
| bcecdbb184 | |||
| d0f01ea18c | |||
| e159e8d297 | |||
| ef40241ade | |||
| 1ce1343882 | |||
| b2bc5f8d1b |
@@ -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
|
||||
@@ -214,3 +218,64 @@ Fases 3, 4/5 y 6 tocan carpetas de rutas distintas (`src/pages/alumno/*`, `src/p
|
||||
- **Backlog futuro explícito, no incluido en esta ronda**: pantalla de onboarding/completar perfil post-login que pida `semestre` (y posibles otros datos) una sola vez a usuarios nuevos. La columna ya existe en `profiles`, solo falta el endpoint `PATCH` (la policy `profiles_update_self` de `0001_init.sql` ya lo permite) y la UI.
|
||||
- **Edge case menor, no bloqueante, anotado para si se vuelve a tocar este código**: la RPC `crear_solicitud` no dedupe `material_id` repetidos dentro del mismo array de `items` — si dos renglones apuntan al mismo material con cantidades que combinadas exceden el stock, cada uno se valida contra el disponible total de forma independiente (no acumulativa) al momento de crear la solicitud. En la práctica no ocurre porque el carrito del alumno (`SolicitudCart.tsx`) dedupea por `material_id` incrementando cantidad en vez de crear renglones duplicados; y aunque ocurriera, el trigger `sync_stock` al aprobar sí acumula correctamente sobre la fila real de `materiales`, así que el peor caso es que la aprobación falle por el `check (cantidad_disponible >= 0)` en vez de fallar silenciosamente.
|
||||
- **Deploy completado**: 2 commits separados (uno para el fix de contraste pendiente de la sesión anterior, otro para el rediseño multi-ítem — se separaron porque venían mezclados en el working tree). Push a `git.buglabs.dev/LakG/labre-web` con token efímero de Gitea (mismo patrón que Fase 8: nunca vivió en el `remote` local, queda revocable a mano desde Gitea → Settings → Applications ya que la CLI de Gitea no tiene comando de revocación). `ssh buglabs 'cd ~/labre-web && git pull && docker compose up -d --build'` → contenedor recreado, `healthy` en el healthcheck. Smoke test público: `/login` → 200, `/` sin sesión → 302, `POST /api/solicitudes` sin sesión → 302 (el middleware protege `/api/*` antes de llegar al handler, igual que el endpoint viejo — no es un 401 porque nunca llega a esa lógica). **Sistema en producción con el modelo de vale multi-ítem.**
|
||||
|
||||
- **2026-08-22 — 5 features grandes en un pase: buscador, grid+fotos, unidades individuales, estadísticas separadas, realtime pendientes**. El usuario listó 5 pedidos operativos y pidió arrancar con un plan revisable. Se hizo Fase 1 (exploración) con 2 subagentes Explore en paralelo (frontend + backend), Fase 2 sin Plan agent (contexto suficiente después de la exploración), 4 preguntas cerradas al usuario sobre decisiones que cambiaban el diseño (alcance de unidades, charts, notificaciones, storage), plan escrito y aprobado, luego ejecución en 4 tracks (3 paralelos + 1 secuencial).
|
||||
- **Decisiones tomadas con el usuario antes de escribir código**: (1) unidades = **flag por material** (`trackeado_por_unidad`), no todos los materiales — evita disruptivo; (2) charts = **recharts** (nueva dep, ~90KB) en vez de SVG a mano — se pidió la librería estándar; (3) notificaciones = badge en nav + Supabase Realtime, **sin browser API ni PWA**; (4) storage = bucket público simple (fotos de material no son sensibles). El usuario pidió expresamente verificar Realtime en la instancia antes de comprometer el plan — se confirmó que `realtime-dev.supabase-realtime` estaba `healthy`, Kong ruteaba `/realtime/v1/*`, y la publication `supabase_realtime` existía pero **vacía** (0 tablas) — necesitaba `alter publication ... add table`.
|
||||
- **Migración `0003_grid_unidades_realtime.sql`** aplicada limpia en un solo `psql -f` (con `BEGIN`/`COMMIT` propios del archivo, mismo patrón que 0001/0002; sin dry-run porque no hubo error): `materiales.imagen_path`, `materiales.trackeado_por_unidad boolean`, nueva tabla `material_unidades(id, material_id, etiqueta, estado, notas)` con trigger `sync_material_desde_unidad` que mantiene `materiales.cantidad_total`/`cantidad_disponible` sincronizados automáticamente en INSERT/UPDATE/DELETE de unidades, `solicitud_items.material_unidad_id` (nullable, solo para trackeados), RPC `crear_solicitud` reescrita para asignar la primera unidad `disponible` (`FOR UPDATE SKIP LOCKED`) y marcarla `prestado` **al momento de crear la solicitud** (no al aprobar) para blindar contra carreras entre 2 alumnos que piden simultáneo — trade-off: obligó a extender `sync_stock` con una rama para liberar unidades en transición `pendiente → rechazado` (que antes no disparaba nada). Nueva RPC `reasignar_unidad(item_id, nueva_unidad_id)` admin-only con `is_admin()` guard y `audit_log payload jsonb`. `publication supabase_realtime += prestamos.solicitudes` con `DO $$ ... IF NOT EXISTS ... END $$` (idempotente). En la misma migración se creó el **bucket `materiales-fotos` público** vía `insert into storage.buckets` + policies `for select to anon/authenticated using bucket_id=...` y `for all to authenticated using is_admin()` — todo idempotente con `on conflict do update` y `drop policy if exists` primero.
|
||||
- **4 tracks paralelizados** con contratos aislados por carpeta (sin colisión de archivos entre agentes), 3 en el mismo turno + 1 secuencial (D tocaba `AppLayout.astro` que C ya había editado):
|
||||
- **A · Buscador catálogo alumno** (`BuscadorCatalogo.tsx` nuevo + `FiltroCategorias.tsx` + `SolicitudCart.tsx` + `catalogo.astro`): filtro client-side por texto (debounce 120ms, normaliza tildes con NFD, sincroniza `?q=` con `history.replaceState`), combinable AND con filtro de categorías. Decisión no obvia: como Astro hidrata cada `client:*` como raíz React independiente, no hay Context compartido entre 2 islands hermanas — se resolvió con un helper `window.__labreFiltrar` que ambas islands invocan (marcado `// ponytail: coordinar via window para evitar Context entre 2 islands hermanas`). El filtro por categoría dejó de setear `hidden` directo y ahora setea `data-hidden-by-cat`; el helper unificado combina ambos flags.
|
||||
- **B · Grid + imágenes + unidades** (13 archivos): `admin/inventario/index.astro` reescrito como grid `grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5` con foto `aspect-square object-cover` + placeholder SVG mono-color cuando `imagen_path` es null; `MaterialForm.tsx` con `<input type="file">` + preview (`URL.createObjectURL` + cleanup con `revokeObjectURL`) + checkbox "Rastrear por unidad individual"; nuevo `UnidadesManager.tsx` embebido dentro del mismo `<dialog>` de edición (los `<dialog>` nativos no anidan bien) — solo aparece en `mode=edit` con `trackeado_por_unidad=true` ya persistido, un material recién marcado como trackeado debe guardar primero para poder agregar unidades. Nuevo helper `src/lib/materialImg.ts` con `imgUrl(path)` dual `import.meta.env`/`process.env` (mismo patrón que `src/lib/supabase.ts`). Endpoints CRUD de unidades (`POST/GET/PATCH/DELETE`) con 409 traducidos para: etiqueta duplicada, eliminar unidad prestada, cambiar `trackeado_por_unidad` con datos previos. Reasignar unidad desde `VerDetalles.tsx` con dropdown de unidades `disponible` del mismo material → `POST /api/admin/solicitudes/[id]/reasignar-unidad` → RPC. Upload flow en create: POST material → id → upload al bucket path `${id}/${Date.now()}.${ext}` → PATCH `imagen_path` (3 requests aceptables; alternativa de path temporal + rename no vale la lógica extra). Si upload falla, toast rojo pero material queda creado (no bloquea).
|
||||
- **C · Estadísticas separada + charts recharts + nav** (4 archivos): `/admin/estadisticas` nueva ruta con los 6 KPIs (movidos de `/admin/inventario`) + 3 gráficas recharts — barras horizontales top-10 materiales últimos 30 días (agrupado en memoria, no en SQL), línea solicitudes/día (relleno con 0 en días sin data para no dejar huecos), donut distribución de estados. `Chart.tsx` wrapper único para los 3 tipos con paleta UABC (`#00723F` primary, `#DD971A` secondary, `#a83224` danger), tooltip con borde 2px carbón + JetBrains Mono, respeta `prefers-reduced-motion` con `animationDuration=0`. Nav admin actualizado: `Panel · Solicitudes · Inventario (box) · Estadísticas (stats) · Reportes` — el icono `box` ya existía en `ICONS` pero no se usaba (candidato natural detectado en exploración). `admin/index.astro` migrado a `todayMX()` (fix de inconsistencia con `/admin/inventario` detectada en Fase 1).
|
||||
- **D · Badge + realtime** (2 archivos, secuencial tras C): `AppLayout.astro` con SSR count de pendientes cuando `rol='admin'`, badge sobre ícono "Solicitudes" en sidebar (`-top-2 -right-3`) y dock móvil (`-top-1 -right-1`) — chip mono bold 11px, borde 2px carbón, fondo `--color-danger`, texto carbón (no blanco: coral claro `#f38e84` + blanco falla AA con ~2.4:1; carbón pasa ~4.3:1 — la "Text-on-Fill Rule" ya documentada en `DESIGN.md`). Nuevo `BadgeSolicitudes.tsx` `client:idle`, no renderiza nada, monta canal Realtime `solicitudes-pendientes` filtro `estado=eq.pendiente` — en INSERT hace `+1` + toast, en UPDATE **refetch del count** (no delta) porque `replica identity default` de la tabla solo envía la PK en `payload.old` — el estado anterior no llega; marcado `// ponytail: refetch en UPDATE porque payload.old no trae estado en replica identity default; upgrade a delta cuando la tabla tenga replica identity full`.
|
||||
- **Verificación combinada**: `npm run build` limpio en cada agente y en el árbol final (947ms). Sin conflictos de merge entre los 4 tracks (los contratos aislados por carpeta aguantaron). Smoke test público post-deploy: `/login` → 200, `/` sin sesión → 302, `/admin/estadisticas` sin sesión → 302 (middleware protegiendo la ruta nueva), bucket `materiales-fotos` responde 400 al pedir archivo inexistente (confirma que el bucket existe y responde — si no existiera daría 404 de otro shape). No se hizo QA con sesión real desde el navegador esta ronda — el usuario pidió deploy directo confiando en el build combinado + los checks individuales de cada agente.
|
||||
- **Aprendizajes registrados**: (a) para carreras de asignación de unidad entre alumnos concurrentes, `FOR UPDATE SKIP LOCKED` en el `SELECT` aguanta hasta el commit — para blindar entre transacciones distintas hay que **cambiar el estado al momento de crear** (no al aprobar), y eso obliga a extender los triggers para las transiciones que antes no importaban (`pendiente → rechazado` para liberar la reserva); (b) Supabase Realtime en self-hosted con `replica identity default` solo emite la PK en `payload.old`, así que cualquier lógica que dependa del estado anterior en UPDATE necesita refetch — o cambiar la tabla a `replica identity full` (más ancho de banda, pero payload completo); (c) contratos de agentes aislados por carpeta escalan bien cuando cada uno reescribe un archivo entero — el conflicto real es cuando 2 agentes editan el mismo archivo con `Edit` string-match, ahí sí hay que serializar; (d) los `<dialog>` HTML nativos no anidan (abrir uno dentro de otro rompe el foco) — cuando se necesita "submodal", mejor embebido en el mismo dialog o pasar a `<div>` posicionado; (e) `CLAUDE.md` en este repo es symlink a `AGENTS.md` — editar el path real (aprendido cuando `Edit` rechazó el symlink).
|
||||
- **Deliberadamente NO tocado**: (i) cards del alumno en el catálogo no tienen foto todavía — el select SSR ya trae `imagen_path` (agregado por el track B para no romper el shape), pero pintar la miniatura en la card del alumno es un paso siguiente si el usuario lo pide; (ii) onboarding para `semestre` sigue pendiente (mismo backlog que 2026-08-17); (iii) `browser-image-compression` no se instaló — si las fotos que suba el admin pesan mucho, se agrega después.
|
||||
- **Deploy completado**: 2 commits separados (migración + código, mismo patrón que 0002). Push a `git.buglabs.dev/LakG/labre-web` con token efímero de Gitea (mismo patrón que Fase 8/0002). `ssh buglabs 'cd ~/labre-web && git pull && docker compose up -d --build'` → contenedor recreado, `healthy` en ~26s. **Sistema en producción con las 5 features vivas en https://prestamos.buglabs.dev.**
|
||||
|
||||
- **2026-08-24 — Perfil de usuario + onboarding + home reformulado + fix del bug de upload + 5 bugs mobile**. Iteración de UX y bugs sobre la app ya viva en prod. Se hizo Fase 1 con 3 Explore agents (bug upload, home/onboarding, bugs mobile), 4 preguntas cerradas al usuario sobre decisiones grandes (maestro en clase, tabla maestros, foto default, onboarding bloqueante), plan escrito y aprobado, ejecución en 4 tracks paralelos + 1 migración BD.
|
||||
- **Decisiones tomadas con el usuario**: (1) `maestro_responsable` = **`<textarea>` que el alumno escribe a mano si está en clase**; placeholder muestra el nombre del tutor; si queda vacío, la RPC autocompleta con el tutor guardado — cero infra de horarios, cero fricción; (2) **tabla admin-managed `maestros`** simple (solo nombre + activo), CRUD en nueva subpágina bajo `/admin/maestros`; (3) foto default = **`avatar_url` del OAuth de Google** (viene en `user_metadata`), fallback a iniciales sobre color HSL derivado del email; (4) onboarding = **todo opcional** pero el checkout bloquea con CTA "Completa tu perfil" si faltan matrícula o tutor.
|
||||
- **Migración `0004_perfil_maestros_avatares.sql`** aplicada limpia en un solo `psql -f` (mismo patrón que 0001/0002/0003): tabla `prestamos.maestros(id, nombre unique, activo, created_at)` con RLS (todos leen, solo admin escribe) + seed `('Sin especificar')`; `prestamos.profiles.tutor_id int` (FK → maestros on delete set null) y `prestamos.profiles.foto_path text`; bucket público `avatares` con policies `for select to anon/authenticated` y `for all to authenticated using (bucket_id='avatares' and (storage.foldername(name))[1] = auth.uid()::text)` — el prefijo de carpeta es el UUID del usuario, así RLS deja escribir solo en tu propia carpeta. RPC `crear_solicitud` reescrita con fallback: si `p_maestro_responsable` viene null/vacío, hace `select nombre from maestros where id = (select tutor_id from profiles where id = auth.uid())`; si tampoco hay tutor, sigue el raise `maestro_responsable_requerido` que ahora cae al bloqueo del checkout.
|
||||
- **4 tracks paralelizados** — 2 completos limpios (F, C), 2 cortados por límite de sesión pero con la mayoría del trabajo escrito antes de morir (H y P). El orquestador completó los archivos que faltaban directamente en el hilo principal.
|
||||
- **F · Fixes** (8 archivos, completo): (i) Bug upload — `Dockerfile` con `ARG PUBLIC_SUPABASE_URL/ANON_KEY/APP_URL` + `ENV` al inicio del stage `build`, `docker-compose.yml` con `build.args: {PUBLIC_*: ${VAR}}` (`SUPABASE_SERVICE_ROLE_KEY` deliberadamente fuera — solo runtime), `.dockerignore` intacto (no exponer `.env.production` en la imagen), `src/lib/supabase.ts` con `browserClient()` unificado sobre las constantes que ya usan fallback `process.env ?? import.meta.env`. Verificado post-deploy con `grep "supabase\.buglabs\.dev" /app/dist/client/_astro/*.js` — la URL correcta aparece en `MaterialForm`, `PerfilForm`, y el chunk compartido de supabase; el bug está muerto. (ii) Grid inventario mobile — wrapper `flex gap-2` → `grid grid-cols-2 gap-2`; nuevo prop `compact` en `MaterialForm` y `EliminarMaterial` que renderiza el trigger con `w-full text-sm px-2 minHeight:36px`. (iii) `UnidadesManager` mobile — tabla reemplazada por cards `grid-cols-1 sm:grid-cols-[1fr_auto_auto]`, select estado con `minWidth:130px`, notas full-width abajo. (iv) `Chart` donut — con la skill `dataviz`: quitar labels internos (`label={false}`), agregar `<Legend layout="horizontal" verticalAlign="bottom">` con `formatter` "label (N)" — patrón canónico de la skill ("legend always present for ≥ 2 series"; "selective direct labels — never a number on every point"). (v) `Chart` barras — `YAxis width={100}` fijo + `tickFormatter` que trunca `> 14 chars`; se descartó `window.innerWidth` en render (SSR undefined + recharts no re-renderea `YAxis` en resize) y se descartó rotar labels a `-45°` (viola legibilidad); el tooltip mantiene el label completo, cero pérdida de información.
|
||||
- **C · Checkout con tutor auto** (2 archivos, completo): `catalogo.astro` hace SSR directo del `profiles.matricula + tutor_id` y join a `maestros.nombre` (el middleware no expone tutor_id explícito — se consulta local); pasa `tutorNombre` y `perfilCompleto: !!(profile.matricula && profile.tutor_id)` como props al `CartProvider`. En `SolicitudCart`, el `<input type="text">` de maestro responsable es ahora `<textarea rows={2} maxLength={200}>` sin `required`, con `placeholder={tutorNombre ?? 'Escribe...'}` + hint verde "se usará: <tutor>" si hay tutor. Guard perfil incompleto en el footer del modal: si `!perfilCompleto`, reemplaza botones por card con CTA "Completa tu perfil" → `/perfil`; el `submit()` early-returns como defensa extra. El `toastAfterReload` post-envío detecta cuando el textarea quedó vacío y hubo tutor, y lo menciona en la descripción ("maestro: X (tu tutor)").
|
||||
- **H · Home reformulado** (parcial → completado en hilo principal): agente alcanzó a escribir `src/pages/index.astro` (alumno con saludo, CTA "¿Qué vas a pedir hoy?", card de último préstamo activo con estado + preview + fecha humanizada, empty state amigable) y agregar `saludoHora()` + `fmtFechaRelativa()` a `src/lib/date.ts` (ambos con offset MX vía `Intl.DateTimeFormat`, respetan DST). Falló antes de reescribir `admin/index.astro`. El orquestador lo completó con: saludo por hora + 2 KPIs de alerta compactos (Pendientes verde si >0 / Vencidos rojo si >0) + sección "Requieren tu atención" con últimas 2 pendientes en cards grid 2-col + sección "Actividad reciente" con últimas 5 (**tabla desktop, cards mobile — arregla de paso el bug 6**). Los 2 KPIs restantes (En préstamo, Agotados) se movieron a `/admin/estadisticas` que ya los tiene.
|
||||
- **P · Perfil + onboarding + maestros CRUD** (parcial → completado en hilo principal): agente alcanzó a escribir `src/pages/perfil.astro`, `src/pages/onboarding.astro`, `src/pages/api/profile.ts`, `src/components/profile/{Avatar,PerfilForm}.tsx`, `src/lib/avatar.ts` (con prioridad `foto_path` → `googleAvatarUrl` → iniciales sobre HSL hash del email), y a editar `middleware.ts`/`env.d.ts`/`AppLayout.astro`. Falló antes de crear el CRUD admin de maestros. El orquestador completó: `POST /api/admin/maestros`, `PATCH/DELETE /api/admin/maestros/[id]` (con 23503 → "N alumnos como tutor, reasígnalos"), `MaestroForm.tsx` (adaptado de `CategoriaForm` + checkbox `activo`), `EliminarMaestro.tsx` (adaptado de `EliminarCategoria`), `/admin/maestros` con tabla desktop/cards mobile + subnav Materiales · Categorías · Maestros, y edit de `categorias.astro` para agregar la tab Maestros al subnav existente.
|
||||
- **Verificación combinada**: `npm run build` limpio (2.24s). Deploy: 2 commits (migración + código), push con token efímero, `ssh buglabs 'cd ~/labre-web && git pull && set -a && . .env.production && set +a && docker compose up -d --build'` — el `set -a; . .env.production; set +a` es nuevo: Docker Compose expande `${VAR}` en `build.args` desde el **shell** (no desde `env_file`), así que hay que exportar las vars del `.env.production` al environment antes del compose. Container `healthy` en 31s. Smoke test público: `/login` → 200; `/perfil`, `/admin/maestros`, `/onboarding` → 302 (middleware protegiendo); bundle client contiene `supabase.buglabs.dev` en los 3 chunks esperados (bug del upload confirmado muerto).
|
||||
- **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.
|
||||
|
||||
@@ -6,6 +6,12 @@ RUN npm ci
|
||||
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
ARG PUBLIC_SUPABASE_URL
|
||||
ARG PUBLIC_SUPABASE_ANON_KEY
|
||||
ARG PUBLIC_APP_URL
|
||||
ENV PUBLIC_SUPABASE_URL=$PUBLIC_SUPABASE_URL \
|
||||
PUBLIC_SUPABASE_ANON_KEY=$PUBLIC_SUPABASE_ANON_KEY \
|
||||
PUBLIC_APP_URL=$PUBLIC_APP_URL
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
@@ -1,46 +1,102 @@
|
||||
# Astro Starter Kit: Basics
|
||||
# LabRe — Sistema de Préstamos LSC UABC
|
||||
|
||||
Aplicación web para gestionar préstamos de material del Laboratorio de Sistemas Computacionales (Facultad de Ingeniería, UABC). Modela el vale físico multi-ítem: un trámite agrupa varios materiales, cada renglón con cantidad y descripción.
|
||||
|
||||
**Producción**: https://prestamos.buglabs.dev · **Login**: exclusivo Google con correo `@uabc.edu.mx`
|
||||
|
||||
## Roles
|
||||
|
||||
- **Alumno**: navega el catálogo con búsqueda por texto y filtro por categoría, arma un vale multi-ítem, consulta el estado de sus préstamos.
|
||||
- **Admin**: aprueba/rechaza/marca devoluciones, gestiona inventario (grid con fotos), rastrea equipos por unidad individual (laptops, proyectores), reasigna unidades entre préstamos, consulta estadísticas con gráficas y exporta reportes CSV.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Frontend**: Astro 7 (SSR, `output: 'server'`) + React 19 islands + Tailwind v4 (tokens en `src/styles/global.css`)
|
||||
- **Backend**: Supabase self-hosted (Postgres 15, GoTrue, PostgREST, Storage, Realtime)
|
||||
- **Aislamiento**: schema Postgres propio `prestamos` (coexiste con otras apps en la misma instancia)
|
||||
- **Auth**: Google OAuth restringido a `@uabc.edu.mx` (verificación a nivel aplicación en middleware)
|
||||
- **Gráficas**: recharts
|
||||
- **Hosting**: Docker + adaptador `@astrojs/node` detrás de Cloudflare Tunnel
|
||||
- **CI/deploy**: git + `docker compose up -d --build` en el homelab
|
||||
|
||||
## Requisitos
|
||||
|
||||
- Node 22.12+
|
||||
- Acceso a una instancia de Supabase con el schema `prestamos` (ver migraciones)
|
||||
- Cuenta Google `@uabc.edu.mx` para probar el flujo
|
||||
|
||||
## Setup local
|
||||
|
||||
```sh
|
||||
npm create astro@latest -- --template basics
|
||||
cp .env.example .env # llenar PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY, PUBLIC_APP_URL
|
||||
npm install
|
||||
npm run dev # http://localhost:4321
|
||||
```
|
||||
|
||||
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||
Antes del primer login, aplica las migraciones y promuévete a admin manualmente:
|
||||
|
||||
## 🚀 Project Structure
|
||||
```sql
|
||||
update prestamos.profiles set rol = 'admin' where email = 'tu-correo@uabc.edu.mx';
|
||||
```
|
||||
|
||||
Inside of your Astro project, you'll see the following folders and files:
|
||||
## Base de datos
|
||||
|
||||
Migraciones en `supabase/migrations/`, aplican con `psql -f <archivo>` contra la instancia. Cada migración es idempotente (usa `if not exists`, `drop policy if exists`, etc.) y trae su propio `BEGIN`/`COMMIT`.
|
||||
|
||||
- `0001_init.sql` — schema, tablas base, RLS, triggers, seed.
|
||||
- `0002_solicitudes_multi_item.sql` — rediseño a "1 vale = N renglones", RPC `crear_solicitud`.
|
||||
- `0003_grid_unidades_realtime.sql` — imagen del material, unidades individuales, RPC `reasignar_unidad`, publication realtime, bucket público `materiales-fotos`.
|
||||
|
||||
## Estructura
|
||||
|
||||
```text
|
||||
/
|
||||
├── public/
|
||||
│ └── favicon.svg
|
||||
├── src
|
||||
│ ├── assets
|
||||
│ │ └── astro.svg
|
||||
│ ├── components
|
||||
│ │ └── Welcome.astro
|
||||
│ ├── layouts
|
||||
│ │ └── Layout.astro
|
||||
│ └── pages
|
||||
│ └── index.astro
|
||||
└── package.json
|
||||
src/
|
||||
├── layouts/
|
||||
│ └── AppLayout.astro # sidebar desktop + dock móvil + toaster + badge realtime
|
||||
├── lib/
|
||||
│ ├── supabase.ts # server/browser clients (schema 'prestamos')
|
||||
│ ├── date.ts # todayMX() con Intl + DST correcto
|
||||
│ └── materialImg.ts # URL pública del bucket
|
||||
├── middleware.ts # auth + dominio + verificación de rol
|
||||
├── pages/
|
||||
│ ├── login.astro
|
||||
│ ├── alumno/
|
||||
│ │ ├── catalogo.astro # grid + filtro categorías + buscador
|
||||
│ │ └── mis-prestamos.astro
|
||||
│ ├── admin/
|
||||
│ │ ├── index.astro # panel con KPIs
|
||||
│ │ ├── solicitudes/ # bandeja, activos, historial
|
||||
│ │ ├── inventario/ # grid CRUD + categorías
|
||||
│ │ ├── estadisticas.astro # 6 KPIs + 3 gráficas recharts
|
||||
│ │ └── reportes.astro # filtros + export CSV
|
||||
│ └── api/ # endpoints REST (guardados por middleware)
|
||||
├── components/
|
||||
│ ├── alumno/ # SolicitudCart, AgregarMaterial, FiltroCategorias, BuscadorCatalogo
|
||||
│ ├── admin/ # solicitudes, inventario (MaterialForm, UnidadesManager), estadisticas (Chart), BadgeSolicitudes
|
||||
│ └── Toaster.tsx # notificaciones globales estilo Sileo
|
||||
└── styles/global.css # tokens paleta UABC + sistema neobrutalista
|
||||
```
|
||||
|
||||
To learn more about the folder structure of an Astro project, refer to [our guide on project structure](https://docs.astro.build/en/basics/project-structure/).
|
||||
## Deploy
|
||||
|
||||
## 🧞 Commands
|
||||
Ver `deploy/README.md` para el detalle. Flujo resumido:
|
||||
|
||||
All commands are run from the root of the project, from a terminal:
|
||||
```sh
|
||||
git push
|
||||
ssh buglabs 'cd ~/labre-web && git pull && docker compose up -d --build'
|
||||
```
|
||||
|
||||
| Command | Action |
|
||||
| :------------------------ | :----------------------------------------------- |
|
||||
| `npm install` | Installs dependencies |
|
||||
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||
| `npm run build` | Build your production site to `./dist/` |
|
||||
| `npm run preview` | Preview your build locally, before deploying |
|
||||
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||
## Comandos
|
||||
|
||||
## 👀 Want to learn more?
|
||||
| Comando | Acción |
|
||||
| :---------------- | :-------------------------------------------- |
|
||||
| `npm install` | Instala dependencias |
|
||||
| `npm run dev` | Dev server en `localhost:4321` |
|
||||
| `npm run build` | Compila a `./dist/` (SSR bundle) |
|
||||
| `npm run preview` | Preview del build |
|
||||
|
||||
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
||||
## Documentación interna
|
||||
|
||||
- `AGENTS.md` — decisiones de arquitectura, bitácora de sesiones, backlog. Fuente única de verdad del progreso.
|
||||
- `DESIGN.md` — sistema de diseño (paleta UABC, tipografía, sombra dura, Text-on-Fill Rule).
|
||||
- `PRODUCT.md` — visión de producto y brand commitments.
|
||||
|
||||
+6
-1
@@ -1,6 +1,11 @@
|
||||
services:
|
||||
labre-web:
|
||||
build: .
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
PUBLIC_SUPABASE_URL: ${PUBLIC_SUPABASE_URL}
|
||||
PUBLIC_SUPABASE_ANON_KEY: ${PUBLIC_SUPABASE_ANON_KEY}
|
||||
PUBLIC_APP_URL: ${PUBLIC_APP_URL}
|
||||
container_name: labre-web
|
||||
restart: unless-stopped
|
||||
env_file: .env.production
|
||||
|
||||
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(
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
LabelList,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
|
||||
type Barras = { tipo: 'barras'; datos: { label: string; valor: number }[]; alto?: number };
|
||||
@@ -108,10 +109,11 @@ export default function Chart(props: Props) {
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="label"
|
||||
width={140}
|
||||
width={100}
|
||||
stroke={INK}
|
||||
tick={{ ...MONO, fontSize: 10 } as any}
|
||||
interval={0}
|
||||
tickFormatter={(v: string) => (v.length > 14 ? v.slice(0, 13) + '…' : v)}
|
||||
/>
|
||||
<Tooltip content={<TooltipBox />} cursor={{ fill: 'rgba(56,56,56,0.06)' }} />
|
||||
<Bar dataKey="valor" fill={PRIMARY} stroke={INK} strokeWidth={1.5} animationDuration={anim} name="Solicitado">
|
||||
@@ -148,7 +150,6 @@ export default function Chart(props: Props) {
|
||||
|
||||
// donut
|
||||
const datos = props.datos;
|
||||
const total = datos.reduce((s, d) => s + d.valor, 0);
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={alto}>
|
||||
<PieChart margin={{ top: 8, right: 8, left: 8, bottom: 8 }}>
|
||||
@@ -163,13 +164,24 @@ export default function Chart(props: Props) {
|
||||
strokeWidth={1.5}
|
||||
isAnimationActive={!reduced}
|
||||
animationDuration={anim}
|
||||
label={({ label, valor }: any) => (total ? `${label} (${valor})` : label)}
|
||||
labelLine={{ stroke: PENCIL }}
|
||||
label={false}
|
||||
labelLine={false}
|
||||
>
|
||||
{datos.map((d, i) => (
|
||||
<Cell key={d.label} fill={donutColor(d.label, i)} />
|
||||
))}
|
||||
</Pie>
|
||||
<Legend
|
||||
layout="horizontal"
|
||||
verticalAlign="bottom"
|
||||
align="center"
|
||||
iconType="square"
|
||||
wrapperStyle={{ ...MONO, marginTop: 8 }}
|
||||
formatter={(value: string, entry: any) => {
|
||||
const v = entry?.payload?.valor;
|
||||
return v != null ? `${value} (${v})` : value;
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
|
||||
@@ -4,9 +4,10 @@ type Props = {
|
||||
id: number;
|
||||
nombre: string;
|
||||
tienePrestamos?: boolean;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
export default function EliminarMaterial({ id, nombre, tienePrestamos }: Props) {
|
||||
export default function EliminarMaterial({ id, nombre, tienePrestamos, compact }: Props) {
|
||||
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -53,7 +54,10 @@ export default function EliminarMaterial({ id, nombre, tienePrestamos }: Props)
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost hover:!bg-[color:var(--color-danger)] hover:!text-[color:var(--color-ink)]"
|
||||
className={
|
||||
'btn btn-ghost hover:!bg-[color:var(--color-danger)] hover:!text-[color:var(--color-ink)]' +
|
||||
(compact ? ' w-full text-sm px-2' : '')
|
||||
}
|
||||
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||
onClick={open}
|
||||
>
|
||||
|
||||
@@ -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';
|
||||
@@ -22,11 +21,10 @@ type Props = {
|
||||
mode: 'create' | 'edit';
|
||||
material?: Material;
|
||||
categorias: Categoria[];
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
const BUCKET = 'materiales-fotos';
|
||||
|
||||
export default function MaterialForm({ mode, material, categorias }: Props) {
|
||||
export default function MaterialForm({ mode, material, categorias, compact }: Props) {
|
||||
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
||||
const firstFieldRef = useRef<HTMLInputElement | null>(null);
|
||||
const errorRef = useRef<HTMLParagraphElement | null>(null);
|
||||
@@ -98,18 +96,16 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
||||
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) => {
|
||||
@@ -150,26 +146,14 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
||||
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,
|
||||
@@ -180,7 +164,7 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
||||
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' },
|
||||
@@ -200,13 +184,18 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
||||
};
|
||||
|
||||
const triggerLabel = mode === 'create' ? 'Nuevo material' : 'Editar';
|
||||
const triggerClass = mode === 'create' ? 'btn btn-primary' : 'btn btn-ghost';
|
||||
const triggerClass =
|
||||
(mode === 'create' ? 'btn btn-primary' : 'btn btn-ghost') +
|
||||
(compact ? ' w-full text-sm px-2' : '');
|
||||
const triggerStyle = compact
|
||||
? { minHeight: '36px', paddingBlock: '0.25rem' as const }
|
||||
: undefined;
|
||||
|
||||
const currentImg = previewUrl ?? imgUrl(imagenPath);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" className={triggerClass} onClick={open}>
|
||||
<button type="button" className={triggerClass} style={triggerStyle} onClick={open}>
|
||||
{triggerLabel}
|
||||
</button>
|
||||
|
||||
|
||||
@@ -114,86 +114,65 @@ export default function UnidadesManager({ materialId }: { materialId: number })
|
||||
) : unidades.length === 0 ? (
|
||||
<p className="text-sm opacity-70">Sin unidades registradas.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead
|
||||
className="text-left"
|
||||
style={{ background: 'color-mix(in oklab, var(--color-ink) 4%, transparent)' }}
|
||||
>
|
||||
<tr>
|
||||
<th className="px-2 py-2 font-medium">Etiqueta</th>
|
||||
<th className="px-2 py-2 font-medium">Estado</th>
|
||||
<th className="px-2 py-2 font-medium">Notas</th>
|
||||
<th className="px-2 py-2 font-medium text-right">Acción</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{unidades.map((u) => {
|
||||
const bloqueada = u.estado === 'prestado';
|
||||
return (
|
||||
<tr
|
||||
key={u.id}
|
||||
className="border-t"
|
||||
style={{ borderColor: 'color-mix(in oklab, var(--color-ink) 12%, transparent)' }}
|
||||
>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||
defaultValue={u.etiqueta}
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
if (v && v !== u.etiqueta) patch(u, { etiqueta: v });
|
||||
else e.target.value = u.etiqueta;
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<select
|
||||
className="input"
|
||||
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||
value={bloqueada ? 'prestado' : u.estado}
|
||||
disabled={bloqueada}
|
||||
onChange={(e) => patch(u, { estado: e.target.value as Unidad['estado'] })}
|
||||
>
|
||||
{bloqueada && <option value="prestado">Prestado</option>}
|
||||
{ESTADOS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s[0].toUpperCase() + s.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||
defaultValue={u.notas ?? ''}
|
||||
placeholder="Opcional…"
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value;
|
||||
if ((u.notas ?? '') !== v) patch(u, { notas: v || null });
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||
disabled={bloqueada}
|
||||
onClick={() => eliminar(u)}
|
||||
>
|
||||
Eliminar
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="flex flex-col gap-2">
|
||||
{unidades.map((u) => {
|
||||
const bloqueada = u.estado === 'prestado';
|
||||
return (
|
||||
<div
|
||||
key={u.id}
|
||||
className="card p-3 grid grid-cols-1 sm:grid-cols-[1fr_auto_auto] gap-2 items-center"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
aria-label="Etiqueta"
|
||||
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||
defaultValue={u.etiqueta}
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
if (v && v !== u.etiqueta) patch(u, { etiqueta: v });
|
||||
else e.target.value = u.etiqueta;
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
className="input"
|
||||
aria-label="Estado"
|
||||
style={{ minHeight: '36px', paddingBlock: '0.25rem', minWidth: '130px' }}
|
||||
value={bloqueada ? 'prestado' : u.estado}
|
||||
disabled={bloqueada}
|
||||
onChange={(e) => patch(u, { estado: e.target.value as Unidad['estado'] })}
|
||||
>
|
||||
{bloqueada && <option value="prestado">Prestado</option>}
|
||||
{ESTADOS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s[0].toUpperCase() + s.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||
disabled={bloqueada}
|
||||
onClick={() => eliminar(u)}
|
||||
>
|
||||
Eliminar
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
className="input sm:col-span-3"
|
||||
aria-label="Notas"
|
||||
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||
defaultValue={u.notas ?? ''}
|
||||
placeholder="Notas (opcional)…"
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value;
|
||||
if ((u.notas ?? '') !== v) patch(u, { notas: v || null });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
id: number;
|
||||
nombre: string;
|
||||
alumnosCount: number;
|
||||
};
|
||||
|
||||
export default function EliminarMaestro({ id, nombre, alumnosCount }: Props) {
|
||||
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const titleId = `del-mae-${id}`;
|
||||
const bloqueado = alumnosCount > 0;
|
||||
|
||||
const open = () => {
|
||||
setError(null);
|
||||
dialogRef.current?.showModal();
|
||||
};
|
||||
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);
|
||||
});
|
||||
|
||||
const submit = async () => {
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/maestros/${id}`, { method: 'DELETE' });
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(json?.error ?? 'No se pudo eliminar');
|
||||
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' }}
|
||||
onClick={open}
|
||||
>
|
||||
Eliminar
|
||||
</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)]"
|
||||
>
|
||||
<div className="p-5 sm:p-6 flex flex-col gap-4">
|
||||
<h2 id={titleId} className="text-lg font-semibold">
|
||||
Eliminar maestro
|
||||
</h2>
|
||||
|
||||
{bloqueado ? (
|
||||
<p className="text-sm">
|
||||
No se puede eliminar <strong>{nombre}</strong>: tiene{' '}
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{alumnosCount}</span>{' '}
|
||||
alumno{alumnosCount === 1 ? '' : 's'} como tutor. Reasígnalos primero desde su perfil.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm">
|
||||
¿Seguro que quieres eliminar <strong>{nombre}</strong>?
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p role="alert" 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}>
|
||||
{bloqueado ? 'Cerrar' : 'Cancelar'}
|
||||
</button>
|
||||
{!bloqueado && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{ background: 'var(--color-danger)', color: 'var(--color-ink)' }}
|
||||
onClick={submit}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Eliminando…' : 'Eliminar'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
mode: 'create' | 'edit';
|
||||
maestro?: { id: number; nombre: string; activo: boolean; es_tutor: boolean };
|
||||
};
|
||||
|
||||
export default function MaestroForm({ mode, maestro }: Props) {
|
||||
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
||||
const firstFieldRef = useRef<HTMLInputElement | null>(null);
|
||||
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);
|
||||
|
||||
const titleId = `mae-form-${mode}-${maestro?.id ?? 'new'}`;
|
||||
|
||||
const open = () => {
|
||||
setError(null);
|
||||
if (mode === 'create') {
|
||||
setNombre('');
|
||||
setActivo(true);
|
||||
setEsTutor(false);
|
||||
}
|
||||
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 = nombre.trim();
|
||||
if (!trimmed) {
|
||||
setError('El nombre es obligatorio');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const url = mode === 'create' ? '/api/admin/maestros' : `/api/admin/maestros/${maestro!.id}`;
|
||||
const method = mode === 'create' ? 'POST' : 'PATCH';
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
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');
|
||||
dialogRef.current?.close();
|
||||
location.reload();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error inesperado');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const triggerLabel = mode === 'create' ? 'Nuevo maestro' : 'Editar';
|
||||
const triggerClass = mode === 'create' ? 'btn btn-primary' : 'btn btn-ghost';
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" className={triggerClass} onClick={open}>
|
||||
{triggerLabel}
|
||||
</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,24rem)]"
|
||||
>
|
||||
<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">
|
||||
{mode === 'create' ? 'Nuevo maestro' : `Editar: ${maestro?.nombre}`}
|
||||
</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>
|
||||
|
||||
<div>
|
||||
<label className="label" htmlFor={`${titleId}-nombre`}>
|
||||
Nombre
|
||||
</label>
|
||||
<input
|
||||
ref={firstFieldRef}
|
||||
id={`${titleId}-nombre`}
|
||||
className="input"
|
||||
type="text"
|
||||
value={nombre}
|
||||
onChange={(e) => setNombre(e.target.value)}
|
||||
required
|
||||
autoComplete="off"
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? `${titleId}-err` : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={activo}
|
||||
onChange={(e) => setActivo(e.target.checked)}
|
||||
/>
|
||||
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 && (
|
||||
<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 btn-primary" disabled={loading}>
|
||||
{loading ? 'Guardando…' : 'Guardar'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,11 +49,26 @@ export function useCart() {
|
||||
|
||||
const clamp = (n: number, max: number) => Math.max(1, Math.min(max, n));
|
||||
|
||||
export default function CartProvider({ materiales }: { materiales: Material[] }) {
|
||||
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<HTMLInputElement | 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);
|
||||
@@ -116,6 +141,43 @@ export default function CartProvider({ materiales }: { materiales: Material[] })
|
||||
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;
|
||||
@@ -129,15 +191,16 @@ export default function CartProvider({ materiales }: { materiales: Material[] })
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (loading || items.length === 0) return;
|
||||
if (loading || items.length === 0 || !perfilCompleto) return;
|
||||
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,
|
||||
@@ -151,10 +214,13 @@ export default function CartProvider({ materiales }: { materiales: Material[] })
|
||||
throw new Error(json?.error ?? 'No se pudo enviar la solicitud');
|
||||
}
|
||||
setOk(true);
|
||||
const usedFallback = !esDocente && !maestroIdNum && !!tutorNombre;
|
||||
toastAfterReload({
|
||||
kind: 'success',
|
||||
title: 'Solicitud enviada',
|
||||
description: `${items.length} material${items.length === 1 ? '' : 'es'} · pendiente de aprobación`,
|
||||
description: usedFallback
|
||||
? `${items.length} material${items.length === 1 ? '' : 'es'} · maestro: ${tutorNombre} (tu tutor)`
|
||||
: `${items.length} material${items.length === 1 ? '' : 'es'} · pendiente de aprobación`,
|
||||
});
|
||||
setTimeout(() => {
|
||||
dialogRef.current?.close();
|
||||
@@ -173,46 +239,59 @@ export default function CartProvider({ materiales }: { materiales: Material[] })
|
||||
return (
|
||||
<CartContext.Provider value={value}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{materiales.map((m) => (
|
||||
<article
|
||||
key={m.id}
|
||||
className="card flex flex-col gap-3"
|
||||
data-cat={m.categoria?.id ?? 'sin'}
|
||||
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 && (
|
||||
{materiales.map((m) => {
|
||||
const src = imgUrl(m.imagen_path);
|
||||
return (
|
||||
<article
|
||||
key={m.id}
|
||||
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="relative aspect-square w-full bg-[color:var(--color-chalk)] grid place-items-center"
|
||||
style={{ borderBottom: '2px solid var(--color-ink)' }}
|
||||
>
|
||||
{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="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)',
|
||||
}}
|
||||
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}
|
||||
>
|
||||
{m.categoria.nombre}
|
||||
Disponible
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{m.descripcion && <p className="text-sm opacity-75 line-clamp-2">{m.descripcion}</p>}
|
||||
<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>}
|
||||
|
||||
<dl className="text-xs opacity-70 grid grid-cols-2 gap-1">
|
||||
<dt className="opacity-60">Inventario</dt>
|
||||
<dd className="text-right font-mono">{m.numero_inventario ?? '—'}</dd>
|
||||
<dt className="opacity-60">Disponibles</dt>
|
||||
<dd className="text-right" style={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
{m.cantidad_disponible} / {m.cantidad_total}
|
||||
</dd>
|
||||
</dl>
|
||||
{m.descripcion && <p className="text-sm opacity-75 line-clamp-2">{m.descripcion}</p>}
|
||||
|
||||
<div className="mt-auto">
|
||||
<AgregarMaterial material={{ id: m.id, nombre: m.nombre, cantidad_disponible: m.cantidad_disponible }} />
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
<dl className="text-xs opacity-70 grid grid-cols-2 gap-1">
|
||||
<dt className="opacity-60">Inventario</dt>
|
||||
<dd className="text-right font-mono">{m.numero_inventario ?? '—'}</dd>
|
||||
<dt className="opacity-60">Disponibles</dt>
|
||||
<dd className="text-right" style={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
{m.cantidad_disponible} / {m.cantidad_total}
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<div className="mt-auto">
|
||||
<AgregarMaterial material={{ id: m.id, nombre: m.nombre, cantidad_disponible: m.cantidad_disponible }} />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{items.length > 0 && (
|
||||
@@ -306,19 +385,31 @@ export default function CartProvider({ materiales }: { materiales: Material[] })
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="label" htmlFor="maestro-responsable">Maestro responsable</label>
|
||||
<input
|
||||
ref={firstFieldRef}
|
||||
id="maestro-responsable"
|
||||
className="input"
|
||||
type="text"
|
||||
required
|
||||
maxLength={200}
|
||||
value={maestroResponsable}
|
||||
onChange={(e) => setMaestroResponsable(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{!esDocente && (
|
||||
<div>
|
||||
<label className="label" htmlFor="maestro-select">Maestro responsable</label>
|
||||
<select
|
||||
ref={firstFieldRef}
|
||||
id="maestro-select"
|
||||
className="input"
|
||||
value={maestroId}
|
||||
onChange={(e) => setMaestroId(e.target.value)}
|
||||
aria-describedby="maestro-hint"
|
||||
>
|
||||
<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">
|
||||
{tutorNombre
|
||||
? <>Si no eliges nadie se usará tu tutor: <strong>{tutorNombre}</strong>.</>
|
||||
: 'Debes elegir un maestro; no tienes tutor guardado.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="label" htmlFor="notas-cart">Motivo del préstamo</label>
|
||||
@@ -326,7 +417,7 @@ export default function CartProvider({ materiales }: { materiales: Material[] })
|
||||
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?"
|
||||
@@ -334,7 +425,7 @@ export default function CartProvider({ materiales }: { materiales: Material[] })
|
||||
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>
|
||||
|
||||
@@ -349,14 +440,31 @@ export default function CartProvider({ materiales }: { materiales: Material[] })
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<button type="button" className="btn btn-ghost" onClick={closeCheckout} disabled={loading}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={loading || ok || items.length === 0}>
|
||||
{loading ? 'Enviando…' : 'Enviar solicitud'}
|
||||
</button>
|
||||
</div>
|
||||
{!perfilCompleto ? (
|
||||
<div className="card p-3">
|
||||
<p className="text-sm mb-3">Necesitas completar tu perfil (matrícula + tutor) para crear vales.</p>
|
||||
<a href="/perfil" className="btn btn-primary">Completar perfil</a>
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
<button type="submit" className="btn btn-primary" disabled={loading || ok || items.length === 0}>
|
||||
{loading ? 'Enviando…' : 'Enviar solicitud'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</dialog>
|
||||
</CartContext.Provider>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { AvatarInfo } from '@/lib/avatar';
|
||||
|
||||
type Props = {
|
||||
info: AvatarInfo;
|
||||
size?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
// Contraste rápido: fondo HSL con L=55% → texto blanco pasa AA con casi todos los hues.
|
||||
// Mantenemos blanco fijo para consistencia visual.
|
||||
export default function Avatar({ info, size = 40, className = '' }: Props) {
|
||||
const style: React.CSSProperties = {
|
||||
width: size,
|
||||
height: size,
|
||||
border: '2px solid var(--color-ink)',
|
||||
borderRadius: 2,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
overflow: 'hidden',
|
||||
background: info.src ? 'white' : info.hueColor,
|
||||
color: 'white',
|
||||
fontWeight: 700,
|
||||
fontSize: Math.max(11, Math.floor(size * 0.4)),
|
||||
lineHeight: 1,
|
||||
flexShrink: 0,
|
||||
};
|
||||
return (
|
||||
<div className={className} style={style} aria-hidden="true">
|
||||
{info.src ? (
|
||||
<img
|
||||
src={info.src}
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<span>{info.initials}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { avatarInfo } from '@/lib/avatar';
|
||||
import { toast, toastAfterReload } from '@/lib/toast';
|
||||
import Avatar from './Avatar';
|
||||
|
||||
type Maestro = { id: number; nombre: string };
|
||||
|
||||
type Props = {
|
||||
userId: string;
|
||||
email: string;
|
||||
nombre: string | null;
|
||||
rol: 'alumno' | 'docente' | 'admin';
|
||||
initialProfile: {
|
||||
matricula: string | null;
|
||||
semestre: string | null;
|
||||
tutor_id: number | null;
|
||||
foto_path: string | null;
|
||||
};
|
||||
maestros: Maestro[];
|
||||
googleAvatarUrl?: string | null;
|
||||
mode?: 'edit' | 'onboarding';
|
||||
};
|
||||
|
||||
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>(
|
||||
initialProfile.tutor_id != null ? String(initialProfile.tutor_id) : '',
|
||||
);
|
||||
const [fotoPath, setFotoPath] = useState<string | null>(initialProfile.foto_path);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!file) {
|
||||
setPreviewUrl(null);
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(file);
|
||||
setPreviewUrl(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [file]);
|
||||
|
||||
const info = avatarInfo({
|
||||
email,
|
||||
nombre,
|
||||
fotoPath,
|
||||
googleAvatarUrl,
|
||||
});
|
||||
const previewInfo = previewUrl
|
||||
? { ...info, src: previewUrl }
|
||||
: info;
|
||||
|
||||
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 true;
|
||||
}
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// La foto se sube por su propio endpoint (que ya persiste foto_path).
|
||||
// El PATCH /api/profile solo maneja matricula/semestre/tutor.
|
||||
if (file) {
|
||||
await uploadAvatar(file);
|
||||
}
|
||||
const body: Record<string, string | number | null> = {
|
||||
matricula: matricula.trim() || null,
|
||||
};
|
||||
if (soloAlumno) {
|
||||
body.semestre = semestre || null;
|
||||
body.tutor_id = tutorId ? Number(tutorId) : null;
|
||||
}
|
||||
|
||||
const res = await fetch('/api/profile', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(json?.error ?? 'no se pudo guardar');
|
||||
|
||||
if (mode === 'onboarding') {
|
||||
toastAfterReload({ kind: 'success', title: 'Perfil guardado', description: 'Ya puedes crear vales.' });
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
toastAfterReload({ kind: 'success', title: 'Perfil actualizado' });
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error inesperado');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="flex flex-col gap-5" autoComplete="off">
|
||||
<div>
|
||||
<label className="label" htmlFor="p-foto">Foto</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar info={previewInfo} size={72} />
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
id="p-foto"
|
||||
className="input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
{(file || fotoPath) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost self-start"
|
||||
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||
onClick={() => {
|
||||
setFile(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
setFotoPath(null);
|
||||
}}
|
||||
>
|
||||
Quitar foto
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!file && !fotoPath && googleAvatarUrl && (
|
||||
<p className="text-xs opacity-70 mt-2">Mostrando tu foto de Google. Sube una para reemplazarla.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label" htmlFor="p-mat">{matriculaLabel}</label>
|
||||
<input
|
||||
id="p-mat"
|
||||
className="input"
|
||||
type="text"
|
||||
value={matricula}
|
||||
onChange={(e) => setMatricula(e.target.value)}
|
||||
placeholder="123456"
|
||||
autoComplete="off"
|
||||
maxLength={15}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{soloAlumno && (
|
||||
<div>
|
||||
<label className="label" htmlFor="p-sem">Semestre</label>
|
||||
<select
|
||||
id="p-sem"
|
||||
className="input"
|
||||
value={semestre}
|
||||
onChange={(e) => setSemestre(e.target.value)}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{SEM_OPTS.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{soloAlumno && (
|
||||
<div>
|
||||
<label className="label" htmlFor="p-tutor">Tutor</label>
|
||||
<select
|
||||
id="p-tutor"
|
||||
className="input"
|
||||
value={tutorId}
|
||||
onChange={(e) => setTutorId(e.target.value)}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{maestros.map((m) => (
|
||||
<option key={m.id} value={String(m.id)}>{m.nombre}</option>
|
||||
))}
|
||||
</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)' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 justify-end pt-2 flex-wrap">
|
||||
{mode === 'onboarding' && (
|
||||
<a href="/" className="btn btn-ghost">Omitir por ahora</a>
|
||||
)}
|
||||
<button type="submit" className="btn btn-primary" disabled={loading}>
|
||||
{loading ? 'Guardando…' : mode === 'onboarding' ? 'Guardar y continuar' : 'Guardar cambios'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Vendored
+4
-1
@@ -7,7 +7,10 @@ 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;
|
||||
};
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -3,15 +3,35 @@ import Layout from './Layout.astro';
|
||||
import Toaster from '@/components/Toaster.tsx';
|
||||
import BadgeSolicitudes from '@/components/admin/BadgeSolicitudes.tsx';
|
||||
import BrandMark from '@/components/BrandMark.astro';
|
||||
import Avatar from '@/components/profile/Avatar.tsx';
|
||||
import { avatarInfo } from '@/lib/avatar';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
}
|
||||
const { title } = Astro.props;
|
||||
|
||||
const user = Astro.locals.user;
|
||||
const profile = Astro.locals.profile;
|
||||
const isAdmin = profile?.rol === 'admin';
|
||||
|
||||
const googleAvatarUrl = user
|
||||
? ((user.user_metadata as Record<string, unknown> | undefined)?.avatar_url as string | undefined) ||
|
||||
((user.user_metadata as Record<string, unknown> | undefined)?.picture as string | undefined) ||
|
||||
null
|
||||
: null;
|
||||
|
||||
const avatar = profile
|
||||
? avatarInfo({
|
||||
email: profile.email,
|
||||
nombre: profile.nombre,
|
||||
fotoPath: profile.foto_path,
|
||||
googleAvatarUrl,
|
||||
})
|
||||
: null;
|
||||
|
||||
const perfilIncompleto = profile?.rol === 'alumno' && (!profile.matricula || !profile.tutor_id);
|
||||
|
||||
let pendientesCount = 0;
|
||||
if (isAdmin) {
|
||||
const { count } = await Astro.locals.supabase
|
||||
@@ -37,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[] = [
|
||||
@@ -50,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;
|
||||
|
||||
@@ -112,11 +134,24 @@ const activeCheck = (item: NavItem) => {
|
||||
))}
|
||||
</nav>
|
||||
<div class="p-3" style="border-top: 2px solid var(--color-ink);">
|
||||
<div class="px-3 py-2 text-sm">
|
||||
<div class="font-medium truncate">{profile?.nombre ?? profile?.email}</div>
|
||||
<div class="text-xs uppercase tracking-wide" style="color: var(--color-pencil);">{profile?.rol}</div>
|
||||
</div>
|
||||
<form method="POST" action="/api/auth/signout">
|
||||
<a href="/perfil" class="flex items-center gap-3 px-2 py-2 rounded-[2px] hover:bg-[color:var(--color-chalk)] transition-colors">
|
||||
{avatar && <Avatar client:load info={avatar} size={36} />}
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-medium truncate">{profile?.nombre ?? profile?.email}</div>
|
||||
<div class="text-xs uppercase tracking-wide flex items-center gap-1.5" style="color: var(--color-pencil);">
|
||||
<span>Ver perfil</span>
|
||||
{perfilIncompleto && (
|
||||
<span
|
||||
aria-label="Perfil incompleto"
|
||||
title="Perfil incompleto"
|
||||
class="inline-block w-2 h-2 rounded-full motion-safe:animate-pulse"
|
||||
style="background: var(--color-danger);"
|
||||
></span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<form method="POST" action="/api/auth/signout" class="mt-1">
|
||||
<button type="submit" class="w-full text-left px-3 py-2 rounded-[2px] text-sm uppercase tracking-wide hover:bg-[color:var(--color-chalk)] transition-colors flex items-center gap-2">
|
||||
<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-hidden="true">
|
||||
<path d={ICONS.logout} />
|
||||
@@ -135,10 +170,16 @@ const activeCheck = (item: NavItem) => {
|
||||
</div>
|
||||
<span class="font-semibold text-sm uppercase tracking-wide">LabPréstamos</span>
|
||||
</a>
|
||||
<div class="text-xs truncate max-w-[45%] text-right">
|
||||
<div class="font-medium truncate">{profile?.nombre ?? profile?.email}</div>
|
||||
<div class="uppercase tracking-wide" style="color: var(--color-pencil);">{profile?.rol}</div>
|
||||
</div>
|
||||
<a href="/perfil" class="relative flex items-center gap-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--color-ink)] rounded-[2px]" aria-label="Mi perfil">
|
||||
{avatar && <Avatar client:load info={avatar} size={36} />}
|
||||
{perfilIncompleto && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="absolute -top-0.5 -right-0.5 w-2.5 h-2.5 rounded-full motion-safe:animate-pulse"
|
||||
style="background: var(--color-danger); border: 1.5px solid var(--color-ink);"
|
||||
></span>
|
||||
)}
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<main id="main" class="flex-1 p-4 md:p-8 pb-24 md:pb-8 md:ml-64 sidebar-aware transition-[margin] duration-200 ease-out">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// Helper puro para resolver avatar del usuario: URL, iniciales y color de fondo.
|
||||
export type AvatarInfo = {
|
||||
src?: string | null;
|
||||
initials: string;
|
||||
hueColor: string;
|
||||
};
|
||||
|
||||
function supabaseBase(): string | undefined {
|
||||
return (
|
||||
(import.meta.env.PUBLIC_SUPABASE_URL as string | undefined) ??
|
||||
(process.env.PUBLIC_SUPABASE_URL as string | undefined)
|
||||
);
|
||||
}
|
||||
|
||||
function fotoUrl(path: string): string | null {
|
||||
const base = supabaseBase();
|
||||
if (!base) return null;
|
||||
return `${base}/storage/v1/object/public/avatares/${path}`;
|
||||
}
|
||||
|
||||
function initialsFrom(nombre: string | null | undefined, email: string): string {
|
||||
const src = (nombre ?? '').trim();
|
||||
if (src) {
|
||||
const parts = src.split(/\s+/).filter(Boolean);
|
||||
const letters = parts.slice(0, 2).map((p) => p[0]!.toUpperCase());
|
||||
if (letters.length) return letters.join('');
|
||||
}
|
||||
const local = (email.split('@')[0] ?? '').trim();
|
||||
return (local[0] ?? '?').toUpperCase();
|
||||
}
|
||||
|
||||
function hashHue(seed: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
h = (h * 31 + seed.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return h % 360;
|
||||
}
|
||||
|
||||
export function avatarInfo(args: {
|
||||
email: string;
|
||||
nombre?: string | null;
|
||||
fotoPath?: string | null;
|
||||
googleAvatarUrl?: string | null;
|
||||
}): AvatarInfo {
|
||||
const src = args.fotoPath
|
||||
? fotoUrl(args.fotoPath)
|
||||
: (args.googleAvatarUrl ?? undefined);
|
||||
return {
|
||||
src: src ?? undefined,
|
||||
initials: initialsFrom(args.nombre, args.email),
|
||||
hueColor: `hsl(${hashHue(args.email.toLowerCase())}, 60%, 55%)`,
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,43 @@ function tzOffset(date: Date, tz: string): string {
|
||||
return m?.[1] ?? '+00:00';
|
||||
}
|
||||
|
||||
function hourMX(now: Date = new Date()): number {
|
||||
const parts = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: MX_TZ,
|
||||
hour: '2-digit',
|
||||
hour12: false,
|
||||
}).formatToParts(now);
|
||||
const h = parseInt(parts.find((p) => p.type === 'hour')?.value ?? '0', 10);
|
||||
return h % 24; // "24" en algunos runtimes al filo de medianoche.
|
||||
}
|
||||
|
||||
export function saludoHora(
|
||||
now: Date = new Date()
|
||||
): 'Buenos días' | 'Buenas tardes' | 'Buenas noches' {
|
||||
const h = hourMX(now);
|
||||
if (h >= 5 && h < 12) return 'Buenos días';
|
||||
if (h >= 12 && h < 19) return 'Buenas tardes';
|
||||
return 'Buenas noches';
|
||||
}
|
||||
|
||||
// "Hoy a las 15:32" / "Ayer a las 10:00" / "Hace 3 días" / fecha absoluta.
|
||||
export function fmtFechaRelativa(input: string | Date, now: Date = new Date()): string {
|
||||
const d = typeof input === 'string' ? new Date(input) : input;
|
||||
const hoy = todayMX(now);
|
||||
const suDia = todayMX(d);
|
||||
const diffDays = Math.round((hoy.startUTC.getTime() - suDia.startUTC.getTime()) / 86400000);
|
||||
const hora = new Intl.DateTimeFormat('es-MX', {
|
||||
timeZone: MX_TZ,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(d);
|
||||
if (diffDays === 0) return `Hoy a las ${hora}`;
|
||||
if (diffDays === 1) return `Ayer a las ${hora}`;
|
||||
if (diffDays > 1 && diffDays < 7) return `Hace ${diffDays} días`;
|
||||
return new Intl.DateTimeFormat('es-MX', { dateStyle: 'medium', timeZone: MX_TZ }).format(d);
|
||||
}
|
||||
|
||||
export function todayMX(now: Date = new Date()): {
|
||||
label: string;
|
||||
startUTC: Date;
|
||||
|
||||
+9
-5
@@ -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.
|
||||
@@ -43,8 +47,8 @@ export function serverClient(cookies: AstroCookies) {
|
||||
|
||||
export function browserClient() {
|
||||
return createBrowserClient(
|
||||
import.meta.env.PUBLIC_SUPABASE_URL,
|
||||
import.meta.env.PUBLIC_SUPABASE_ANON_KEY,
|
||||
SUPABASE_URL,
|
||||
SUPABASE_ANON_KEY,
|
||||
{ db: { schema: SCHEMA }, cookieOptions },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const truncate = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '…' : s);
|
||||
+31
-2
@@ -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);
|
||||
@@ -19,11 +21,29 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||
context.locals.profile = null;
|
||||
|
||||
if (user) {
|
||||
const { data: profile } = await supabase
|
||||
const select = 'id, email, nombre, matricula, rol, semestre, tutor_id, foto_path';
|
||||
let { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, email, nombre, matricula, rol')
|
||||
.select(select)
|
||||
.eq('id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
// Self-heal: el trigger prestamos_on_auth_user_created coexiste con otro
|
||||
// trigger del proyecto vecino en auth.users y no siempre dispara. Si el
|
||||
// user existe pero no hay profile, lo creamos aquí con los datos del OAuth.
|
||||
if (!profile && user.email?.toLowerCase().endsWith(UABC_DOMAIN)) {
|
||||
const nombre =
|
||||
(user.user_metadata?.full_name as string | undefined) ??
|
||||
(user.user_metadata?.name as string | undefined) ??
|
||||
null;
|
||||
const { data: creado } = await supabase
|
||||
.from('profiles')
|
||||
.upsert({ id: user.id, email: user.email, nombre }, { onConflict: 'id' })
|
||||
.select(select)
|
||||
.maybeSingle();
|
||||
profile = creado ?? null;
|
||||
}
|
||||
|
||||
context.locals.profile = profile ?? null;
|
||||
}
|
||||
|
||||
@@ -38,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');
|
||||
}
|
||||
|
||||
+156
-64
@@ -1,32 +1,52 @@
|
||||
---
|
||||
import AppLayout from '@/layouts/AppLayout.astro';
|
||||
import { todayMX } from '@/lib/date';
|
||||
import { todayMX, saludoHora, fmtFechaRelativa } from '@/lib/date';
|
||||
|
||||
const supabase = Astro.locals.supabase;
|
||||
const profile = Astro.locals.profile;
|
||||
const dia = todayMX();
|
||||
const saludo = saludoHora();
|
||||
|
||||
const [pend, activos, vencidos, agotados, ultimas] = await Promise.all([
|
||||
const nombreCorto =
|
||||
profile?.nombre?.trim().split(/\s+/)[0] ??
|
||||
profile?.email?.split('@')[0] ??
|
||||
'';
|
||||
|
||||
type Estado = 'pendiente' | 'aprobado' | 'activo' | 'rechazado' | 'devuelto' | 'vencido';
|
||||
type Item = { cantidad: number; material: { nombre: string } | null };
|
||||
type Alumno = { nombre: string | null; email: string } | null;
|
||||
type Sol = {
|
||||
id: number;
|
||||
estado: Estado;
|
||||
fecha_solicitud: string;
|
||||
alumno: Alumno;
|
||||
items: Item[];
|
||||
};
|
||||
|
||||
const [pend, vencidos, pendientes2, ultimas] = await Promise.all([
|
||||
supabase.from('solicitudes').select('*', { count: 'exact', head: true }).eq('estado', 'pendiente'),
|
||||
supabase.from('solicitudes').select('*', { count: 'exact', head: true }).in('estado', ['aprobado', 'activo']),
|
||||
supabase.from('solicitudes').select('*', { count: 'exact', head: true }).in('estado', ['aprobado', 'activo']).lt('fecha_devolucion_estimada', dia.isoDate),
|
||||
supabase.from('materiales').select('*', { count: 'exact', head: true }).eq('cantidad_disponible', 0),
|
||||
supabase
|
||||
.from('solicitudes')
|
||||
.select('id, fecha_solicitud, estado, alumno:profiles!alumno_id(nombre, email), items:solicitud_items(cantidad, material:materiales(nombre))')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.in('estado', ['aprobado', 'activo'])
|
||||
.lt('fecha_devolucion_estimada', dia.isoDate),
|
||||
supabase
|
||||
.from('solicitudes')
|
||||
.select('id, estado, fecha_solicitud, alumno:profiles!alumno_id(nombre, email), items:solicitud_items(cantidad, material:materiales(nombre))')
|
||||
.eq('estado', 'pendiente')
|
||||
.order('fecha_solicitud', { ascending: false })
|
||||
.limit(2),
|
||||
supabase
|
||||
.from('solicitudes')
|
||||
.select('id, estado, fecha_solicitud, alumno:profiles!alumno_id(nombre, email), items:solicitud_items(cantidad, material:materiales(nombre))')
|
||||
.order('fecha_solicitud', { ascending: false })
|
||||
.limit(5),
|
||||
]);
|
||||
|
||||
const fmtFecha = new Intl.DateTimeFormat('es-MX', { dateStyle: 'medium', timeStyle: 'short' });
|
||||
const pendientesData = (pendientes2.data ?? []) as unknown as Sol[];
|
||||
const ultimasData = (ultimas.data ?? []) as unknown as Sol[];
|
||||
|
||||
const kpis = [
|
||||
{ label: 'Pendientes', value: pend.count ?? 0, href: '/admin/solicitudes', tone: 'primary' },
|
||||
{ label: 'En préstamo', value: activos.count ?? 0, href: '/admin/solicitudes/activos', tone: 'secondary' },
|
||||
{ label: 'Vencidos', value: vencidos.count ?? 0, href: '/admin/solicitudes/activos', tone: 'danger' },
|
||||
{ label: 'Agotados', value: agotados.count ?? 0, href: '/admin/inventario', tone: 'ink' },
|
||||
];
|
||||
|
||||
const estadoLabel: Record<string, string> = {
|
||||
const estadoLabel: Record<Estado, string> = {
|
||||
pendiente: 'Pendiente',
|
||||
aprobado: 'En préstamo',
|
||||
activo: 'En préstamo',
|
||||
@@ -34,65 +54,137 @@ const estadoLabel: Record<string, string> = {
|
||||
rechazado: 'Rechazado',
|
||||
vencido: 'Vencido',
|
||||
};
|
||||
const estadoColor: Record<Estado, string> = {
|
||||
pendiente: 'var(--color-ink)',
|
||||
aprobado: 'var(--color-positive)',
|
||||
activo: 'var(--color-positive)',
|
||||
devuelto: 'var(--color-ink)',
|
||||
rechazado: 'var(--color-danger)',
|
||||
vencido: 'var(--color-danger)',
|
||||
};
|
||||
const badgeStyle = (e: Estado) =>
|
||||
`background: color-mix(in oklab, ${estadoColor[e]} 18%, white); color: var(--color-ink); border: 1.5px solid ${estadoColor[e]};`;
|
||||
|
||||
const resumenItems = (items: Item[]) => {
|
||||
const preview = items.slice(0, 3).map((i) => `${i.cantidad}× ${i.material?.nombre ?? '—'}`).join(', ');
|
||||
const extra = items.length - 3;
|
||||
return extra > 0 ? `${preview} (+${extra})` : preview;
|
||||
};
|
||||
|
||||
const kpiPend = pend.count ?? 0;
|
||||
const kpiVenc = vencidos.count ?? 0;
|
||||
---
|
||||
<AppLayout title="Panel — LabPréstamos">
|
||||
<div class="max-w-6xl">
|
||||
<header class="mb-6">
|
||||
<h1 class="text-2xl md:text-3xl font-semibold">Panel</h1>
|
||||
<p class="text-sm mt-1" style="color: var(--color-pencil);">Resumen del laboratorio.</p>
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<header class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
|
||||
<h1 class="font-mono uppercase text-3xl md:text-5xl font-semibold leading-tight" style="letter-spacing: -0.01em;">
|
||||
{saludo}, {nombreCorto}
|
||||
</h1>
|
||||
<div class="flex gap-3 shrink-0">
|
||||
<a href="/admin/solicitudes" class="card-flat px-4 py-3 text-center">
|
||||
<div class="text-2xl font-semibold leading-none" style={`font-variant-numeric: tabular-nums; color: ${kpiPend > 0 ? 'var(--color-primary)' : 'var(--color-ink)'};`}>{kpiPend}</div>
|
||||
<div class="mt-1 text-xs uppercase tracking-wide" style="color: var(--color-pencil);">Pendientes</div>
|
||||
</a>
|
||||
<a href="/admin/solicitudes/activos" class="card-flat px-4 py-3 text-center">
|
||||
<div class="text-2xl font-semibold leading-none" style={`font-variant-numeric: tabular-nums; color: ${kpiVenc > 0 ? 'var(--color-danger)' : 'var(--color-ink)'};`}>{kpiVenc}</div>
|
||||
<div class="mt-1 text-xs uppercase tracking-wide" style="color: var(--color-pencil);">Vencidos</div>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section aria-label="Indicadores" class="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-4 mb-8">
|
||||
{kpis.map((k) => (
|
||||
<a href={k.href} class="card-link">
|
||||
<div
|
||||
class="text-4xl font-semibold leading-none"
|
||||
style={`font-variant-numeric: tabular-nums; color: var(--color-${k.tone === 'ink' ? 'ink' : k.tone});`}
|
||||
>
|
||||
{k.value}
|
||||
</div>
|
||||
<div class="mt-2 text-sm uppercase tracking-wide" style="color: var(--color-pencil);">{k.label}</div>
|
||||
</a>
|
||||
))}
|
||||
<section aria-labelledby="atencion-h" class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 id="atencion-h" class="text-lg font-semibold uppercase tracking-wide">Requieren tu atención</h2>
|
||||
{pendientesData.length > 0 && (
|
||||
<a href="/admin/solicitudes" class="text-sm underline decoration-[color:var(--color-ink)] underline-offset-4">Ver todas</a>
|
||||
)}
|
||||
</div>
|
||||
{pendientesData.length === 0 ? (
|
||||
<div class="card text-center">
|
||||
<p class="text-sm opacity-70">Todo al día. No hay solicitudes pendientes.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
{pendientesData.map((s) => (
|
||||
<a href="/admin/solicitudes" class="card-link">
|
||||
<div class="flex items-start justify-between gap-3 mb-2">
|
||||
<h3 class="font-semibold leading-tight">{s.alumno?.nombre ?? s.alumno?.email ?? '—'}</h3>
|
||||
<span class="badge shrink-0" style={badgeStyle(s.estado)}>{estadoLabel[s.estado]}</span>
|
||||
</div>
|
||||
<p class="text-sm leading-snug mb-3" style="color: var(--color-pencil);">{resumenItems(s.items ?? [])}</p>
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span style="color: var(--color-pencil);">{fmtFechaRelativa(s.fecha_solicitud)}</span>
|
||||
<span class="underline decoration-[color:var(--color-ink)] underline-offset-4">Revisar</span>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="ultimas-h">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 id="ultimas-h" class="text-lg font-semibold uppercase tracking-wide">Últimas 5 solicitudes</h2>
|
||||
<a href="/admin/solicitudes" class="text-sm underline decoration-[color:var(--color-ink)] underline-offset-4">Ver todas</a>
|
||||
<h2 id="ultimas-h" class="text-lg font-semibold uppercase tracking-wide">Actividad reciente</h2>
|
||||
<a href="/admin/solicitudes/historial" class="text-sm underline decoration-[color:var(--color-ink)] underline-offset-4">Historial</a>
|
||||
</div>
|
||||
<div class="card-flat p-0 overflow-hidden">
|
||||
{ultimas.data && ultimas.data.length > 0 ? (
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
|
||||
<tr>
|
||||
<th class="p-3 font-medium">Alumno</th>
|
||||
<th class="p-3 font-medium">Material</th>
|
||||
<th class="p-3 font-medium">Estado</th>
|
||||
<th class="p-3 font-medium">Solicitado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ultimas.data.map((r: any) => (
|
||||
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);">
|
||||
<td class="p-3">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</td>
|
||||
<td class="p-3">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{(r.items ?? []).map((i: any) => (
|
||||
<div>{i.cantidad}× {i.material?.nombre ?? '—'}</div>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-3">{estadoLabel[r.estado] ?? r.estado}</td>
|
||||
<td class="p-3 opacity-80">{fmtFecha.format(new Date(r.fecha_solicitud))}</td>
|
||||
|
||||
{ultimasData.length === 0 ? (
|
||||
<div class="card text-center">
|
||||
<p class="text-sm opacity-70">Aún no hay solicitudes registradas.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div class="hidden md:block card-flat 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="p-3 font-medium">Alumno</th>
|
||||
<th class="p-3 font-medium">Material</th>
|
||||
<th class="p-3 font-medium">Estado</th>
|
||||
<th class="p-3 font-medium">Solicitado</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p class="p-6 opacity-70 text-sm">Aún no hay solicitudes registradas.</p>
|
||||
)}
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ultimasData.map((r) => (
|
||||
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);">
|
||||
<td class="p-3">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</td>
|
||||
<td class="p-3">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{(r.items ?? []).map((i) => (
|
||||
<div>{i.cantidad}× {i.material?.nombre ?? '—'}</div>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-3">
|
||||
<span class="badge" style={badgeStyle(r.estado)}>{estadoLabel[r.estado]}</span>
|
||||
</td>
|
||||
<td class="p-3 opacity-80">{fmtFechaRelativa(r.fecha_solicitud)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="md:hidden flex flex-col gap-3">
|
||||
{ultimasData.map((r) => (
|
||||
<article class="card">
|
||||
<div class="flex items-start justify-between gap-3 mb-2">
|
||||
<div class="min-w-0">
|
||||
<h3 class="font-semibold leading-tight truncate">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</h3>
|
||||
<p class="text-xs mt-1" style="color: var(--color-pencil);">{fmtFechaRelativa(r.fecha_solicitud)}</p>
|
||||
</div>
|
||||
<span class="badge shrink-0" style={badgeStyle(r.estado)}>{estadoLabel[r.estado]}</span>
|
||||
</div>
|
||||
<div class="text-sm leading-snug">
|
||||
{(r.items ?? []).map((i) => (
|
||||
<div>{i.cantidad}× {i.material?.nombre ?? '—'}</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
@@ -37,6 +37,9 @@ const categorias = ((data ?? []) as unknown as CategoriaRow[]).map((c) => ({
|
||||
<a href="/admin/inventario/categorias" class="px-4 py-2 text-sm font-medium border-b-2" style="border-color: var(--color-primary); color: var(--color-primary);">
|
||||
Categorías
|
||||
</a>
|
||||
<a href="/admin/maestros" class="px-4 py-2 text-sm font-medium border-b-2 border-transparent opacity-70 transition-colors duration-150 hover:opacity-100">
|
||||
Maestros
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
<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">
|
||||
<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}>
|
||||
{badge.label}
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
@@ -181,9 +164,10 @@ const estadoBadge = (e: Material['estado']) => {
|
||||
{m.numero_inventario && (
|
||||
<p class="text-xs opacity-60 font-mono truncate">{m.numero_inventario}</p>
|
||||
)}
|
||||
<div class="flex gap-2 mt-auto pt-2">
|
||||
<div class="grid grid-cols-2 gap-2 mt-auto pt-2">
|
||||
<MaterialForm
|
||||
mode="edit"
|
||||
compact
|
||||
material={{
|
||||
id: m.id,
|
||||
nombre: m.nombre,
|
||||
@@ -199,7 +183,7 @@ const estadoBadge = (e: Material['estado']) => {
|
||||
categorias={categorias}
|
||||
client:load
|
||||
/>
|
||||
<EliminarMaterial id={m.id} nombre={m.nombre} client:load />
|
||||
<EliminarMaterial id={m.id} nombre={m.nombre} compact client:load />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
---
|
||||
import AppLayout from '@/layouts/AppLayout.astro';
|
||||
import MaestroForm from '@/components/admin/maestros/MaestroForm.tsx';
|
||||
import EliminarMaestro from '@/components/admin/maestros/EliminarMaestro.tsx';
|
||||
|
||||
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, 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,
|
||||
}));
|
||||
---
|
||||
<AppLayout title="Maestros — Admin">
|
||||
<div class="max-w-4xl">
|
||||
<header class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<h1 class="text-2xl md:text-3xl font-semibold">Maestros</h1>
|
||||
<p class="text-sm opacity-70 mt-1">Catálogo de tutores que los alumnos eligen en su perfil.</p>
|
||||
</div>
|
||||
<MaestroForm mode="create" client:load />
|
||||
</header>
|
||||
|
||||
<nav class="flex gap-2 mb-6 border-b" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);" aria-label="Sub-navegación de inventario">
|
||||
<a href="/admin/inventario" class="px-4 py-2 text-sm font-medium border-b-2 border-transparent opacity-70 transition-colors duration-150 hover:opacity-100">
|
||||
Materiales
|
||||
</a>
|
||||
<a href="/admin/inventario/categorias" class="px-4 py-2 text-sm font-medium border-b-2 border-transparent opacity-70 transition-colors duration-150 hover:opacity-100">
|
||||
Categorías
|
||||
</a>
|
||||
<a href="/admin/maestros" class="px-4 py-2 text-sm font-medium border-b-2" style="border-color: var(--color-primary); color: var(--color-primary);">
|
||||
Maestros
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
{error && (
|
||||
<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 maestros. Recarga la página.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && maestros.length === 0 && (
|
||||
<div class="card text-center">
|
||||
<h2 class="text-lg font-semibold mb-1">Sin maestros aún</h2>
|
||||
<p class="text-sm opacity-70">Agrega el primero para que los alumnos puedan elegirlo como tutor.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && maestros.length > 0 && (
|
||||
<>
|
||||
<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">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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{maestros.map((m) => (
|
||||
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 8%, transparent);">
|
||||
<td class="px-4 py-3 font-medium">{m.nombre}</td>
|
||||
<td class="px-4 py-3">
|
||||
{m.activo ? (
|
||||
<span class="text-xs uppercase tracking-wide" style="color: var(--color-primary);">Activo</span>
|
||||
) : (
|
||||
<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, es_tutor: m.es_tutor }} client:load />
|
||||
<EliminarMaestro id={m.id} nombre={m.nombre} alumnosCount={m.count} client:load />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="md:hidden flex flex-col gap-3">
|
||||
{maestros.map((m) => (
|
||||
<article class="card flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="font-semibold">{m.nombre}</h3>
|
||||
<p class="text-xs opacity-70 mt-1">
|
||||
{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, es_tutor: m.es_tutor }} client:load />
|
||||
<EliminarMaestro id={m.id} nombre={m.nombre} alumnosCount={m.count} client:load />
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AppLayout>
|
||||
@@ -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>
|
||||
@@ -6,6 +6,45 @@ import BuscadorCatalogo from '@/components/alumno/BuscadorCatalogo.tsx';
|
||||
|
||||
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;
|
||||
let tutorId: number | null = null;
|
||||
if (userId) {
|
||||
const { data: p } = await supabase
|
||||
.from('profiles')
|
||||
.select('matricula, tutor_id')
|
||||
.eq('id', userId)
|
||||
.maybeSingle();
|
||||
matricula = p?.matricula ?? null;
|
||||
tutorId = (p as { tutor_id?: number | null } | null)?.tutor_id ?? null;
|
||||
}
|
||||
|
||||
let tutorNombre: string | null = null;
|
||||
if (tutorId) {
|
||||
const { data } = await supabase.from('maestros').select('nombre').eq('id', tutorId).maybeSingle();
|
||||
tutorNombre = data?.nombre ?? null;
|
||||
}
|
||||
|
||||
// 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;
|
||||
nombre: string;
|
||||
@@ -72,7 +111,7 @@ const categoriasFiltro = grupoList
|
||||
Ningún material en esta categoría. Prueba con otra.
|
||||
</p>
|
||||
|
||||
<CartProvider materiales={materiales} 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 });
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const parseId = (raw: string | undefined) => {
|
||||
const id = Number(raw);
|
||||
return Number.isInteger(id) && id > 0 ? id : null;
|
||||
};
|
||||
|
||||
export const PATCH: APIRoute = async ({ request, locals, params }) => {
|
||||
if (locals.profile?.rol !== 'admin') {
|
||||
return Response.json({ error: 'No autorizado' }, { status: 403 });
|
||||
}
|
||||
|
||||
const id = parseId(params.id);
|
||||
if (!id) return Response.json({ error: 'ID inválido' }, { status: 400 });
|
||||
|
||||
let body: { nombre?: unknown; activo?: unknown; es_tutor?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: 'JSON inválido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (typeof body.nombre === 'string') {
|
||||
const nombre = body.nombre.trim();
|
||||
if (!nombre) return Response.json({ error: 'El nombre no puede estar vacío' }, { status: 400 });
|
||||
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 });
|
||||
}
|
||||
|
||||
const { error } = await locals.supabase.from('maestros').update(patch).eq('id', id);
|
||||
|
||||
if (error) {
|
||||
if ((error as { code?: string }).code === '23505') {
|
||||
return Response.json({ error: 'Ya existe un maestro con ese nombre' }, { status: 409 });
|
||||
}
|
||||
return Response.json({ error: 'No se pudo actualizar' }, { status: 500 });
|
||||
}
|
||||
|
||||
return Response.json({ ok: true });
|
||||
};
|
||||
|
||||
export const DELETE: APIRoute = async ({ locals, params }) => {
|
||||
if (locals.profile?.rol !== 'admin') {
|
||||
return Response.json({ error: 'No autorizado' }, { status: 403 });
|
||||
}
|
||||
|
||||
const id = parseId(params.id);
|
||||
if (!id) return Response.json({ error: 'ID inválido' }, { status: 400 });
|
||||
|
||||
const { count } = await locals.supabase
|
||||
.from('profiles')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('tutor_id', id);
|
||||
|
||||
const { error } = await locals.supabase.from('maestros').delete().eq('id', id);
|
||||
|
||||
if (error) {
|
||||
if ((error as { code?: string }).code === '23503') {
|
||||
return Response.json(
|
||||
{ error: `Tiene ${count ?? 'algún'} alumno${(count ?? 0) === 1 ? '' : 's'} como tutor — reasígnalos primero` },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
return Response.json({ error: 'No se pudo eliminar' }, { status: 500 });
|
||||
}
|
||||
|
||||
return Response.json({ ok: true });
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
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: { nombre?: unknown; activo?: unknown; es_tutor?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: 'JSON inválido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const nombre = typeof body.nombre === 'string' ? body.nombre.trim() : '';
|
||||
if (!nombre) {
|
||||
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, es_tutor })
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
if ((error as { code?: string }).code === '23505') {
|
||||
return Response.json({ error: 'Ya existe un maestro con ese nombre' }, { status: 409 });
|
||||
}
|
||||
return Response.json({ error: 'No se pudo crear el maestro' }, { status: 500 });
|
||||
}
|
||||
|
||||
return Response.json({ id: data.id }, { status: 201 });
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const MAT_RE = /^[A-Za-z0-9]{5,15}$/;
|
||||
const SEM_VALID = new Set(['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12']);
|
||||
|
||||
type PatchBody = {
|
||||
matricula?: unknown;
|
||||
semestre?: unknown;
|
||||
tutor_id?: unknown;
|
||||
foto_path?: unknown;
|
||||
};
|
||||
|
||||
function cleanStr(v: unknown): string | null | undefined {
|
||||
if (v === undefined) return undefined;
|
||||
if (v === null) return null;
|
||||
if (typeof v !== 'string') return undefined;
|
||||
const t = v.trim();
|
||||
return t === '' ? null : t;
|
||||
}
|
||||
|
||||
export const PATCH: APIRoute = async ({ request, locals }) => {
|
||||
const user = locals.user;
|
||||
if (!user) return Response.json({ error: 'no autorizado' }, { status: 401 });
|
||||
|
||||
let body: PatchBody;
|
||||
try {
|
||||
body = (await request.json()) as PatchBody;
|
||||
} catch {
|
||||
return Response.json({ error: 'JSON inválido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const payload: Record<string, string | number | null> = {};
|
||||
|
||||
const matricula = cleanStr(body.matricula);
|
||||
if (matricula !== undefined) {
|
||||
if (matricula !== null && !MAT_RE.test(matricula)) {
|
||||
return Response.json({ error: 'matrícula: 5–15 caracteres alfanuméricos' }, { status: 400 });
|
||||
}
|
||||
payload.matricula = matricula;
|
||||
}
|
||||
|
||||
const semestre = cleanStr(body.semestre);
|
||||
if (semestre !== undefined) {
|
||||
if (semestre !== null && !SEM_VALID.has(semestre)) {
|
||||
return Response.json({ error: 'semestre: debe ser entre 1 y 12' }, { status: 400 });
|
||||
}
|
||||
payload.semestre = semestre;
|
||||
}
|
||||
|
||||
if (body.tutor_id !== undefined) {
|
||||
if (body.tutor_id === null) {
|
||||
payload.tutor_id = null;
|
||||
} else if (typeof body.tutor_id === 'number' && Number.isInteger(body.tutor_id)) {
|
||||
payload.tutor_id = body.tutor_id;
|
||||
} else {
|
||||
return Response.json({ error: 'tutor_id inválido' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const fotoPath = cleanStr(body.foto_path);
|
||||
if (fotoPath !== undefined) payload.foto_path = fotoPath;
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
return Response.json({ error: 'sin cambios' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data, error } = await locals.supabase
|
||||
.from('profiles')
|
||||
.update(payload)
|
||||
.eq('id', user.id)
|
||||
.select('id, email, nombre, matricula, rol, semestre, tutor_id, foto_path')
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
if ((error as { code?: string }).code === '23503') {
|
||||
return Response.json({ error: 'tutor no válido' }, { status: 400 });
|
||||
}
|
||||
return Response.json({ error: 'no se pudo actualizar' }, { status: 500 });
|
||||
}
|
||||
|
||||
return Response.json({ ok: true, profile: data });
|
||||
};
|
||||
@@ -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>
|
||||
+95
-34
@@ -1,45 +1,106 @@
|
||||
---
|
||||
import AppLayout from '@/layouts/AppLayout.astro';
|
||||
import { saludoHora, fmtFechaRelativa } from '@/lib/date';
|
||||
|
||||
const profile = Astro.locals.profile;
|
||||
const isAdmin = profile?.rol === 'admin';
|
||||
const nombre = profile?.nombre ?? profile?.email?.split('@')[0] ?? '';
|
||||
|
||||
if (profile?.rol === 'admin') {
|
||||
return Astro.redirect('/admin', 302);
|
||||
}
|
||||
|
||||
const user = Astro.locals.user;
|
||||
const supabase = Astro.locals.supabase;
|
||||
|
||||
const nombreCorto =
|
||||
profile?.nombre?.trim().split(/\s+/)[0] ??
|
||||
profile?.email?.split('@')[0] ??
|
||||
'';
|
||||
const saludo = saludoHora();
|
||||
|
||||
type Estado = 'pendiente' | 'aprobado' | 'activo' | 'rechazado' | 'devuelto' | 'vencido';
|
||||
type Item = { cantidad: number; material: { nombre: string } | null };
|
||||
type Actual = { id: number; estado: Estado; fecha_solicitud: string; items: Item[] } | null;
|
||||
|
||||
let actual: Actual = null;
|
||||
if (user) {
|
||||
const { data } = await supabase
|
||||
.from('solicitudes')
|
||||
.select('id, estado, fecha_solicitud, items:solicitud_items(cantidad, material:materiales(nombre))')
|
||||
.eq('alumno_id', user.id)
|
||||
.in('estado', ['pendiente', 'aprobado', 'activo'])
|
||||
.order('fecha_solicitud', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
actual = (data as unknown as Actual) ?? null;
|
||||
}
|
||||
|
||||
const estadoLabel: Record<Estado, string> = {
|
||||
pendiente: 'Pendiente',
|
||||
aprobado: 'Aprobado',
|
||||
activo: 'Activo',
|
||||
rechazado: 'Rechazado',
|
||||
devuelto: 'Devuelto',
|
||||
vencido: 'Vencido',
|
||||
};
|
||||
const estadoColor: Record<Estado, string> = {
|
||||
pendiente: 'var(--color-ink)',
|
||||
aprobado: 'var(--color-positive)',
|
||||
activo: 'var(--color-positive)',
|
||||
devuelto: 'var(--color-ink)',
|
||||
rechazado: 'var(--color-danger)',
|
||||
vencido: 'var(--color-danger)',
|
||||
};
|
||||
const badgeStyle = (e: Estado) =>
|
||||
`background: color-mix(in oklab, ${estadoColor[e]} 18%, white); color: var(--color-ink); border: 1.5px solid ${estadoColor[e]};`;
|
||||
|
||||
const items = actual?.items ?? [];
|
||||
const preview = items.slice(0, 3);
|
||||
const extra = items.length - preview.length;
|
||||
---
|
||||
<AppLayout title="Inicio — LabPréstamos">
|
||||
<div class="max-w-3xl">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<header class="mb-8">
|
||||
<p class="text-sm" style="color: var(--color-pencil);">Bienvenido{nombre ? ',' : ''}</p>
|
||||
<h1 class="text-2xl md:text-3xl font-semibold">{nombre || 'Laboratorio de Sistemas'}</h1>
|
||||
<h1 class="font-mono uppercase text-3xl md:text-5xl font-semibold leading-tight" style="letter-spacing: -0.01em;">
|
||||
{saludo}, {nombreCorto}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-5 md:grid-cols-2">
|
||||
{isAdmin ? (
|
||||
<>
|
||||
<a href="/admin/solicitudes" class="card-link">
|
||||
<h2 class="font-semibold text-lg mb-1 uppercase tracking-wide">Solicitudes</h2>
|
||||
<p class="text-sm" style="color: var(--color-pencil);">Aprobar o rechazar peticiones de material.</p>
|
||||
</a>
|
||||
<a href="/admin/inventario" class="card-link">
|
||||
<h2 class="font-semibold text-lg mb-1 uppercase tracking-wide">Inventario</h2>
|
||||
<p class="text-sm" style="color: var(--color-pencil);">Materiales y categorías del laboratorio.</p>
|
||||
</a>
|
||||
<a href="/admin/reportes" class="card-link md:col-span-2">
|
||||
<h2 class="font-semibold text-lg mb-1 uppercase tracking-wide">Reportes</h2>
|
||||
<p class="text-sm" style="color: var(--color-pencil);">Historial y exportación de préstamos.</p>
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<a href="/alumno/catalogo" class="card-link">
|
||||
<h2 class="font-semibold text-lg mb-1 uppercase tracking-wide">Catálogo</h2>
|
||||
<p class="text-sm" style="color: var(--color-pencil);">Ver material disponible y solicitar un préstamo.</p>
|
||||
</a>
|
||||
<a href="/alumno/mis-prestamos" class="card-link">
|
||||
<h2 class="font-semibold text-lg mb-1 uppercase tracking-wide">Mis préstamos</h2>
|
||||
<p class="text-sm" style="color: var(--color-pencil);">Estado de tus solicitudes actuales e historial.</p>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<section class="card-raised mb-8">
|
||||
<p class="text-xs uppercase tracking-wide mb-1" style="color: var(--color-pencil);">Nuevo préstamo</p>
|
||||
<h2 class="text-xl md:text-2xl font-semibold mb-5">¿Qué vas a pedir hoy?</h2>
|
||||
<a href="/alumno/catalogo" class="btn btn-primary">Ir al catálogo</a>
|
||||
</section>
|
||||
|
||||
{actual ? (
|
||||
<section aria-labelledby="actual-h">
|
||||
<div class="flex items-center justify-between gap-3 mb-3">
|
||||
<h2 id="actual-h" class="text-lg font-semibold uppercase tracking-wide">Tu préstamo actual</h2>
|
||||
<span class="badge" style={badgeStyle(actual.estado)}>{estadoLabel[actual.estado]}</span>
|
||||
</div>
|
||||
<a href="/alumno/mis-prestamos" class="card-link">
|
||||
<ul class="space-y-1 mb-4">
|
||||
{preview.map((it) => (
|
||||
<li class="leading-snug">
|
||||
<span class="font-semibold" style="font-variant-numeric: tabular-nums;">{it.cantidad}×</span>{' '}
|
||||
<span class="font-semibold">{it.material?.nombre ?? 'Material eliminado'}</span>
|
||||
</li>
|
||||
))}
|
||||
{extra > 0 && (
|
||||
<li class="text-sm opacity-70">(y {extra} más)</li>
|
||||
)}
|
||||
</ul>
|
||||
<div class="flex items-center justify-between gap-3 text-sm">
|
||||
<span style="color: var(--color-pencil);">Solicitado {fmtFechaRelativa(actual.fecha_solicitud)}</span>
|
||||
<span class="underline decoration-[color:var(--color-ink)] underline-offset-4">Ver detalles</span>
|
||||
</div>
|
||||
</a>
|
||||
</section>
|
||||
) : (
|
||||
<section class="card text-center">
|
||||
<h2 class="text-lg font-semibold mb-1">Aún no tienes préstamos activos</h2>
|
||||
<p class="text-sm opacity-70 mb-5">¿Vamos por algo del laboratorio?</p>
|
||||
<a href="/alumno/catalogo" class="btn btn-primary">Ir al catálogo</a>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
import Layout from '@/layouts/Layout.astro';
|
||||
import BrandMark from '@/components/BrandMark.astro';
|
||||
import PerfilForm from '@/components/profile/PerfilForm.tsx';
|
||||
|
||||
const user = Astro.locals.user!;
|
||||
const profile = Astro.locals.profile!;
|
||||
|
||||
const googleAvatarUrl =
|
||||
(user.user_metadata as Record<string, unknown> | undefined)?.avatar_url as string | undefined ||
|
||||
(user.user_metadata as Record<string, unknown> | undefined)?.picture as string | undefined ||
|
||||
null;
|
||||
|
||||
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">
|
||||
<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] bg-[color:var(--color-primary)] p-4 mb-4" style="border: 2px solid var(--color-ink); box-shadow: var(--shadow-hard);">
|
||||
<BrandMark class="w-full h-full text-white" />
|
||||
</div>
|
||||
<h1 class="text-2xl md:text-3xl font-semibold uppercase tracking-wide">
|
||||
Bienvenido, {primerNombre}
|
||||
</h1>
|
||||
<p class="text-sm mt-2" style="color: var(--color-pencil);">
|
||||
{copyIntro}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card-raised">
|
||||
<PerfilForm
|
||||
client:load
|
||||
mode="onboarding"
|
||||
userId={user.id}
|
||||
email={profile.email}
|
||||
nombre={profile.nombre}
|
||||
rol={profile.rol}
|
||||
initialProfile={{
|
||||
matricula: profile.matricula,
|
||||
semestre: profile.semestre,
|
||||
tutor_id: profile.tutor_id,
|
||||
foto_path: profile.foto_path,
|
||||
}}
|
||||
maestros={maestros}
|
||||
googleAvatarUrl={googleAvatarUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</Layout>
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
import AppLayout from '@/layouts/AppLayout.astro';
|
||||
import PerfilForm from '@/components/profile/PerfilForm.tsx';
|
||||
import Avatar from '@/components/profile/Avatar.tsx';
|
||||
import { avatarInfo } from '@/lib/avatar';
|
||||
|
||||
const user = Astro.locals.user!;
|
||||
const profile = Astro.locals.profile!;
|
||||
|
||||
const googleAvatarUrl =
|
||||
(user.user_metadata as Record<string, unknown> | undefined)?.avatar_url as string | undefined ||
|
||||
(user.user_metadata as Record<string, unknown> | undefined)?.picture as string | undefined ||
|
||||
null;
|
||||
|
||||
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,
|
||||
fotoPath: profile.foto_path,
|
||||
googleAvatarUrl,
|
||||
});
|
||||
---
|
||||
<AppLayout title="Mi perfil — LabPréstamos">
|
||||
<div class="max-w-md mx-auto">
|
||||
<header class="flex items-center gap-4 mb-6">
|
||||
<Avatar client:load info={info} size={80} />
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-xl md:text-2xl font-semibold uppercase tracking-wide leading-tight truncate">
|
||||
{profile.nombre ?? profile.email.split('@')[0]}
|
||||
</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)'};`}>
|
||||
{rolLabel}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="card-raised">
|
||||
<h2 class="text-sm uppercase tracking-wide font-semibold mb-4">Datos del perfil</h2>
|
||||
<PerfilForm
|
||||
client:load
|
||||
userId={user.id}
|
||||
email={profile.email}
|
||||
nombre={profile.nombre}
|
||||
rol={profile.rol}
|
||||
initialProfile={{
|
||||
matricula: profile.matricula,
|
||||
semestre: profile.semestre,
|
||||
tutor_id: profile.tutor_id,
|
||||
foto_path: profile.foto_path,
|
||||
}}
|
||||
maestros={maestros}
|
||||
googleAvatarUrl={googleAvatarUrl}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</AppLayout>
|
||||
@@ -0,0 +1,203 @@
|
||||
-- 0004_perfil_maestros_avatares.sql
|
||||
-- 3 bloques:
|
||||
-- A) tabla prestamos.maestros (catalogo de tutores) + RLS
|
||||
-- B) profiles.tutor_id + profiles.foto_path
|
||||
-- C) bucket 'avatares' publico con RLS por carpeta = uid
|
||||
-- + update de crear_solicitud: fallback a nombre del tutor si el
|
||||
-- alumno deja p_maestro_responsable vacio.
|
||||
|
||||
begin;
|
||||
|
||||
-- ============================================================
|
||||
-- A) Tabla de maestros (catalogo admin-managed)
|
||||
-- ============================================================
|
||||
create table if not exists prestamos.maestros (
|
||||
id serial primary key,
|
||||
nombre text not null unique,
|
||||
activo boolean not null default true,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
alter table prestamos.maestros enable row level security;
|
||||
|
||||
drop policy if exists maestros_read_all on prestamos.maestros;
|
||||
drop policy if exists maestros_admin_write on prestamos.maestros;
|
||||
|
||||
create policy maestros_read_all on prestamos.maestros
|
||||
for select to authenticated using (true);
|
||||
|
||||
create policy maestros_admin_write on prestamos.maestros
|
||||
for all to authenticated
|
||||
using (prestamos.is_admin())
|
||||
with check (prestamos.is_admin());
|
||||
|
||||
grant select, insert, update, delete on prestamos.maestros to authenticated, service_role;
|
||||
grant usage, select on prestamos.maestros_id_seq to authenticated, service_role;
|
||||
|
||||
insert into prestamos.maestros (nombre) values
|
||||
('Sin especificar')
|
||||
on conflict (nombre) do nothing;
|
||||
|
||||
-- ============================================================
|
||||
-- B) profiles: tutor_id + foto_path
|
||||
-- ============================================================
|
||||
alter table prestamos.profiles
|
||||
add column if not exists tutor_id int references prestamos.maestros(id) on delete set null,
|
||||
add column if not exists foto_path text;
|
||||
|
||||
-- policy profiles_update_self (definida en 0001) usa:
|
||||
-- with check (id = auth.uid() and rol = (select rol from ...))
|
||||
-- solo bloquea cambios al rol, permite editar matricula/semestre/tutor_id/foto_path.
|
||||
-- No requiere cambios.
|
||||
|
||||
-- ============================================================
|
||||
-- C) Bucket 'avatares' publico + policies por carpeta = uid
|
||||
-- Path pattern esperado: '<uid>/<timestamp>.<ext>'
|
||||
-- ============================================================
|
||||
insert into storage.buckets (id, name, public)
|
||||
values ('avatares', 'avatares', true)
|
||||
on conflict (id) do update set public = true;
|
||||
|
||||
drop policy if exists "avatares_read_all" on storage.objects;
|
||||
drop policy if exists "avatares_owner_write" on storage.objects;
|
||||
|
||||
create policy "avatares_read_all" on storage.objects
|
||||
for select to anon, authenticated
|
||||
using (bucket_id = 'avatares');
|
||||
|
||||
create policy "avatares_owner_write" on storage.objects
|
||||
for all to authenticated
|
||||
using (
|
||||
bucket_id = 'avatares'
|
||||
and (storage.foldername(name))[1] = auth.uid()::text
|
||||
)
|
||||
with check (
|
||||
bucket_id = 'avatares'
|
||||
and (storage.foldername(name))[1] = auth.uid()::text
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- D) crear_solicitud: si p_maestro_responsable llega vacio,
|
||||
-- autocompletar con el nombre del tutor guardado en profiles.
|
||||
-- Si el usuario tampoco tiene tutor, sigue el raise
|
||||
-- 'maestro_responsable_requerido' original -> el checkout ya
|
||||
-- guarda contra ese caso (perfil incompleto).
|
||||
-- ============================================================
|
||||
create or replace function prestamos.crear_solicitud(
|
||||
p_maestro_responsable text,
|
||||
p_notas text,
|
||||
p_items jsonb
|
||||
) returns bigint
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = prestamos, pg_temp
|
||||
as $$
|
||||
declare
|
||||
v_alumno uuid := auth.uid();
|
||||
v_solicitud_id bigint;
|
||||
v_item jsonb;
|
||||
v_material_id int;
|
||||
v_cantidad int;
|
||||
v_descripcion text;
|
||||
v_disponible int;
|
||||
v_estado_mat text;
|
||||
v_trackeado boolean;
|
||||
v_unidad_id bigint;
|
||||
v_asignadas int;
|
||||
v_maestro text;
|
||||
begin
|
||||
if v_alumno is null then
|
||||
raise exception 'no_autenticado';
|
||||
end if;
|
||||
|
||||
v_maestro := nullif(trim(coalesce(p_maestro_responsable, '')), '');
|
||||
|
||||
-- fallback al tutor del perfil si el alumno no escribio nada
|
||||
if v_maestro is null then
|
||||
select m.nombre into v_maestro
|
||||
from prestamos.profiles p
|
||||
join prestamos.maestros m on m.id = p.tutor_id
|
||||
where p.id = v_alumno;
|
||||
end if;
|
||||
|
||||
if v_maestro is null then
|
||||
raise exception 'maestro_responsable_requerido';
|
||||
end if;
|
||||
|
||||
if p_items is null or jsonb_typeof(p_items) <> 'array' or jsonb_array_length(p_items) = 0 then
|
||||
raise exception 'items_requeridos';
|
||||
end if;
|
||||
|
||||
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(text, text, jsonb) to authenticated;
|
||||
|
||||
commit;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- 0005_profiles_insert_self.sql
|
||||
-- Permite al usuario autenticado insertar su propio profile.
|
||||
-- Necesario para el self-heal del middleware cuando el trigger
|
||||
-- prestamos_on_auth_user_created no dispara (coexiste con otro trigger
|
||||
-- del proyecto vecino en auth.users que a veces impide la ejecucion).
|
||||
-- rol='alumno' obligatorio en el CHECK -> el user no puede promoverse
|
||||
-- a admin insertando; solo el admin puede modificar rol via
|
||||
-- profiles_admin_all (definida en 0001).
|
||||
|
||||
begin;
|
||||
|
||||
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 = 'alumno');
|
||||
|
||||
commit;
|
||||
@@ -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