Compare commits
16 Commits
c52bc905ca
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 800ec60a53 | |||
| 3cccf758a8 | |||
| dea268d975 | |||
| c43f45eca1 | |||
| 3e576426e1 | |||
| fe1ef7b70d | |||
| 0f2ea59754 | |||
| 44fe7527be | |||
| bcecdbb184 | |||
| d0f01ea18c | |||
| e159e8d297 | |||
| ef40241ade | |||
| 1ce1343882 | |||
| b2bc5f8d1b | |||
| 544bbf38a9 | |||
| 43959056f8 |
@@ -8,6 +8,10 @@ astro dev --background
|
|||||||
|
|
||||||
Manage the background server with `astro dev stop`, `astro dev status`, and `astro dev logs`.
|
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
|
## Documentation
|
||||||
|
|
||||||
Full documentation: https://docs.astro.build
|
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.
|
- **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.
|
- **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.**
|
- **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
|
FROM node:22-alpine AS build
|
||||||
WORKDIR /app
|
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 --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
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
|
```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
|
```text
|
||||||
/
|
src/
|
||||||
├── public/
|
├── layouts/
|
||||||
│ └── favicon.svg
|
│ └── AppLayout.astro # sidebar desktop + dock móvil + toaster + badge realtime
|
||||||
├── src
|
├── lib/
|
||||||
│ ├── assets
|
│ ├── supabase.ts # server/browser clients (schema 'prestamos')
|
||||||
│ │ └── astro.svg
|
│ ├── date.ts # todayMX() con Intl + DST correcto
|
||||||
│ ├── components
|
│ └── materialImg.ts # URL pública del bucket
|
||||||
│ │ └── Welcome.astro
|
├── middleware.ts # auth + dominio + verificación de rol
|
||||||
│ ├── layouts
|
├── pages/
|
||||||
│ │ └── Layout.astro
|
│ ├── login.astro
|
||||||
│ └── pages
|
│ ├── alumno/
|
||||||
│ └── index.astro
|
│ │ ├── catalogo.astro # grid + filtro categorías + buscador
|
||||||
└── package.json
|
│ │ └── 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 |
|
## Comandos
|
||||||
| :------------------------ | :----------------------------------------------- |
|
|
||||||
| `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 |
|
|
||||||
|
|
||||||
## 👀 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:
|
services:
|
||||||
labre-web:
|
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
|
container_name: labre-web
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: .env.production
|
env_file: .env.production
|
||||||
|
|||||||
Generated
+384
@@ -20,6 +20,7 @@
|
|||||||
"astro": "^7.2.2",
|
"astro": "^7.2.2",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
|
"recharts": "^3.10.1",
|
||||||
"tailwindcss": "^4.3.3"
|
"tailwindcss": "^4.3.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1896,6 +1897,32 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
||||||
|
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^11.0.0",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz",
|
||||||
@@ -2244,6 +2271,18 @@
|
|||||||
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
|
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@supabase/auth-js": {
|
"node_modules/@supabase/auth-js": {
|
||||||
"version": "2.112.3",
|
"version": "2.112.3",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.112.3.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.112.3.tgz",
|
||||||
@@ -2951,6 +2990,69 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
@@ -3017,6 +3119,12 @@
|
|||||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@ungap/structured-clone": {
|
"node_modules/@ungap/structured-clone": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz",
|
||||||
@@ -3535,6 +3643,127 @@
|
|||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -3552,6 +3781,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/defu": {
|
"node_modules/defu": {
|
||||||
"version": "6.1.7",
|
"version": "6.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
|
||||||
@@ -3747,6 +3982,18 @@
|
|||||||
"integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
|
"integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/es-toolkit": {
|
||||||
|
"version": "1.51.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.51.0.tgz",
|
||||||
|
"integrity": "sha512-zC2lQGkM7QX+Gm6iM3+WIdZJzthsEd14LvRNJneSO2hzyz/zNBENR8+YXWo1cKxgPBtV6ksPYHELbcwBRzmdCw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"docs",
|
||||||
|
"benchmarks",
|
||||||
|
"tests/types",
|
||||||
|
"tests/browser-compat"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.28.2",
|
"version": "0.28.2",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||||
@@ -4149,12 +4396,31 @@
|
|||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "11.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz",
|
||||||
|
"integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/inherits": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/iron-webcrypto": {
|
"node_modules/iron-webcrypto": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz",
|
||||||
@@ -5039,6 +5305,36 @@
|
|||||||
"react": "^19.2.8"
|
"react": "^19.2.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-is": {
|
||||||
|
"version": "19.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz",
|
||||||
|
"integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
||||||
|
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.18.0",
|
"version": "0.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||||
@@ -5061,6 +5357,51 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "3.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz",
|
||||||
|
"integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"www"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"decimal.js-light": "^2.5.1",
|
||||||
|
"es-toolkit": "^1.39.3",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"immer": "^11.1.8",
|
||||||
|
"react-redux": "8.x.x || 9.x.x",
|
||||||
|
"reselect": "5.2.0",
|
||||||
|
"tiny-invariant": "^1.3.3",
|
||||||
|
"use-sync-external-store": "^1.2.2",
|
||||||
|
"victory-vendor": "^37.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/regex": {
|
"node_modules/regex": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
|
||||||
@@ -5085,6 +5426,12 @@
|
|||||||
"integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==",
|
"integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resolve-pkg-maps": {
|
"node_modules/resolve-pkg-maps": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||||
@@ -5429,6 +5776,12 @@
|
|||||||
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-invariant": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyclip": {
|
"node_modules/tinyclip": {
|
||||||
"version": "0.1.15",
|
"version": "0.1.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.15.tgz",
|
||||||
@@ -5749,6 +6102,15 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"browserslist": ">= 4.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vfile": {
|
"node_modules/vfile": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
||||||
@@ -5791,6 +6153,28 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "37.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||||
|
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||||
|
"license": "MIT AND ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.2.1",
|
"version": "8.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"astro": "^7.2.2",
|
"astro": "^7.2.2",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
|
"recharts": "^3.10.1",
|
||||||
"tailwindcss": "^4.3.3"
|
"tailwindcss": "^4.3.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,60 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { browserClient } from '@/lib/supabase';
|
||||||
|
import { toast } from '@/lib/toast';
|
||||||
|
|
||||||
|
function setBadge(n: number) {
|
||||||
|
const v = Math.max(0, n | 0);
|
||||||
|
document.querySelectorAll<HTMLElement>('[data-badge="pendientes"]').forEach((el) => {
|
||||||
|
el.textContent = String(v);
|
||||||
|
if (v > 0) el.removeAttribute('hidden');
|
||||||
|
else el.setAttribute('hidden', '');
|
||||||
|
el.setAttribute('aria-label', `${v} solicitudes pendientes`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBadge(): number {
|
||||||
|
const el = document.querySelector<HTMLElement>('[data-badge="pendientes"]');
|
||||||
|
const n = parseInt(el?.textContent ?? '0', 10);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BadgeSolicitudes() {
|
||||||
|
useEffect(() => {
|
||||||
|
const supabase = browserClient();
|
||||||
|
|
||||||
|
const refetch = async () => {
|
||||||
|
const { count } = await supabase
|
||||||
|
.from('solicitudes')
|
||||||
|
.select('*', { count: 'exact', head: true })
|
||||||
|
.eq('estado', 'pendiente');
|
||||||
|
setBadge(count ?? 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const channel = supabase
|
||||||
|
.channel('solicitudes-pendientes')
|
||||||
|
.on(
|
||||||
|
'postgres_changes',
|
||||||
|
{ event: 'INSERT', schema: 'prestamos', table: 'solicitudes', filter: 'estado=eq.pendiente' },
|
||||||
|
() => {
|
||||||
|
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(
|
||||||
|
// ponytail: refetch en UPDATE porque payload.old no trae estado en replica identity default;
|
||||||
|
// upgrade a delta cuando la tabla tenga replica identity full.
|
||||||
|
'postgres_changes',
|
||||||
|
{ event: 'UPDATE', schema: 'prestamos', table: 'solicitudes' },
|
||||||
|
() => { refetch(); },
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
supabase.removeChannel(channel);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
LabelList,
|
||||||
|
Legend,
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
type Barras = { tipo: 'barras'; datos: { label: string; valor: number }[]; alto?: number };
|
||||||
|
type Linea = { tipo: 'linea'; datos: { fecha: string; valor: number }[]; alto?: number };
|
||||||
|
type Donut = { tipo: 'donut'; datos: { label: string; valor: number }[]; alto?: number };
|
||||||
|
type Props = Barras | Linea | Donut;
|
||||||
|
|
||||||
|
const INK = '#383838';
|
||||||
|
const PENCIL = '#a1a1a1';
|
||||||
|
const PRIMARY = '#00723F';
|
||||||
|
const SECONDARY = '#DD971A';
|
||||||
|
const DANGER = '#a83224';
|
||||||
|
const POSITIVE = '#38c1b0';
|
||||||
|
const ATTENTION = '#ffde00';
|
||||||
|
|
||||||
|
const MONO: React.CSSProperties = {
|
||||||
|
fontFamily: "'JetBrains Mono Variable', ui-monospace, monospace",
|
||||||
|
fontSize: 11,
|
||||||
|
color: INK,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Paleta rotativa para el donut. Coloreado por label cuando aplique.
|
||||||
|
const DONUT_BY_LABEL: Record<string, string> = {
|
||||||
|
pendiente: ATTENTION,
|
||||||
|
aprobado: PRIMARY,
|
||||||
|
activo: PRIMARY,
|
||||||
|
devuelto: POSITIVE,
|
||||||
|
rechazado: PENCIL,
|
||||||
|
vencido: DANGER,
|
||||||
|
};
|
||||||
|
const DONUT_FALLBACK = [PRIMARY, SECONDARY, POSITIVE, ATTENTION, PENCIL, DANGER];
|
||||||
|
|
||||||
|
function donutColor(label: string, i: number): string {
|
||||||
|
return DONUT_BY_LABEL[label.toLowerCase()] ?? DONUT_FALLBACK[i % DONUT_FALLBACK.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipBox({ active, payload, label }: any) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
border: `2px solid ${INK}`,
|
||||||
|
borderRadius: 2,
|
||||||
|
padding: '6px 10px',
|
||||||
|
...MONO,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label !== undefined && <div style={{ fontWeight: 600 }}>{label}</div>}
|
||||||
|
{payload.map((p: any, i: number) => (
|
||||||
|
<div key={i}>
|
||||||
|
{p.name}: <strong>{p.value}</strong>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function usePrefersReducedMotion(): boolean {
|
||||||
|
const [reduced, setReduced] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||||
|
setReduced(mq.matches);
|
||||||
|
const on = () => setReduced(mq.matches);
|
||||||
|
mq.addEventListener?.('change', on);
|
||||||
|
return () => mq.removeEventListener?.('change', on);
|
||||||
|
}, []);
|
||||||
|
return reduced;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtFechaCorta(iso: string): string {
|
||||||
|
// 'YYYY-MM-DD' → 'DD/MM'
|
||||||
|
const [, m, d] = iso.split('-');
|
||||||
|
return `${d}/${m}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Chart(props: Props) {
|
||||||
|
const reduced = usePrefersReducedMotion();
|
||||||
|
const anim = reduced ? 0 : 400;
|
||||||
|
const alto = props.alto ?? 260;
|
||||||
|
|
||||||
|
if (props.tipo === 'barras') {
|
||||||
|
// Horizontal para que quepan nombres largos de material.
|
||||||
|
const datos = useMemo(
|
||||||
|
() => props.datos.slice().sort((a, b) => a.valor - b.valor),
|
||||||
|
[props.datos],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={alto}>
|
||||||
|
<BarChart data={datos} layout="vertical" margin={{ top: 8, right: 24, left: 8, bottom: 8 }}>
|
||||||
|
<CartesianGrid stroke={PENCIL} strokeDasharray="2 3" horizontal={false} />
|
||||||
|
<XAxis type="number" allowDecimals={false} stroke={INK} tick={MONO as any} />
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="label"
|
||||||
|
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">
|
||||||
|
<LabelList dataKey="valor" position="right" style={MONO as any} />
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.tipo === 'linea') {
|
||||||
|
const datos = props.datos.map((d) => ({ ...d, fechaCorta: fmtFechaCorta(d.fecha) }));
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={alto}>
|
||||||
|
<LineChart data={datos} margin={{ top: 8, right: 16, left: 0, bottom: 8 }}>
|
||||||
|
<CartesianGrid stroke={PENCIL} strokeDasharray="2 3" />
|
||||||
|
<XAxis dataKey="fechaCorta" stroke={INK} tick={MONO as any} interval="preserveStartEnd" />
|
||||||
|
<YAxis allowDecimals={false} stroke={INK} tick={MONO as any} />
|
||||||
|
<Tooltip content={<TooltipBox />} cursor={{ stroke: INK, strokeWidth: 1, strokeDasharray: '2 3' }} />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="valor"
|
||||||
|
stroke={PRIMARY}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ fill: PRIMARY, stroke: INK, strokeWidth: 1.5, r: 3 }}
|
||||||
|
activeDot={{ fill: SECONDARY, stroke: INK, strokeWidth: 1.5, r: 5 }}
|
||||||
|
animationDuration={anim}
|
||||||
|
name="Solicitudes"
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// donut
|
||||||
|
const datos = props.datos;
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={alto}>
|
||||||
|
<PieChart margin={{ top: 8, right: 8, left: 8, bottom: 8 }}>
|
||||||
|
<Tooltip content={<TooltipBox />} />
|
||||||
|
<Pie
|
||||||
|
data={datos}
|
||||||
|
dataKey="valor"
|
||||||
|
nameKey="label"
|
||||||
|
innerRadius="55%"
|
||||||
|
outerRadius="85%"
|
||||||
|
stroke={INK}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
isAnimationActive={!reduced}
|
||||||
|
animationDuration={anim}
|
||||||
|
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;
|
id: number;
|
||||||
nombre: string;
|
nombre: string;
|
||||||
tienePrestamos?: boolean;
|
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 dialogRef = useRef<HTMLDialogElement | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -53,7 +54,10 @@ export default function EliminarMaterial({ id, nombre, tienePrestamos }: Props)
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="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' }}
|
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||||
onClick={open}
|
onClick={open}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { imgUrl } from '@/lib/materialImg';
|
||||||
|
import { toast } from '@/lib/toast';
|
||||||
|
import UnidadesManager from './UnidadesManager';
|
||||||
|
|
||||||
type Categoria = { id: number; nombre: string };
|
type Categoria = { id: number; nombre: string };
|
||||||
type Material = {
|
type Material = {
|
||||||
@@ -10,18 +13,22 @@ type Material = {
|
|||||||
cantidad_disponible: number;
|
cantidad_disponible: number;
|
||||||
numero_inventario: string | null;
|
numero_inventario: string | null;
|
||||||
estado: 'disponible' | 'mantenimiento' | 'baja';
|
estado: 'disponible' | 'mantenimiento' | 'baja';
|
||||||
|
imagen_path?: string | null;
|
||||||
|
trackeado_por_unidad?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
mode: 'create' | 'edit';
|
mode: 'create' | 'edit';
|
||||||
material?: Material;
|
material?: Material;
|
||||||
categorias: Categoria[];
|
categorias: Categoria[];
|
||||||
|
compact?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function MaterialForm({ mode, material, categorias }: Props) {
|
export default function MaterialForm({ mode, material, categorias, compact }: Props) {
|
||||||
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
||||||
const firstFieldRef = useRef<HTMLInputElement | null>(null);
|
const firstFieldRef = useRef<HTMLInputElement | null>(null);
|
||||||
const errorRef = useRef<HTMLParagraphElement | null>(null);
|
const errorRef = useRef<HTMLParagraphElement | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const [nombre, setNombre] = useState(material?.nombre ?? '');
|
const [nombre, setNombre] = useState(material?.nombre ?? '');
|
||||||
const [categoriaId, setCategoriaId] = useState<string>(
|
const [categoriaId, setCategoriaId] = useState<string>(
|
||||||
@@ -33,11 +40,26 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
|||||||
);
|
);
|
||||||
const [numeroInventario, setNumeroInventario] = useState(material?.numero_inventario ?? '');
|
const [numeroInventario, setNumeroInventario] = useState(material?.numero_inventario ?? '');
|
||||||
const [estado, setEstado] = useState<Material['estado']>(material?.estado ?? 'disponible');
|
const [estado, setEstado] = useState<Material['estado']>(material?.estado ?? 'disponible');
|
||||||
|
const [trackeado, setTrackeado] = useState<boolean>(material?.trackeado_por_unidad ?? false);
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
const [imagenPath, setImagenPath] = useState<string | null>(material?.imagen_path ?? null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const titleId = `mat-form-${mode}-${material?.id ?? 'new'}`;
|
const titleId = `mat-form-${mode}-${material?.id ?? 'new'}`;
|
||||||
|
|
||||||
|
// preview URL con revoke en cleanup
|
||||||
|
useEffect(() => {
|
||||||
|
if (!file) {
|
||||||
|
setPreviewUrl(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
setPreviewUrl(url);
|
||||||
|
return () => URL.revokeObjectURL(url);
|
||||||
|
}, [file]);
|
||||||
|
|
||||||
const open = () => {
|
const open = () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
if (mode === 'create') {
|
if (mode === 'create') {
|
||||||
@@ -47,6 +69,9 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
|||||||
setCantidadTotal('0');
|
setCantidadTotal('0');
|
||||||
setNumeroInventario('');
|
setNumeroInventario('');
|
||||||
setEstado('disponible');
|
setEstado('disponible');
|
||||||
|
setTrackeado(false);
|
||||||
|
setFile(null);
|
||||||
|
setImagenPath(null);
|
||||||
}
|
}
|
||||||
dialogRef.current?.showModal();
|
dialogRef.current?.showModal();
|
||||||
queueMicrotask(() => firstFieldRef.current?.focus());
|
queueMicrotask(() => firstFieldRef.current?.focus());
|
||||||
@@ -68,49 +93,87 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (error) {
|
if (error) errorRef.current?.focus();
|
||||||
errorRef.current?.focus();
|
|
||||||
}
|
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
|
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 json.path as string;
|
||||||
|
}
|
||||||
|
|
||||||
const submit = async (e: React.FormEvent) => {
|
const submit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const payload = {
|
const nombreT = nombre.trim();
|
||||||
nombre: nombre.trim(),
|
if (!nombreT) {
|
||||||
categoria_id: categoriaId === '' ? null : Number(categoriaId),
|
|
||||||
descripcion: descripcion.trim() || null,
|
|
||||||
cantidad_total: Number(cantidadTotal),
|
|
||||||
numero_inventario: numeroInventario.trim() || null,
|
|
||||||
estado,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!payload.nombre) {
|
|
||||||
setError('El nombre es obligatorio');
|
setError('El nombre es obligatorio');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!Number.isInteger(payload.cantidad_total) || payload.cantidad_total < 0) {
|
const cantidad = Number(cantidadTotal);
|
||||||
|
if (!trackeado && (!Number.isInteger(cantidad) || cantidad < 0)) {
|
||||||
setError('La cantidad total debe ser un entero mayor o igual a 0');
|
setError('La cantidad total debe ser un entero mayor o igual a 0');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const url =
|
|
||||||
mode === 'create' ? '/api/admin/materiales' : `/api/admin/materiales/${material!.id}`;
|
|
||||||
const method = mode === 'create' ? 'POST' : 'PATCH';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, {
|
if (mode === 'create') {
|
||||||
method,
|
// 1) crear material sin imagen
|
||||||
|
const res = await fetch('/api/admin/materiales', {
|
||||||
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify({
|
||||||
|
nombre: nombreT,
|
||||||
|
categoria_id: categoriaId === '' ? null : Number(categoriaId),
|
||||||
|
descripcion: descripcion.trim() || null,
|
||||||
|
cantidad_total: trackeado ? 0 : cantidad,
|
||||||
|
numero_inventario: numeroInventario.trim() || null,
|
||||||
|
estado,
|
||||||
|
trackeado_por_unidad: trackeado,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const json = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(json?.error ?? 'No se pudo crear');
|
||||||
|
|
||||||
|
// 2) subir foto (si hay) — el endpoint ya persiste imagen_path
|
||||||
|
if (file && json.id) {
|
||||||
|
await uploadFotoFor(json.id, file);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// edit — sube nueva foto primero (si hay); el endpoint persiste imagen_path
|
||||||
|
if (file && material) {
|
||||||
|
await uploadFotoFor(material.id, file);
|
||||||
|
}
|
||||||
|
const patch: Record<string, unknown> = {
|
||||||
|
nombre: nombreT,
|
||||||
|
categoria_id: categoriaId === '' ? null : Number(categoriaId),
|
||||||
|
descripcion: descripcion.trim() || null,
|
||||||
|
numero_inventario: numeroInventario.trim() || null,
|
||||||
|
estado,
|
||||||
|
trackeado_por_unidad: trackeado,
|
||||||
|
};
|
||||||
|
if (!trackeado) patch.cantidad_total = cantidad;
|
||||||
|
// 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' },
|
||||||
|
body: JSON.stringify(patch),
|
||||||
});
|
});
|
||||||
const json = await res.json().catch(() => ({}));
|
const json = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) throw new Error(json?.error ?? 'No se pudo guardar');
|
if (!res.ok) throw new Error(json?.error ?? 'No se pudo guardar');
|
||||||
|
}
|
||||||
|
|
||||||
dialogRef.current?.close();
|
dialogRef.current?.close();
|
||||||
location.reload();
|
location.reload();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -121,18 +184,25 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const triggerLabel = mode === 'create' ? 'Nuevo material' : 'Editar';
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<button type="button" className={triggerClass} onClick={open}>
|
<button type="button" className={triggerClass} style={triggerStyle} onClick={open}>
|
||||||
{triggerLabel}
|
{triggerLabel}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<dialog
|
<dialog
|
||||||
ref={dialogRef}
|
ref={dialogRef}
|
||||||
aria-labelledby={titleId}
|
aria-labelledby={titleId}
|
||||||
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(92vw,32rem)]"
|
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(94vw,36rem)]"
|
||||||
>
|
>
|
||||||
<form onSubmit={submit} className="p-5 sm:p-6 flex flex-col gap-4" autoComplete="off">
|
<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">
|
<div className="flex items-start justify-between gap-3">
|
||||||
@@ -151,10 +221,53 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Foto */}
|
||||||
<div>
|
<div>
|
||||||
<label className="label" htmlFor={`${titleId}-nombre`}>
|
<label className="label" htmlFor={`${titleId}-file`}>Foto</label>
|
||||||
Nombre
|
<div className="flex items-start gap-3">
|
||||||
</label>
|
<div
|
||||||
|
className="w-24 h-24 shrink-0 grid place-items-center bg-[color:var(--color-chalk)]"
|
||||||
|
style={{ border: '2px solid var(--color-ink)' }}
|
||||||
|
>
|
||||||
|
{currentImg ? (
|
||||||
|
<img src={currentImg} alt="" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" 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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex flex-col gap-2">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
id={`${titleId}-file`}
|
||||||
|
className="input"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
{(file || imagenPath) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost self-start"
|
||||||
|
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||||
|
onClick={() => {
|
||||||
|
setFile(null);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
if (imagenPath && mode === 'edit') setImagenPath(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Quitar foto
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label" htmlFor={`${titleId}-nombre`}>Nombre</label>
|
||||||
<input
|
<input
|
||||||
ref={firstFieldRef}
|
ref={firstFieldRef}
|
||||||
id={`${titleId}-nombre`}
|
id={`${titleId}-nombre`}
|
||||||
@@ -171,9 +284,7 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="label" htmlFor={`${titleId}-cat`}>
|
<label className="label" htmlFor={`${titleId}-cat`}>Categoría</label>
|
||||||
Categoría
|
|
||||||
</label>
|
|
||||||
<select
|
<select
|
||||||
id={`${titleId}-cat`}
|
id={`${titleId}-cat`}
|
||||||
className="input"
|
className="input"
|
||||||
@@ -182,17 +293,13 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
|||||||
>
|
>
|
||||||
<option value="">Sin categoría</option>
|
<option value="">Sin categoría</option>
|
||||||
{categorias.map((c) => (
|
{categorias.map((c) => (
|
||||||
<option key={c.id} value={String(c.id)}>
|
<option key={c.id} value={String(c.id)}>{c.nombre}</option>
|
||||||
{c.nombre}
|
|
||||||
</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label" htmlFor={`${titleId}-inv`}>
|
<label className="label" htmlFor={`${titleId}-inv`}>Nº de inventario</label>
|
||||||
Nº de inventario
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
id={`${titleId}-inv`}
|
id={`${titleId}-inv`}
|
||||||
className="input"
|
className="input"
|
||||||
@@ -206,9 +313,7 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label" htmlFor={`${titleId}-desc`}>
|
<label className="label" htmlFor={`${titleId}-desc`}>Descripción</label>
|
||||||
Descripción
|
|
||||||
</label>
|
|
||||||
<textarea
|
<textarea
|
||||||
id={`${titleId}-desc`}
|
id={`${titleId}-desc`}
|
||||||
className="input"
|
className="input"
|
||||||
@@ -221,11 +326,24 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={trackeado}
|
||||||
|
onChange={(e) => setTrackeado(e.target.checked)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="font-medium">Rastrear cada unidad por separado</span>
|
||||||
|
<span className="block opacity-70 text-xs">
|
||||||
|
Ej. Laptop-01, Laptop-02… El stock se calcula del número de unidades registradas abajo.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="label" htmlFor={`${titleId}-total`}>
|
<label className="label" htmlFor={`${titleId}-total`}>Cantidad total</label>
|
||||||
Cantidad total
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
id={`${titleId}-total`}
|
id={`${titleId}-total`}
|
||||||
className="input"
|
className="input"
|
||||||
@@ -233,12 +351,17 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
|||||||
min={0}
|
min={0}
|
||||||
step={1}
|
step={1}
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={cantidadTotal}
|
value={trackeado ? (material?.cantidad_total ?? 0) : cantidadTotal}
|
||||||
onChange={(e) => setCantidadTotal(e.target.value)}
|
onChange={(e) => setCantidadTotal(e.target.value)}
|
||||||
required
|
required={!trackeado}
|
||||||
|
disabled={trackeado}
|
||||||
|
readOnly={trackeado}
|
||||||
style={{ fontVariantNumeric: 'tabular-nums' }}
|
style={{ fontVariantNumeric: 'tabular-nums' }}
|
||||||
/>
|
/>
|
||||||
{mode === 'edit' && material && (
|
{trackeado && (
|
||||||
|
<p className="text-xs opacity-70 mt-1">Se calcula del número de unidades.</p>
|
||||||
|
)}
|
||||||
|
{!trackeado && mode === 'edit' && material && (
|
||||||
<p className="text-xs opacity-60 mt-1">
|
<p className="text-xs opacity-60 mt-1">
|
||||||
Prestados: {material.cantidad_total - material.cantidad_disponible}
|
Prestados: {material.cantidad_total - material.cantidad_disponible}
|
||||||
</p>
|
</p>
|
||||||
@@ -246,9 +369,7 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label" htmlFor={`${titleId}-estado`}>
|
<label className="label" htmlFor={`${titleId}-estado`}>Estado</label>
|
||||||
Estado
|
|
||||||
</label>
|
|
||||||
<select
|
<select
|
||||||
id={`${titleId}-estado`}
|
id={`${titleId}-estado`}
|
||||||
className="input"
|
className="input"
|
||||||
@@ -284,6 +405,16 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* Gestor de unidades — solo edit + trackeado */}
|
||||||
|
{mode === 'edit' && material && material.trackeado_por_unidad && (
|
||||||
|
<div
|
||||||
|
className="border-t-2 p-5 sm:p-6"
|
||||||
|
style={{ borderColor: 'var(--color-ink)', background: 'var(--color-chalk)' }}
|
||||||
|
>
|
||||||
|
<UnidadesManager materialId={material.id} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</dialog>
|
</dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { toast } from '@/lib/toast';
|
||||||
|
|
||||||
|
type Unidad = {
|
||||||
|
id: number;
|
||||||
|
etiqueta: string;
|
||||||
|
estado: 'disponible' | 'prestado' | 'mantenimiento' | 'baja';
|
||||||
|
notas: string | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ESTADOS = ['disponible', 'mantenimiento', 'baja'] as const;
|
||||||
|
|
||||||
|
export default function UnidadesManager({ materialId }: { materialId: number }) {
|
||||||
|
const [unidades, setUnidades] = useState<Unidad[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [nuevaEtiqueta, setNuevaEtiqueta] = useState('');
|
||||||
|
const [creando, setCreando] = useState(false);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/materiales/${materialId}/unidades`);
|
||||||
|
const j = await res.json();
|
||||||
|
if (!res.ok) throw new Error(j?.error ?? 'Error al cargar');
|
||||||
|
setUnidades(j.unidades ?? []);
|
||||||
|
} catch (e) {
|
||||||
|
toast({ kind: 'error', title: 'No se pudieron cargar las unidades', description: e instanceof Error ? e.message : '' });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [materialId]);
|
||||||
|
|
||||||
|
const crear = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const et = nuevaEtiqueta.trim();
|
||||||
|
if (!et || creando) return;
|
||||||
|
setCreando(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/materiales/${materialId}/unidades`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ etiqueta: et }),
|
||||||
|
});
|
||||||
|
const j = await res.json();
|
||||||
|
if (!res.ok) throw new Error(j?.error ?? 'No se pudo crear');
|
||||||
|
setNuevaEtiqueta('');
|
||||||
|
await load();
|
||||||
|
toast({ kind: 'success', title: 'Unidad agregada', description: et });
|
||||||
|
} catch (e) {
|
||||||
|
toast({ kind: 'error', title: 'No se pudo agregar', description: e instanceof Error ? e.message : '' });
|
||||||
|
} finally {
|
||||||
|
setCreando(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const patch = async (u: Unidad, body: Partial<Pick<Unidad, 'etiqueta' | 'estado' | 'notas'>>) => {
|
||||||
|
const res = await fetch(`/api/admin/materiales/unidades/${u.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
toast({ kind: 'error', title: 'No se pudo actualizar', description: j?.error });
|
||||||
|
await load();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUnidades((prev) => prev.map((x) => (x.id === u.id ? { ...x, ...body } as Unidad : x)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const eliminar = async (u: Unidad) => {
|
||||||
|
if (!confirm(`Eliminar unidad "${u.etiqueta}"?`)) return;
|
||||||
|
const res = await fetch(`/api/admin/materiales/unidades/${u.id}`, { method: 'DELETE' });
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
toast({ kind: 'error', title: 'No se pudo eliminar', description: j?.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUnidades((prev) => prev.filter((x) => x.id !== u.id));
|
||||||
|
toast({ kind: 'success', title: 'Unidad eliminada' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold uppercase tracking-wider mb-3">Unidades individuales</h3>
|
||||||
|
|
||||||
|
<form onSubmit={crear} className="flex gap-2 mb-4">
|
||||||
|
<input
|
||||||
|
className="input flex-1"
|
||||||
|
type="text"
|
||||||
|
placeholder="Etiqueta (ej. Laptop-01)"
|
||||||
|
value={nuevaEtiqueta}
|
||||||
|
onChange={(e) => setNuevaEtiqueta(e.target.value)}
|
||||||
|
maxLength={80}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={creando || !nuevaEtiqueta.trim()}
|
||||||
|
style={{ minHeight: '44px' }}
|
||||||
|
>
|
||||||
|
+ Agregar
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm opacity-70">Cargando…</p>
|
||||||
|
) : unidades.length === 0 ? (
|
||||||
|
<p className="text-sm opacity-70">Sin unidades registradas.</p>
|
||||||
|
) : (
|
||||||
|
<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 }) {
|
function AprobarDialog({ prestamo }: { prestamo: Prestamo }) {
|
||||||
const dlg = useDialog();
|
const dlg = useDialog();
|
||||||
const [fecha, setFecha] = useState(enDias(7));
|
const [fecha, setFecha] = useState(enDias(1));
|
||||||
const [notas, setNotas] = useState('');
|
const [notas, setNotas] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { toast, toastAfterReload } from '@/lib/toast';
|
||||||
|
|
||||||
|
type Unidad = { id: number; etiqueta: string };
|
||||||
type Item = {
|
type Item = {
|
||||||
|
id: number;
|
||||||
cantidad: number;
|
cantidad: number;
|
||||||
descripcion: string | null;
|
descripcion: string | null;
|
||||||
material: { nombre: string; numero_inventario: string | null } | null;
|
material: { id: number; nombre: string; numero_inventario: string | null; trackeado_por_unidad?: boolean } | null;
|
||||||
|
material_unidad: Unidad | null;
|
||||||
};
|
};
|
||||||
type Detalles = {
|
type Detalles = {
|
||||||
prestamo: {
|
prestamo: {
|
||||||
@@ -23,6 +27,117 @@ type Detalles = {
|
|||||||
|
|
||||||
const fmt = new Intl.DateTimeFormat('es-MX', { dateStyle: 'medium', timeStyle: 'short' });
|
const fmt = new Intl.DateTimeFormat('es-MX', { dateStyle: 'medium', timeStyle: 'short' });
|
||||||
|
|
||||||
|
const ERROR_TXT: Record<string, string> = {
|
||||||
|
no_autorizado: 'No autorizado',
|
||||||
|
item_no_existe: 'El renglón ya no existe',
|
||||||
|
solicitud_no_activa: 'La solicitud no está activa',
|
||||||
|
item_sin_unidad: 'Este renglón no tiene unidad asignada',
|
||||||
|
unidad_no_existe: 'La unidad seleccionada no existe',
|
||||||
|
unidad_de_otro_material: 'La unidad no corresponde a este material',
|
||||||
|
unidad_no_disponible: 'La unidad ya no está disponible',
|
||||||
|
};
|
||||||
|
|
||||||
|
function ReasignarUnidad({
|
||||||
|
solicitudId,
|
||||||
|
item,
|
||||||
|
}: {
|
||||||
|
solicitudId: number;
|
||||||
|
item: Item;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [unidades, setUnidades] = useState<Unidad[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selected, setSelected] = useState<string>('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const abrir = async () => {
|
||||||
|
setOpen(true);
|
||||||
|
if (unidades.length || !item.material) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/admin/materiales/${item.material.id}/unidades?estado=disponible`);
|
||||||
|
const j = await r.json();
|
||||||
|
if (!r.ok) throw new Error(j?.error ?? 'Error');
|
||||||
|
setUnidades(j.unidades ?? []);
|
||||||
|
} catch (e) {
|
||||||
|
toast({ kind: 'error', title: 'No se pudieron cargar unidades', description: e instanceof Error ? e.message : '' });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmar = async () => {
|
||||||
|
if (!selected || saving) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/admin/solicitudes/${solicitudId}/reasignar-unidad`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ item_id: item.id, nueva_unidad_id: Number(selected) }),
|
||||||
|
});
|
||||||
|
const j = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok) {
|
||||||
|
const key = String(j?.error ?? '');
|
||||||
|
toast({ kind: 'error', title: ERROR_TXT[key] ?? 'No se pudo reasignar' });
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toastAfterReload({ kind: 'success', title: 'Unidad reasignada' });
|
||||||
|
location.reload();
|
||||||
|
} catch {
|
||||||
|
toast({ kind: 'error', title: 'Error inesperado' });
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ minHeight: '30px', paddingBlock: '0.15rem', fontSize: '0.75rem' }}
|
||||||
|
onClick={abrir}
|
||||||
|
>
|
||||||
|
Cambiar unidad
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-1 mt-1">
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
style={{ minHeight: '32px', paddingBlock: '0.2rem', fontSize: '0.85rem' }}
|
||||||
|
value={selected}
|
||||||
|
onChange={(e) => setSelected(e.target.value)}
|
||||||
|
disabled={loading || saving}
|
||||||
|
>
|
||||||
|
<option value="">{loading ? 'Cargando…' : 'Selecciona unidad…'}</option>
|
||||||
|
{unidades.map((u) => (
|
||||||
|
<option key={u.id} value={String(u.id)}>{u.etiqueta}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ minHeight: '32px', paddingBlock: '0.2rem', fontSize: '0.85rem' }}
|
||||||
|
onClick={confirmar}
|
||||||
|
disabled={!selected || saving}
|
||||||
|
>
|
||||||
|
{saving ? '…' : 'Confirmar'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ minHeight: '32px', paddingBlock: '0.2rem', fontSize: '0.85rem' }}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function VerDetalles({ prestamoId }: { prestamoId: number }) {
|
export default function VerDetalles({ prestamoId }: { prestamoId: number }) {
|
||||||
const ref = useRef<HTMLDialogElement>(null);
|
const ref = useRef<HTMLDialogElement>(null);
|
||||||
const [data, setData] = useState<Detalles | null>(null);
|
const [data, setData] = useState<Detalles | null>(null);
|
||||||
@@ -53,6 +168,8 @@ export default function VerDetalles({ prestamoId }: { prestamoId: number }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const canReasignar = data && ['aprobado', 'activo'].includes(data.prestamo.estado);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button type="button" className="btn btn-ghost" onClick={open}>Detalles</button>
|
<button type="button" className="btn btn-ghost" onClick={open}>Detalles</button>
|
||||||
@@ -80,15 +197,24 @@ export default function VerDetalles({ prestamoId }: { prestamoId: number }) {
|
|||||||
<dt className="opacity-70">Alumno</dt>
|
<dt className="opacity-70">Alumno</dt>
|
||||||
<dd className="col-span-2">{data.prestamo.alumno?.nombre ?? data.prestamo.alumno?.email}</dd>
|
<dd className="col-span-2">{data.prestamo.alumno?.nombre ?? data.prestamo.alumno?.email}</dd>
|
||||||
<dt className="opacity-70">Maestro</dt>
|
<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>
|
<dt className="opacity-70">Materiales</dt>
|
||||||
<dd className="col-span-2">
|
<dd className="col-span-2">
|
||||||
<ul className="list-disc pl-4">
|
<ul className="list-disc pl-4 space-y-2">
|
||||||
{data.prestamo.items.map((i, idx) => (
|
{data.prestamo.items.map((i) => (
|
||||||
<li key={idx}>
|
<li key={i.id}>
|
||||||
<strong style={{ fontVariantNumeric: 'tabular-nums' }}>{i.cantidad}</strong>× {i.material?.nombre ?? '—'}{' '}
|
<strong style={{ fontVariantNumeric: 'tabular-nums' }}>{i.cantidad}</strong>× {i.material?.nombre ?? '—'}{' '}
|
||||||
<span className="opacity-70">· Inv. {i.material?.numero_inventario ?? '—'}</span>
|
<span className="opacity-70">· Inv. {i.material?.numero_inventario ?? '—'}</span>
|
||||||
|
{i.material_unidad?.etiqueta && (
|
||||||
|
<div className="text-xs">
|
||||||
|
<span className="opacity-70">Unidad: </span>
|
||||||
|
<span className="font-semibold">{i.material_unidad.etiqueta}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{i.descripcion && <div className="text-xs opacity-70">{i.descripcion}</div>}
|
{i.descripcion && <div className="text-xs opacity-70">{i.descripcion}</div>}
|
||||||
|
{canReasignar && i.material_unidad && (
|
||||||
|
<ReasignarUnidad solicitudId={data.prestamo.id} item={i} />
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
// ponytail: coordinar via window para evitar Context entre 2 islands hermanas
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__labreFiltrar?: () => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const norm = (s: string) =>
|
||||||
|
s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
||||||
|
|
||||||
|
function unify() {
|
||||||
|
const cards = document.querySelectorAll<HTMLElement>('[data-cat]');
|
||||||
|
let anyVisible = false;
|
||||||
|
cards.forEach((el) => {
|
||||||
|
const hidden = el.dataset.hiddenByCat === 'true' || el.dataset.hiddenBySearch === 'true';
|
||||||
|
el.hidden = hidden;
|
||||||
|
if (!hidden) anyVisible = true;
|
||||||
|
});
|
||||||
|
const empty = document.getElementById('catalogo-empty-filter');
|
||||||
|
if (empty) empty.hidden = anyVisible || cards.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyText(value: string) {
|
||||||
|
const needle = norm(value.trim());
|
||||||
|
const cards = document.querySelectorAll<HTMLElement>('[data-cat]');
|
||||||
|
cards.forEach((el) => {
|
||||||
|
if (!needle) {
|
||||||
|
delete el.dataset.hiddenBySearch;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nombre = norm(el.dataset.nombre ?? '');
|
||||||
|
const inv = norm(el.dataset.numeroInventario ?? '');
|
||||||
|
const match = nombre.includes(needle) || (!!inv && inv.includes(needle));
|
||||||
|
if (match) delete el.dataset.hiddenBySearch;
|
||||||
|
else el.dataset.hiddenBySearch = 'true';
|
||||||
|
});
|
||||||
|
unify();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BuscadorCatalogo({ q: qInitial = '' }: { q?: string }) {
|
||||||
|
const [q, setQ] = useState(qInitial);
|
||||||
|
const timerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.__labreFiltrar = unify;
|
||||||
|
applyText(qInitial);
|
||||||
|
return () => {
|
||||||
|
window.__labreFiltrar = undefined;
|
||||||
|
if (timerRef.current) window.clearTimeout(timerRef.current);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const syncUrl = (value: string) => {
|
||||||
|
const url = new URL(location.href);
|
||||||
|
const clean = value.trim();
|
||||||
|
if (clean) url.searchParams.set('q', clean);
|
||||||
|
else url.searchParams.delete('q');
|
||||||
|
history.replaceState(null, '', url.toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
const onChange = (value: string) => {
|
||||||
|
setQ(value);
|
||||||
|
if (timerRef.current) window.clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = window.setTimeout(() => {
|
||||||
|
applyText(value);
|
||||||
|
syncUrl(value);
|
||||||
|
}, 120);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
setQ('');
|
||||||
|
if (timerRef.current) window.clearTimeout(timerRef.current);
|
||||||
|
applyText('');
|
||||||
|
syncUrl('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="label sr-only" htmlFor="buscador-catalogo">
|
||||||
|
Buscar material
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="buscador-catalogo"
|
||||||
|
className="input"
|
||||||
|
type="search"
|
||||||
|
placeholder="Buscar por nombre o número de inventario…"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
style={{ paddingRight: q ? '2.5rem' : undefined }}
|
||||||
|
/>
|
||||||
|
{q && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clear}
|
||||||
|
aria-label="Limpiar búsqueda"
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded-[2px] hover:bg-black/5 transition-colors"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
|
||||||
|
<path d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,12 +10,21 @@ function applyFilter(value: string) {
|
|||||||
cards.forEach((el) => {
|
cards.forEach((el) => {
|
||||||
const cat = el.dataset.cat;
|
const cat = el.dataset.cat;
|
||||||
const show = value === ALL || cat === value;
|
const show = value === ALL || cat === value;
|
||||||
el.hidden = !show;
|
if (show) delete el.dataset.hiddenByCat;
|
||||||
|
else el.dataset.hiddenByCat = 'true';
|
||||||
|
});
|
||||||
|
// El buscador (BuscadorCatalogo, client:load) define este helper y unifica
|
||||||
|
// ambos flags (cat + search). Fallback si aún no montó: aplicar directo.
|
||||||
|
if (typeof window !== 'undefined' && window.__labreFiltrar) {
|
||||||
|
window.__labreFiltrar();
|
||||||
|
} else {
|
||||||
|
let anyVisible = false;
|
||||||
|
cards.forEach((el) => {
|
||||||
|
el.hidden = el.dataset.hiddenByCat === 'true';
|
||||||
|
if (!el.hidden) anyVisible = true;
|
||||||
});
|
});
|
||||||
const empty = document.getElementById('catalogo-empty-filter');
|
const empty = document.getElementById('catalogo-empty-filter');
|
||||||
if (empty) {
|
if (empty) empty.hidden = anyVisible || cards.length === 0;
|
||||||
const anyVisible = Array.from(cards).some((el) => !el.hidden);
|
|
||||||
empty.hidden = anyVisible || cards.length === 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +71,8 @@ export default function FiltroCategorias({ categorias }: Props) {
|
|||||||
aria-selected={active === ALL}
|
aria-selected={active === ALL}
|
||||||
className={chipClass(ALL)}
|
className={chipClass(ALL)}
|
||||||
style={chipStyle(ALL)}
|
style={chipStyle(ALL)}
|
||||||
|
data-cat-value={ALL}
|
||||||
|
data-cat-active={active === ALL ? 'true' : undefined}
|
||||||
onClick={() => pick(ALL)}
|
onClick={() => pick(ALL)}
|
||||||
>
|
>
|
||||||
Todas
|
Todas
|
||||||
@@ -76,6 +87,8 @@ export default function FiltroCategorias({ categorias }: Props) {
|
|||||||
aria-selected={active === v}
|
aria-selected={active === v}
|
||||||
className={chipClass(v)}
|
className={chipClass(v)}
|
||||||
style={chipStyle(v)}
|
style={chipStyle(v)}
|
||||||
|
data-cat-value={v}
|
||||||
|
data-cat-active={active === v ? 'true' : undefined}
|
||||||
onClick={() => pick(v)}
|
onClick={() => pick(v)}
|
||||||
>
|
>
|
||||||
{c.nombre}
|
{c.nombre}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { toast, toastAfterReload } from '@/lib/toast';
|
import { toast, toastAfterReload } from '@/lib/toast';
|
||||||
|
import { imgUrl } from '@/lib/materialImg';
|
||||||
import AgregarMaterial from './AgregarMaterial';
|
import AgregarMaterial from './AgregarMaterial';
|
||||||
|
|
||||||
type Material = {
|
type Material = {
|
||||||
@@ -9,9 +10,18 @@ type Material = {
|
|||||||
cantidad_disponible: number;
|
cantidad_disponible: number;
|
||||||
cantidad_total: number;
|
cantidad_total: number;
|
||||||
numero_inventario: string | null;
|
numero_inventario: string | null;
|
||||||
|
imagen_path: string | null;
|
||||||
categoria: { id: number; nombre: 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 = {
|
type CartItem = {
|
||||||
material_id: number;
|
material_id: number;
|
||||||
nombre: string;
|
nombre: string;
|
||||||
@@ -39,11 +49,26 @@ export function useCart() {
|
|||||||
|
|
||||||
const clamp = (n: number, max: number) => Math.max(1, Math.min(max, n));
|
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 [cart, setCart] = useState<Map<number, CartItem>>(new Map());
|
||||||
|
const cartHydrated = useRef(false);
|
||||||
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
||||||
const firstFieldRef = useRef<HTMLInputElement | null>(null);
|
const firstFieldRef = useRef<HTMLSelectElement | null>(null);
|
||||||
const [maestroResponsable, setMaestroResponsable] = useState('');
|
const [maestroId, setMaestroId] = useState<string>('');
|
||||||
const [notas, setNotas] = useState('');
|
const [notas, setNotas] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -116,6 +141,43 @@ export default function CartProvider({ materiales }: { materiales: Material[] })
|
|||||||
dialogRef.current?.close();
|
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
|
// Cerrar al click en backdrop
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const dlg = dialogRef.current;
|
const dlg = dialogRef.current;
|
||||||
@@ -129,15 +191,16 @@ export default function CartProvider({ materiales }: { materiales: Material[] })
|
|||||||
|
|
||||||
const submit = async (e: React.FormEvent) => {
|
const submit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (loading || items.length === 0) return;
|
if (loading || items.length === 0 || !perfilCompleto) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
const maestroIdNum = maestroId ? Number(maestroId) : null;
|
||||||
const res = await fetch('/api/solicitudes', {
|
const res = await fetch('/api/solicitudes', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
maestro_responsable: maestroResponsable,
|
maestro_id: esDocente ? null : maestroIdNum,
|
||||||
notas: notas || undefined,
|
notas: notas || undefined,
|
||||||
items: items.map((i) => ({
|
items: items.map((i) => ({
|
||||||
material_id: i.material_id,
|
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');
|
throw new Error(json?.error ?? 'No se pudo enviar la solicitud');
|
||||||
}
|
}
|
||||||
setOk(true);
|
setOk(true);
|
||||||
|
const usedFallback = !esDocente && !maestroIdNum && !!tutorNombre;
|
||||||
toastAfterReload({
|
toastAfterReload({
|
||||||
kind: 'success',
|
kind: 'success',
|
||||||
title: 'Solicitud enviada',
|
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(() => {
|
setTimeout(() => {
|
||||||
dialogRef.current?.close();
|
dialogRef.current?.close();
|
||||||
@@ -173,24 +239,41 @@ export default function CartProvider({ materiales }: { materiales: Material[] })
|
|||||||
return (
|
return (
|
||||||
<CartContext.Provider value={value}>
|
<CartContext.Provider value={value}>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{materiales.map((m) => (
|
{materiales.map((m) => {
|
||||||
<article key={m.id} className="card flex flex-col gap-3" data-cat={m.categoria?.id ?? 'sin'}>
|
const src = imgUrl(m.imagen_path);
|
||||||
<div className="flex items-start justify-between gap-2">
|
return (
|
||||||
<h3 className="font-semibold leading-snug">{m.nombre}</h3>
|
<article
|
||||||
{m.categoria && (
|
key={m.id}
|
||||||
<span
|
className="card p-0 flex flex-col overflow-hidden"
|
||||||
className="text-xs px-2 py-0.5 rounded-[2px] whitespace-nowrap uppercase tracking-wide"
|
data-cat={m.categoria?.id ?? 'sin'}
|
||||||
style={{
|
data-nombre={m.nombre}
|
||||||
background: 'color-mix(in oklab, var(--color-primary) 25%, white)',
|
data-numero-inventario={m.numero_inventario ?? ''}
|
||||||
color: 'var(--color-ink)',
|
|
||||||
border: '1.5px solid var(--color-primary)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{m.categoria.nombre}
|
<div
|
||||||
</span>
|
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="absolute bottom-1.5 right-1.5 text-xs px-2 py-0.5 rounded-[2px] whitespace-nowrap shadow-[var(--shadow-hard-sm)]"
|
||||||
|
style={DISPONIBLE_BADGE_STYLE}
|
||||||
|
>
|
||||||
|
Disponible
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 flex flex-col gap-3 flex-1">
|
||||||
|
<h3 className="font-semibold leading-snug">{m.nombre}</h3>
|
||||||
|
{m.categoria && <p className="text-xs opacity-70 -mt-1">{m.categoria.nombre}</p>}
|
||||||
|
|
||||||
{m.descripcion && <p className="text-sm opacity-75 line-clamp-2">{m.descripcion}</p>}
|
{m.descripcion && <p className="text-sm opacity-75 line-clamp-2">{m.descripcion}</p>}
|
||||||
|
|
||||||
<dl className="text-xs opacity-70 grid grid-cols-2 gap-1">
|
<dl className="text-xs opacity-70 grid grid-cols-2 gap-1">
|
||||||
@@ -205,8 +288,10 @@ export default function CartProvider({ materiales }: { materiales: Material[] })
|
|||||||
<div className="mt-auto">
|
<div className="mt-auto">
|
||||||
<AgregarMaterial material={{ id: m.id, nombre: m.nombre, cantidad_disponible: m.cantidad_disponible }} />
|
<AgregarMaterial material={{ id: m.id, nombre: m.nombre, cantidad_disponible: m.cantidad_disponible }} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
@@ -300,19 +385,31 @@ export default function CartProvider({ materiales }: { materiales: Material[] })
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!esDocente && (
|
||||||
<div>
|
<div>
|
||||||
<label className="label" htmlFor="maestro-responsable">Maestro responsable</label>
|
<label className="label" htmlFor="maestro-select">Maestro responsable</label>
|
||||||
<input
|
<select
|
||||||
ref={firstFieldRef}
|
ref={firstFieldRef}
|
||||||
id="maestro-responsable"
|
id="maestro-select"
|
||||||
className="input"
|
className="input"
|
||||||
type="text"
|
value={maestroId}
|
||||||
required
|
onChange={(e) => setMaestroId(e.target.value)}
|
||||||
maxLength={200}
|
aria-describedby="maestro-hint"
|
||||||
value={maestroResponsable}
|
>
|
||||||
onChange={(e) => setMaestroResponsable(e.target.value)}
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label" htmlFor="notas-cart">Motivo del préstamo</label>
|
<label className="label" htmlFor="notas-cart">Motivo del préstamo</label>
|
||||||
@@ -320,7 +417,7 @@ export default function CartProvider({ materiales }: { materiales: Material[] })
|
|||||||
id="notas-cart"
|
id="notas-cart"
|
||||||
className="input"
|
className="input"
|
||||||
rows={3}
|
rows={3}
|
||||||
maxLength={500}
|
maxLength={100}
|
||||||
value={notas}
|
value={notas}
|
||||||
onChange={(e) => setNotas(e.target.value)}
|
onChange={(e) => setNotas(e.target.value)}
|
||||||
placeholder="¿Para qué clase o proyecto lo necesitas?"
|
placeholder="¿Para qué clase o proyecto lo necesitas?"
|
||||||
@@ -328,7 +425,7 @@ export default function CartProvider({ materiales }: { materiales: Material[] })
|
|||||||
aria-describedby="notas-cart-hint"
|
aria-describedby="notas-cart-hint"
|
||||||
/>
|
/>
|
||||||
<p id="notas-cart-hint" className="text-xs opacity-60 mt-1">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -343,7 +440,22 @@ export default function CartProvider({ materiales }: { materiales: Material[] })
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-2 justify-end pt-2">
|
{!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}>
|
<button type="button" className="btn btn-ghost" onClick={closeCheckout} disabled={loading}>
|
||||||
Cancelar
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
@@ -351,6 +463,8 @@ export default function CartProvider({ materiales }: { materiales: Material[] })
|
|||||||
{loading ? 'Enviando…' : 'Enviar solicitud'}
|
{loading ? 'Enviando…' : 'Enviar solicitud'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
</CartContext.Provider>
|
</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;
|
email: string;
|
||||||
nombre: string | null;
|
nombre: string | null;
|
||||||
matricula: 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 {
|
declare global {
|
||||||
|
|||||||
+85
-12
@@ -1,15 +1,45 @@
|
|||||||
---
|
---
|
||||||
import Layout from './Layout.astro';
|
import Layout from './Layout.astro';
|
||||||
import Toaster from '@/components/Toaster.tsx';
|
import Toaster from '@/components/Toaster.tsx';
|
||||||
|
import BadgeSolicitudes from '@/components/admin/BadgeSolicitudes.tsx';
|
||||||
import BrandMark from '@/components/BrandMark.astro';
|
import BrandMark from '@/components/BrandMark.astro';
|
||||||
|
import Avatar from '@/components/profile/Avatar.tsx';
|
||||||
|
import { avatarInfo } from '@/lib/avatar';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title?: string;
|
title?: string;
|
||||||
}
|
}
|
||||||
const { title } = Astro.props;
|
const { title } = Astro.props;
|
||||||
|
|
||||||
|
const user = Astro.locals.user;
|
||||||
const profile = Astro.locals.profile;
|
const profile = Astro.locals.profile;
|
||||||
const isAdmin = profile?.rol === 'admin';
|
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
|
||||||
|
.from('solicitudes')
|
||||||
|
.select('*', { count: 'exact', head: true })
|
||||||
|
.eq('estado', 'pendiente');
|
||||||
|
pendientesCount = count ?? 0;
|
||||||
|
}
|
||||||
const path = Astro.url.pathname;
|
const path = Astro.url.pathname;
|
||||||
const isActive = (href: string) => path === href || path.startsWith(href + '/');
|
const isActive = (href: string) => path === href || path.startsWith(href + '/');
|
||||||
|
|
||||||
@@ -27,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',
|
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',
|
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',
|
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[] = [
|
const alumnoNav: NavItem[] = [
|
||||||
@@ -37,8 +68,10 @@ const alumnoNav: NavItem[] = [
|
|||||||
const adminNav: NavItem[] = [
|
const adminNav: NavItem[] = [
|
||||||
{ href: '/admin', label: 'Panel', icon: 'dashboard' },
|
{ href: '/admin', label: 'Panel', icon: 'dashboard' },
|
||||||
{ href: '/admin/solicitudes', label: 'Solicitudes', icon: 'inbox' },
|
{ href: '/admin/solicitudes', label: 'Solicitudes', icon: 'inbox' },
|
||||||
{ href: '/admin/inventario', label: 'Estadísticas', icon: 'stats' },
|
{ href: '/admin/inventario', label: 'Inventario', icon: 'box' },
|
||||||
|
{ href: '/admin/estadisticas', label: 'Estadísticas', icon: 'stats' },
|
||||||
{ href: '/admin/reportes', label: 'Reportes', icon: 'chart' },
|
{ href: '/admin/reportes', label: 'Reportes', icon: 'chart' },
|
||||||
|
{ href: '/admin/usuarios', label: 'Usuarios', icon: 'users' },
|
||||||
];
|
];
|
||||||
const nav = isAdmin ? adminNav : alumnoNav;
|
const nav = isAdmin ? adminNav : alumnoNav;
|
||||||
|
|
||||||
@@ -76,25 +109,49 @@ const activeCheck = (item: NavItem) => {
|
|||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
class:list={[
|
class:list={[
|
||||||
'flex items-center gap-3 px-3 py-2.5 rounded-[2px] text-sm font-medium uppercase tracking-wide whitespace-nowrap transition-colors',
|
'relative flex items-center gap-3 px-3 py-2.5 rounded-[2px] text-sm font-medium uppercase tracking-wide whitespace-nowrap transition-colors',
|
||||||
activeCheck(item) ? 'bg-[color:var(--color-primary)] text-white' : 'hover:bg-[color:var(--color-chalk)]'
|
activeCheck(item) ? 'bg-[color:var(--color-primary)] text-white' : 'hover:bg-[color:var(--color-chalk)]'
|
||||||
]}
|
]}
|
||||||
style={activeCheck(item) ? 'border: 2px solid var(--color-ink);' : 'border: 2px solid transparent;'}
|
style={activeCheck(item) ? 'border: 2px solid var(--color-ink);' : 'border: 2px solid transparent;'}
|
||||||
aria-current={activeCheck(item) ? 'page' : undefined}
|
aria-current={activeCheck(item) ? 'page' : undefined}
|
||||||
>
|
>
|
||||||
|
<span class="relative inline-grid place-items-center">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
<svg width="20" height="20" 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[item.icon as keyof typeof ICONS]} />
|
<path d={ICONS[item.icon as keyof typeof ICONS]} />
|
||||||
</svg>
|
</svg>
|
||||||
|
{item.href === '/admin/solicitudes' && (
|
||||||
|
<span
|
||||||
|
data-badge="pendientes"
|
||||||
|
hidden={pendientesCount === 0}
|
||||||
|
aria-label={`${pendientesCount} solicitudes pendientes`}
|
||||||
|
class="absolute -top-2 -right-3 min-w-[18px] h-[18px] px-1 grid place-items-center rounded-[2px] font-mono font-bold text-[11px] leading-none"
|
||||||
|
style="background: var(--color-danger); color: var(--color-ink); border: 2px solid var(--color-ink);"
|
||||||
|
>{pendientesCount}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
{item.label}
|
{item.label}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<div class="p-3" style="border-top: 2px solid var(--color-ink);">
|
<div class="p-3" style="border-top: 2px solid var(--color-ink);">
|
||||||
<div class="px-3 py-2 text-sm">
|
<a href="/perfil" class="flex items-center gap-3 px-2 py-2 rounded-[2px] hover:bg-[color:var(--color-chalk)] transition-colors">
|
||||||
<div class="font-medium truncate">{profile?.nombre ?? profile?.email}</div>
|
{avatar && <Avatar client:load info={avatar} size={36} />}
|
||||||
<div class="text-xs uppercase tracking-wide" style="color: var(--color-pencil);">{profile?.rol}</div>
|
<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>
|
||||||
<form method="POST" action="/api/auth/signout">
|
</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">
|
<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">
|
<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} />
|
<path d={ICONS.logout} />
|
||||||
@@ -113,10 +170,16 @@ const activeCheck = (item: NavItem) => {
|
|||||||
</div>
|
</div>
|
||||||
<span class="font-semibold text-sm uppercase tracking-wide">LabPréstamos</span>
|
<span class="font-semibold text-sm uppercase tracking-wide">LabPréstamos</span>
|
||||||
</a>
|
</a>
|
||||||
<div class="text-xs truncate max-w-[45%] text-right">
|
<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">
|
||||||
<div class="font-medium truncate">{profile?.nombre ?? profile?.email}</div>
|
{avatar && <Avatar client:load info={avatar} size={36} />}
|
||||||
<div class="uppercase tracking-wide" style="color: var(--color-pencil);">{profile?.rol}</div>
|
{perfilIncompleto && (
|
||||||
</div>
|
<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>
|
</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">
|
<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">
|
||||||
@@ -149,11 +212,11 @@ const activeCheck = (item: NavItem) => {
|
|||||||
href={item.href}
|
href={item.href}
|
||||||
aria-label={item.label}
|
aria-label={item.label}
|
||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
class="flex-1 grid place-items-center py-2.5"
|
class="relative flex-1 grid place-items-center py-2.5"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class:list={[
|
class:list={[
|
||||||
'grid place-items-center rounded-[2px] w-11 h-11 transition-[background-color,transform] duration-100 motion-safe:active:scale-95',
|
'relative grid place-items-center rounded-[2px] w-11 h-11 transition-[background-color,transform] duration-100 motion-safe:active:scale-95',
|
||||||
active ? 'bg-[color:var(--color-primary)] text-white' : ''
|
active ? 'bg-[color:var(--color-primary)] text-white' : ''
|
||||||
]}
|
]}
|
||||||
style={active ? 'border: 2px solid var(--color-ink);' : ''}
|
style={active ? 'border: 2px solid var(--color-ink);' : ''}
|
||||||
@@ -161,6 +224,15 @@ const activeCheck = (item: NavItem) => {
|
|||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
<svg width="20" height="20" 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[item.icon as keyof typeof ICONS]} />
|
<path d={ICONS[item.icon as keyof typeof ICONS]} />
|
||||||
</svg>
|
</svg>
|
||||||
|
{item.href === '/admin/solicitudes' && (
|
||||||
|
<span
|
||||||
|
data-badge="pendientes"
|
||||||
|
hidden={pendientesCount === 0}
|
||||||
|
aria-label={`${pendientesCount} solicitudes pendientes`}
|
||||||
|
class="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 grid place-items-center rounded-[2px] font-mono font-bold text-[11px] leading-none"
|
||||||
|
style="background: var(--color-danger); color: var(--color-ink); border: 2px solid var(--color-ink);"
|
||||||
|
>{pendientesCount}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
@@ -181,6 +253,7 @@ const activeCheck = (item: NavItem) => {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<Toaster client:idle />
|
<Toaster client:idle />
|
||||||
|
{isAdmin && <BadgeSolicitudes client:idle />}
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
|
|||||||
@@ -38,5 +38,70 @@ const { title = 'Sistema de Préstamos — Laboratorio UABC' } = Astro.props;
|
|||||||
Saltar al contenido
|
Saltar al contenido
|
||||||
</a>
|
</a>
|
||||||
<slot />
|
<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>
|
</body>
|
||||||
</html>
|
</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';
|
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()): {
|
export function todayMX(now: Date = new Date()): {
|
||||||
label: string;
|
label: string;
|
||||||
startUTC: Date;
|
startUTC: Date;
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// URL publica del bucket 'materiales-fotos' a partir del path guardado.
|
||||||
|
export function imgUrl(path?: string | null): string | null {
|
||||||
|
if (!path) return null;
|
||||||
|
const base =
|
||||||
|
(import.meta.env.PUBLIC_SUPABASE_URL as string | undefined) ??
|
||||||
|
(process.env.PUBLIC_SUPABASE_URL as string | undefined);
|
||||||
|
if (!base) return null;
|
||||||
|
return `${base}/storage/v1/object/public/materiales-fotos/${path}`;
|
||||||
|
}
|
||||||
+9
-5
@@ -4,9 +4,13 @@ import type { AstroCookies } from 'astro';
|
|||||||
|
|
||||||
const SCHEMA = 'prestamos';
|
const SCHEMA = 'prestamos';
|
||||||
|
|
||||||
const SUPABASE_URL = process.env.PUBLIC_SUPABASE_URL ?? import.meta.env.PUBLIC_SUPABASE_URL;
|
// `process` no existe en el bundle de browser (islands con client:load que
|
||||||
const SUPABASE_ANON_KEY = process.env.PUBLIC_SUPABASE_ANON_KEY ?? import.meta.env.PUBLIC_SUPABASE_ANON_KEY;
|
// importan browserClient) — hay que checar typeof antes de leerlo o revienta
|
||||||
const SUPABASE_SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY ?? import.meta.env.SUPABASE_SERVICE_ROLE_KEY;
|
// 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.
|
// ponytail: solo para preview local sin login. Bypass RLS con service_role.
|
||||||
// Su uso queda tras un flag de DEV en el middleware.
|
// Su uso queda tras un flag de DEV en el middleware.
|
||||||
@@ -43,8 +47,8 @@ export function serverClient(cookies: AstroCookies) {
|
|||||||
|
|
||||||
export function browserClient() {
|
export function browserClient() {
|
||||||
return createBrowserClient(
|
return createBrowserClient(
|
||||||
import.meta.env.PUBLIC_SUPABASE_URL,
|
SUPABASE_URL,
|
||||||
import.meta.env.PUBLIC_SUPABASE_ANON_KEY,
|
SUPABASE_ANON_KEY,
|
||||||
{ db: { schema: SCHEMA }, cookieOptions },
|
{ 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 UABC_DOMAIN = '@uabc.edu.mx';
|
||||||
const PUBLIC_ROUTES = ['/login', '/api/auth/signin', '/api/auth/callback', '/api/auth/signout'];
|
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) => {
|
export const onRequest = defineMiddleware(async (context, next) => {
|
||||||
const supabase = serverClient(context.cookies);
|
const supabase = serverClient(context.cookies);
|
||||||
@@ -19,11 +21,29 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|||||||
context.locals.profile = null;
|
context.locals.profile = null;
|
||||||
|
|
||||||
if (user) {
|
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')
|
.from('profiles')
|
||||||
.select('id, email, nombre, matricula, rol')
|
.select(select)
|
||||||
.eq('id', user.id)
|
.eq('id', user.id)
|
||||||
.maybeSingle();
|
.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;
|
context.locals.profile = profile ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +58,15 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|||||||
return context.redirect('/');
|
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') {
|
if (pathname.startsWith('/admin') && context.locals.profile?.rol !== 'admin') {
|
||||||
return context.rewrite('/403');
|
return context.rewrite('/403');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
---
|
||||||
|
import AppLayout from '@/layouts/AppLayout.astro';
|
||||||
|
import Chart from '@/components/admin/estadisticas/Chart.tsx';
|
||||||
|
import { todayMX } from '@/lib/date';
|
||||||
|
|
||||||
|
// El middleware ya garantiza rol admin en /admin/*.
|
||||||
|
const supabase = Astro.locals.supabase;
|
||||||
|
const dia = todayMX();
|
||||||
|
const countHead = { count: 'exact' as const, head: true };
|
||||||
|
const startISO = dia.startUTC.toISOString();
|
||||||
|
const endISO = dia.endUTC.toISOString();
|
||||||
|
|
||||||
|
// KPIs del día — misma lógica que /admin/inventario legacy.
|
||||||
|
const [
|
||||||
|
{ count: nSolicitadas },
|
||||||
|
{ count: nAprobadas },
|
||||||
|
{ count: nRechazadas },
|
||||||
|
{ count: nDevueltas },
|
||||||
|
{ count: nEnPrestamo },
|
||||||
|
{ count: nVencidos },
|
||||||
|
] = await Promise.all([
|
||||||
|
supabase.from('solicitudes').select('id', countHead).gte('fecha_solicitud', startISO).lt('fecha_solicitud', endISO),
|
||||||
|
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'aprobado').gte('at', startISO).lt('at', endISO),
|
||||||
|
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'rechazado').gte('at', startISO).lt('at', endISO),
|
||||||
|
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'devuelto').gte('at', startISO).lt('at', endISO),
|
||||||
|
supabase.from('solicitudes').select('id', countHead).in('estado', ['aprobado', 'activo']),
|
||||||
|
supabase.from('solicitudes').select('id', countHead).in('estado', ['aprobado', 'activo']).lt('fecha_devolucion_estimada', dia.isoDate),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const kpis = [
|
||||||
|
{ label: 'Solicitadas', value: nSolicitadas ?? 0, tone: 'primary' },
|
||||||
|
{ label: 'Aprobadas', value: nAprobadas ?? 0, tone: 'primary' },
|
||||||
|
{ label: 'Rechazadas', value: nRechazadas ?? 0, tone: 'neutral' },
|
||||||
|
{ label: 'Devoluciones', value: nDevueltas ?? 0, tone: 'primary' },
|
||||||
|
{ label: 'En préstamo', value: nEnPrestamo ?? 0, tone: 'secondary', total: true },
|
||||||
|
{ label: 'Vencidos', value: nVencidos ?? 0, tone: (nVencidos ?? 0) > 0 ? 'danger' : 'neutral', total: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const toneStyle = (tone: string) => {
|
||||||
|
if (tone === 'primary') return 'color: var(--color-primary);';
|
||||||
|
if (tone === 'secondary') return 'color: var(--color-secondary-hover);';
|
||||||
|
if (tone === 'danger') return 'color: var(--color-danger-text);';
|
||||||
|
return 'color: color-mix(in oklab, var(--color-ink) 65%, transparent);';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ventana 30 días (rango inclusivo del día actual).
|
||||||
|
const DIAS = 30;
|
||||||
|
const desde = new Date(dia.startUTC.getTime() - (DIAS - 1) * 86400000).toISOString();
|
||||||
|
|
||||||
|
// (1) Top 10 materiales solicitados últimos 30 días.
|
||||||
|
const { data: topMaterialesRaw } = await supabase
|
||||||
|
.from('solicitud_items')
|
||||||
|
.select('cantidad, material:materiales(nombre), solicitud:solicitudes!inner(fecha_solicitud)')
|
||||||
|
.gte('solicitud.fecha_solicitud', desde);
|
||||||
|
|
||||||
|
const topMapa = new Map<string, number>();
|
||||||
|
for (const r of (topMaterialesRaw ?? []) as any[]) {
|
||||||
|
const nom = r.material?.nombre ?? '—';
|
||||||
|
topMapa.set(nom, (topMapa.get(nom) ?? 0) + (r.cantidad ?? 0));
|
||||||
|
}
|
||||||
|
const topMateriales = [...topMapa.entries()]
|
||||||
|
.map(([label, valor]) => ({ label, valor }))
|
||||||
|
.sort((a, b) => b.valor - a.valor)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
// (2) Solicitudes por día últimos 30 días. Rellena huecos con 0.
|
||||||
|
const { data: solicitudesUlt30 } = await supabase
|
||||||
|
.from('solicitudes')
|
||||||
|
.select('fecha_solicitud')
|
||||||
|
.gte('fecha_solicitud', desde)
|
||||||
|
.order('fecha_solicitud', { ascending: true });
|
||||||
|
|
||||||
|
// Genera todas las fechas de la ventana en formato YYYY-MM-DD (zona MX).
|
||||||
|
function toIsoDateMX(d: Date): string {
|
||||||
|
return new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: 'America/Tijuana',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
}).format(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
const conteoPorDia = new Map<string, number>();
|
||||||
|
for (const r of (solicitudesUlt30 ?? []) as { fecha_solicitud: string }[]) {
|
||||||
|
const k = toIsoDateMX(new Date(r.fecha_solicitud));
|
||||||
|
conteoPorDia.set(k, (conteoPorDia.get(k) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const solicitudesPorDia: { fecha: string; valor: number }[] = [];
|
||||||
|
for (let i = 0; i < DIAS; i++) {
|
||||||
|
const d = new Date(dia.startUTC.getTime() - (DIAS - 1 - i) * 86400000);
|
||||||
|
const iso = toIsoDateMX(d);
|
||||||
|
solicitudesPorDia.push({ fecha: iso, valor: conteoPorDia.get(iso) ?? 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// (3) Distribución de estados de todas las solicitudes.
|
||||||
|
const { data: estadosRaw } = await supabase.from('solicitudes').select('estado');
|
||||||
|
const estadoLabelMap: Record<string, string> = {
|
||||||
|
pendiente: 'Pendiente',
|
||||||
|
aprobado: 'Aprobado',
|
||||||
|
activo: 'Activo',
|
||||||
|
devuelto: 'Devuelto',
|
||||||
|
rechazado: 'Rechazado',
|
||||||
|
vencido: 'Vencido',
|
||||||
|
};
|
||||||
|
const estadoMapa = new Map<string, number>();
|
||||||
|
for (const r of (estadosRaw ?? []) as { estado: string | null }[]) {
|
||||||
|
if (!r.estado) continue;
|
||||||
|
estadoMapa.set(r.estado, (estadoMapa.get(r.estado) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const distribEstados = [...estadoMapa.entries()].map(([k, v]) => ({
|
||||||
|
label: estadoLabelMap[k] ?? k,
|
||||||
|
valor: v,
|
||||||
|
}));
|
||||||
|
---
|
||||||
|
<AppLayout title="Estadísticas — LabPréstamos">
|
||||||
|
<div class="max-w-6xl">
|
||||||
|
<header class="mb-6">
|
||||||
|
<h1 class="text-2xl md:text-3xl font-semibold">Estadísticas</h1>
|
||||||
|
<p class="text-sm mt-1 first-letter:uppercase" style="color: var(--color-pencil);">
|
||||||
|
Actividad del día · <time datetime={dia.isoDate}>{dia.label}</time>
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section aria-label="Métricas del día" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 mb-6">
|
||||||
|
{kpis.map((k) => (
|
||||||
|
<div class="card p-4">
|
||||||
|
<div class="text-xs" style="color: var(--color-pencil);">{k.label}{k.total ? ' (total)' : ' hoy'}</div>
|
||||||
|
<div class="text-3xl font-semibold mt-1 leading-none" style={`font-variant-numeric: tabular-nums; ${toneStyle(k.tone)}`}>
|
||||||
|
{k.value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-label="Análisis" class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<div class="card">
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wide mb-3">Solicitudes por día · últimos {DIAS}</h3>
|
||||||
|
{solicitudesPorDia.some((d) => d.valor > 0) ? (
|
||||||
|
<Chart tipo="linea" datos={solicitudesPorDia} client:idle />
|
||||||
|
) : (
|
||||||
|
<p class="text-sm py-8 text-center" style="color: var(--color-pencil);">Sin actividad en los últimos {DIAS} días.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wide mb-3">Distribución de estados</h3>
|
||||||
|
{distribEstados.length > 0 ? (
|
||||||
|
<Chart tipo="donut" datos={distribEstados} client:idle />
|
||||||
|
) : (
|
||||||
|
<p class="text-sm py-8 text-center" style="color: var(--color-pencil);">Aún no hay solicitudes registradas.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card lg:col-span-2">
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wide mb-3">Top 10 materiales · últimos {DIAS} días</h3>
|
||||||
|
{topMateriales.length > 0 ? (
|
||||||
|
<Chart tipo="barras" datos={topMateriales} alto={320} client:idle />
|
||||||
|
) : (
|
||||||
|
<p class="text-sm py-8 text-center" style="color: var(--color-pencil);">Ningún material solicitado en los últimos {DIAS} días.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
+132
-39
@@ -1,31 +1,52 @@
|
|||||||
---
|
---
|
||||||
import AppLayout from '@/layouts/AppLayout.astro';
|
import AppLayout from '@/layouts/AppLayout.astro';
|
||||||
|
import { todayMX, saludoHora, fmtFechaRelativa } from '@/lib/date';
|
||||||
|
|
||||||
const supabase = Astro.locals.supabase;
|
const supabase = Astro.locals.supabase;
|
||||||
const today = new Date().toISOString().split('T')[0];
|
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 }).eq('estado', 'pendiente'),
|
||||||
supabase.from('solicitudes').select('*', { count: 'exact', head: true }).in('estado', ['aprobado', 'activo']),
|
|
||||||
supabase.from('solicitudes').select('*', { count: 'exact', head: true }).in('estado', ['aprobado', 'activo']).lt('fecha_devolucion_estimada', today),
|
|
||||||
supabase.from('materiales').select('*', { count: 'exact', head: true }).eq('cantidad_disponible', 0),
|
|
||||||
supabase
|
supabase
|
||||||
.from('solicitudes')
|
.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 })
|
.order('fecha_solicitud', { ascending: false })
|
||||||
.limit(5),
|
.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 = [
|
const estadoLabel: Record<Estado, string> = {
|
||||||
{ 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> = {
|
|
||||||
pendiente: 'Pendiente',
|
pendiente: 'Pendiente',
|
||||||
aprobado: 'En préstamo',
|
aprobado: 'En préstamo',
|
||||||
activo: 'En préstamo',
|
activo: 'En préstamo',
|
||||||
@@ -33,35 +54,87 @@ const estadoLabel: Record<string, string> = {
|
|||||||
rechazado: 'Rechazado',
|
rechazado: 'Rechazado',
|
||||||
vencido: 'Vencido',
|
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">
|
<AppLayout title="Panel — LabPréstamos">
|
||||||
<div class="max-w-6xl">
|
<div class="max-w-5xl mx-auto">
|
||||||
<header class="mb-6">
|
<header class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
|
||||||
<h1 class="text-2xl md:text-3xl font-semibold">Panel</h1>
|
<h1 class="font-mono uppercase text-3xl md:text-5xl font-semibold leading-tight" style="letter-spacing: -0.01em;">
|
||||||
<p class="text-sm mt-1" style="color: var(--color-pencil);">Resumen del laboratorio.</p>
|
{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>
|
</header>
|
||||||
|
|
||||||
<section aria-label="Indicadores" class="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-4 mb-8">
|
<section aria-labelledby="atencion-h" class="mb-10">
|
||||||
{kpis.map((k) => (
|
<div class="flex items-baseline justify-between mb-3">
|
||||||
<a href={k.href} class="card-link">
|
<h2 id="atencion-h" class="text-lg font-semibold uppercase tracking-wide">Requieren tu atención</h2>
|
||||||
<div
|
{pendientesData.length > 0 && (
|
||||||
class="text-4xl font-semibold leading-none"
|
<a href="/admin/solicitudes" class="text-sm underline decoration-[color:var(--color-ink)] underline-offset-4">Ver todas</a>
|
||||||
style={`font-variant-numeric: tabular-nums; color: var(--color-${k.tone === 'ink' ? 'ink' : k.tone});`}
|
)}
|
||||||
>
|
</div>
|
||||||
{k.value}
|
{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>
|
</div>
|
||||||
<div class="mt-2 text-sm uppercase tracking-wide" style="color: var(--color-pencil);">{k.label}</div>
|
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section aria-labelledby="ultimas-h">
|
<section aria-labelledby="ultimas-h">
|
||||||
<div class="flex items-baseline justify-between mb-3">
|
<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>
|
<h2 id="ultimas-h" class="text-lg font-semibold uppercase tracking-wide">Actividad reciente</h2>
|
||||||
<a href="/admin/solicitudes" class="text-sm underline decoration-[color:var(--color-ink)] underline-offset-4">Ver todas</a>
|
<a href="/admin/solicitudes/historial" class="text-sm underline decoration-[color:var(--color-ink)] underline-offset-4">Historial</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-flat p-0 overflow-hidden">
|
|
||||||
{ultimas.data && ultimas.data.length > 0 ? (
|
{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">
|
<table class="w-full text-sm">
|
||||||
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
|
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -72,26 +145,46 @@ const estadoLabel: Record<string, string> = {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{ultimas.data.map((r: any) => (
|
{ultimasData.map((r) => (
|
||||||
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);">
|
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);">
|
||||||
<td class="p-3">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</td>
|
<td class="p-3">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</td>
|
||||||
<td class="p-3">
|
<td class="p-3">
|
||||||
<div class="flex flex-col gap-0.5">
|
<div class="flex flex-col gap-0.5">
|
||||||
{(r.items ?? []).map((i: any) => (
|
{(r.items ?? []).map((i) => (
|
||||||
<div>{i.cantidad}× {i.material?.nombre ?? '—'}</div>
|
<div>{i.cantidad}× {i.material?.nombre ?? '—'}</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="p-3">{estadoLabel[r.estado] ?? r.estado}</td>
|
<td class="p-3">
|
||||||
<td class="p-3 opacity-80">{fmtFecha.format(new Date(r.fecha_solicitud))}</td>
|
<span class="badge" style={badgeStyle(r.estado)}>{estadoLabel[r.estado]}</span>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 opacity-80">{fmtFechaRelativa(r.fecha_solicitud)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
) : (
|
|
||||||
<p class="p-6 opacity-70 text-sm">Aún no hay solicitudes registradas.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</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);">
|
<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
|
Categorías
|
||||||
</a>
|
</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>
|
</nav>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import AppLayout from '@/layouts/AppLayout.astro';
|
import AppLayout from '@/layouts/AppLayout.astro';
|
||||||
import MaterialForm from '@/components/admin/inventario/MaterialForm.tsx';
|
import MaterialForm from '@/components/admin/inventario/MaterialForm.tsx';
|
||||||
import EliminarMaterial from '@/components/admin/inventario/EliminarMaterial.tsx';
|
import EliminarMaterial from '@/components/admin/inventario/EliminarMaterial.tsx';
|
||||||
import { todayMX } from '@/lib/date';
|
import { imgUrl } from '@/lib/materialImg';
|
||||||
|
|
||||||
type Material = {
|
type Material = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -12,48 +12,11 @@ type Material = {
|
|||||||
cantidad_disponible: number;
|
cantidad_disponible: number;
|
||||||
numero_inventario: string | null;
|
numero_inventario: string | null;
|
||||||
estado: 'disponible' | 'mantenimiento' | 'baja';
|
estado: 'disponible' | 'mantenimiento' | 'baja';
|
||||||
|
imagen_path: string | null;
|
||||||
|
trackeado_por_unidad: boolean;
|
||||||
categoria: { id: number; nombre: string } | null;
|
categoria: { id: number; nombre: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// KPIs del día (fecha automática, timezone MX con DST)
|
|
||||||
const dia = todayMX();
|
|
||||||
const supabase = Astro.locals.supabase;
|
|
||||||
const countHead = { count: 'exact' as const, head: true };
|
|
||||||
const startISO = dia.startUTC.toISOString();
|
|
||||||
const endISO = dia.endUTC.toISOString();
|
|
||||||
|
|
||||||
const [
|
|
||||||
{ count: nSolicitadas },
|
|
||||||
{ count: nAprobadas },
|
|
||||||
{ count: nRechazadas },
|
|
||||||
{ count: nDevueltas },
|
|
||||||
{ count: nEnPrestamo },
|
|
||||||
{ count: nVencidos },
|
|
||||||
] = await Promise.all([
|
|
||||||
supabase.from('solicitudes').select('id', countHead).gte('fecha_solicitud', startISO).lt('fecha_solicitud', endISO),
|
|
||||||
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'aprobado').gte('at', startISO).lt('at', endISO),
|
|
||||||
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'rechazado').gte('at', startISO).lt('at', endISO),
|
|
||||||
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'devuelto').gte('at', startISO).lt('at', endISO),
|
|
||||||
supabase.from('solicitudes').select('id', countHead).in('estado', ['aprobado', 'activo']),
|
|
||||||
supabase.from('solicitudes').select('id', countHead).in('estado', ['aprobado', 'activo']).lt('fecha_devolucion_estimada', dia.isoDate),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const kpis = [
|
|
||||||
{ label: 'Solicitadas', value: nSolicitadas ?? 0, tone: 'primary' },
|
|
||||||
{ label: 'Aprobadas', value: nAprobadas ?? 0, tone: 'primary' },
|
|
||||||
{ label: 'Rechazadas', value: nRechazadas ?? 0, tone: 'neutral' },
|
|
||||||
{ label: 'Devoluciones', value: nDevueltas ?? 0, tone: 'primary' },
|
|
||||||
{ label: 'En préstamo', value: nEnPrestamo ?? 0, tone: 'secondary', total: true },
|
|
||||||
{ label: 'Vencidos', value: nVencidos ?? 0, tone: (nVencidos ?? 0) > 0 ? 'danger' : 'neutral', total: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
const toneStyle = (tone: string) => {
|
|
||||||
if (tone === 'primary') return 'color: var(--color-primary);';
|
|
||||||
if (tone === 'secondary') return 'color: var(--color-secondary-hover);';
|
|
||||||
if (tone === 'danger') return 'color: var(--color-danger);';
|
|
||||||
return 'color: color-mix(in oklab, var(--color-ink) 65%, transparent);';
|
|
||||||
};
|
|
||||||
|
|
||||||
const q = (Astro.url.searchParams.get('q') ?? '').trim();
|
const q = (Astro.url.searchParams.get('q') ?? '').trim();
|
||||||
const catRaw = Astro.url.searchParams.get('cat') ?? '';
|
const catRaw = Astro.url.searchParams.get('cat') ?? '';
|
||||||
const catId = catRaw && /^\d+$/.test(catRaw) ? Number(catRaw) : null;
|
const catId = catRaw && /^\d+$/.test(catRaw) ? Number(catRaw) : null;
|
||||||
@@ -63,7 +26,7 @@ const estado = ['disponible', 'mantenimiento', 'baja'].includes(estadoRaw) ? est
|
|||||||
let query = Astro.locals.supabase
|
let query = Astro.locals.supabase
|
||||||
.from('materiales')
|
.from('materiales')
|
||||||
.select(
|
.select(
|
||||||
'id, nombre, descripcion, cantidad_total, cantidad_disponible, numero_inventario, estado, categoria:categorias(id, nombre)',
|
'id, nombre, descripcion, cantidad_total, cantidad_disponible, numero_inventario, estado, imagen_path, trackeado_por_unidad, categoria:categorias(id, nombre)',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (q) query = query.or(`nombre.ilike.%${q}%,numero_inventario.ilike.%${q}%`);
|
if (q) query = query.or(`nombre.ilike.%${q}%,numero_inventario.ilike.%${q}%`);
|
||||||
@@ -96,34 +59,16 @@ const estadoBadge = (e: Material['estado']) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
---
|
---
|
||||||
<AppLayout title="Estadísticas — LabPréstamos">
|
<AppLayout title="Inventario — LabPréstamos">
|
||||||
<div class="max-w-6xl">
|
<div class="max-w-7xl">
|
||||||
<header class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 mb-6">
|
<header class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl md:text-3xl font-semibold">Estadísticas</h1>
|
<h1 class="text-2xl md:text-3xl font-semibold">Inventario</h1>
|
||||||
<p class="text-sm opacity-70 mt-1 first-letter:uppercase">
|
<p class="text-sm opacity-70 mt-1">Materiales del laboratorio.</p>
|
||||||
Actividad del día · <time datetime={dia.isoDate}>{dia.label}</time>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<MaterialForm mode="create" categorias={categorias} client:load />
|
<MaterialForm mode="create" categorias={categorias} client:load />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section aria-label="Métricas del día" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 mb-6">
|
|
||||||
{kpis.map((k) => (
|
|
||||||
<div class="card p-4">
|
|
||||||
<div class="text-xs opacity-70">{k.label}{k.total ? ' (total)' : ' hoy'}</div>
|
|
||||||
<div class="text-3xl font-semibold mt-1 leading-none" style={`font-variant-numeric: tabular-nums; ${toneStyle(k.tone)}`}>
|
|
||||||
{k.value}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<h2 class="text-lg font-semibold">Inventario</h2>
|
|
||||||
<p class="text-sm opacity-70">Materiales del laboratorio.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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">
|
<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" style="border-color: var(--color-primary); color: var(--color-primary);">
|
<a href="/admin/inventario" class="px-4 py-2 text-sm font-medium border-b-2" style="border-color: var(--color-primary); color: var(--color-primary);">
|
||||||
Materiales
|
Materiales
|
||||||
@@ -133,10 +78,10 @@ const estadoBadge = (e: Material['estado']) => {
|
|||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<form method="GET" class="card mb-6 grid grid-cols-1 sm:grid-cols-4 gap-3" role="search">
|
<form id="inv-filter-form" method="GET" class="card mb-6 grid grid-cols-1 sm:grid-cols-4 gap-3" role="search">
|
||||||
<div class="sm:col-span-2">
|
<div class="sm:col-span-2">
|
||||||
<label class="label" for="filtro-q">Buscar</label>
|
<label class="label" for="filtro-q">Buscar</label>
|
||||||
<input id="filtro-q" name="q" type="search" class="input" value={q} placeholder="Nombre o Nº inventario…" autocomplete="off" />
|
<input id="filtro-q" name="q" type="search" class="input" value={q} placeholder="Buscar por nombre o Nº de inventario" autocomplete="off" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="label" for="filtro-cat">Categoría</label>
|
<label class="label" for="filtro-cat">Categoría</label>
|
||||||
@@ -184,95 +129,45 @@ const estadoBadge = (e: Material['estado']) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!matError && materiales.length > 0 && (
|
{!matError && materiales.length > 0 && (
|
||||||
<>
|
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||||
<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">Categoría</th>
|
|
||||||
<th class="px-4 py-3 font-medium">Nº inventario</th>
|
|
||||||
<th class="px-4 py-3 font-medium text-right">Stock</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>
|
|
||||||
{materiales.map((m) => {
|
{materiales.map((m) => {
|
||||||
const badge = estadoBadge(m.estado);
|
const badge = estadoBadge(m.estado);
|
||||||
|
const src = imgUrl(m.imagen_path);
|
||||||
return (
|
return (
|
||||||
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 8%, transparent);">
|
<article class="card p-0 flex flex-col overflow-hidden">
|
||||||
<td class="px-4 py-3">
|
<div
|
||||||
<div class="font-medium">{m.nombre}</div>
|
class="relative aspect-square w-full bg-[color:var(--color-chalk)] grid place-items-center"
|
||||||
{m.descripcion && (
|
style="border-bottom: 2px solid var(--color-ink);"
|
||||||
<div class="text-xs opacity-60 line-clamp-1">{m.descripcion}</div>
|
>
|
||||||
|
{src ? (
|
||||||
|
<img src={src} alt={m.nombre} class="w-full h-full object-cover" loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="var(--color-ink)" stroke-width="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>
|
||||||
)}
|
)}
|
||||||
</td>
|
<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}>
|
||||||
<td class="px-4 py-3 opacity-80">{m.categoria?.nombre ?? '—'}</td>
|
|
||||||
<td class="px-4 py-3 font-mono text-xs opacity-80">{m.numero_inventario ?? '—'}</td>
|
|
||||||
<td class="px-4 py-3 text-right" style="font-variant-numeric: tabular-nums;">
|
|
||||||
{m.cantidad_disponible} / {m.cantidad_total}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<span class="text-xs px-2 py-0.5 rounded-[2px] whitespace-nowrap" style={badge.style}>
|
|
||||||
{badge.label}
|
{badge.label}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<div class="flex gap-2 justify-end">
|
|
||||||
<MaterialForm
|
|
||||||
mode="edit"
|
|
||||||
material={{
|
|
||||||
id: m.id,
|
|
||||||
nombre: m.nombre,
|
|
||||||
categoria_id: m.categoria?.id ?? null,
|
|
||||||
descripcion: m.descripcion,
|
|
||||||
cantidad_total: m.cantidad_total,
|
|
||||||
cantidad_disponible: m.cantidad_disponible,
|
|
||||||
numero_inventario: m.numero_inventario,
|
|
||||||
estado: m.estado,
|
|
||||||
}}
|
|
||||||
categorias={categorias}
|
|
||||||
client:load
|
|
||||||
/>
|
|
||||||
<EliminarMaterial id={m.id} nombre={m.nombre} client:load />
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
<div class="p-3 flex flex-col gap-2 flex-1">
|
||||||
</tr>
|
<h3 class="uppercase font-semibold text-sm leading-snug line-clamp-2">{m.nombre}</h3>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="md:hidden flex flex-col gap-3">
|
|
||||||
{materiales.map((m) => {
|
|
||||||
const badge = estadoBadge(m.estado);
|
|
||||||
return (
|
|
||||||
<article class="card flex flex-col gap-3">
|
|
||||||
<div class="flex items-start justify-between gap-2">
|
|
||||||
<div>
|
|
||||||
<h3 class="font-semibold leading-snug">{m.nombre}</h3>
|
|
||||||
{m.categoria && (
|
{m.categoria && (
|
||||||
<p class="text-xs opacity-70 mt-0.5">{m.categoria.nombre}</p>
|
<p class="text-xs opacity-70 -mt-1">{m.categoria.nombre}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
<p class="text-xs" style="font-variant-numeric: tabular-nums;">
|
||||||
<span class="text-xs px-2 py-0.5 rounded-[2px] whitespace-nowrap" style={badge.style}>
|
<span class="font-semibold">{m.cantidad_disponible}</span> / {m.cantidad_total} disponibles
|
||||||
{badge.label}
|
{m.trackeado_por_unidad && <span class="opacity-70"> · por unidad</span>}
|
||||||
</span>
|
</p>
|
||||||
</div>
|
{m.numero_inventario && (
|
||||||
{m.descripcion && <p class="text-sm opacity-75 line-clamp-2">{m.descripcion}</p>}
|
<p class="text-xs opacity-60 font-mono truncate">{m.numero_inventario}</p>
|
||||||
<dl class="text-xs opacity-70 grid grid-cols-2 gap-1">
|
)}
|
||||||
<dt class="opacity-60">Inventario</dt>
|
<div class="grid grid-cols-2 gap-2 mt-auto pt-2">
|
||||||
<dd class="text-right font-mono">{m.numero_inventario ?? '—'}</dd>
|
|
||||||
<dt class="opacity-60">Stock</dt>
|
|
||||||
<dd class="text-right" style="font-variant-numeric: tabular-nums;">
|
|
||||||
{m.cantidad_disponible} / {m.cantidad_total}
|
|
||||||
</dd>
|
|
||||||
</dl>
|
|
||||||
<div class="flex gap-2 justify-end mt-auto">
|
|
||||||
<MaterialForm
|
<MaterialForm
|
||||||
mode="edit"
|
mode="edit"
|
||||||
|
compact
|
||||||
material={{
|
material={{
|
||||||
id: m.id,
|
id: m.id,
|
||||||
nombre: m.nombre,
|
nombre: m.nombre,
|
||||||
@@ -282,17 +177,19 @@ const estadoBadge = (e: Material['estado']) => {
|
|||||||
cantidad_disponible: m.cantidad_disponible,
|
cantidad_disponible: m.cantidad_disponible,
|
||||||
numero_inventario: m.numero_inventario,
|
numero_inventario: m.numero_inventario,
|
||||||
estado: m.estado,
|
estado: m.estado,
|
||||||
|
imagen_path: m.imagen_path,
|
||||||
|
trackeado_por_unidad: m.trackeado_por_unidad,
|
||||||
}}
|
}}
|
||||||
categorias={categorias}
|
categorias={categorias}
|
||||||
client:load
|
client:load
|
||||||
/>
|
/>
|
||||||
<EliminarMaterial id={m.id} nombre={m.nombre} client:load />
|
<EliminarMaterial id={m.id} nombre={m.nombre} compact client:load />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|||||||
@@ -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 AppLayout from '@/layouts/AppLayout.astro';
|
||||||
import MarcarDevuelto from '@/components/admin/solicitudes/MarcarDevuelto.tsx';
|
import MarcarDevuelto from '@/components/admin/solicitudes/MarcarDevuelto.tsx';
|
||||||
import VerDetalles from '@/components/admin/solicitudes/VerDetalles.tsx';
|
import VerDetalles from '@/components/admin/solicitudes/VerDetalles.tsx';
|
||||||
|
import { truncate } from '@/lib/text';
|
||||||
|
|
||||||
const supabase = Astro.locals.supabase;
|
const supabase = Astro.locals.supabase;
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
@@ -94,7 +95,7 @@ const tabs = [
|
|||||||
<td class="p-3">
|
<td class="p-3">
|
||||||
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
|
<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">{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>
|
||||||
<td class="p-3">
|
<td class="p-3">
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1.5">
|
||||||
@@ -107,7 +108,7 @@ const tabs = [
|
|||||||
</div>
|
</div>
|
||||||
{r.notas && (
|
{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);">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
@@ -150,7 +151,7 @@ const tabs = [
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</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">
|
<div class="flex flex-col gap-1.5">
|
||||||
{(r.items ?? []).map((i: any) => (
|
{(r.items ?? []).map((i: any) => (
|
||||||
<div class="text-sm">
|
<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>
|
<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 && (
|
{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);">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div class="mt-3 flex gap-2 flex-wrap">
|
<div class="mt-3 flex gap-2 flex-wrap">
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ const estadoLabel: Record<string, string> = {
|
|||||||
<td class="p-3">
|
<td class="p-3">
|
||||||
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
|
<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">{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>
|
||||||
<td class="p-3">
|
<td class="p-3">
|
||||||
<div class="flex flex-col gap-1.5">
|
<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>
|
<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>
|
||||||
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</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">
|
<div class="flex flex-col gap-1 mt-2">
|
||||||
{(r.items ?? []).map((i: any) => (
|
{(r.items ?? []).map((i: any) => (
|
||||||
<div class="text-sm">{i.cantidad}× {i.material?.nombre ?? '—'}</div>
|
<div class="text-sm">{i.cantidad}× {i.material?.nombre ?? '—'}</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
import AppLayout from '@/layouts/AppLayout.astro';
|
import AppLayout from '@/layouts/AppLayout.astro';
|
||||||
import AccionesSolicitud from '@/components/admin/solicitudes/AccionesSolicitud.tsx';
|
import AccionesSolicitud from '@/components/admin/solicitudes/AccionesSolicitud.tsx';
|
||||||
|
import { truncate } from '@/lib/text';
|
||||||
|
|
||||||
const supabase = Astro.locals.supabase;
|
const supabase = Astro.locals.supabase;
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
@@ -84,7 +85,7 @@ const tabs = [
|
|||||||
<td class="p-3">
|
<td class="p-3">
|
||||||
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
|
<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">{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>
|
||||||
<td class="p-3">
|
<td class="p-3">
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1.5">
|
||||||
@@ -99,7 +100,7 @@ const tabs = [
|
|||||||
</div>
|
</div>
|
||||||
{r.notas && (
|
{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);">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
@@ -124,7 +125,7 @@ const tabs = [
|
|||||||
<div class="text-xs opacity-70">{fmtFecha.format(new Date(r.fecha_solicitud))}</div>
|
<div class="text-xs opacity-70">{fmtFecha.format(new Date(r.fecha_solicitud))}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</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">
|
<div class="flex flex-col gap-1.5">
|
||||||
{(r.items ?? []).map((i: any) => (
|
{(r.items ?? []).map((i: any) => (
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
@@ -135,7 +136,7 @@ const tabs = [
|
|||||||
</div>
|
</div>
|
||||||
{r.notas && (
|
{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);">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div class="mt-3">
|
<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>
|
||||||
@@ -2,6 +2,48 @@
|
|||||||
import AppLayout from '@/layouts/AppLayout.astro';
|
import AppLayout from '@/layouts/AppLayout.astro';
|
||||||
import CartProvider from '@/components/alumno/SolicitudCart.tsx';
|
import CartProvider from '@/components/alumno/SolicitudCart.tsx';
|
||||||
import FiltroCategorias from '@/components/alumno/FiltroCategorias.tsx';
|
import FiltroCategorias from '@/components/alumno/FiltroCategorias.tsx';
|
||||||
|
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 = {
|
type Material = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -10,12 +52,13 @@ type Material = {
|
|||||||
cantidad_disponible: number;
|
cantidad_disponible: number;
|
||||||
cantidad_total: number;
|
cantidad_total: number;
|
||||||
numero_inventario: string | null;
|
numero_inventario: string | null;
|
||||||
|
imagen_path: string | null;
|
||||||
categoria: { id: number; nombre: string } | null;
|
categoria: { id: number; nombre: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data, error } = await Astro.locals.supabase
|
const { data, error } = await Astro.locals.supabase
|
||||||
.from('materiales')
|
.from('materiales')
|
||||||
.select('id, nombre, descripcion, cantidad_disponible, cantidad_total, numero_inventario, categoria:categorias(id, nombre)')
|
.select('id, nombre, descripcion, cantidad_disponible, cantidad_total, numero_inventario, imagen_path, categoria:categorias(id, nombre)')
|
||||||
.eq('estado', 'disponible')
|
.eq('estado', 'disponible')
|
||||||
.order('nombre');
|
.order('nombre');
|
||||||
|
|
||||||
@@ -57,17 +100,18 @@ const categoriasFiltro = grupoList
|
|||||||
|
|
||||||
{materiales.length > 0 && (
|
{materiales.length > 0 && (
|
||||||
<>
|
<>
|
||||||
|
<div class="mb-6 flex flex-col gap-3">
|
||||||
|
<BuscadorCatalogo q={qInitial} client:load />
|
||||||
{categoriasFiltro.length > 0 && (
|
{categoriasFiltro.length > 0 && (
|
||||||
<div class="mb-6">
|
|
||||||
<FiltroCategorias categorias={categoriasFiltro} client:idle />
|
<FiltroCategorias categorias={categoriasFiltro} client:idle />
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<p id="catalogo-empty-filter" hidden class="text-sm opacity-70 mb-4">
|
<p id="catalogo-empty-filter" hidden class="text-sm opacity-70 mb-4">
|
||||||
Ningún material en esta categoría. Prueba con otra.
|
Ningún material en esta categoría. Prueba con otra.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<CartProvider materiales={materiales} client:load />
|
<CartProvider materiales={materiales} tutorNombre={tutorNombre} perfilCompleto={perfilCompleto} esDocente={esDocente} maestros={maestros} client:load />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ type Item = {
|
|||||||
cantidad: number;
|
cantidad: number;
|
||||||
descripcion: string | null;
|
descripcion: string | null;
|
||||||
material: { id: number; nombre: string; numero_inventario: string | null } | null;
|
material: { id: number; nombre: string; numero_inventario: string | null } | null;
|
||||||
|
material_unidad: { etiqueta: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Solicitud = {
|
type Solicitud = {
|
||||||
@@ -25,7 +26,7 @@ const user = Astro.locals.user!;
|
|||||||
|
|
||||||
const { data, error } = await Astro.locals.supabase
|
const { data, error } = await Astro.locals.supabase
|
||||||
.from('solicitudes')
|
.from('solicitudes')
|
||||||
.select('id, estado, fecha_solicitud, fecha_aprobacion, fecha_devolucion_estimada, fecha_devolucion_real, notas, maestro_responsable, items:solicitud_items(cantidad, descripcion, material:materiales(id, nombre, numero_inventario))')
|
.select('id, estado, fecha_solicitud, fecha_aprobacion, fecha_devolucion_estimada, fecha_devolucion_real, notas, maestro_responsable, items:solicitud_items(cantidad, descripcion, material:materiales(id, nombre, numero_inventario), material_unidad:material_unidades(etiqueta))')
|
||||||
.eq('alumno_id', user.id)
|
.eq('alumno_id', user.id)
|
||||||
.order('fecha_solicitud', { ascending: false });
|
.order('fecha_solicitud', { ascending: false });
|
||||||
|
|
||||||
@@ -101,6 +102,9 @@ const badgeStyle = (e: Estado) =>
|
|||||||
{it.material?.numero_inventario && (
|
{it.material?.numero_inventario && (
|
||||||
<span class="text-xs opacity-60 font-mono ml-1.5">{it.material.numero_inventario}</span>
|
<span class="text-xs opacity-60 font-mono ml-1.5">{it.material.numero_inventario}</span>
|
||||||
)}
|
)}
|
||||||
|
{it.material_unidad?.etiqueta && (
|
||||||
|
<div class="text-xs opacity-70">Unidad: {it.material_unidad.etiqueta}</div>
|
||||||
|
)}
|
||||||
{it.descripcion && (
|
{it.descripcion && (
|
||||||
<p class="text-xs opacity-60 ml-0.5">{it.descripcion}</p>
|
<p class="text-xs opacity-60 ml-0.5">{it.descripcion}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
};
|
||||||
@@ -10,6 +10,8 @@ type Patch = {
|
|||||||
estado?: string;
|
estado?: string;
|
||||||
cantidad_total?: number;
|
cantidad_total?: number;
|
||||||
cantidad_disponible?: number;
|
cantidad_disponible?: number;
|
||||||
|
imagen_path?: string | null;
|
||||||
|
trackeado_por_unidad?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseId = (raw: string | undefined) => {
|
const parseId = (raw: string | undefined) => {
|
||||||
@@ -72,21 +74,74 @@ export const PATCH: APIRoute = async ({ request, locals, params }) => {
|
|||||||
patch.estado = estado;
|
patch.estado = estado;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ('imagen_path' in body) {
|
||||||
|
if (body.imagen_path === null) {
|
||||||
|
patch.imagen_path = null;
|
||||||
|
} else if (typeof body.imagen_path === 'string' && body.imagen_path.trim()) {
|
||||||
|
patch.imagen_path = body.imagen_path.trim();
|
||||||
|
} else if (typeof body.imagen_path === 'string') {
|
||||||
|
patch.imagen_path = null;
|
||||||
|
} else {
|
||||||
|
return Response.json({ error: 'imagen_path inválido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle trackeado_por_unidad — cargamos estado actual del material una vez si se pide algo que dependa de él
|
||||||
|
let actual:
|
||||||
|
| { cantidad_total: number; cantidad_disponible: number; trackeado_por_unidad: boolean }
|
||||||
|
| null = null;
|
||||||
|
const necesitaActual = 'cantidad_total' in body || 'trackeado_por_unidad' in body;
|
||||||
|
if (necesitaActual) {
|
||||||
|
const { data, error: selErr } = await locals.supabase
|
||||||
|
.from('materiales')
|
||||||
|
.select('cantidad_total, cantidad_disponible, trackeado_por_unidad')
|
||||||
|
.eq('id', id)
|
||||||
|
.maybeSingle();
|
||||||
|
if (selErr || !data)
|
||||||
|
return Response.json({ error: 'Material no encontrado' }, { status: 404 });
|
||||||
|
actual = data as typeof actual;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('trackeado_por_unidad' in body) {
|
||||||
|
const nuevo = body.trackeado_por_unidad === true;
|
||||||
|
if (nuevo !== actual!.trackeado_por_unidad) {
|
||||||
|
if (nuevo) {
|
||||||
|
// Solo permitir activar si aún no hay stock — evita perder datos
|
||||||
|
if (actual!.cantidad_total !== 0) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: 'Para activar rastreo por unidad, la cantidad total debe ser 0. Registra las unidades después.' },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Solo desactivar si no hay unidades registradas
|
||||||
|
const { count } = await locals.supabase
|
||||||
|
.from('material_unidades')
|
||||||
|
.select('id', { count: 'exact', head: true })
|
||||||
|
.eq('material_id', id);
|
||||||
|
if ((count ?? 0) > 0) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: 'Para desactivar rastreo por unidad, elimina primero las unidades registradas.' },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
patch.trackeado_por_unidad = nuevo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ('cantidad_total' in body) {
|
if ('cantidad_total' in body) {
|
||||||
|
// Si el material ya es (o va a quedar) trackeado, cantidad_total lo maneja el trigger; no aceptamos edición manual.
|
||||||
|
const efectivoTrackeado =
|
||||||
|
patch.trackeado_por_unidad ?? actual!.trackeado_por_unidad;
|
||||||
|
if (efectivoTrackeado) {
|
||||||
|
// ignorar silenciosamente cantidad_total en modo trackeado
|
||||||
|
} else {
|
||||||
const nuevoTotal = Number(body.cantidad_total);
|
const nuevoTotal = Number(body.cantidad_total);
|
||||||
if (!Number.isInteger(nuevoTotal) || nuevoTotal < 0)
|
if (!Number.isInteger(nuevoTotal) || nuevoTotal < 0)
|
||||||
return Response.json({ error: 'Cantidad total inválida' }, { status: 400 });
|
return Response.json({ error: 'Cantidad total inválida' }, { status: 400 });
|
||||||
|
|
||||||
const { data: actual, error: selErr } = await locals.supabase
|
const prestados = actual!.cantidad_total - actual!.cantidad_disponible;
|
||||||
.from('materiales')
|
|
||||||
.select('cantidad_total, cantidad_disponible')
|
|
||||||
.eq('id', id)
|
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
if (selErr || !actual)
|
|
||||||
return Response.json({ error: 'Material no encontrado' }, { status: 404 });
|
|
||||||
|
|
||||||
const prestados = actual.cantidad_total - actual.cantidad_disponible;
|
|
||||||
if (nuevoTotal < prestados) {
|
if (nuevoTotal < prestados) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ error: `No puede quedar debajo de lo prestado (${prestados})` },
|
{ error: `No puede quedar debajo de lo prestado (${prestados})` },
|
||||||
@@ -94,7 +149,8 @@ export const PATCH: APIRoute = async ({ request, locals, params }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
patch.cantidad_total = nuevoTotal;
|
patch.cantidad_total = nuevoTotal;
|
||||||
patch.cantidad_disponible = actual.cantidad_disponible + (nuevoTotal - actual.cantidad_total);
|
patch.cantidad_disponible = actual!.cantidad_disponible + (nuevoTotal - actual!.cantidad_total);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(patch).length === 0) {
|
if (Object.keys(patch).length === 0) {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
const json = (body: unknown, status = 200) =>
|
||||||
|
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
||||||
|
|
||||||
|
const parseId = (raw: string | undefined) => {
|
||||||
|
const id = Number(raw);
|
||||||
|
return Number.isInteger(id) && id > 0 ? id : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ locals, params, url }) => {
|
||||||
|
if (locals.profile?.rol !== 'admin') return json({ error: 'No autorizado' }, 403);
|
||||||
|
const materialId = parseId(params.id);
|
||||||
|
if (!materialId) return json({ error: 'ID inválido' }, 400);
|
||||||
|
|
||||||
|
let q = locals.supabase
|
||||||
|
.from('material_unidades')
|
||||||
|
.select('id, etiqueta, estado, notas, created_at')
|
||||||
|
.eq('material_id', materialId)
|
||||||
|
.order('id');
|
||||||
|
|
||||||
|
const estado = url.searchParams.get('estado');
|
||||||
|
if (estado && ['disponible', 'prestado', 'mantenimiento', 'baja'].includes(estado)) {
|
||||||
|
q = q.eq('estado', estado);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await q;
|
||||||
|
if (error) return json({ error: 'No se pudo cargar' }, 500);
|
||||||
|
return json({ unidades: data ?? [] });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ locals, params, request }) => {
|
||||||
|
if (locals.profile?.rol !== 'admin') return json({ error: 'No autorizado' }, 403);
|
||||||
|
const materialId = parseId(params.id);
|
||||||
|
if (!materialId) return json({ error: 'ID inválido' }, 400);
|
||||||
|
|
||||||
|
let body: { etiqueta?: unknown; notas?: unknown };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return json({ error: 'JSON inválido' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const etiqueta = typeof body.etiqueta === 'string' ? body.etiqueta.trim() : '';
|
||||||
|
if (!etiqueta) return json({ error: 'La etiqueta es obligatoria' }, 400);
|
||||||
|
const notas =
|
||||||
|
typeof body.notas === 'string' && body.notas.trim() ? body.notas.trim() : null;
|
||||||
|
|
||||||
|
const { data, error } = await locals.supabase
|
||||||
|
.from('material_unidades')
|
||||||
|
.insert({ material_id: materialId, etiqueta, notas })
|
||||||
|
.select('id, etiqueta, estado, notas, created_at')
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if ((error as { code?: string }).code === '23505') {
|
||||||
|
return json({ error: 'Etiqueta duplicada para este material' }, 409);
|
||||||
|
}
|
||||||
|
if ((error as { code?: string }).code === '23503') {
|
||||||
|
return json({ error: 'Material inexistente' }, 409);
|
||||||
|
}
|
||||||
|
return json({ error: 'No se pudo crear la unidad' }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ unidad: data }, 201);
|
||||||
|
};
|
||||||
@@ -14,6 +14,8 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
cantidad_total?: unknown;
|
cantidad_total?: unknown;
|
||||||
numero_inventario?: unknown;
|
numero_inventario?: unknown;
|
||||||
estado?: unknown;
|
estado?: unknown;
|
||||||
|
imagen_path?: unknown;
|
||||||
|
trackeado_por_unidad?: unknown;
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
@@ -26,8 +28,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
return Response.json({ error: 'El nombre es obligatorio' }, { status: 400 });
|
return Response.json({ error: 'El nombre es obligatorio' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const cantidad_total = Number(body.cantidad_total);
|
const trackeado_por_unidad = body.trackeado_por_unidad === true;
|
||||||
if (!Number.isInteger(cantidad_total) || cantidad_total < 0) {
|
|
||||||
|
let cantidad_total = Number(body.cantidad_total);
|
||||||
|
if (trackeado_por_unidad) {
|
||||||
|
// trigger material_unidades sube total/disponible conforme se agreguen unidades
|
||||||
|
cantidad_total = 0;
|
||||||
|
} else if (!Number.isInteger(cantidad_total) || cantidad_total < 0) {
|
||||||
return Response.json({ error: 'La cantidad total debe ser un entero mayor o igual a 0' }, { status: 400 });
|
return Response.json({ error: 'La cantidad total debe ser un entero mayor o igual a 0' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +58,9 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
return Response.json({ error: 'Estado inválido' }, { status: 400 });
|
return Response.json({ error: 'Estado inválido' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const imagen_path =
|
||||||
|
typeof body.imagen_path === 'string' && body.imagen_path.trim() ? body.imagen_path.trim() : null;
|
||||||
|
|
||||||
const { data, error } = await locals.supabase
|
const { data, error } = await locals.supabase
|
||||||
.from('materiales')
|
.from('materiales')
|
||||||
.insert({
|
.insert({
|
||||||
@@ -61,6 +71,8 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
cantidad_disponible: cantidad_total,
|
cantidad_disponible: cantidad_total,
|
||||||
numero_inventario,
|
numero_inventario,
|
||||||
estado: estadoIn,
|
estado: estadoIn,
|
||||||
|
imagen_path,
|
||||||
|
trackeado_por_unidad,
|
||||||
})
|
})
|
||||||
.select('id')
|
.select('id')
|
||||||
.single();
|
.single();
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
const json = (body: unknown, status = 200) =>
|
||||||
|
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
||||||
|
|
||||||
|
const parseId = (raw: string | undefined) => {
|
||||||
|
const id = Number(raw);
|
||||||
|
return Number.isInteger(id) && id > 0 ? id : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PATCH: APIRoute = async ({ locals, params, request }) => {
|
||||||
|
if (locals.profile?.rol !== 'admin') return json({ error: 'No autorizado' }, 403);
|
||||||
|
const unidadId = parseId(params.unidadId);
|
||||||
|
if (!unidadId) return json({ error: 'ID inválido' }, 400);
|
||||||
|
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return json({ error: 'JSON inválido' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if ('etiqueta' in body) {
|
||||||
|
const etiqueta = typeof body.etiqueta === 'string' ? body.etiqueta.trim() : '';
|
||||||
|
if (!etiqueta) return json({ error: 'La etiqueta no puede quedar vacía' }, 400);
|
||||||
|
patch.etiqueta = etiqueta;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('estado' in body) {
|
||||||
|
const estado = typeof body.estado === 'string' ? body.estado : '';
|
||||||
|
if (!['disponible', 'mantenimiento', 'baja'].includes(estado)) {
|
||||||
|
return json({ error: "Estado inválido — 'prestado' lo asigna el sistema" }, 400);
|
||||||
|
}
|
||||||
|
patch.estado = estado;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('notas' in body) {
|
||||||
|
patch.notas =
|
||||||
|
typeof body.notas === 'string' && body.notas.trim() ? body.notas.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(patch).length === 0) return json({ error: 'Nada que actualizar' }, 400);
|
||||||
|
|
||||||
|
// Si se cambia estado, no permitir tocarlo si la unidad está actualmente 'prestado'
|
||||||
|
if ('estado' in patch) {
|
||||||
|
const { data: cur, error: selErr } = await locals.supabase
|
||||||
|
.from('material_unidades')
|
||||||
|
.select('estado')
|
||||||
|
.eq('id', unidadId)
|
||||||
|
.maybeSingle();
|
||||||
|
if (selErr || !cur) return json({ error: 'Unidad no encontrada' }, 404);
|
||||||
|
if (cur.estado === 'prestado') {
|
||||||
|
return json({ error: 'Unidad prestada — el sistema controla su estado' }, 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await locals.supabase
|
||||||
|
.from('material_unidades')
|
||||||
|
.update(patch)
|
||||||
|
.eq('id', unidadId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if ((error as { code?: string }).code === '23505') {
|
||||||
|
return json({ error: 'Etiqueta duplicada para este material' }, 409);
|
||||||
|
}
|
||||||
|
return json({ error: 'No se pudo actualizar' }, 500);
|
||||||
|
}
|
||||||
|
return json({ ok: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DELETE: APIRoute = async ({ locals, params }) => {
|
||||||
|
if (locals.profile?.rol !== 'admin') return json({ error: 'No autorizado' }, 403);
|
||||||
|
const unidadId = parseId(params.unidadId);
|
||||||
|
if (!unidadId) return json({ error: 'ID inválido' }, 400);
|
||||||
|
|
||||||
|
const { data: cur, error: selErr } = await locals.supabase
|
||||||
|
.from('material_unidades')
|
||||||
|
.select('estado')
|
||||||
|
.eq('id', unidadId)
|
||||||
|
.maybeSingle();
|
||||||
|
if (selErr || !cur) return json({ error: 'Unidad no encontrada' }, 404);
|
||||||
|
if (cur.estado === 'prestado') {
|
||||||
|
return json({ error: 'Unidad prestada — no puedes eliminarla' }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await locals.supabase.from('material_unidades').delete().eq('id', unidadId);
|
||||||
|
if (error) {
|
||||||
|
if ((error as { code?: string }).code === '23503') {
|
||||||
|
return json({ error: 'Tiene renglones asociados — no se puede eliminar' }, 409);
|
||||||
|
}
|
||||||
|
return json({ error: 'No se pudo eliminar' }, 500);
|
||||||
|
}
|
||||||
|
return json({ ok: true });
|
||||||
|
};
|
||||||
@@ -13,7 +13,7 @@ export const GET: APIRoute = async ({ params, locals }) => {
|
|||||||
const prestamoQ = locals.supabase
|
const prestamoQ = locals.supabase
|
||||||
.from('solicitudes')
|
.from('solicitudes')
|
||||||
.select(
|
.select(
|
||||||
'id, estado, fecha_solicitud, fecha_aprobacion, fecha_devolucion_estimada, fecha_devolucion_real, notas, maestro_responsable, alumno:profiles!alumno_id(id, nombre, email, matricula), items:solicitud_items(cantidad, descripcion, material:materiales(id, nombre, numero_inventario))'
|
'id, estado, fecha_solicitud, fecha_aprobacion, fecha_devolucion_estimada, fecha_devolucion_real, notas, maestro_responsable, alumno:profiles!alumno_id(id, nombre, email, matricula), items:solicitud_items(id, cantidad, descripcion, material:materiales(id, nombre, numero_inventario, trackeado_por_unidad), material_unidad:material_unidades(id, etiqueta))'
|
||||||
)
|
)
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
const json = (body: unknown, status = 200) =>
|
||||||
|
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
||||||
|
|
||||||
|
// Traduce excepciones nombradas de prestamos.reasignar_unidad a códigos HTTP.
|
||||||
|
const CODE_MAP: Record<string, number> = {
|
||||||
|
no_autorizado: 403,
|
||||||
|
item_no_existe: 404,
|
||||||
|
solicitud_no_activa: 409,
|
||||||
|
item_sin_unidad: 409,
|
||||||
|
unidad_no_existe: 404,
|
||||||
|
unidad_de_otro_material: 409,
|
||||||
|
unidad_no_disponible: 409,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ locals, request }) => {
|
||||||
|
if (locals.profile?.rol !== 'admin') return json({ error: 'No autorizado' }, 403);
|
||||||
|
|
||||||
|
let body: { item_id?: unknown; nueva_unidad_id?: unknown };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return json({ error: 'JSON inválido' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemId = Number(body.item_id);
|
||||||
|
const nuevaId = Number(body.nueva_unidad_id);
|
||||||
|
if (!Number.isInteger(itemId) || itemId <= 0) return json({ error: 'item_id inválido' }, 400);
|
||||||
|
if (!Number.isInteger(nuevaId) || nuevaId <= 0)
|
||||||
|
return json({ error: 'nueva_unidad_id inválido' }, 400);
|
||||||
|
|
||||||
|
const { error } = await locals.supabase.rpc('reasignar_unidad', {
|
||||||
|
p_item_id: itemId,
|
||||||
|
p_nueva_unidad_id: nuevaId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
const raw = String((error as { message?: string }).message ?? '');
|
||||||
|
for (const key of Object.keys(CODE_MAP)) {
|
||||||
|
if (raw.includes(key)) return json({ error: key }, CODE_MAP[key]);
|
||||||
|
}
|
||||||
|
return json({ error: 'No se pudo reasignar' }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ ok: true });
|
||||||
|
};
|
||||||
@@ -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')) {
|
if (message.startsWith('no_autenticado')) {
|
||||||
return { status: 401, error: '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 (
|
if (
|
||||||
message.startsWith('maestro_responsable_requerido') ||
|
message.startsWith('maestro_responsable_requerido') ||
|
||||||
message.startsWith('items_requeridos') ||
|
message.startsWith('items_requeridos') ||
|
||||||
@@ -24,6 +30,9 @@ function statusForError(message: string): { status: number; error: string } {
|
|||||||
if (message.startsWith('stock_insuficiente')) {
|
if (message.startsWith('stock_insuficiente')) {
|
||||||
return { status: 409, error: 'Sin stock suficiente para uno de los materiales' };
|
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' };
|
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 });
|
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 {
|
try {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
} catch {
|
} catch {
|
||||||
return Response.json({ error: 'JSON inválido' }, { status: 400 });
|
return Response.json({ error: 'JSON inválido' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const maestro_responsable = typeof body.maestro_responsable === 'string' ? body.maestro_responsable.trim() : '';
|
// maestro_id: number opcional. Si docente o si alumno sin elección, va null
|
||||||
const notas = typeof body.notas === 'string' && body.notas.trim() ? body.notas.trim() : 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) {
|
if (!Array.isArray(body.items) || body.items.length === 0) {
|
||||||
return Response.json({ error: 'Agrega al menos un material' }, { status: 400 });
|
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', {
|
const { data, error } = await locals.supabase.rpc('crear_solicitud', {
|
||||||
p_maestro_responsable: maestro_responsable,
|
p_maestro_id: maestro_id,
|
||||||
p_notas: notas,
|
p_notas: notas,
|
||||||
p_items: items,
|
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>
|
||||||
+92
-31
@@ -1,45 +1,106 @@
|
|||||||
---
|
---
|
||||||
import AppLayout from '@/layouts/AppLayout.astro';
|
import AppLayout from '@/layouts/AppLayout.astro';
|
||||||
|
import { saludoHora, fmtFechaRelativa } from '@/lib/date';
|
||||||
|
|
||||||
const profile = Astro.locals.profile;
|
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">
|
<AppLayout title="Inicio — LabPréstamos">
|
||||||
<div class="max-w-3xl">
|
<div class="max-w-2xl mx-auto">
|
||||||
<header class="mb-8">
|
<header class="mb-8">
|
||||||
<p class="text-sm" style="color: var(--color-pencil);">Bienvenido{nombre ? ',' : ''}</p>
|
<h1 class="font-mono uppercase text-3xl md:text-5xl font-semibold leading-tight" style="letter-spacing: -0.01em;">
|
||||||
<h1 class="text-2xl md:text-3xl font-semibold">{nombre || 'Laboratorio de Sistemas'}</h1>
|
{saludo}, {nombreCorto}
|
||||||
|
</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="grid gap-5 md:grid-cols-2">
|
<section class="card-raised mb-8">
|
||||||
{isAdmin ? (
|
<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="/admin/solicitudes" class="card-link">
|
<a href="/alumno/catalogo" class="btn btn-primary">Ir al catálogo</a>
|
||||||
<h2 class="font-semibold text-lg mb-1 uppercase tracking-wide">Solicitudes</h2>
|
</section>
|
||||||
<p class="text-sm" style="color: var(--color-pencil);">Aprobar o rechazar peticiones de material.</p>
|
|
||||||
</a>
|
{actual ? (
|
||||||
<a href="/admin/inventario" class="card-link">
|
<section aria-labelledby="actual-h">
|
||||||
<h2 class="font-semibold text-lg mb-1 uppercase tracking-wide">Inventario</h2>
|
<div class="flex items-center justify-between gap-3 mb-3">
|
||||||
<p class="text-sm" style="color: var(--color-pencil);">Materiales y categorías del laboratorio.</p>
|
<h2 id="actual-h" class="text-lg font-semibold uppercase tracking-wide">Tu préstamo actual</h2>
|
||||||
</a>
|
<span class="badge" style={badgeStyle(actual.estado)}>{estadoLabel[actual.estado]}</span>
|
||||||
<a href="/admin/reportes" class="card-link md:col-span-2">
|
</div>
|
||||||
<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">
|
<a href="/alumno/mis-prestamos" class="card-link">
|
||||||
<h2 class="font-semibold text-lg mb-1 uppercase tracking-wide">Mis préstamos</h2>
|
<ul class="space-y-1 mb-4">
|
||||||
<p class="text-sm" style="color: var(--color-pencil);">Estado de tus solicitudes actuales e historial.</p>
|
{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>
|
</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>
|
</div>
|
||||||
</div>
|
|
||||||
</AppLayout>
|
</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,498 @@
|
|||||||
|
-- 0003_grid_unidades_realtime.sql
|
||||||
|
-- 3 features de BD en una migracion:
|
||||||
|
-- A) imagen_path en materiales (bucket 'materiales-fotos' se crea aparte)
|
||||||
|
-- B) material_unidades (rastreo por unidad para laptops/proyectores) +
|
||||||
|
-- flag trackeado_por_unidad + material_unidad_id en solicitud_items +
|
||||||
|
-- sync_stock/crear_solicitud reescritos + RPC reasignar_unidad(admin)
|
||||||
|
-- C) publication supabase_realtime += prestamos.solicitudes (badge admin)
|
||||||
|
|
||||||
|
begin;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- A) Imagen del material (guarda solo el path dentro del bucket)
|
||||||
|
-- ============================================================
|
||||||
|
alter table prestamos.materiales add column imagen_path text;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- B.1) Flag + tabla de unidades individuales
|
||||||
|
-- ============================================================
|
||||||
|
alter table prestamos.materiales
|
||||||
|
add column trackeado_por_unidad boolean not null default false;
|
||||||
|
|
||||||
|
create table prestamos.material_unidades (
|
||||||
|
id bigserial primary key,
|
||||||
|
material_id int not null references prestamos.materiales(id) on delete cascade,
|
||||||
|
etiqueta text not null,
|
||||||
|
estado text not null default 'disponible'
|
||||||
|
check (estado in ('disponible','prestado','mantenimiento','baja')),
|
||||||
|
notas text,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
unique (material_id, etiqueta)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index material_unidades_material_estado_idx
|
||||||
|
on prestamos.material_unidades(material_id, estado);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- B.2) Ligar renglon de solicitud a unidad especifica (nullable)
|
||||||
|
-- ============================================================
|
||||||
|
alter table prestamos.solicitud_items
|
||||||
|
add column material_unidad_id bigint references prestamos.material_unidades(id);
|
||||||
|
|
||||||
|
create index solicitud_items_unidad_idx
|
||||||
|
on prestamos.solicitud_items(material_unidad_id)
|
||||||
|
where material_unidad_id is not null;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- B.3) sync_stock reescrito: itera renglones y, si el renglon tiene
|
||||||
|
-- material_unidad_id, tambien alterna el estado de esa unidad.
|
||||||
|
-- cantidad_disponible del material sigue reflejando el count
|
||||||
|
-- real disponible en ambos modos (por unidad o por cantidad).
|
||||||
|
-- ============================================================
|
||||||
|
create or replace function prestamos.sync_stock()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = prestamos, pg_temp
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
item record;
|
||||||
|
begin
|
||||||
|
-- sacar stock: pendiente -> aprobado/activo
|
||||||
|
if new.estado in ('aprobado','activo') and old.estado not in ('aprobado','activo') then
|
||||||
|
for item in
|
||||||
|
select material_id, cantidad, material_unidad_id
|
||||||
|
from prestamos.solicitud_items
|
||||||
|
where solicitud_id = new.id
|
||||||
|
order by material_id
|
||||||
|
loop
|
||||||
|
update prestamos.materiales
|
||||||
|
set cantidad_disponible = cantidad_disponible - item.cantidad
|
||||||
|
where id = item.material_id;
|
||||||
|
|
||||||
|
if item.material_unidad_id is not null then
|
||||||
|
update prestamos.material_unidades
|
||||||
|
set estado = 'prestado'
|
||||||
|
where id = item.material_unidad_id;
|
||||||
|
end if;
|
||||||
|
end loop;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- devolver stock: aprobado/activo -> devuelto/rechazado
|
||||||
|
if new.estado in ('devuelto','rechazado') and old.estado in ('aprobado','activo') then
|
||||||
|
for item in
|
||||||
|
select material_id, cantidad, material_unidad_id
|
||||||
|
from prestamos.solicitud_items
|
||||||
|
where solicitud_id = new.id
|
||||||
|
order by material_id
|
||||||
|
loop
|
||||||
|
update prestamos.materiales
|
||||||
|
set cantidad_disponible = cantidad_disponible + item.cantidad
|
||||||
|
where id = item.material_id;
|
||||||
|
|
||||||
|
if item.material_unidad_id is not null then
|
||||||
|
update prestamos.material_unidades
|
||||||
|
set estado = 'disponible'
|
||||||
|
where id = item.material_unidad_id;
|
||||||
|
end if;
|
||||||
|
end loop;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- B.4) Trigger para mantener cantidad_total y cantidad_disponible
|
||||||
|
-- del material sincronizados con material_unidades cuando
|
||||||
|
-- trackeado_por_unidad=true. Cambios a considerar:
|
||||||
|
-- - INSERT unidad (nueva ficha) -> total += 1, disponible += (1 si 'disponible')
|
||||||
|
-- - DELETE unidad -> total -= 1, disponible -= (1 si 'disponible')
|
||||||
|
-- - UPDATE estado unidad -> disponible +/- 1 segun transicion
|
||||||
|
-- (tomando 'disponible' como el unico estado que cuenta como stock)
|
||||||
|
-- ============================================================
|
||||||
|
create or replace function prestamos.sync_material_desde_unidad()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = prestamos, pg_temp
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_delta_total int := 0;
|
||||||
|
v_delta_disp int := 0;
|
||||||
|
v_material_id int;
|
||||||
|
begin
|
||||||
|
if tg_op = 'INSERT' then
|
||||||
|
v_material_id := new.material_id;
|
||||||
|
v_delta_total := 1;
|
||||||
|
if new.estado = 'disponible' then v_delta_disp := 1; end if;
|
||||||
|
|
||||||
|
elsif tg_op = 'DELETE' then
|
||||||
|
v_material_id := old.material_id;
|
||||||
|
v_delta_total := -1;
|
||||||
|
if old.estado = 'disponible' then v_delta_disp := -1; end if;
|
||||||
|
|
||||||
|
elsif tg_op = 'UPDATE' then
|
||||||
|
v_material_id := new.material_id;
|
||||||
|
if old.estado = 'disponible' and new.estado <> 'disponible' then
|
||||||
|
v_delta_disp := -1;
|
||||||
|
elsif old.estado <> 'disponible' and new.estado = 'disponible' then
|
||||||
|
v_delta_disp := 1;
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_delta_total <> 0 or v_delta_disp <> 0 then
|
||||||
|
update prestamos.materiales
|
||||||
|
set cantidad_total = cantidad_total + v_delta_total,
|
||||||
|
cantidad_disponible = cantidad_disponible + v_delta_disp
|
||||||
|
where id = v_material_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists prestamos_sync_material_desde_unidad on prestamos.material_unidades;
|
||||||
|
create trigger prestamos_sync_material_desde_unidad
|
||||||
|
after insert or update of estado or delete on prestamos.material_unidades
|
||||||
|
for each row execute function prestamos.sync_material_desde_unidad();
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- B.5) crear_solicitud reescrito: si el material esta trackeado_por_unidad,
|
||||||
|
-- toma la primera unidad 'disponible' (FOR UPDATE SKIP LOCKED) y la
|
||||||
|
-- liga al renglon; deja que sync_stock marque la unidad al aprobar.
|
||||||
|
-- No descuenta stock en creacion (comportamiento igual al 0002).
|
||||||
|
-- ============================================================
|
||||||
|
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;
|
||||||
|
begin
|
||||||
|
if v_alumno is null then
|
||||||
|
raise exception 'no_autenticado';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if p_maestro_responsable is null or length(trim(p_maestro_responsable)) = 0 then
|
||||||
|
raise exception 'maestro_responsable_requerido';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if p_items is null or jsonb_typeof(p_items) <> 'array' or jsonb_array_length(p_items) = 0 then
|
||||||
|
raise exception 'items_requeridos';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- lock por orden ascendente de material_id, evita deadlocks
|
||||||
|
for v_material_id in
|
||||||
|
select distinct (elem->>'material_id')::int
|
||||||
|
from jsonb_array_elements(p_items) elem
|
||||||
|
order by 1
|
||||||
|
loop
|
||||||
|
perform 1 from prestamos.materiales where id = v_material_id for update;
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
insert into prestamos.solicitudes (alumno_id, estado, maestro_responsable, notas)
|
||||||
|
values (v_alumno, 'pendiente', trim(p_maestro_responsable), nullif(trim(coalesce(p_notas, '')), ''))
|
||||||
|
returning id into v_solicitud_id;
|
||||||
|
|
||||||
|
for v_item in select * from jsonb_array_elements(p_items)
|
||||||
|
loop
|
||||||
|
v_material_id := (v_item->>'material_id')::int;
|
||||||
|
v_cantidad := (v_item->>'cantidad')::int;
|
||||||
|
v_descripcion := nullif(trim(coalesce(v_item->>'descripcion', '')), '');
|
||||||
|
|
||||||
|
if v_material_id is null or v_cantidad is null or v_cantidad <= 0 then
|
||||||
|
raise exception 'item_invalido';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select cantidad_disponible, estado, 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
|
||||||
|
-- asignar 1 unidad por copia solicitada; N renglones (uno por unidad)
|
||||||
|
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);
|
||||||
|
|
||||||
|
-- marcamos la unidad como reservada usando 'prestado' aqui NO seria
|
||||||
|
-- correcto (aun no aprobada). Usamos un truco simple: dejar la
|
||||||
|
-- unidad como 'disponible' hasta que sync_stock la mueva al aprobar,
|
||||||
|
-- pero excluirla de nuevas asignaciones via join con
|
||||||
|
-- solicitud_items donde solicitud.estado='pendiente'.
|
||||||
|
-- Para evitar carrera con otro alumno pidiendo la misma:
|
||||||
|
-- volvemos a lockear via el WHERE de arriba (FOR UPDATE SKIP LOCKED
|
||||||
|
-- solo aguanta hasta el commit). Solucion pragmatica: dentro de
|
||||||
|
-- ESTA misma transaccion no habra colision porque estamos en un
|
||||||
|
-- unico crear_solicitud. Entre transacciones concurrentes, dos
|
||||||
|
-- alumnos pueden llegar a asignar la misma unidad si ambos hacen
|
||||||
|
-- crear_solicitud simultaneo y la unidad aun esta 'disponible':
|
||||||
|
-- para blindar eso, cambiamos a 'mantenimiento' temporal seria feo.
|
||||||
|
-- La opcion limpia: reflejar la reserva marcando la unidad como
|
||||||
|
-- 'prestado' YA en la creacion. Trade-off: la unidad queda ocupada
|
||||||
|
-- aunque el admin luego rechace. El sync_stock al rechazar
|
||||||
|
-- ('pendiente' -> 'rechazado' no dispara sync porque el OLD ya es
|
||||||
|
-- pendiente); necesitamos liberar la unidad en rechazar tambien.
|
||||||
|
-- Elegimos ESTA ruta (marcar como 'prestado' aqui) por seguridad
|
||||||
|
-- de concurrencia, y extendemos sync_stock/log de rechazo para
|
||||||
|
-- liberar unidades cuando se rechaza desde 'pendiente'.
|
||||||
|
update prestamos.material_unidades
|
||||||
|
set estado = 'prestado'
|
||||||
|
where id = v_unidad_id;
|
||||||
|
|
||||||
|
v_asignadas := v_asignadas + 1;
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
-- para materiales trackeados, cantidad_disponible se mantiene
|
||||||
|
-- automatico via sync_material_desde_unidad(); nada mas que hacer.
|
||||||
|
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;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- B.6) sync_stock: cubrir tambien la ruta pendiente->rechazado para
|
||||||
|
-- liberar unidades reservadas (crear_solicitud las marca 'prestado'
|
||||||
|
-- preventivamente para bloquear la carrera).
|
||||||
|
-- Para materiales no-trackeados, el stock nunca se descuenta antes
|
||||||
|
-- de aprobar, asi que este bloque solo importa para material_unidades.
|
||||||
|
-- ============================================================
|
||||||
|
create or replace function prestamos.sync_stock()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = prestamos, pg_temp
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
item record;
|
||||||
|
begin
|
||||||
|
-- sacar stock: !aprobado/activo -> aprobado/activo
|
||||||
|
if new.estado in ('aprobado','activo') and old.estado not in ('aprobado','activo') then
|
||||||
|
for item in
|
||||||
|
select material_id, cantidad, material_unidad_id
|
||||||
|
from prestamos.solicitud_items
|
||||||
|
where solicitud_id = new.id
|
||||||
|
order by material_id
|
||||||
|
loop
|
||||||
|
-- materiales no-trackeados: descontar cantidad_disponible
|
||||||
|
-- (para trackeados, la unidad ya se marco 'prestado' en crear_solicitud
|
||||||
|
-- y sync_material_desde_unidad ya bajo cantidad_disponible en su momento;
|
||||||
|
-- no volvemos a bajar aqui)
|
||||||
|
if item.material_unidad_id is null then
|
||||||
|
update prestamos.materiales
|
||||||
|
set cantidad_disponible = cantidad_disponible - item.cantidad
|
||||||
|
where id = item.material_id;
|
||||||
|
end if;
|
||||||
|
end loop;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- devolver stock: aprobado/activo -> devuelto/rechazado
|
||||||
|
if new.estado in ('devuelto','rechazado') and old.estado in ('aprobado','activo') then
|
||||||
|
for item in
|
||||||
|
select material_id, cantidad, material_unidad_id
|
||||||
|
from prestamos.solicitud_items
|
||||||
|
where solicitud_id = new.id
|
||||||
|
order by material_id
|
||||||
|
loop
|
||||||
|
if item.material_unidad_id is null then
|
||||||
|
update prestamos.materiales
|
||||||
|
set cantidad_disponible = cantidad_disponible + item.cantidad
|
||||||
|
where id = item.material_id;
|
||||||
|
else
|
||||||
|
update prestamos.material_unidades
|
||||||
|
set estado = 'disponible'
|
||||||
|
where id = item.material_unidad_id;
|
||||||
|
end if;
|
||||||
|
end loop;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- pendiente -> rechazado: liberar unidades reservadas
|
||||||
|
-- (no toca cantidad_disponible de no-trackeados: aun no se descontaba)
|
||||||
|
if new.estado = 'rechazado' and old.estado = 'pendiente' then
|
||||||
|
for item in
|
||||||
|
select material_unidad_id
|
||||||
|
from prestamos.solicitud_items
|
||||||
|
where solicitud_id = new.id and material_unidad_id is not null
|
||||||
|
loop
|
||||||
|
update prestamos.material_unidades
|
||||||
|
set estado = 'disponible'
|
||||||
|
where id = item.material_unidad_id;
|
||||||
|
end loop;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- B.7) RPC reasignar_unidad: admin cambia la unidad ligada a un renglon.
|
||||||
|
-- Solo permitido si la solicitud esta en aprobado/activo y el item
|
||||||
|
-- ya tiene unidad. La unidad nueva debe ser del mismo material y
|
||||||
|
-- estar 'disponible'. Log en audit_log con accion='reasignar_unidad'.
|
||||||
|
-- ============================================================
|
||||||
|
create or replace function prestamos.reasignar_unidad(
|
||||||
|
p_item_id bigint,
|
||||||
|
p_nueva_unidad_id bigint
|
||||||
|
) returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = prestamos, pg_temp
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_solicitud_id bigint;
|
||||||
|
v_estado_sol text;
|
||||||
|
v_material_id int;
|
||||||
|
v_unidad_vieja bigint;
|
||||||
|
v_material_nueva int;
|
||||||
|
v_estado_nueva text;
|
||||||
|
begin
|
||||||
|
if not prestamos.is_admin() then
|
||||||
|
raise exception 'no_autorizado';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select si.solicitud_id, s.estado, si.material_id, si.material_unidad_id
|
||||||
|
into v_solicitud_id, v_estado_sol, v_material_id, v_unidad_vieja
|
||||||
|
from prestamos.solicitud_items si
|
||||||
|
join prestamos.solicitudes s on s.id = si.solicitud_id
|
||||||
|
where si.id = p_item_id;
|
||||||
|
|
||||||
|
if v_solicitud_id is null then
|
||||||
|
raise exception 'item_no_existe';
|
||||||
|
end if;
|
||||||
|
if v_estado_sol not in ('aprobado','activo') then
|
||||||
|
raise exception 'solicitud_no_activa';
|
||||||
|
end if;
|
||||||
|
if v_unidad_vieja is null then
|
||||||
|
raise exception 'item_sin_unidad';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select material_id, estado into v_material_nueva, v_estado_nueva
|
||||||
|
from prestamos.material_unidades
|
||||||
|
where id = p_nueva_unidad_id
|
||||||
|
for update;
|
||||||
|
|
||||||
|
if v_material_nueva is null then
|
||||||
|
raise exception 'unidad_no_existe';
|
||||||
|
end if;
|
||||||
|
if v_material_nueva <> v_material_id then
|
||||||
|
raise exception 'unidad_de_otro_material';
|
||||||
|
end if;
|
||||||
|
if v_estado_nueva <> 'disponible' then
|
||||||
|
raise exception 'unidad_no_disponible';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
update prestamos.material_unidades set estado = 'disponible' where id = v_unidad_vieja;
|
||||||
|
update prestamos.material_unidades set estado = 'prestado' where id = p_nueva_unidad_id;
|
||||||
|
update prestamos.solicitud_items set material_unidad_id = p_nueva_unidad_id where id = p_item_id;
|
||||||
|
|
||||||
|
insert into prestamos.audit_log
|
||||||
|
(solicitud_id, actor_id, accion, estado_anterior, estado_nuevo, payload)
|
||||||
|
values (
|
||||||
|
v_solicitud_id, auth.uid(), 'reasignar_unidad', null, null,
|
||||||
|
jsonb_build_object('item_id', p_item_id, 'unidad_vieja', v_unidad_vieja, 'unidad_nueva', p_nueva_unidad_id)
|
||||||
|
);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
grant execute on function prestamos.reasignar_unidad(bigint, bigint) to authenticated;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- B.8) RLS material_unidades: todos leen, solo admin escribe
|
||||||
|
-- ============================================================
|
||||||
|
alter table prestamos.material_unidades enable row level security;
|
||||||
|
|
||||||
|
create policy material_unidades_read_all on prestamos.material_unidades
|
||||||
|
for select to authenticated using (true);
|
||||||
|
|
||||||
|
create policy material_unidades_admin_write on prestamos.material_unidades
|
||||||
|
for all to authenticated
|
||||||
|
using (prestamos.is_admin())
|
||||||
|
with check (prestamos.is_admin());
|
||||||
|
|
||||||
|
grant select, insert, update, delete on prestamos.material_unidades to authenticated, service_role;
|
||||||
|
grant usage, select on prestamos.material_unidades_id_seq to authenticated, service_role;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- C) Publication para Realtime (badge de pendientes)
|
||||||
|
-- replica identity default alcanza para INSERT/DELETE; para UPDATE
|
||||||
|
-- Realtime tambien emite pero solo con columnas de PK. Suficiente
|
||||||
|
-- para el caso de uso (contar cambios a/desde 'pendiente').
|
||||||
|
-- ============================================================
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
if not exists (
|
||||||
|
select 1 from pg_publication_tables
|
||||||
|
where pubname = 'supabase_realtime'
|
||||||
|
and schemaname = 'prestamos'
|
||||||
|
and tablename = 'solicitudes'
|
||||||
|
) then
|
||||||
|
alter publication supabase_realtime add table prestamos.solicitudes;
|
||||||
|
end if;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- D) Bucket publico 'materiales-fotos' + policies de storage
|
||||||
|
-- Lectura anonima (bucket publico); escritura solo si is_admin().
|
||||||
|
-- ============================================================
|
||||||
|
insert into storage.buckets (id, name, public)
|
||||||
|
values ('materiales-fotos', 'materiales-fotos', true)
|
||||||
|
on conflict (id) do update set public = true;
|
||||||
|
|
||||||
|
drop policy if exists "materiales_fotos_read_all" on storage.objects;
|
||||||
|
drop policy if exists "materiales_fotos_admin_write" on storage.objects;
|
||||||
|
|
||||||
|
create policy "materiales_fotos_read_all" on storage.objects
|
||||||
|
for select to anon, authenticated
|
||||||
|
using (bucket_id = 'materiales-fotos');
|
||||||
|
|
||||||
|
create policy "materiales_fotos_admin_write" on storage.objects
|
||||||
|
for all to authenticated
|
||||||
|
using (bucket_id = 'materiales-fotos' and prestamos.is_admin())
|
||||||
|
with check (bucket_id = 'materiales-fotos' and prestamos.is_admin());
|
||||||
|
|
||||||
|
commit;
|
||||||
@@ -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