Sistema de préstamos LabRe UABC — implementación inicial

Sistema web para gestión de préstamos de material del Laboratorio de
Sistemas Computacionales de la UABC.

- Backend: Supabase self-hosted, schema aislado `prestamos` con RLS,
  triggers de stock y audit log (supabase/migrations/0001_init.sql).
- Auth: Google OAuth restringido a @uabc.edu.mx, verificado en
  middleware y como segunda línea en trigger de DB.
- Frontend: Astro 7 (SSR con adapter Node) + React islands + Tailwind
  v4 con paleta UABC (primary #00723F, secondary #DD971A) bajo regla
  60/30/10.
- Interfaz alumno mobile-first: catálogo con filtro por categorías,
  solicitud de préstamos, historial personal.
- Interfaz admin desktop-first: panel con KPIs, bandeja de solicitudes
  (aprobar/rechazar/devolver), CRUD de inventario y categorías,
  reportes filtrables con export CSV nativo.
- Modales con `<dialog>` nativo, cero librerías de UI adicionales.
- Deploy: Dockerfile multi-stage node:22-alpine + docker-compose para
  publicar bajo prestamos.buglabs.dev vía Cloudflare Tunnel.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 19:37:49 -07:00
parent 6bbd6d3a6b
commit 1121ab7199
58 changed files with 6281 additions and 252 deletions
+20
View File
@@ -0,0 +1,20 @@
node_modules
dist
.astro
.env
.env.*
!.env.example
.git
.github
.gitignore
.vscode
.claude
scratchpad
supabase/migrations/*.sql
AGENTS.md
CLAUDE.md
*.md
!README.md
Dockerfile
.dockerignore
docker-compose.yml
+4
View File
@@ -0,0 +1,4 @@
PUBLIC_SUPABASE_URL=https://supabase.buglabs.dev
PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
PUBLIC_APP_URL=http://localhost:4321
+5
View File
@@ -0,0 +1,5 @@
# Copia como .env.production y rellena antes de docker compose up
PUBLIC_SUPABASE_URL=https://supabase.buglabs.dev
PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
PUBLIC_APP_URL=https://prestamos.buglabs.dev
+99
View File
@@ -20,3 +20,102 @@ Consult these guides before working on related tasks:
- [Adding or managing content](https://docs.astro.build/en/guides/content-collections/) - [Adding or managing content](https://docs.astro.build/en/guides/content-collections/)
- [Adding styles or using Tailwind](https://docs.astro.build/en/guides/styling/) - [Adding styles or using Tailwind](https://docs.astro.build/en/guides/styling/)
- [Supporting multiple languages](https://docs.astro.build/en/guides/internationalization/) - [Supporting multiple languages](https://docs.astro.build/en/guides/internationalization/)
## Proceso de trabajo de este proyecto
Al final de cada prompt del usuario, actualizar la sección **Bitácora** de este archivo con lo que se hizo, decisiones tomadas y qué sigue. No crear archivos de plan aparte: este CLAUDE.md es la única fuente de verdad del progreso.
Antes de ejecutar cambios de infraestructura (SSH a buglabs, docker, Cloudflare tunnel, DB) pedir confirmación al usuario si el cambio es destructivo o afecta a otros proyectos que viven en el mismo servidor (genqbar, gitea, etc).
Cuando el trabajo se pueda dividir en partes que no toquen los mismos archivos, lanzar agentes en paralelo (Agent tool) en vez de hacerlo secuencial.
---
# Proyecto: Sistema de Préstamos — Laboratorio de Sistemas Computacionales UABC
Sistema web para gestionar préstamos de material del laboratorio. Dos roles: **alumno** (solicita material) y **admin** (gestiona solicitudes, inventario y reportes). Login exclusivo con Google restringido a correo institucional `@uabc.edu.mx`.
## Decisiones de arquitectura
| Decisión | Elegido | Motivo |
|---|---|---|
| Backend | Supabase **self-hosted existente** en buglabs (`supabase.buglabs.dev`) | Ya está corriendo (Postgres 15, Auth, Kong, Studio) — no se despliega uno nuevo. Compartido con otro proyecto (genqbar). |
| Aislamiento de datos | Schema Postgres propio: **`prestamos`** | `public` ya lo usa genqbar (tablas `profiles`, `edificios`, `eventos`, etc). Evita colisión de nombres. |
| Auth | Supabase Auth + proveedor Google OAuth (a configurar, hoy no está activo en la instancia) | Auth es compartido entre apps del mismo Supabase; el proveedor Google se habilita una vez a nivel instancia. |
| Restricción de dominio `@uabc.edu.mx` | **A nivel aplicación** (middleware de Astro tras login), no a nivel GoTrue | GoTrue es compartido con otras apps del servidor; no se puede bloquear el signup global sin afectar a genqbar. |
| Alta de admin inicial | Manual vía Supabase Studio, después del primer login | Decisión del usuario — no se hardcodea ningún correo admin. |
| Frontend interactivo | Astro + **React** (islands) | Ya es un proyecto Astro; React da el ecosistema más grande para tablas/formularios/export. |
| Hosting de la app | Servidor propio (buglabs), adapter **`@astrojs/node`** | Mismo homelab que Supabase; se agrega contenedor + ruta en el túnel de Cloudflare existente. |
| Subdominio | **prestamos.buglabs.dev** | Se añade al mismo túnel Cloudflare (`3de17b3c-...`) junto a supabase/git/status/genqbar. |
| Exportar reportes | CSV nativo (sin librería) para v1 | Cero dependencias nuevas; se sube a PDF/Excel solo si se pide explícitamente. |
## Modelo de datos propuesto (schema `prestamos`)
- `profiles` — id (=auth.users.id), email, nombre, matricula, rol (`alumno`|`admin`), created_at
- `categorias` — id, nombre
- `materiales` — id, nombre, categoria_id, descripcion, cantidad_total, cantidad_disponible, numero_inventario, estado (`disponible`|`mantenimiento`|`baja`)
- `prestamos` — id, alumno_id, material_id, cantidad, estado (`pendiente`|`aprobado`|`rechazado`|`activo`|`devuelto`|`vencido`), fecha_solicitud, fecha_aprobacion, fecha_devolucion_estimada, fecha_devolucion_real, aprobado_por, notas
RLS: alumno solo ve/edita sus propios préstamos; admin ve y gestiona todo. Enforced con `auth.uid()` contra `profiles.id` y policies por rol.
## Fases de implementación
1. **Backend Supabase** — schema `prestamos`, tablas, RLS, trigger `handle_new_user`, seed de categorías. (bloqueante, va primero)
2. **Scaffolding Astro** — integración React, cliente Supabase, middleware de auth + verificación de dominio/rol, layout base.
3. **Interfaz alumno** — catálogo de materiales, solicitar préstamo, ver mis préstamos y su estado.
4. **Interfaz admin — solicitudes** — bandeja de solicitudes (aprobar/rechazar), marcar devoluciones.
5. **Interfaz admin — inventario** — alta/baja/edición de materiales y categorías.
6. **Reportes** — filtros (fecha, material, alumno, estado), export CSV, vista de vencidos.
7. **Deploy** — Dockerfile + compose para el adapter Node, ruta `prestamos.buglabs.dev` en el túnel de Cloudflare de buglabs.
8. **QA end-to-end** — flujo real de login + alumno + admin en producción.
Fases 3, 4/5 y 6 tocan carpetas de rutas distintas (`src/pages/alumno/*`, `src/pages/admin/*`) y pueden correr como agentes en paralelo una vez completa la fase 2. Fase 7 (infra) también es independiente del código de UI y puede prepararse en paralelo.
## Bitácora
- **2026-08-13** — Plan inicial definido. Se hizo reconocimiento por SSH del homelab buglabs: Supabase self-hosted ya corre ahí (compartido con proyecto "genqbar" en schema `public`), túnel Cloudflare ya gestiona varios subdominios de buglabs.dev, Docker/Compose disponibles. Decisiones de arquitectura tomadas junto con el usuario (ver tabla arriba). Pendiente: confirmación del usuario para arrancar Fase 1 (backend Supabase).
- **2026-08-14** — Plan refinado y aprobado. Ajustes: (1) schema `public.profiles` NO se reutiliza — el sistema de préstamos usa `prestamos.profiles` propio y aislado; (2) sí se agrega `prestamos.audit_log` con trigger para trazabilidad de cambios de estado; (3) v1 sin reglas duras de límite/duración (admin decide caso por caso). Se corrigió la identificación del vecino que usa el schema `public`: no es genqbar (genqbar es un estático sin auth), es otra app de check-in por QR corriendo en el 8080. Google OAuth se confirmó ya habilitado en la instancia de Supabase (`GOOGLE_ENABLED=true`, con CLIENT_ID/SECRET presentes en `~/supabase/docker/.env`), no requiere alta nueva. Se añadieron normas de diseño al plan: paleta UABC (primario `#00723F` verde, secundario `#DD971A` dorado, terciario `#F4F7F5`, neutral `#2D3748`) bajo regla 60/30/10, admin=desktop-first y alumno=mobile-first pero ambos cross-device, minimalista con animaciones ligeras, Inter Variable como tipografía, WCAG AA no negociable. Plan guardado en `~/.claude/plans/inicia-con-la-faze-modular-reef.md`.
- **2026-08-14 — Fase 1 completada (Backend Supabase)**.
- Escrita migración `supabase/migrations/0001_init.sql` con: schema `prestamos`; tablas `profiles, categorias, materiales, prestamos, audit_log`; función helper `prestamos.is_admin()`; trigger `prestamos_on_auth_user_created` en `auth.users` que rechaza emails no `@uabc.edu.mx` (segunda línea de defensa) y crea el perfil; triggers `prestamos_sync_stock` (ajusta `cantidad_disponible` según transiciones de estado) y `prestamos_log_estado` (registra cambios en `audit_log`); 11 policies RLS distribuidas en las 5 tablas usando `is_admin()`; seed de 6 categorías.
- Aplicada por `psql` sobre la instancia de buglabs. Verificado: 5 tablas creadas, 11 policies activas, 3 triggers propios registrados, 6 categorías seed presentes.
- Editado `~/supabase/docker/.env` en buglabs (backup previo con timestamp): `PGRST_DB_SCHEMAS` incluye ahora `prestamos`, `ADDITIONAL_REDIRECT_URLS` incluye `https://prestamos.buglabs.dev/**`.
- Recreados contenedores `supabase-rest` y `supabase-auth` con `docker compose up -d` (un simple `restart` no relee `.env`; ese fue un aprendizaje del proceso). Downtime real: ~5s.
- Corrección aplicada tras primer curl de verificación: faltaba `grant` a `service_role`. Se añadió `grant ... to service_role` + `alter default privileges` (tanto en la DB como en el archivo de migración, para que sea idempotente si se re-aplica). Verificado con `curl` que `GET /rest/v1/categorias` con `Accept-Profile: prestamos` retorna las 6 categorías.
- Estado: backend listo para consumir desde la app. Próximo paso: Fase 2 (scaffolding Astro + React + Tailwind + adapter Node + cliente Supabase SSR + middleware de auth con verificación de dominio).
- **2026-08-14 — Fase 2 completada (Scaffolding Astro)**.
- Instaladas integraciones vía `npx astro add react node tailwind --yes`. Añadido a `package.json`: `@astrojs/react`, `@astrojs/node`, `tailwindcss` (v4, sin config JS — configura en CSS con `@theme`), `react`, `react-dom`. Adaptador Node en modo `standalone`.
- Añadidas deps runtime: `@supabase/supabase-js`, `@supabase/ssr`, `@fontsource-variable/inter`.
- `astro.config.mjs`: se explicitó `output: 'server'` (sin esto, Astro intentaba prerenderizar rutas SSR y el cliente Supabase reventaba). Integraciones: React + Tailwind v4 (vía `@tailwindcss/vite`).
- `tsconfig.json`: añadido `baseUrl: '.'` + `paths: { "@/*": ["src/*"] }` para import alias.
- `src/styles/global.css`: paleta UABC como tokens Tailwind v4 (`@theme` con `--color-primary #00723F, --color-secondary #DD971A, --color-surface #F4F7F5, --color-ink #2D3748, --color-danger #C53030`), Inter Variable como `--font-sans`, componentes `.btn`, `.btn-primary`, `.btn-secondary`, `.btn-ghost`, `.card`, `.input`, `.label`. Tap targets ≥44px. `touch-action: manipulation` y `-webkit-tap-highlight-color: transparent` en botones. `text-wrap: balance/pretty` en tipografía.
- `src/lib/supabase.ts`: helpers `serverClient(cookies)` y `browserClient()`. Ambos apuntan a `db: { schema: 'prestamos' }` para que las queries no necesiten prefijo. Cookies con `httpOnly`, `sameSite: 'lax'`, `secure` solo en prod. Usa el patrón `get/set/remove` (el `getAll/setAll` moderno de `@supabase/ssr` no funcionó con `AstroCookies` de Astro 7 — el método `.getAll()` no existe ahí).
- `src/env.d.ts`: tipos para `App.Locals` (`supabase`, `user`, `profile`) y `ImportMetaEnv` (`PUBLIC_SUPABASE_URL`, `PUBLIC_SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `PUBLIC_APP_URL`).
- `src/middleware.ts`: instancia `serverClient`, obtiene user, verifica dominio `@uabc.edu.mx` (rechaza + redirect `/login?error=dominio`), carga `profile` desde `prestamos.profiles`, protege rutas privadas (redirect a `/login` si no hay user; 403 en `/admin/*` si no es admin), redirige a `/` si un user logueado visita `/login`.
- `src/pages/login.astro`: pantalla completa aparte, tarjeta centrada con logo, `<h1>Sistema de Préstamos</h1>`, `<a>` Google (verde primario) que arma `authorize` URL de Supabase, alerta accesible si `?error=dominio|oauth`, nota de dominio institucional al pie. Animación entrada solo detrás de `motion-safe:`.
- `src/pages/api/auth/callback.ts`: intercambia `code` por sesión, verifica dominio por segunda vez y redirige a `/` (o a `/login?error=…` según el caso).
- `src/pages/api/auth/signout.ts`: POST → `signOut` → redirect `/login`.
- `src/layouts/Layout.astro`: HTML base (`lang="es"`, `meta viewport`, `meta theme-color=#00723F`, `color-scheme: light`), incluye skip-to-content link con `focus:not-sr-only`. `title` prop.
- `src/layouts/AppLayout.astro`: sidebar `bg-primary` con logo enlazado a `/`, nav responsive (sidebar en desktop, topbar en mobile), items admin vs alumno según `rol`, botón signout desktop full + icon-only en mobile con `aria-label`.
- `src/pages/index.astro`: dashboard con tarjetas según rol (alumno: catálogo + mis préstamos; admin: solicitudes + inventario + reportes). Placeholders navegables — sus destinos se implementarán en Fases 36.
- `.env` local creado (obtenidas ANON y SERVICE_ROLE keys por SSH de buglabs). `.env.example` versionado con placeholders. `.env*` ya estaba en `.gitignore`.
- Borrado `src/components/Welcome.astro` y `src/assets/*` del starter, `Layout.astro` reescrito para nuestros propósitos.
- Auditoría de UI con skill `web-design-guidelines` (checklist Vercel Labs): resueltos 6 hallazgos accionables — `transition:all` reemplazado por transiciones específicas por propiedad; `role="button"` sobrante en `<a>` de Google removido; `theme-color` y `color-scheme` metas añadidos; `touch-action: manipulation` en interactivos; logo mobile con `aria-label` sobre link y letra `aria-hidden`; `text-wrap: balance/pretty` en tipografía.
- Verificación: `npm run build` sin errores. `npm run dev` (background) → `GET /` sin sesión redirige `/login` (200), `/login` renderiza copy correcto, `?error=dominio` muestra la alerta esperada.
- Aprendizajes registrados: (a) Tailwind v4 requiere `@reference "tailwindcss"` dentro de `<style>` scoped de `.astro` para usar `@apply`; (b) `output: 'server'` es obligatorio en `astro.config.mjs` incluso con adaptador Node — no se infiere; (c) `AstroCookies.getAll()` no existe en Astro 7, usar `get(name)`.
- Estado: base lista. Fases 36 pueden arrancar en paralelo con agentes ahora que hay: middleware con sesión + `profile` en `Astro.locals`, cliente Supabase apuntando al schema `prestamos`, layout con nav responsive, tokens de diseño Tailwind y clases utilitarias `.btn/.card/.input`.
- **2026-08-14 — Fases 3, 4, 5, 6, 7 completadas en paralelo (5 agentes)**.
- Se lanzaron 5 subagentes simultáneos en un solo turno con contratos aislados por carpeta (sin colisión de archivos). Todos terminaron el core aunque tres (Fases 4, 5, 6) marcaron `failed` al final por límite de sesión durante refinamientos post-implementación; el orquestador consolidó y validó que todo compila.
- **Fase 3 (Alumno)** — 5 archivos: `src/pages/alumno/{catalogo,mis-prestamos}.astro`, `src/pages/api/prestamos/index.ts`, `src/components/alumno/{SolicitarModal,FiltroCategorias}.tsx`. Mobile-first. Catálogo agrupa por categoría en SSR, modal `<dialog>` nativo para solicitar, filtro por chips que sincroniza `?cat=` en URL. Mis-préstamos con secciones "Activos" (pendiente/aprobado/activo) y "Historial" en `<details>`. Endpoint POST valida stock antes de insertar.
- **Fase 4 (Admin solicitudes)** — 11 archivos: `src/pages/admin/index.astro` (panel con KPIs), `src/pages/admin/solicitudes/{index,activos,historial}.astro`, `src/components/admin/solicitudes/{AccionesSolicitud,MarcarDevuelto,VerDetalles}.tsx`, `src/pages/api/admin/prestamos/[id]/{aprobar,rechazar,devolver,detalles}.ts`. Desktop-first tabla / mobile cards. Flujo simplificado: `pendiente → aprobado` (que también significa activo) → `devuelto`. El estado `activo` del enum queda reservado. Los endpoints usan `.eq('estado', 'pendiente')` como guard anti doble-click; los triggers de DB manejan stock y audit_log.
- **Fase 5 (Admin inventario)** — 8 archivos: `src/pages/admin/inventario/{index,categorias}.astro`, `src/components/admin/inventario/{MaterialForm,EliminarMaterial,CategoriaForm,EliminarCategoria}.tsx`, `src/pages/api/admin/{materiales,categorias}/[index,\[id\]].ts`. Filtros SSR vía query params. Edición de `cantidad_total` valida que no quede debajo de lo prestado. Manejo de FK violations (23503) con mensaje útil.
- **Fase 6 (Reportes)** — 6 archivos: `src/pages/admin/reportes.astro` (tabs Historial/Vencidos con filtros por rango de fecha/estado/material/alumno), `src/components/admin/reportes/{Autocomplete,AutocompleteMaterial,AutocompleteAlumno}.tsx` (patrón combobox con teclado y aria-*, se extrajo helper compartido `Autocomplete.tsx`), `src/pages/api/admin/reportes/{materiales,alumnos,export}.ts`. Export CSV nativo (sin librería) con BOM UTF-8, escape de `,"` y `\n`, fechas ISO. Marca `// ponytail: LIMIT 500, paginar cuando pase de eso`.
- **Fase 7 (Deploy)** — 5 archivos: `Dockerfile` (multi-stage node:22-alpine, 3 stages, healthcheck vía `node -e http.get('/login')`, USER node), `.dockerignore`, `docker-compose.yml` (puerto `127.0.0.1:8082:4321`, log rotation 10MB×3), `.env.production.example`, `deploy/README.md` con pasos de primer deploy + update + rollback + snippet Cloudflare tunnel. Sin nginx delante, sin pm2. Tamaño de imagen esperado ~180-220 MB.
- **Correcciones post-agentes**: (a) botón "Cerrar sesión" del AppLayout usaba `transition` (all) — cambiado a `transition-colors`. (b) `AutocompleteMaterial` y `AutocompleteAlumno` originalmente tenían `<span class="label">` externo sin asociación al input; el agente 6 detectó la regresión y consolidó en `Autocomplete.tsx` con `<label htmlFor>` propio.
- **Verificación end-to-end en dev**: `npm run build` pasa limpio. Smoke test sin sesión sobre 14 rutas (páginas + API): `/login` → 200; todas las demás → 302 a `/login`. Middleware protege correctamente `/alumno/*`, `/admin/*` y `/api/*`.
- Total: **35 archivos nuevos** (25 páginas/endpoints + 10 componentes React + 5 archivos de deploy). Cero librerías nuevas más allá de lo que quedó en Fase 2. Todos los modales son `<dialog>` HTML nativo.
- **Bugs/hallazgos secundarios** detectados por los agentes (registrados para atender después, no bloqueantes): (i) el middleware ejecuta `getUser()` + fetch de perfil en cada request incluyendo `/api/*`, sin cache — bajo carga alta convendría memoizar por request. (ii) Si `Astro.locals.user` es null, el error de `.id` en el endpoint podría reventar; los endpoints actuales lo cubren con early return pero conviene revisar en la fase de QA.
- Estado: código completo. Falta solo Fase 8 (deploy real al servidor + QA end-to-end con login real). Pendiente confirmación del usuario para arrancar el deploy.
+23
View File
@@ -0,0 +1,23 @@
# syntax=docker/dockerfile:1
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production HOST=0.0.0.0 PORT=4321
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
EXPOSE 4321
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD node -e "require('http').get('http://127.0.0.1:4321/login',r=>process.exit(r.statusCode>=500?1:0)).on('error',()=>process.exit(1))"
CMD ["node", "./dist/server/entry.mjs"]
+16 -1
View File
@@ -1,5 +1,20 @@
// @ts-check // @ts-check
import { defineConfig } from 'astro/config'; import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import node from '@astrojs/node';
import tailwindcss from '@tailwindcss/vite';
// https://astro.build/config // https://astro.build/config
export default defineConfig({}); export default defineConfig({
output: 'server',
integrations: [react()],
adapter: node({
mode: 'standalone'
}),
vite: {
plugins: [tailwindcss()]
}
});
+82
View File
@@ -0,0 +1,82 @@
# Despliegue LabRe web (buglabs)
App Astro SSR (Node adapter) empaquetada como contenedor. Corre en el mismo host que Supabase self-hosted; Cloudflare Tunnel la publica en `prestamos.buglabs.dev`.
## Primer despliegue
```bash
# 1. Subir código al server (git clone o rsync) a ~/labre-web/
git clone <repo> ~/labre-web && cd ~/labre-web
# 2. Crear .env.production a partir del ejemplo y rellenar secretos
cp .env.production.example .env.production
$EDITOR .env.production # PUBLIC_SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY
# 3. Build + up (compose construye la imagen local)
docker compose up -d --build
# 4. Añadir hostname al Cloudflare Tunnel
# Editar /etc/cloudflared/config.yml (root) e insertar ANTES del catch-all
# "service: http_status:404":
#
# - hostname: prestamos.buglabs.dev
# service: http://localhost:8082
#
sudo systemctl restart cloudflared
# 5. En dashboard Cloudflare: crear CNAME
# prestamos → <TUNNEL_ID>.cfargotunnel.com (proxied)
# 6. Auth OAuth (Google):
# - En Supabase Studio > Auth > URL Config:
# Site URL = https://prestamos.buglabs.dev
# Redirect URLs += https://prestamos.buglabs.dev/api/auth/callback
# - En Google Cloud Console (mismo project del CLIENT_ID Supabase):
# confirmar https://supabase.buglabs.dev/auth/v1/callback ya existe
# (no hace falta añadir el dominio de la app, Google redirige a Supabase)
# 7. Verificar
docker compose ps
docker compose logs -f --tail=50 labre-web
curl -sI https://prestamos.buglabs.dev/login # esperado: 200
```
## Actualizaciones
```bash
cd ~/labre-web
git pull
docker compose up -d --build
docker image prune -f
```
`restart: unless-stopped` reinicia el contenedor si crashea o si el host reboota.
## Rollback
Compose no versiona imágenes por sí solo. Dos opciones:
**A) Volver al commit anterior (más simple):**
```bash
cd ~/labre-web
git log --oneline -n 10
git checkout <sha-bueno>
docker compose up -d --build
```
**B) Etiquetar imágenes manualmente antes de cada deploy:**
```bash
# Antes de subir cambios:
docker tag labre-web-labre-web:latest labre-web:$(date +%Y%m%d-%H%M)
docker images labre-web
# Para volver:
# editar docker-compose.yml -> image: labre-web:<tag> (quitar 'build: .')
docker compose up -d
```
## Troubleshooting
- **502 en el subdominio**: Tunnel no encuentra el puerto. `curl -I http://127.0.0.1:8082/login` en el host; revisar `cloudflared` logs (`journalctl -u cloudflared -f`).
- **Contenedor en `unhealthy`**: `docker compose logs labre-web`. Suele ser env var faltante (`SUPABASE_SERVICE_ROLE_KEY`) o Supabase caído.
- **Callback OAuth falla**: verificar Site URL en Supabase y que `PUBLIC_APP_URL` coincida exactamente (con https, sin barra final).
+19
View File
@@ -0,0 +1,19 @@
services:
labre-web:
build: .
container_name: labre-web
restart: unless-stopped
env_file: .env.production
ports:
- "127.0.0.1:8082:4321"
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:4321/login',r=>process.exit(r.statusCode>=500?1:0)).on('error',()=>process.exit(1))"]
interval: 30s
timeout: 5s
start_period: 15s
retries: 3
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
+1553 -5
View File
File diff suppressed because it is too large Load Diff
+12 -1
View File
@@ -12,6 +12,17 @@
"astro": "astro" "astro": "astro"
}, },
"dependencies": { "dependencies": {
"astro": "^7.2.2" "@astrojs/node": "^11.1.2",
"@astrojs/react": "^6.0.2",
"@fontsource-variable/inter": "^5.3.0",
"@supabase/ssr": "^0.12.4",
"@supabase/supabase-js": "^2.112.3",
"@tailwindcss/vite": "^4.3.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"astro": "^7.2.2",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"tailwindcss": "^4.3.3"
} }
} }
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" width="115" height="48"><path fill="#17191E" d="M7.77 36.35C6.4 35.11 6 32.51 6.57 30.62c.99 1.2 2.35 1.57 3.75 1.78 2.18.33 4.31.2 6.33-.78.23-.12.44-.27.7-.42.18.55.23 1.1.17 1.67a4.56 4.56 0 0 1-1.94 3.23c-.43.32-.9.61-1.34.91-1.38.94-1.76 2.03-1.24 3.62l.05.17a3.63 3.63 0 0 1-1.6-1.38 3.87 3.87 0 0 1-.63-2.1c0-.37 0-.74-.05-1.1-.13-.9-.55-1.3-1.33-1.32a1.56 1.56 0 0 0-1.63 1.26c0 .06-.03.12-.05.2Z"/><path fill="url(#a)" d="M7.77 36.35C6.4 35.11 6 32.51 6.57 30.62c.99 1.2 2.35 1.57 3.75 1.78 2.18.33 4.31.2 6.33-.78.23-.12.44-.27.7-.42.18.55.23 1.1.17 1.67a4.56 4.56 0 0 1-1.94 3.23c-.43.32-.9.61-1.34.91-1.38.94-1.76 2.03-1.24 3.62l.05.17a3.63 3.63 0 0 1-1.6-1.38 3.87 3.87 0 0 1-.63-2.1c0-.37 0-.74-.05-1.1-.13-.9-.55-1.3-1.33-1.32a1.56 1.56 0 0 0-1.63 1.26c0 .06-.03.12-.05.2Z"/><path fill="#17191E" d="M.02 30.31s4.02-1.95 8.05-1.95l3.04-9.4c.11-.45.44-.76.82-.76.37 0 .7.31.82.76l3.04 9.4c4.77 0 8.05 1.95 8.05 1.95L17 11.71c-.2-.56-.53-.91-.98-.91H7.83c-.44 0-.76.35-.97.9L.02 30.31Zm42.37-5.97c0 1.64-2.05 2.62-4.88 2.62-1.85 0-2.5-.45-2.5-1.41 0-1 .8-1.49 2.65-1.49 1.67 0 3.09.03 4.73.23v.05Zm.03-2.04a21.37 21.37 0 0 0-4.37-.36c-5.32 0-7.82 1.25-7.82 4.18 0 3.04 1.71 4.2 5.68 4.2 3.35 0 5.63-.84 6.46-2.92h.14c-.03.5-.05 1-.05 1.4 0 1.07.18 1.16 1.06 1.16h4.15a16.9 16.9 0 0 1-.36-4c0-1.67.06-2.93.06-4.62 0-3.45-2.07-5.64-8.56-5.64-2.8 0-5.9.48-8.26 1.19.22.93.54 2.83.7 4.06 2.04-.96 4.95-1.37 7.2-1.37 3.11 0 3.97.71 3.97 2.15v.57Zm11.37 3c-.56.07-1.33.07-2.12.07-.83 0-1.6-.03-2.12-.1l-.02.58c0 2.85 1.87 4.52 8.45 4.52 6.2 0 8.2-1.64 8.2-4.55 0-2.74-1.33-4.09-7.2-4.39-4.58-.2-4.99-.7-4.99-1.28 0-.66.59-1 3.65-1 3.18 0 4.03.43 4.03 1.35v.2a46.13 46.13 0 0 1 4.24.03l.02-.55c0-3.36-2.8-4.46-8.2-4.46-6.08 0-8.13 1.49-8.13 4.39 0 2.6 1.64 4.23 7.48 4.48 4.3.14 4.77.62 4.77 1.28 0 .7-.7 1.03-3.71 1.03-3.47 0-4.35-.48-4.35-1.47v-.13Zm19.82-12.05a17.5 17.5 0 0 1-6.24 3.48c.03.84.03 2.4.03 3.24l1.5.02c-.02 1.63-.04 3.6-.04 4.9 0 3.04 1.6 5.32 6.58 5.32 2.1 0 3.5-.23 5.23-.6a43.77 43.77 0 0 1-.46-4.13c-1.03.34-2.34.53-3.78.53-2 0-2.82-.55-2.82-2.13 0-1.37 0-2.65.03-3.84 2.57.02 5.13.07 6.64.11-.02-1.18.03-2.9.1-4.04-2.2.04-4.65.07-6.68.07l.07-2.93h-.16Zm13.46 6.04a767.33 767.33 0 0 1 .07-3.18H82.6c.07 1.96.07 3.98.07 6.92 0 2.95-.03 4.99-.07 6.93h5.18c-.09-1.37-.11-3.68-.11-5.65 0-3.1 1.26-4 4.12-4 1.33 0 2.28.16 3.1.46.03-1.16.26-3.43.4-4.43-.86-.25-1.81-.41-2.96-.41-2.46-.03-4.26.98-5.1 3.38l-.17-.02Zm22.55 3.65c0 2.5-1.8 3.66-4.64 3.66-2.81 0-4.61-1.1-4.61-3.66s1.82-3.52 4.61-3.52c2.82 0 4.64 1.03 4.64 3.52Zm4.71-.11c0-4.96-3.87-7.18-9.35-7.18-5.5 0-9.23 2.22-9.23 7.18 0 4.94 3.49 7.59 9.21 7.59 5.77 0 9.37-2.65 9.37-7.6Z"/><defs><linearGradient id="a" x1="6.33" x2="19.43" y1="40.8" y2="34.6" gradientUnits="userSpaceOnUse"><stop stop-color="#D83333"/><stop offset="1" stop-color="#F041FF"/></linearGradient></defs></svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="1024" fill="none"><path fill="url(#a)" fill-rule="evenodd" d="M-217.58 475.75c91.82-72.02 225.52-29.38 341.2-44.74C240 415.56 372.33 315.14 466.77 384.9c102.9 76.02 44.74 246.76 90.31 366.31 29.83 78.24 90.48 136.14 129.48 210.23 57.92 109.99 169.67 208.23 155.9 331.77-13.52 121.26-103.42 264.33-224.23 281.37-141.96 20.03-232.72-220.96-374.06-196.99-151.7 25.73-172.68 330.24-325.85 315.72-128.6-12.2-110.9-230.73-128.15-358.76-12.16-90.14 65.87-176.25 44.1-264.57-26.42-107.2-167.12-163.46-176.72-273.45-10.15-116.29 33.01-248.75 124.87-320.79Z" clip-rule="evenodd" style="opacity:.154"/><path fill="url(#b)" fill-rule="evenodd" d="M1103.43 115.43c146.42-19.45 275.33-155.84 413.5-103.59 188.09 71.13 409 212.64 407.06 413.88-1.94 201.25-259.28 278.6-414.96 405.96-130 106.35-240.24 294.39-405.6 265.3-163.7-28.8-161.93-274.12-284.34-386.66-134.95-124.06-436-101.46-445.82-284.6-9.68-180.38 247.41-246.3 413.54-316.9 101.01-42.93 207.83 21.06 316.62 6.61Z" clip-rule="evenodd" style="opacity:.154"/><defs><linearGradient id="b" x1="373" x2="1995.44" y1="1100" y2="118.03" gradientUnits="userSpaceOnUse"><stop stop-color="#D83333"/><stop offset="1" stop-color="#F041FF"/></linearGradient><linearGradient id="a" x1="107.37" x2="1130.66" y1="1993.35" y2="1026.31" gradientUnits="userSpaceOnUse"><stop stop-color="#3245FF"/><stop offset="1" stop-color="#BC52EE"/></linearGradient></defs></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

-210
View File
@@ -1,210 +0,0 @@
---
import astroLogo from '../assets/astro.svg';
import background from '../assets/background.svg';
---
<div id="container">
<img id="background" src={background.src} alt="" fetchpriority="high" />
<main>
<section id="hero">
<a href="https://astro.build"
><img src={astroLogo.src} width="115" height="48" alt="Astro Homepage" /></a
>
<h1>
To get started, open the <code><pre>src/pages</pre></code> directory in your project.
</h1>
<section id="links">
<a class="button" href="https://docs.astro.build">Read our docs</a>
<a href="https://astro.build/chat"
>Join our Discord <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 127.14 96.36"
><path
fill="currentColor"
d="M107.7 8.07A105.15 105.15 0 0 0 81.47 0a72.06 72.06 0 0 0-3.36 6.83 97.68 97.68 0 0 0-29.11 0A72.37 72.37 0 0 0 45.64 0a105.89 105.89 0 0 0-26.25 8.09C2.79 32.65-1.71 56.6.54 80.21a105.73 105.73 0 0 0 32.17 16.15 77.7 77.7 0 0 0 6.89-11.11 68.42 68.42 0 0 1-10.85-5.18c.91-.66 1.8-1.34 2.66-2a75.57 75.57 0 0 0 64.32 0c.87.71 1.76 1.39 2.66 2a68.68 68.68 0 0 1-10.87 5.19 77 77 0 0 0 6.89 11.1 105.25 105.25 0 0 0 32.19-16.14c2.64-27.38-4.51-51.11-18.9-72.15ZM42.45 65.69C36.18 65.69 31 60 31 53s5-12.74 11.43-12.74S54 46 53.89 53s-5.05 12.69-11.44 12.69Zm42.24 0C78.41 65.69 73.25 60 73.25 53s5-12.74 11.44-12.74S96.23 46 96.12 53s-5.04 12.69-11.43 12.69Z"
></path></svg
>
</a>
</section>
</section>
</main>
<a href="https://astro.build/blog/astro-7/" id="news" class="box">
<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"
><path
d="M24.667 12c1.333 1.414 2 3.192 2 5.334 0 4.62-4.934 5.7-7.334 12C18.444 28.567 18 27.456 18 26c0-4.642 6.667-7.053 6.667-14Zm-5.334-5.333c1.6 1.65 2.4 3.43 2.4 5.333 0 6.602-8.06 7.59-6.4 17.334C13.111 27.787 12 25.564 12 22.666c0-4.434 7.333-8 7.333-16Zm-6-5.333C15.111 3.555 16 5.556 16 7.333c0 8.333-11.333 10.962-5.333 22-3.488-.774-6-4-6-8 0-8.667 8.666-10 8.666-20Z"
fill="#111827"></path></svg
>
<h2>What's New in Astro 7.0?</h2>
<p>
Rust-powered compiler, advanced routing, AI agent support, and more! Click to explore Astro
7.0's new features.
</p>
</a>
</div>
<style>
#background {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
filter: blur(100px);
}
#container {
font-family: Inter, Roboto, 'Helvetica Neue', 'Arial Nova', 'Nimbus Sans', Arial, sans-serif;
height: 100%;
}
main {
height: 100%;
display: flex;
justify-content: center;
}
#hero {
display: flex;
align-items: start;
flex-direction: column;
justify-content: center;
padding: 16px;
}
h1 {
font-size: 22px;
margin-top: 0.25em;
}
#links {
display: flex;
gap: 16px;
}
#links a {
display: flex;
align-items: center;
padding: 10px 12px;
color: #111827;
text-decoration: none;
transition: color 0.2s;
}
#links a:hover {
color: rgb(78, 80, 86);
}
#links a svg {
height: 1em;
margin-left: 8px;
}
#links a.button {
color: white;
background: linear-gradient(83.21deg, #3245ff 0%, #bc52ee 100%);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.12),
inset 0 -2px 0 rgba(0, 0, 0, 0.24);
border-radius: 10px;
}
#links a.button:hover {
color: rgb(230, 230, 230);
box-shadow: none;
}
pre {
font-family:
ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono',
monospace;
font-weight: normal;
background: linear-gradient(14deg, #d83333 0%, #f041ff 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin: 0;
}
h2 {
margin: 0 0 1em;
font-weight: normal;
color: #111827;
font-size: 20px;
}
p {
color: #4b5563;
font-size: 16px;
line-height: 24px;
letter-spacing: -0.006em;
margin: 0;
}
code {
display: inline-block;
background:
linear-gradient(66.77deg, #f3cddd 0%, #f5cee7 100%) padding-box,
linear-gradient(155deg, #d83333 0%, #f041ff 18%, #f5cee7 45%) border-box;
border-radius: 8px;
border: 1px solid transparent;
padding: 6px 8px;
}
.box {
padding: 16px;
background: rgba(255, 255, 255, 1);
border-radius: 16px;
border: 1px solid white;
}
#news {
position: absolute;
bottom: 16px;
right: 16px;
max-width: 300px;
text-decoration: none;
transition: background 0.2s;
backdrop-filter: blur(50px);
}
#news:hover {
background: rgba(255, 255, 255, 0.55);
}
@media screen and (max-height: 368px) {
#news {
display: none;
}
}
@media screen and (max-width: 768px) {
#container {
display: flex;
flex-direction: column;
}
#hero {
display: block;
padding-top: 10%;
}
#links {
flex-wrap: wrap;
}
#links a.button {
padding: 14px 18px;
}
#news {
right: 16px;
left: 16px;
bottom: 2.5rem;
max-width: 100%;
}
h1 {
line-height: 1.5;
}
}
</style>
@@ -0,0 +1,144 @@
import { useEffect, useRef, useState } from 'react';
type Props = {
mode: 'create' | 'edit';
categoria?: { id: number; nombre: string };
};
export default function CategoriaForm({ mode, categoria }: Props) {
const dialogRef = useRef<HTMLDialogElement | null>(null);
const firstFieldRef = useRef<HTMLInputElement | null>(null);
const errorRef = useRef<HTMLParagraphElement | null>(null);
const [nombre, setNombre] = useState(categoria?.nombre ?? '');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const titleId = `cat-form-${mode}-${categoria?.id ?? 'new'}`;
const open = () => {
setError(null);
if (mode === 'create') setNombre('');
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/categorias' : `/api/admin/categorias/${categoria!.id}`;
const method = mode === 'create' ? 'POST' : 'PATCH';
try {
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nombre: trimmed }),
});
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' ? 'Nueva categoría' : '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)] 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' ? 'Nueva categoría' : `Editar: ${categoria?.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>
{error && (
<p
ref={errorRef}
id={`${titleId}-err`}
role="alert"
tabIndex={-1}
className="text-sm"
style={{ color: 'var(--color-danger)' }}
>
{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>
</>
);
}
@@ -0,0 +1,130 @@
import { useEffect, useRef, useState } from 'react';
type Props = {
id: number;
nombre: string;
materialesCount: number;
};
export default function EliminarCategoria({ id, nombre, materialesCount }: Props) {
const dialogRef = useRef<HTMLDialogElement | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const titleId = `del-cat-${id}`;
const bloqueado = materialesCount > 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/categorias/${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 transition-colors duration-150"
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
onClick={open}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--color-danger)';
e.currentTarget.style.color = 'white';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = 'var(--color-ink)';
}}
>
Eliminar
</button>
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] 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 categoría
</h2>
{bloqueado ? (
<>
<p className="text-sm">
No se puede eliminar <strong>{nombre}</strong>: tiene{' '}
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{materialesCount}</span>{' '}
material{materialesCount === 1 ? '' : 'es'} asociado
{materialesCount === 1 ? '' : 's'}. Reasígnalos primero.
</p>
<a
href={`/admin/inventario?cat=${id}`}
className="text-sm underline"
style={{ color: 'var(--color-primary)' }}
>
Ver materiales de esta categoría
</a>
</>
) : (
<p className="text-sm">
¿Seguro que quieres eliminar <strong>{nombre}</strong>?
</p>
)}
{error && (
<p role="alert" className="text-sm" style={{ color: 'var(--color-danger)' }}>
{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: 'white' }}
onClick={submit}
disabled={loading}
>
{loading ? 'Eliminando…' : 'Eliminar'}
</button>
)}
</div>
</div>
</dialog>
</>
);
}
@@ -0,0 +1,105 @@
import { useEffect, useRef, useState } from 'react';
type Props = {
id: number;
nombre: string;
tienePrestamos?: boolean;
};
export default function EliminarMaterial({ id, nombre, tienePrestamos }: Props) {
const dialogRef = useRef<HTMLDialogElement | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const titleId = `del-mat-${id}`;
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/materiales/${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 transition-colors duration-150 hover:!text-white"
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
onClick={open}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-danger)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
Eliminar
</button>
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] 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">
Eliminar material
</h2>
<p className="text-sm">
¿Seguro que quieres eliminar <strong>{nombre}</strong>? Esta acción no se puede deshacer.
</p>
{tienePrestamos && (
<p className="text-sm" style={{ color: 'var(--color-danger)' }}>
Este material tiene préstamos asociados y no podrá eliminarse.
</p>
)}
{error && (
<p role="alert" className="text-sm" style={{ color: 'var(--color-danger)' }}>
{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="button"
className="btn"
style={{ background: 'var(--color-danger)', color: 'white' }}
onClick={submit}
disabled={loading}
>
{loading ? 'Eliminando…' : 'Eliminar'}
</button>
</div>
</div>
</dialog>
</>
);
}
@@ -0,0 +1,288 @@
import { useEffect, useRef, useState } from 'react';
type Categoria = { id: number; nombre: string };
type Material = {
id: number;
nombre: string;
categoria_id: number | null;
descripcion: string | null;
cantidad_total: number;
cantidad_disponible: number;
numero_inventario: string | null;
estado: 'disponible' | 'mantenimiento' | 'baja';
};
type Props = {
mode: 'create' | 'edit';
material?: Material;
categorias: Categoria[];
};
export default function MaterialForm({ mode, material, categorias }: Props) {
const dialogRef = useRef<HTMLDialogElement | null>(null);
const firstFieldRef = useRef<HTMLInputElement | null>(null);
const errorRef = useRef<HTMLParagraphElement | null>(null);
const [nombre, setNombre] = useState(material?.nombre ?? '');
const [categoriaId, setCategoriaId] = useState<string>(
material?.categoria_id != null ? String(material.categoria_id) : '',
);
const [descripcion, setDescripcion] = useState(material?.descripcion ?? '');
const [cantidadTotal, setCantidadTotal] = useState<string>(
material ? String(material.cantidad_total) : '0',
);
const [numeroInventario, setNumeroInventario] = useState(material?.numero_inventario ?? '');
const [estado, setEstado] = useState<Material['estado']>(material?.estado ?? 'disponible');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const titleId = `mat-form-${mode}-${material?.id ?? 'new'}`;
const open = () => {
setError(null);
if (mode === 'create') {
setNombre('');
setCategoriaId('');
setDescripcion('');
setCantidadTotal('0');
setNumeroInventario('');
setEstado('disponible');
}
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;
setLoading(true);
setError(null);
const payload = {
nombre: nombre.trim(),
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');
setLoading(false);
return;
}
if (!Number.isInteger(payload.cantidad_total) || payload.cantidad_total < 0) {
setError('La cantidad total debe ser un entero mayor o igual a 0');
setLoading(false);
return;
}
const url =
mode === 'create' ? '/api/admin/materiales' : `/api/admin/materiales/${material!.id}`;
const method = mode === 'create' ? 'POST' : 'PATCH';
try {
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
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 material' : '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)] backdrop:bg-black/40 w-[min(92vw,32rem)]"
>
<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 material' : `Editar: ${material?.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>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="label" htmlFor={`${titleId}-cat`}>
Categoría
</label>
<select
id={`${titleId}-cat`}
className="input"
value={categoriaId}
onChange={(e) => setCategoriaId(e.target.value)}
>
<option value="">Sin categoría</option>
{categorias.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.nombre}
</option>
))}
</select>
</div>
<div>
<label className="label" htmlFor={`${titleId}-inv`}>
de inventario
</label>
<input
id={`${titleId}-inv`}
className="input"
type="text"
value={numeroInventario}
onChange={(e) => setNumeroInventario(e.target.value)}
autoComplete="off"
placeholder="Opcional…"
/>
</div>
</div>
<div>
<label className="label" htmlFor={`${titleId}-desc`}>
Descripción
</label>
<textarea
id={`${titleId}-desc`}
className="input"
rows={3}
maxLength={500}
value={descripcion}
onChange={(e) => setDescripcion(e.target.value)}
placeholder="Detalles, especificaciones, notas…"
style={{ minHeight: '80px' }}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="label" htmlFor={`${titleId}-total`}>
Cantidad total
</label>
<input
id={`${titleId}-total`}
className="input"
type="number"
min={0}
step={1}
inputMode="numeric"
value={cantidadTotal}
onChange={(e) => setCantidadTotal(e.target.value)}
required
style={{ fontVariantNumeric: 'tabular-nums' }}
/>
{mode === 'edit' && material && (
<p className="text-xs opacity-60 mt-1">
Prestados: {material.cantidad_total - material.cantidad_disponible}
</p>
)}
</div>
<div>
<label className="label" htmlFor={`${titleId}-estado`}>
Estado
</label>
<select
id={`${titleId}-estado`}
className="input"
value={estado}
onChange={(e) => setEstado(e.target.value as Material['estado'])}
>
<option value="disponible">Disponible</option>
<option value="mantenimiento">Mantenimiento</option>
<option value="baja">Baja</option>
</select>
</div>
</div>
{error && (
<p
ref={errorRef}
id={`${titleId}-err`}
role="alert"
tabIndex={-1}
className="text-sm"
style={{ color: 'var(--color-danger)' }}
>
{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>
</>
);
}
@@ -0,0 +1,167 @@
import { useEffect, useId, useRef, useState } from 'react';
export type AutocompleteItem = { id: number | string; label: string };
type Props = {
name: string;
endpoint: string;
label: string;
placeholder?: string;
initialValue?: number | string | null;
initialLabel?: string | null;
};
// ponytail: combobox + fetch de 10 resultados; sustituir por biblioteca si hace falta agrupamiento, keyboard grouping, virtualización, etc.
export function Autocomplete({ name, endpoint, label, placeholder, initialValue, initialLabel }: Props) {
const listId = useId();
const inputId = useId();
const [text, setText] = useState<string>(initialLabel ?? '');
const [value, setValue] = useState<string>(initialValue != null ? String(initialValue) : '');
const [items, setItems] = useState<AutocompleteItem[]>([]);
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1);
const [loading, setLoading] = useState(false);
const rootRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const abortRef = useRef<AbortController | null>(null);
// debounced fetch
useEffect(() => {
if (!text.trim() || text === initialLabel) {
setItems([]);
return;
}
const t = setTimeout(async () => {
abortRef.current?.abort();
const ac = new AbortController();
abortRef.current = ac;
setLoading(true);
try {
const res = await fetch(`${endpoint}?q=${encodeURIComponent(text.trim())}`, { signal: ac.signal });
if (!res.ok) throw new Error(String(res.status));
const data: AutocompleteItem[] = await res.json();
setItems(data);
setOpen(true);
setActive(data.length ? 0 : -1);
} catch (e) {
if ((e as any)?.name !== 'AbortError') {
setItems([]);
setOpen(false);
}
} finally {
setLoading(false);
}
}, 250);
return () => clearTimeout(t);
}, [text, endpoint, initialLabel]);
// click-outside → cerrar
useEffect(() => {
function onDoc(e: MouseEvent) {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
}
document.addEventListener('mousedown', onDoc);
return () => document.removeEventListener('mousedown', onDoc);
}, []);
function pick(it: AutocompleteItem) {
setValue(String(it.id));
setText(it.label);
setOpen(false);
setActive(-1);
}
function clear() {
setValue('');
setText('');
setItems([]);
setOpen(false);
setActive(-1);
inputRef.current?.focus();
}
function onKey(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'ArrowDown') {
e.preventDefault();
if (!open && items.length) setOpen(true);
setActive((a) => (items.length ? (a + 1) % items.length : -1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActive((a) => (items.length ? (a <= 0 ? items.length - 1 : a - 1) : -1));
} else if (e.key === 'Enter') {
if (open && active >= 0 && items[active]) {
e.preventDefault();
pick(items[active]);
}
} else if (e.key === 'Escape') {
setOpen(false);
}
}
return (
<div ref={rootRef} className="relative">
<label htmlFor={inputId} className="label">{label}</label>
<input type="hidden" name={name} value={value} />
<div className="flex gap-2">
<input
ref={inputRef}
id={inputId}
type="text"
className="input"
role="combobox"
aria-autocomplete="list"
aria-expanded={open}
aria-controls={listId}
aria-activedescendant={active >= 0 ? `${listId}-opt-${active}` : undefined}
placeholder={placeholder}
value={text}
onChange={(e) => {
setText(e.target.value);
if (value) setValue(''); // el usuario editó → invalidar la selección previa
}}
onFocus={() => items.length && setOpen(true)}
onKeyDown={onKey}
autoComplete="off"
spellCheck={false}
/>
{(text || value) && (
<button type="button" className="btn btn-ghost px-3" onClick={clear} aria-label="Limpiar">
<span aria-hidden="true">×</span>
</button>
)}
</div>
{open && (
<ul
id={listId}
role="listbox"
className="absolute z-10 mt-1 w-full bg-white rounded-lg shadow-lg max-h-64 overflow-auto"
style={{ border: '1px solid color-mix(in oklab, var(--color-ink) 15%, transparent)' }}
>
{loading && items.length === 0 && (
<li className="px-3 py-2 text-sm opacity-70">Buscando</li>
)}
{!loading && items.length === 0 && (
<li className="px-3 py-2 text-sm opacity-70">Sin resultados</li>
)}
{items.map((it, i) => (
<li
key={it.id}
id={`${listId}-opt-${i}`}
role="option"
aria-selected={i === active}
className="px-3 py-2 text-sm cursor-pointer transition-colors"
style={i === active ? { background: 'color-mix(in oklab, var(--color-primary) 10%, transparent)' } : undefined}
onMouseEnter={() => setActive(i)}
onMouseDown={(e) => {
e.preventDefault();
pick(it);
}}
>
{it.label}
</li>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,20 @@
import { Autocomplete } from './Autocomplete';
type Props = {
name?: string;
initialValue?: string | null;
initialLabel?: string | null;
};
export default function AutocompleteAlumno({ name = 'alumno_id', initialValue, initialLabel }: Props) {
return (
<Autocomplete
name={name}
endpoint="/api/admin/reportes/alumnos"
label="Alumno"
placeholder="Buscar alumno (nombre, matrícula, email)…"
initialValue={initialValue ?? null}
initialLabel={initialLabel ?? null}
/>
);
}
@@ -0,0 +1,20 @@
import { Autocomplete } from './Autocomplete';
type Props = {
name?: string;
initialValue?: number | null;
initialLabel?: string | null;
};
export default function AutocompleteMaterial({ name = 'material_id', initialValue, initialLabel }: Props) {
return (
<Autocomplete
name={name}
endpoint="/api/admin/reportes/materiales"
label="Material"
placeholder="Buscar material…"
initialValue={initialValue ?? null}
initialLabel={initialLabel ?? null}
/>
);
}
@@ -0,0 +1,209 @@
import { useEffect, useRef, useState } from 'react';
type Prestamo = {
id: number;
cantidad: number;
material: { nombre: string; cantidad_disponible: number } | null;
alumno: { nombre: string | null; email: string } | null;
};
const hoy = () => new Date().toISOString().split('T')[0];
const enDias = (d: number) => new Date(Date.now() + d * 86400000).toISOString().split('T')[0];
export default function AccionesSolicitud({ prestamo }: { prestamo: Prestamo }) {
return (
<div className="flex gap-2 flex-wrap">
<AprobarDialog prestamo={prestamo} />
<RechazarDialog prestamo={prestamo} />
</div>
);
}
function useDialog() {
const ref = useRef<HTMLDialogElement>(null);
const open = () => ref.current?.showModal();
const close = () => ref.current?.close();
// Cerrar al hacer click en backdrop
useEffect(() => {
const d = ref.current;
if (!d) return;
const onClick = (e: MouseEvent) => {
if (e.target === d) d.close();
};
d.addEventListener('click', onClick);
return () => d.removeEventListener('click', onClick);
}, []);
return { ref, open, close };
}
function AprobarDialog({ prestamo }: { prestamo: Prestamo }) {
const dlg = useDialog();
const [fecha, setFecha] = useState(enDias(7));
const [notas, setNotas] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const stockInsuficiente = (prestamo.material?.cantidad_disponible ?? 0) < prestamo.cantidad;
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!fecha || fecha < hoy()) {
setError('La fecha de devolución debe ser hoy o posterior.');
return;
}
setLoading(true);
try {
const res = await fetch(`/api/admin/prestamos/${prestamo.id}/aprobar`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ fecha_devolucion_estimada: fecha, notas: notas.trim() || undefined }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.error ?? `Error ${res.status}`);
}
location.reload();
} catch (err: any) {
setError(err.message ?? 'Error al aprobar.');
setLoading(false);
}
};
return (
<>
<button type="button" className="btn btn-primary" onClick={dlg.open}>Aprobar</button>
<dialog ref={dlg.ref} className="rounded-lg p-0 max-w-md w-[calc(100%-2rem)] backdrop:bg-black/40">
<form onSubmit={submit} className="p-6">
<h2 className="text-lg font-semibold">Aprobar solicitud</h2>
<p className="text-sm opacity-70 mt-1">
{prestamo.alumno?.nombre ?? prestamo.alumno?.email} solicita{' '}
<strong>{prestamo.cantidad}</strong> de <strong>{prestamo.material?.nombre ?? '—'}</strong>.
</p>
{stockInsuficiente && (
<div role="alert" className="mt-3 rounded-lg p-2 text-sm" style={{ background: 'color-mix(in oklab, var(--color-danger) 12%, transparent)', color: 'var(--color-danger)' }}>
Stock disponible ({prestamo.material?.cantidad_disponible ?? 0}) menor a lo solicitado.
</div>
)}
<div className="mt-4">
<label className="label" htmlFor={`fecha-${prestamo.id}`}>Devolver antes de</label>
<input
id={`fecha-${prestamo.id}`}
type="date"
className="input"
value={fecha}
min={hoy()}
onChange={(e) => setFecha(e.target.value)}
required
autoFocus
/>
</div>
<div className="mt-3">
<label className="label" htmlFor={`notas-${prestamo.id}`}>Notas (opcional)</label>
<textarea
id={`notas-${prestamo.id}`}
className="input"
rows={3}
value={notas}
onChange={(e) => setNotas(e.target.value)}
placeholder="Observaciones para el alumno o registro interno…"
/>
</div>
{error && (
<div role="alert" className="mt-3 rounded-lg p-2 text-sm" style={{ background: 'color-mix(in oklab, var(--color-danger) 12%, transparent)', color: 'var(--color-danger)' }}>
{error}
</div>
)}
<div className="mt-6 flex justify-end gap-2">
<button type="button" className="btn btn-ghost" onClick={dlg.close} disabled={loading}>Cancelar</button>
<button type="submit" className="btn btn-primary" disabled={loading}>{loading ? 'Aprobando…' : 'Aprobar solicitud'}</button>
</div>
</form>
</dialog>
</>
);
}
function RechazarDialog({ prestamo }: { prestamo: Prestamo }) {
const dlg = useDialog();
const [motivo, setMotivo] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (motivo.trim().length < 5) {
setError('El motivo debe tener al menos 5 caracteres.');
return;
}
setLoading(true);
try {
const res = await fetch(`/api/admin/prestamos/${prestamo.id}/rechazar`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ motivo: motivo.trim() }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.error ?? `Error ${res.status}`);
}
location.reload();
} catch (err: any) {
setError(err.message ?? 'Error al rechazar.');
setLoading(false);
}
};
return (
<>
<button
type="button"
className="btn btn-ghost"
onClick={dlg.open}
style={{ ['--h' as any]: 'var(--color-danger)' }}
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-danger)')}
onMouseLeave={(e) => (e.currentTarget.style.color = '')}
>
Rechazar
</button>
<dialog ref={dlg.ref} className="rounded-lg p-0 max-w-md w-[calc(100%-2rem)] backdrop:bg-black/40">
<form onSubmit={submit} className="p-6">
<h2 className="text-lg font-semibold">Rechazar solicitud</h2>
<p className="text-sm opacity-70 mt-1">
Explica al alumno por qué se rechaza. Este texto queda registrado.
</p>
<div className="mt-4">
<label className="label" htmlFor={`motivo-${prestamo.id}`}>Motivo</label>
<textarea
id={`motivo-${prestamo.id}`}
className="input"
rows={4}
value={motivo}
onChange={(e) => setMotivo(e.target.value)}
required
minLength={5}
autoFocus
placeholder="Ej. Material no disponible por mantenimiento."
/>
</div>
{error && (
<div role="alert" className="mt-3 rounded-lg p-2 text-sm" style={{ background: 'color-mix(in oklab, var(--color-danger) 12%, transparent)', color: 'var(--color-danger)' }}>
{error}
</div>
)}
<div className="mt-6 flex justify-end gap-2">
<button type="button" className="btn btn-ghost" onClick={dlg.close} disabled={loading}>Cancelar</button>
<button
type="submit"
className="btn"
disabled={loading}
style={{ background: 'var(--color-danger)', color: 'white' }}
>
{loading ? 'Rechazando…' : 'Rechazar solicitud'}
</button>
</div>
</form>
</dialog>
</>
);
}
@@ -0,0 +1,59 @@
import { useEffect, useRef, useState } from 'react';
export default function MarcarDevuelto({ prestamoId, nombreMaterial }: { prestamoId: number; nombreMaterial: string }) {
const ref = useRef<HTMLDialogElement>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const d = ref.current;
if (!d) return;
const onClick = (e: MouseEvent) => { if (e.target === d) d.close(); };
d.addEventListener('click', onClick);
return () => d.removeEventListener('click', onClick);
}, []);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
const res = await fetch(`/api/admin/prestamos/${prestamoId}/devolver`, { method: 'POST' });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.error ?? `Error ${res.status}`);
}
location.reload();
} catch (err: any) {
setError(err.message ?? 'Error al registrar la devolución.');
setLoading(false);
}
};
return (
<>
<button type="button" className="btn btn-secondary" onClick={() => ref.current?.showModal()}>
Marcar devuelto
</button>
<dialog ref={ref} className="rounded-lg p-0 max-w-md w-[calc(100%-2rem)] backdrop:bg-black/40">
<form onSubmit={submit} className="p-6">
<h2 className="text-lg font-semibold">Confirmar devolución</h2>
<p className="text-sm opacity-80 mt-2">
¿Confirmas la devolución de <strong>{nombreMaterial}</strong>? El material regresará al inventario disponible.
</p>
{error && (
<div role="alert" className="mt-3 rounded-lg p-2 text-sm" style={{ background: 'color-mix(in oklab, var(--color-danger) 12%, transparent)', color: 'var(--color-danger)' }}>
{error}
</div>
)}
<div className="mt-6 flex justify-end gap-2">
<button type="button" className="btn btn-ghost" onClick={() => ref.current?.close()} disabled={loading}>Cancelar</button>
<button type="submit" className="btn btn-secondary" disabled={loading}>
{loading ? 'Registrando…' : 'Confirmar devolución'}
</button>
</div>
</form>
</dialog>
</>
);
}
@@ -0,0 +1,109 @@
import { useEffect, useRef, useState } from 'react';
type Detalles = {
prestamo: {
id: number;
cantidad: number;
estado: string;
fecha_solicitud: string;
fecha_aprobacion: string | null;
fecha_devolucion_estimada: string | null;
fecha_devolucion_real: string | null;
notas: string | null;
alumno: { nombre: string | null; email: string; matricula: string | null } | null;
material: { nombre: string; numero_inventario: string | null } | null;
};
audit_log: Array<Record<string, any>>;
};
const fmt = new Intl.DateTimeFormat('es-MX', { dateStyle: 'medium', timeStyle: 'short' });
export default function VerDetalles({ prestamoId }: { prestamoId: number }) {
const ref = useRef<HTMLDialogElement>(null);
const [data, setData] = useState<Detalles | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const d = ref.current;
if (!d) return;
const onClick = (e: MouseEvent) => { if (e.target === d) d.close(); };
d.addEventListener('click', onClick);
return () => d.removeEventListener('click', onClick);
}, []);
const open = async () => {
ref.current?.showModal();
if (data) return;
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/admin/prestamos/${prestamoId}/detalles`);
if (!res.ok) throw new Error(`Error ${res.status}`);
setData(await res.json());
} catch (err: any) {
setError(err.message ?? 'No se pudo cargar el detalle.');
} finally {
setLoading(false);
}
};
return (
<>
<button type="button" className="btn btn-ghost" onClick={open}>Detalles</button>
<dialog ref={ref} className="rounded-lg p-0 max-w-lg w-[calc(100%-2rem)] backdrop:bg-black/40">
<div className="p-6">
<div className="flex items-start justify-between gap-4">
<h2 className="text-lg font-semibold">Detalle del préstamo</h2>
<button type="button" className="btn btn-ghost" onClick={() => ref.current?.close()} aria-label="Cerrar">×</button>
</div>
{loading && <p className="mt-4 text-sm opacity-70">Cargando</p>}
{error && (
<div role="alert" className="mt-3 rounded-lg p-2 text-sm" style={{ background: 'color-mix(in oklab, var(--color-danger) 12%, transparent)', color: 'var(--color-danger)' }}>
{error}
</div>
)}
{data && (
<>
<dl className="mt-4 grid grid-cols-3 gap-y-2 text-sm">
<dt className="opacity-70">Alumno</dt>
<dd className="col-span-2">{data.prestamo.alumno?.nombre ?? data.prestamo.alumno?.email}</dd>
<dt className="opacity-70">Material</dt>
<dd className="col-span-2">{data.prestamo.material?.nombre} <span className="opacity-70">· Inv. {data.prestamo.material?.numero_inventario ?? '—'}</span></dd>
<dt className="opacity-70">Cantidad</dt>
<dd className="col-span-2" style={{ fontVariantNumeric: 'tabular-nums' }}>{data.prestamo.cantidad}</dd>
<dt className="opacity-70">Solicitado</dt>
<dd className="col-span-2">{fmt.format(new Date(data.prestamo.fecha_solicitud))}</dd>
{data.prestamo.fecha_aprobacion && (<><dt className="opacity-70">Aprobado</dt><dd className="col-span-2">{fmt.format(new Date(data.prestamo.fecha_aprobacion))}</dd></>)}
{data.prestamo.fecha_devolucion_estimada && (<><dt className="opacity-70">Devolver antes</dt><dd className="col-span-2">{fmt.format(new Date(data.prestamo.fecha_devolucion_estimada + 'T00:00:00'))}</dd></>)}
{data.prestamo.notas && (<><dt className="opacity-70">Notas</dt><dd className="col-span-2 whitespace-pre-wrap">{data.prestamo.notas}</dd></>)}
</dl>
<h3 className="mt-6 mb-2 font-semibold text-sm">Historial de cambios</h3>
{data.audit_log.length === 0 ? (
<p className="text-sm opacity-70">Sin cambios registrados.</p>
) : (
<ul className="text-sm divide-y" style={{ borderColor: 'color-mix(in oklab, var(--color-ink) 10%, transparent)' }}>
{data.audit_log.map((e, i) => {
const fecha = e.created_at ?? e.fecha ?? e.timestamp;
return (
<li key={i} className="py-2 flex items-baseline gap-2 flex-wrap">
<span className="font-medium">{e.accion ?? 'cambio'}</span>
<span className="opacity-80">
{(e.estado_anterior ?? '—')} {(e.estado_nuevo ?? '—')}
</span>
{fecha && <span className="opacity-60 ml-auto text-xs">{fmt.format(new Date(fecha))}</span>}
</li>
);
})}
</ul>
)}
</>
)}
</div>
</dialog>
</>
);
}
@@ -0,0 +1,87 @@
import { useEffect, useState } from 'react';
type Cat = { id: number; nombre: string };
type Props = { categorias: Cat[] };
const ALL = 'all';
function applyFilter(value: string) {
const cards = document.querySelectorAll<HTMLElement>('[data-cat]');
cards.forEach((el) => {
const cat = el.dataset.cat;
const show = value === ALL || cat === value;
el.hidden = !show;
});
const empty = document.getElementById('catalogo-empty-filter');
if (empty) {
const anyVisible = Array.from(cards).some((el) => !el.hidden);
empty.hidden = anyVisible || cards.length === 0;
}
}
export default function FiltroCategorias({ categorias }: Props) {
const [active, setActive] = useState<string>(ALL);
useEffect(() => {
const url = new URL(location.href);
const cat = url.searchParams.get('cat');
const initial = cat && categorias.some((c) => String(c.id) === cat) ? cat : ALL;
setActive(initial);
applyFilter(initial);
}, [categorias]);
const pick = (value: string) => {
setActive(value);
const url = new URL(location.href);
if (value === ALL) url.searchParams.delete('cat');
else url.searchParams.set('cat', value);
history.replaceState(null, '', url.toString());
applyFilter(value);
};
const chipClass = (v: string) =>
v === active
? 'btn'
: 'btn btn-ghost';
const chipStyle = (v: string): React.CSSProperties =>
v === active
? { background: 'var(--color-primary)', color: 'white', minHeight: '40px', paddingBlock: '0.375rem' }
: { minHeight: '40px', paddingBlock: '0.375rem' };
return (
<div
role="tablist"
aria-label="Filtrar por categoría"
className="flex gap-2 overflow-x-auto -mx-4 px-4 pb-1"
style={{ scrollbarWidth: 'thin' }}
>
<button
type="button"
role="tab"
aria-selected={active === ALL}
className={chipClass(ALL)}
style={chipStyle(ALL)}
onClick={() => pick(ALL)}
>
Todas
</button>
{categorias.map((c) => {
const v = String(c.id);
return (
<button
key={c.id}
type="button"
role="tab"
aria-selected={active === v}
className={chipClass(v)}
style={chipStyle(v)}
onClick={() => pick(v)}
>
{c.nombre}
</button>
);
})}
</div>
);
}
+165
View File
@@ -0,0 +1,165 @@
import { useEffect, useRef, useState } from 'react';
type Props = {
material: {
id: number;
nombre: string;
cantidad_disponible: number;
};
};
export default function SolicitarModal({ material }: Props) {
const dialogRef = useRef<HTMLDialogElement | null>(null);
const firstFieldRef = useRef<HTMLInputElement | null>(null);
const [cantidad, setCantidad] = useState<number>(1);
const [notas, setNotas] = useState<string>('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [ok, setOk] = useState(false);
const titleId = `solicitar-title-${material.id}`;
const open = () => {
setError(null);
setOk(false);
setCantidad(1);
setNotas('');
dialogRef.current?.showModal();
queueMicrotask(() => firstFieldRef.current?.focus());
};
const close = () => {
if (loading) return;
dialogRef.current?.close();
};
// Cerrar al click en backdrop
useEffect(() => {
const dlg = dialogRef.current;
if (!dlg) return;
const onClick = (e: MouseEvent) => {
if (e.target === dlg) close();
};
dlg.addEventListener('click', onClick);
return () => dlg.removeEventListener('click', onClick);
});
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (loading) return;
setLoading(true);
setError(null);
try {
const res = await fetch('/api/prestamos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ material_id: material.id, cantidad, notas }),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(json?.error ?? 'No se pudo enviar la solicitud');
}
setOk(true);
setTimeout(() => {
dialogRef.current?.close();
location.reload();
}, 800);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error inesperado');
} finally {
setLoading(false);
}
};
const disabled = material.cantidad_disponible === 0;
return (
<>
<button
type="button"
className="btn btn-primary w-full"
onClick={open}
disabled={disabled}
>
{disabled ? 'Sin stock' : 'Solicitar'}
</button>
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] backdrop:bg-black/40 w-[min(92vw,28rem)]"
>
<form onSubmit={submit} className="p-5 sm:p-6 flex flex-col gap-4">
<div className="flex items-start justify-between gap-3">
<h2 id={titleId} className="text-lg font-semibold">
Solicitar: {material.nombre}
</h2>
<button
type="button"
onClick={close}
aria-label="Cerrar"
className="rounded-lg p-1 hover:bg-black/5 transition-colors leading-none text-xl"
>
<span aria-hidden="true">×</span>
</button>
</div>
<p className="text-sm opacity-70">
Disponibles: <span style={{ fontVariantNumeric: 'tabular-nums' }}>{material.cantidad_disponible}</span>
</p>
<div>
<label className="label" htmlFor={`cant-${material.id}`}>Cantidad</label>
<input
ref={firstFieldRef}
id={`cant-${material.id}`}
className="input"
type="number"
min={1}
max={material.cantidad_disponible}
value={cantidad}
required
inputMode="numeric"
onChange={(e) => setCantidad(Math.max(1, Math.min(material.cantidad_disponible, Number(e.target.value) || 1)))}
style={{ fontVariantNumeric: 'tabular-nums' }}
/>
</div>
<div>
<label className="label" htmlFor={`notas-${material.id}`}>Notas (opcional)</label>
<textarea
id={`notas-${material.id}`}
className="input"
rows={3}
maxLength={500}
value={notas}
onChange={(e) => setNotas(e.target.value)}
placeholder="Motivo, materia, fecha estimada de devolución…"
style={{ minHeight: '96px' }}
/>
</div>
{error && (
<p role="alert" className="text-sm" style={{ color: 'var(--color-danger)' }}>
{error} · Ajusta la cantidad o intenta de nuevo.
</p>
)}
{ok && (
<p role="status" className="text-sm" style={{ color: 'var(--color-primary)' }}>
Solicitud enviada. Actualizando
</p>
)}
<div className="flex gap-2 justify-end pt-2">
<button type="button" className="btn btn-ghost" onClick={close} disabled={loading}>
Cancelar
</button>
<button type="submit" className="btn btn-primary" disabled={loading || ok}>
{loading ? 'Enviando…' : 'Enviar solicitud'}
</button>
</div>
</form>
</dialog>
</>
);
}
+32
View File
@@ -0,0 +1,32 @@
/// <reference path="../.astro/types.d.ts" />
import type { SupabaseClient, User } from '@supabase/supabase-js';
export type Profile = {
id: string;
email: string;
nombre: string | null;
matricula: string | null;
rol: 'alumno' | 'admin';
};
declare global {
namespace App {
interface Locals {
supabase: SupabaseClient;
user: User | null;
profile: Profile | null;
}
}
}
interface ImportMetaEnv {
readonly PUBLIC_SUPABASE_URL: string;
readonly PUBLIC_SUPABASE_ANON_KEY: string;
readonly SUPABASE_SERVICE_ROLE_KEY: string;
readonly PUBLIC_APP_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+81
View File
@@ -0,0 +1,81 @@
---
import Layout from './Layout.astro';
interface Props {
title?: string;
}
const { title } = Astro.props;
const profile = Astro.locals.profile;
const isAdmin = profile?.rol === 'admin';
const path = Astro.url.pathname;
const isActive = (href: string) => path === href || path.startsWith(href + '/');
---
<Layout title={title}>
<div class="min-h-screen flex flex-col md:flex-row">
<!-- Sidebar (desktop) / topbar (mobile) -->
<aside class="md:w-64 md:min-h-screen bg-[color:var(--color-primary)] text-white flex md:flex-col">
<a href="/" class="p-4 md:p-6 flex items-center gap-3 md:border-b border-white/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white" aria-label="LabPréstamos — Inicio">
<div class="w-9 h-9 rounded-lg bg-[color:var(--color-secondary)] grid place-items-center font-bold" aria-hidden="true">L</div>
<div class="hidden md:block">
<div class="font-semibold leading-tight">LabPréstamos</div>
<div class="text-xs text-white/70">UABC · Lab. Sistemas</div>
</div>
</a>
<nav class="flex-1 flex md:flex-col md:p-3 gap-1 overflow-x-auto md:overflow-visible" aria-label="Navegación principal">
{isAdmin ? (
<>
<a href="/admin" class:list={["nav-item", isActive('/admin') && !path.startsWith('/admin/') && "nav-item-active"]}>Panel</a>
<a href="/admin/solicitudes" class:list={["nav-item", isActive('/admin/solicitudes') && "nav-item-active"]}>Solicitudes</a>
<a href="/admin/inventario" class:list={["nav-item", isActive('/admin/inventario') && "nav-item-active"]}>Inventario</a>
<a href="/admin/reportes" class:list={["nav-item", isActive('/admin/reportes') && "nav-item-active"]}>Reportes</a>
</>
) : (
<>
<a href="/" class:list={["nav-item", path === '/' && "nav-item-active"]}>Inicio</a>
<a href="/alumno/catalogo" class:list={["nav-item", isActive('/alumno/catalogo') && "nav-item-active"]}>Catálogo</a>
<a href="/alumno/mis-prestamos" class:list={["nav-item", isActive('/alumno/mis-prestamos') && "nav-item-active"]}>Mis préstamos</a>
</>
)}
</nav>
<div class="hidden md:block p-3 border-t border-white/10">
<div class="px-3 py-2 text-sm">
<div class="font-medium truncate">{profile?.nombre ?? profile?.email}</div>
<div class="text-white/70 text-xs capitalize">{profile?.rol}</div>
</div>
<form method="POST" action="/api/auth/signout">
<button type="submit" class="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-white/10 transition-colors">
Cerrar sesión
</button>
</form>
</div>
<!-- Mobile signout -->
<form method="POST" action="/api/auth/signout" class="md:hidden ml-auto p-2">
<button type="submit" aria-label="Cerrar sesión" class="p-2 rounded-lg hover:bg-white/10 transition-colors">
<span aria-hidden="true">↩</span>
</button>
</form>
</aside>
<main id="main" class="flex-1 p-4 md:p-8">
<slot />
</main>
</div>
</Layout>
<style>
@reference "tailwindcss";
.nav-item {
@apply block px-3 py-2 md:py-2.5 rounded-lg text-sm font-medium text-white/85
whitespace-nowrap transition-colors duration-150
hover:bg-white/10 hover:text-white
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white;
}
.nav-item-active {
background: rgba(255, 255, 255, 0.15);
color: white;
}
</style>
+16 -13
View File
@@ -1,23 +1,26 @@
---
import '@/styles/global.css';
interface Props {
title?: string;
}
const { title = 'Sistema de Préstamos — Laboratorio UABC' } = Astro.props;
---
<!doctype html> <!doctype html>
<html lang="en"> <html lang="es">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#00723F" />
<meta name="color-scheme" content="light" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.ico" />
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
<title>Astro Basics</title> <title>{title}</title>
</head> </head>
<body> <body>
<a href="#main" class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 btn btn-primary z-50">
Saltar al contenido
</a>
<slot /> <slot />
</body> </body>
</html> </html>
<style>
html,
body {
margin: 0;
width: 100%;
height: 100%;
}
</style>
+36
View File
@@ -0,0 +1,36 @@
import { createServerClient, createBrowserClient, type CookieOptionsWithName } from '@supabase/ssr';
import type { AstroCookies } from 'astro';
const SCHEMA = 'prestamos';
const cookieOptions: CookieOptionsWithName = {
name: 'sb',
path: '/',
sameSite: 'lax',
httpOnly: true,
secure: import.meta.env.PROD,
};
export function serverClient(cookies: AstroCookies) {
return createServerClient(
import.meta.env.PUBLIC_SUPABASE_URL,
import.meta.env.PUBLIC_SUPABASE_ANON_KEY,
{
db: { schema: SCHEMA },
cookieOptions,
cookies: {
get: (name) => cookies.get(name)?.value,
set: (name, value, options) => cookies.set(name, value, options),
remove: (name, options) => cookies.delete(name, options),
},
},
);
}
export function browserClient() {
return createBrowserClient(
import.meta.env.PUBLIC_SUPABASE_URL,
import.meta.env.PUBLIC_SUPABASE_ANON_KEY,
{ db: { schema: SCHEMA }, cookieOptions },
);
}
+46
View File
@@ -0,0 +1,46 @@
import { defineMiddleware } from 'astro:middleware';
import { serverClient } from '@/lib/supabase';
const UABC_DOMAIN = '@uabc.edu.mx';
const PUBLIC_ROUTES = ['/login', '/api/auth/callback', '/api/auth/signout'];
export const onRequest = defineMiddleware(async (context, next) => {
const supabase = serverClient(context.cookies);
context.locals.supabase = supabase;
const { data: { user } } = await supabase.auth.getUser();
if (user && !user.email?.toLowerCase().endsWith(UABC_DOMAIN)) {
await supabase.auth.signOut();
return context.redirect('/login?error=dominio');
}
context.locals.user = user;
context.locals.profile = null;
if (user) {
const { data: profile } = await supabase
.from('profiles')
.select('id, email, nombre, matricula, rol')
.eq('id', user.id)
.maybeSingle();
context.locals.profile = profile ?? null;
}
const { pathname } = context.url;
const isPublic = PUBLIC_ROUTES.includes(pathname);
if (!user && !isPublic) {
return context.redirect('/login');
}
if (user && pathname === '/login') {
return context.redirect('/');
}
if (pathname.startsWith('/admin') && context.locals.profile?.rol !== 'admin') {
return new Response('Acceso denegado', { status: 403 });
}
return next();
});
+96
View File
@@ -0,0 +1,96 @@
---
import AppLayout from '@/layouts/AppLayout.astro';
const supabase = Astro.locals.supabase;
const today = new Date().toISOString().split('T')[0];
const [pend, activos, vencidos, agotados, ultimas] = await Promise.all([
supabase.from('prestamos').select('*', { count: 'exact', head: true }).eq('estado', 'pendiente'),
supabase.from('prestamos').select('*', { count: 'exact', head: true }).in('estado', ['aprobado', 'activo']),
supabase.from('prestamos').select('*', { count: 'exact', head: true }).in('estado', ['aprobado', 'activo']).lt('fecha_devolucion_estimada', today),
supabase.from('materiales').select('*', { count: 'exact', head: true }).eq('cantidad_disponible', 0),
supabase
.from('prestamos')
.select('id, cantidad, fecha_solicitud, estado, alumno:profiles!alumno_id(nombre, email), material:materiales!material_id(nombre)')
.order('fecha_solicitud', { ascending: false })
.limit(5),
]);
const fmtFecha = new Intl.DateTimeFormat('es-MX', { dateStyle: 'medium', timeStyle: 'short' });
const kpis = [
{ label: 'Pendientes', value: pend.count ?? 0, href: '/admin/solicitudes', tone: 'primary' },
{ label: 'En préstamo', value: activos.count ?? 0, href: '/admin/solicitudes/activos', tone: 'secondary' },
{ label: 'Vencidos', value: vencidos.count ?? 0, href: '/admin/solicitudes/activos', tone: 'danger' },
{ label: 'Agotados', value: agotados.count ?? 0, href: '/admin/inventario', tone: 'ink' },
];
const estadoLabel: Record<string, string> = {
pendiente: 'Pendiente',
aprobado: 'En préstamo',
activo: 'En préstamo',
devuelto: 'Devuelto',
rechazado: 'Rechazado',
vencido: 'Vencido',
};
---
<AppLayout title="Panel — LabPréstamos">
<div class="max-w-6xl">
<header class="mb-6">
<h1 class="text-2xl md:text-3xl font-semibold">Panel</h1>
<p class="text-sm opacity-70 mt-1">Resumen del laboratorio.</p>
</header>
<section aria-label="Indicadores" class="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-4 mb-8">
{kpis.map((k) => (
<a
href={k.href}
class="card block group focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--color-primary)] motion-safe:hover:-translate-y-0.5 transition-transform duration-150"
>
<div
class="text-4xl font-semibold leading-none"
style={`font-variant-numeric: tabular-nums; color: var(--color-${k.tone === 'ink' ? 'ink' : k.tone});`}
>
{k.value}
</div>
<div class="mt-2 text-sm opacity-70 group-hover:opacity-100 transition-opacity">{k.label}</div>
</a>
))}
</section>
<section aria-labelledby="ultimas-h">
<div class="flex items-baseline justify-between mb-3">
<h2 id="ultimas-h" class="text-lg font-semibold">Últimas 5 solicitudes</h2>
<a href="/admin/solicitudes" class="text-sm underline decoration-[color:var(--color-primary)] underline-offset-4">Ver todas</a>
</div>
<div class="card p-0 overflow-hidden">
{ultimas.data && ultimas.data.length > 0 ? (
<table class="w-full text-sm">
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
<tr>
<th class="p-3 font-medium">Alumno</th>
<th class="p-3 font-medium">Material</th>
<th class="p-3 font-medium">Cant.</th>
<th class="p-3 font-medium">Estado</th>
<th class="p-3 font-medium">Solicitado</th>
</tr>
</thead>
<tbody>
{ultimas.data.map((r: any) => (
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);">
<td class="p-3">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</td>
<td class="p-3">{r.material?.nombre ?? '—'}</td>
<td class="p-3" style="font-variant-numeric: tabular-nums;">{r.cantidad}</td>
<td class="p-3">{estadoLabel[r.estado] ?? r.estado}</td>
<td class="p-3 opacity-80">{fmtFecha.format(new Date(r.fecha_solicitud))}</td>
</tr>
))}
</tbody>
</table>
) : (
<p class="p-6 opacity-70 text-sm">Aún no hay solicitudes registradas.</p>
)}
</div>
</section>
</div>
</AppLayout>
+118
View File
@@ -0,0 +1,118 @@
---
import AppLayout from '@/layouts/AppLayout.astro';
import CategoriaForm from '@/components/admin/inventario/CategoriaForm.tsx';
import EliminarCategoria from '@/components/admin/inventario/EliminarCategoria.tsx';
type CategoriaRow = {
id: number;
nombre: string;
materiales: { count: number }[];
};
const { data, error } = await Astro.locals.supabase
.from('categorias')
.select('id, nombre, materiales(count)')
.order('nombre');
const categorias = ((data ?? []) as unknown as CategoriaRow[]).map((c) => ({
id: c.id,
nombre: c.nombre,
count: c.materiales?.[0]?.count ?? 0,
}));
---
<AppLayout title="Categorías — 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">Categorías</h1>
<p class="text-sm opacity-70 mt-1">Organiza los materiales por tipo.</p>
</div>
<CategoriaForm 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" style="border-color: var(--color-primary); color: var(--color-primary);">
Categorías
</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);">
No se pudieron cargar las categorías. Recarga la página.
</p>
</div>
)}
{!error && categorias.length === 0 && (
<div class="card text-center">
<h2 class="text-lg font-semibold mb-1">Sin categorías aún</h2>
<p class="text-sm opacity-70">Crea la primera para agrupar tus materiales.</p>
</div>
)}
{!error && categorias.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 text-right"># Materiales</th>
<th class="px-4 py-3 font-medium text-right">Acciones</th>
</tr>
</thead>
<tbody>
{categorias.map((c) => (
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 8%, transparent);">
<td class="px-4 py-3 font-medium">{c.nombre}</td>
<td class="px-4 py-3 text-right" style="font-variant-numeric: tabular-nums;">
{c.count > 0 ? (
<a href={`/admin/inventario?cat=${c.id}`} class="underline" style="color: var(--color-primary);">
{c.count}
</a>
) : (
<span class="opacity-60">0</span>
)}
</td>
<td class="px-4 py-3">
<div class="flex gap-2 justify-end">
<CategoriaForm mode="edit" categoria={{ id: c.id, nombre: c.nombre }} client:load />
<EliminarCategoria id={c.id} nombre={c.nombre} materialesCount={c.count} client:load />
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div class="md:hidden flex flex-col gap-3">
{categorias.map((c) => (
<article class="card flex items-start justify-between gap-3">
<div>
<h3 class="font-semibold">{c.nombre}</h3>
<p class="text-xs opacity-70 mt-1">
{c.count > 0 ? (
<a href={`/admin/inventario?cat=${c.id}`} class="underline" style="color: var(--color-primary);">
{c.count} material{c.count === 1 ? '' : 'es'}
</a>
) : (
<span>Sin materiales</span>
)}
</p>
</div>
<div class="flex flex-col gap-2 shrink-0">
<CategoriaForm mode="edit" categoria={{ id: c.id, nombre: c.nombre }} client:load />
<EliminarCategoria id={c.id} nombre={c.nombre} materialesCount={c.count} client:load />
</div>
</article>
))}
</div>
</>
)}
</div>
</AppLayout>
+240
View File
@@ -0,0 +1,240 @@
---
import AppLayout from '@/layouts/AppLayout.astro';
import MaterialForm from '@/components/admin/inventario/MaterialForm.tsx';
import EliminarMaterial from '@/components/admin/inventario/EliminarMaterial.tsx';
type Material = {
id: number;
nombre: string;
descripcion: string | null;
cantidad_total: number;
cantidad_disponible: number;
numero_inventario: string | null;
estado: 'disponible' | 'mantenimiento' | 'baja';
categoria: { id: number; nombre: string } | null;
};
const q = (Astro.url.searchParams.get('q') ?? '').trim();
const catRaw = Astro.url.searchParams.get('cat') ?? '';
const catId = catRaw && /^\d+$/.test(catRaw) ? Number(catRaw) : null;
const estadoRaw = Astro.url.searchParams.get('estado') ?? '';
const estado = ['disponible', 'mantenimiento', 'baja'].includes(estadoRaw) ? estadoRaw : '';
let query = Astro.locals.supabase
.from('materiales')
.select(
'id, nombre, descripcion, cantidad_total, cantidad_disponible, numero_inventario, estado, categoria:categorias(id, nombre)',
);
if (q) query = query.or(`nombre.ilike.%${q}%,numero_inventario.ilike.%${q}%`);
if (catId != null) query = query.eq('categoria_id', catId);
if (estado) query = query.eq('estado', estado);
const { data: matData, error: matError } = await query.order('nombre');
const materiales = (matData ?? []) as unknown as Material[];
const { data: catData } = await Astro.locals.supabase
.from('categorias')
.select('id, nombre')
.order('nombre');
const categorias = (catData ?? []) as { id: number; nombre: string }[];
const estadoBadge = (e: Material['estado']) => {
if (e === 'disponible')
return {
label: 'Disponible',
style: 'background: color-mix(in oklab, var(--color-primary) 12%, transparent); color: var(--color-primary);',
};
if (e === 'mantenimiento')
return {
label: 'Mantenimiento',
style: 'background: color-mix(in oklab, var(--color-secondary) 15%, transparent); color: var(--color-secondary-hover);',
};
return {
label: 'Baja',
style: 'background: color-mix(in oklab, var(--color-ink) 8%, transparent); color: color-mix(in oklab, var(--color-ink) 60%, transparent);',
};
};
---
<AppLayout title="Inventario — Admin">
<div class="max-w-6xl">
<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">Inventario</h1>
<p class="text-sm opacity-70 mt-1">Materiales del laboratorio.</p>
</div>
<MaterialForm mode="create" categorias={categorias} 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" style="border-color: var(--color-primary); color: var(--color-primary);">
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>
</nav>
<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">
<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" />
</div>
<div>
<label class="label" for="filtro-cat">Categoría</label>
<select id="filtro-cat" name="cat" class="input">
<option value="">Todas</option>
{categorias.map((c) => (
<option value={String(c.id)} selected={catId === c.id}>{c.nombre}</option>
))}
</select>
</div>
<div>
<label class="label" for="filtro-estado">Estado</label>
<select id="filtro-estado" name="estado" class="input">
<option value="">Todos</option>
<option value="disponible" selected={estado === 'disponible'}>Disponible</option>
<option value="mantenimiento" selected={estado === 'mantenimiento'}>Mantenimiento</option>
<option value="baja" selected={estado === 'baja'}>Baja</option>
</select>
</div>
<div class="sm:col-span-4 flex gap-2 justify-end">
{(q || catId != null || estado) && (
<a href="/admin/inventario" class="btn btn-ghost">Limpiar</a>
)}
<button type="submit" class="btn btn-primary">Filtrar</button>
</div>
</form>
{matError && (
<div class="card mb-6" role="alert" style="border-color: color-mix(in oklab, var(--color-danger) 30%, transparent);">
<p class="text-sm" style="color: var(--color-danger);">
No se pudo cargar el inventario. Recarga la página.
</p>
</div>
)}
{!matError && materiales.length === 0 && (
<div class="card text-center">
<h2 class="text-lg font-semibold mb-1">Sin resultados</h2>
<p class="text-sm opacity-70">
{q || catId != null || estado
? 'Ningún material coincide con los filtros. Ajusta la búsqueda.'
: 'Aún no hay material registrado. Agrega uno para comenzar.'}
</p>
</div>
)}
{!matError && materiales.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">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) => {
const badge = estadoBadge(m.estado);
return (
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 8%, transparent);">
<td class="px-4 py-3">
<div class="font-medium">{m.nombre}</div>
{m.descripcion && (
<div class="text-xs opacity-60 line-clamp-1">{m.descripcion}</div>
)}
</td>
<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-full whitespace-nowrap" style={badge.style}>
{badge.label}
</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>
</td>
</tr>
);
})}
</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 && (
<p class="text-xs opacity-70 mt-0.5">{m.categoria.nombre}</p>
)}
</div>
<span class="text-xs px-2 py-0.5 rounded-full whitespace-nowrap" style={badge.style}>
{badge.label}
</span>
</div>
{m.descripcion && <p class="text-sm opacity-75 line-clamp-2">{m.descripcion}</p>}
<dl class="text-xs opacity-70 grid grid-cols-2 gap-1">
<dt class="opacity-60">Inventario</dt>
<dd class="text-right font-mono">{m.numero_inventario ?? '—'}</dd>
<dt class="opacity-60">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
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>
</article>
);
})}
</div>
</>
)}
</div>
</AppLayout>
+397
View File
@@ -0,0 +1,397 @@
---
import AppLayout from '@/layouts/AppLayout.astro';
import AutocompleteMaterial from '@/components/admin/reportes/AutocompleteMaterial';
import AutocompleteAlumno from '@/components/admin/reportes/AutocompleteAlumno';
export const prerender = false;
const supabase = Astro.locals.supabase;
const sp = Astro.url.searchParams;
const vista = sp.get('vista') === 'vencidos' ? 'vencidos' : 'historial';
const desde = sp.get('desde') ?? '';
const hasta = sp.get('hasta') ?? '';
const estado = sp.get('estado') ?? 'all';
const materialId = sp.get('material_id') ?? '';
const alumnoId = sp.get('alumno_id') ?? '';
const HARD_LIMIT = 500;
const today = new Date().toISOString().slice(0, 10);
const fmtFecha = new Intl.DateTimeFormat('es-MX', { dateStyle: 'medium' });
const estadoLabel: Record<string, string> = {
pendiente: 'Pendiente',
aprobado: 'Aprobado',
activo: 'En préstamo',
devuelto: 'Devuelto',
rechazado: 'Rechazado',
vencido: 'Vencido',
};
function fmt(d: string | null | undefined): string {
if (!d) return '—';
const x = new Date(d);
return isNaN(x.getTime()) ? '—' : fmtFecha.format(x);
}
// Prefetch de labels para hidratar autocompletes con valor inicial (SSR-friendly)
let materialInitialLabel: string | null = null;
if (materialId) {
const { data } = await supabase
.from('materiales')
.select('nombre, numero_inventario')
.eq('id', materialId)
.maybeSingle();
if (data) materialInitialLabel = data.numero_inventario ? `${data.nombre} — ${data.numero_inventario}` : data.nombre;
}
let alumnoInitialLabel: string | null = null;
if (alumnoId) {
const { data } = await supabase
.from('profiles')
.select('nombre, email, matricula')
.eq('id', alumnoId)
.maybeSingle();
if (data) {
const mat = data.matricula ? ` (${data.matricula})` : '';
alumnoInitialLabel = `${data.nombre ?? data.email}${mat} — ${data.email}`;
}
}
// —— HISTORIAL ——
let filas: any[] = [];
let total = 0;
let hitLimit = false;
if (vista === 'historial') {
let q = supabase
.from('prestamos')
.select(`
id, cantidad, fecha_solicitud, fecha_aprobacion, fecha_devolucion_estimada, fecha_devolucion_real, estado,
alumno:profiles!alumno_id(nombre, email, matricula),
material:materiales!material_id(nombre, numero_inventario)
`)
.order('fecha_solicitud', { ascending: false })
// ponytail: 500, paginar cuando reportes rutinarios pasen de eso
.limit(HARD_LIMIT);
if (desde) q = q.gte('fecha_solicitud', desde);
// ponytail: bound de día en UTC; cambiar a bounds tz-aware si el reporte cruza medianoche PST/PDT
if (hasta) q = q.lte('fecha_solicitud', `${hasta}T23:59:59.999Z`);
if (estado && estado !== 'all') q = q.eq('estado', estado);
if (materialId) q = q.eq('material_id', materialId);
if (alumnoId) q = q.eq('alumno_id', alumnoId);
const { data, error } = await q;
if (error) console.error('[reportes/historial]', error);
filas = data ?? [];
total = filas.length;
hitLimit = total >= HARD_LIMIT;
}
// —— VENCIDOS ——
let vencidos: any[] = [];
if (vista === 'vencidos') {
const { data, error } = await supabase
.from('prestamos')
.select(`
id, cantidad, fecha_devolucion_estimada,
alumno:profiles!alumno_id(nombre, email, matricula),
material:materiales!material_id(nombre, numero_inventario)
`)
.in('estado', ['aprobado', 'activo'])
.lt('fecha_devolucion_estimada', today)
.order('fecha_devolucion_estimada', { ascending: true });
if (error) console.error('[reportes/vencidos]', error);
vencidos = data ?? [];
}
function diasAtraso(fecha: string): number {
const d = new Date(fecha);
const t = new Date(today);
return Math.floor((t.getTime() - d.getTime()) / 86_400_000);
}
// URL del export = mismos filtros
const exportQs = new URLSearchParams();
if (desde) exportQs.set('desde', desde);
if (hasta) exportQs.set('hasta', hasta);
if (estado && estado !== 'all') exportQs.set('estado', estado);
if (materialId) exportQs.set('material_id', materialId);
if (alumnoId) exportQs.set('alumno_id', alumnoId);
const exportHref = `/api/admin/reportes/export${exportQs.toString() ? `?${exportQs}` : ''}`;
const exportFilename = `reporte-${today}.csv`;
---
<AppLayout title="Reportes — LabPréstamos">
<div class="max-w-6xl">
<header class="mb-6">
<h1 class="text-2xl md:text-3xl font-semibold">Reportes</h1>
<p class="text-sm opacity-70 mt-1">Historial filtrable de préstamos y lista de vencidos.</p>
</header>
<nav class="mb-6 border-b" style="border-color: color-mix(in oklab, var(--color-ink) 12%, transparent);" aria-label="Vistas de reportes">
<ul class="flex gap-1">
<li>
<a
href="?vista=historial"
aria-current={vista === 'historial' ? 'page' : undefined}
class:list={['tab', vista === 'historial' && 'tab-active']}
>Historial</a>
</li>
<li>
<a
href="?vista=vencidos"
aria-current={vista === 'vencidos' ? 'page' : undefined}
class:list={['tab', vista === 'vencidos' && 'tab-active']}
>Vencidos</a>
</li>
</ul>
</nav>
{vista === 'historial' && (
<>
<form method="get" class="card mb-6">
<input type="hidden" name="vista" value="historial" />
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<label class="label" for="desde">Desde</label>
<input class="input" type="date" id="desde" name="desde" value={desde} />
</div>
<div>
<label class="label" for="hasta">Hasta</label>
<input class="input" type="date" id="hasta" name="hasta" value={hasta} />
</div>
<div>
<label class="label" for="estado">Estado</label>
<select class="input" id="estado" name="estado">
<option value="all" selected={estado === 'all'}>Todos</option>
<option value="pendiente" selected={estado === 'pendiente'}>Pendiente</option>
<option value="aprobado" selected={estado === 'aprobado'}>Aprobado</option>
<option value="activo" selected={estado === 'activo'}>En préstamo</option>
<option value="devuelto" selected={estado === 'devuelto'}>Devuelto</option>
<option value="rechazado" selected={estado === 'rechazado'}>Rechazado</option>
<option value="vencido" selected={estado === 'vencido'}>Vencido</option>
</select>
</div>
<div>
<AutocompleteMaterial
client:load
initialValue={materialId ? Number(materialId) : null}
initialLabel={materialInitialLabel}
/>
</div>
<div>
<AutocompleteAlumno
client:load
initialValue={alumnoId || null}
initialLabel={alumnoInitialLabel}
/>
</div>
</div>
<div class="mt-5 flex flex-col md:flex-row md:items-center gap-3 md:justify-between">
<div class="flex flex-col md:flex-row gap-2">
<button type="submit" class="btn btn-primary">Filtrar</button>
<a href="/admin/reportes?vista=historial" class="btn btn-ghost">Limpiar</a>
</div>
<a
class="btn btn-secondary w-full md:w-auto"
href={exportHref}
download={exportFilename}
>
Exportar CSV
</a>
</div>
</form>
{hitLimit && (
<div
role="status"
class="mb-4 rounded-lg p-3 text-sm"
style="background: color-mix(in oklab, var(--color-secondary) 12%, transparent); border: 1px solid color-mix(in oklab, var(--color-secondary) 30%, transparent);"
>
Se muestran los {HARD_LIMIT} más recientes — refina los filtros o exporta a CSV.
</div>
)}
{filas.length === 0 ? (
<div class="card text-center">
<p class="opacity-75">Sin resultados para los filtros seleccionados.</p>
<p class="text-sm opacity-60 mt-1">Ajusta los filtros o exporta todo el historial.</p>
</div>
) : (
<>
{/* Desktop: tabla */}
<div class="card p-0 overflow-hidden hidden md:block">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
<tr>
<th class="p-3 font-medium">Fecha solicitud</th>
<th class="p-3 font-medium">Alumno</th>
<th class="p-3 font-medium">Material</th>
<th class="p-3 font-medium">Cant.</th>
<th class="p-3 font-medium">Estado</th>
<th class="p-3 font-medium">Fecha devolución</th>
</tr>
</thead>
<tbody>
{filas.map((r: any) => (
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);">
<td class="p-3 opacity-90 whitespace-nowrap">{fmt(r.fecha_solicitud)}</td>
<td class="p-3">
<div class="font-medium">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</div>
{r.alumno?.matricula && <div class="text-xs opacity-70">{r.alumno.matricula}</div>}
</td>
<td class="p-3">
<div>{r.material?.nombre ?? '—'}</div>
{r.material?.numero_inventario && (
<div class="text-xs opacity-70">{r.material.numero_inventario}</div>
)}
</td>
<td class="p-3" style="font-variant-numeric: tabular-nums;">{r.cantidad}</td>
<td class="p-3">{estadoLabel[r.estado] ?? r.estado}</td>
<td class="p-3 opacity-90 whitespace-nowrap">
{fmt(r.fecha_devolucion_real ?? r.fecha_devolucion_estimada)}
{!r.fecha_devolucion_real && r.fecha_devolucion_estimada && (
<span class="text-xs opacity-60"> (est.)</span>
)}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 15%, transparent); background: color-mix(in oklab, var(--color-ink) 3%, transparent);">
<td class="p-3 text-sm font-medium" colspan="6">
Total: <span style="font-variant-numeric: tabular-nums;">{total}</span> {total === 1 ? 'préstamo' : 'préstamos'}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
{/* Mobile: cards apiladas */}
<ul class="md:hidden space-y-3">
{filas.map((r: any) => (
<li class="card">
<div class="flex items-baseline justify-between gap-3">
<span class="text-xs opacity-70">{fmt(r.fecha_solicitud)}</span>
<span class="text-xs font-medium">{estadoLabel[r.estado] ?? r.estado}</span>
</div>
<div class="mt-1 font-medium">{r.material?.nombre ?? '—'}</div>
<div class="text-sm opacity-80">
{r.alumno?.nombre ?? r.alumno?.email ?? '—'}
{r.alumno?.matricula && <span class="opacity-60"> · {r.alumno.matricula}</span>}
</div>
<div class="mt-2 flex items-center justify-between text-sm">
<span style="font-variant-numeric: tabular-nums;">Cant. {r.cantidad}</span>
<span class="opacity-80">
Dev.: {fmt(r.fecha_devolucion_real ?? r.fecha_devolucion_estimada)}
</span>
</div>
</li>
))}
<li class="text-center text-sm opacity-70 pt-1">Total: {total} {total === 1 ? 'préstamo' : 'préstamos'}</li>
</ul>
</>
)}
</>
)}
{vista === 'vencidos' && (
<>
{vencidos.length === 0 ? (
<div class="card text-center">
<p class="opacity-75">No hay préstamos vencidos. Todo al día.</p>
</div>
) : (
<>
<div class="card p-0 overflow-hidden hidden md:block">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
<tr>
<th class="p-3 font-medium">Alumno</th>
<th class="p-3 font-medium">Material</th>
<th class="p-3 font-medium">Cant.</th>
<th class="p-3 font-medium">Debía devolver el</th>
<th class="p-3 font-medium">Días de atraso</th>
</tr>
</thead>
<tbody>
{vencidos.map((r: any) => {
const dias = diasAtraso(r.fecha_devolucion_estimada);
return (
<tr
class="border-t"
style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent); background: color-mix(in oklab, var(--color-danger) 8%, transparent);"
>
<td class="p-3">
<div class="font-medium">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</div>
{r.alumno?.matricula && <div class="text-xs opacity-70">{r.alumno.matricula}</div>}
</td>
<td class="p-3">
<div>{r.material?.nombre ?? '—'}</div>
{r.material?.numero_inventario && (
<div class="text-xs opacity-70">{r.material.numero_inventario}</div>
)}
</td>
<td class="p-3" style="font-variant-numeric: tabular-nums;">{r.cantidad}</td>
<td class="p-3 whitespace-nowrap">{fmt(r.fecha_devolucion_estimada)}</td>
<td class="p-3 font-medium" style={`font-variant-numeric: tabular-nums; color: var(--color-danger);`}>
{dias} {dias === 1 ? 'día' : 'días'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
<ul class="md:hidden space-y-3">
{vencidos.map((r: any) => {
const dias = diasAtraso(r.fecha_devolucion_estimada);
return (
<li class="card" style="background: color-mix(in oklab, var(--color-danger) 8%, transparent);">
<div class="font-medium">{r.material?.nombre ?? '—'}</div>
<div class="text-sm opacity-80">
{r.alumno?.nombre ?? r.alumno?.email ?? '—'}
{r.alumno?.matricula && <span class="opacity-60"> · {r.alumno.matricula}</span>}
</div>
<div class="mt-2 flex items-center justify-between text-sm">
<span>Debía devolver el {fmt(r.fecha_devolucion_estimada)}</span>
<span class="font-medium" style="font-variant-numeric: tabular-nums; color: var(--color-danger);">
{dias} {dias === 1 ? 'día' : 'días'}
</span>
</div>
</li>
);
})}
</ul>
</>
)}
</>
)}
</div>
</AppLayout>
<style>
@reference "tailwindcss";
.tab {
@apply inline-block px-4 py-2.5 text-sm font-medium rounded-t-lg
transition-[background-color,color,border-color] duration-150
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--color-primary)];
color: color-mix(in oklab, var(--color-ink) 70%, transparent);
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.tab:hover {
color: var(--color-ink);
background: color-mix(in oklab, var(--color-ink) 4%, transparent);
}
.tab-active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
</style>
+152
View File
@@ -0,0 +1,152 @@
---
import AppLayout from '@/layouts/AppLayout.astro';
import MarcarDevuelto from '@/components/admin/solicitudes/MarcarDevuelto.tsx';
import VerDetalles from '@/components/admin/solicitudes/VerDetalles.tsx';
const supabase = Astro.locals.supabase;
const { data, error } = await supabase
.from('prestamos')
.select(
'id, cantidad, fecha_solicitud, fecha_aprobacion, fecha_devolucion_estimada, notas, alumno:profiles!alumno_id(id, nombre, email, matricula), material:materiales!material_id(id, nombre, numero_inventario, cantidad_disponible)'
)
.in('estado', ['aprobado', 'activo'])
.order('fecha_devolucion_estimada', { ascending: true });
const filas = data ?? [];
const fmtFecha = new Intl.DateTimeFormat('es-MX', { dateStyle: 'medium' });
const today = new Date().toISOString().split('T')[0];
const isVencido = (f?: string | null) => !!f && f < today;
const path = Astro.url.pathname;
const tabs = [
{ href: '/admin/solicitudes', label: 'Pendientes' },
{ href: '/admin/solicitudes/activos', label: 'En préstamo' },
{ href: '/admin/solicitudes/historial', label: 'Historial' },
];
---
<AppLayout title="Préstamos activos — LabPréstamos">
<div class="max-w-6xl">
<header class="mb-4">
<div class="flex items-center gap-3 flex-wrap">
<h1 class="text-2xl md:text-3xl font-semibold">En préstamo</h1>
<span
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-sm font-semibold"
style="background: color-mix(in oklab, var(--color-primary) 12%, transparent); color: var(--color-primary); font-variant-numeric: tabular-nums;"
aria-label={`${filas.length} préstamos activos`}
>
{filas.length}
</span>
</div>
</header>
<nav aria-label="Vistas de solicitudes" class="mb-6 border-b flex gap-1 overflow-x-auto" style="border-color: color-mix(in oklab, var(--color-ink) 12%, transparent);">
{tabs.map((t) => {
const active = path === t.href;
return (
<a
href={t.href}
class:list={['px-4 py-2 text-sm font-medium whitespace-nowrap border-b-2 transition-colors -mb-px', active ? 'text-[color:var(--color-primary)]' : 'opacity-70 hover:opacity-100 border-transparent']}
style={active ? 'border-color: var(--color-primary);' : ''}
aria-current={active ? 'page' : undefined}
>
{t.label}
</a>
);
})}
</nav>
{error && (
<div role="alert" class="mb-4 rounded-lg p-3 text-sm" style="background: color-mix(in oklab, var(--color-danger) 12%, transparent); color: var(--color-danger);">
Error cargando préstamos: {error.message}
</div>
)}
{filas.length === 0 ? (
<div class="card text-center py-12">
<p class="font-medium">No hay préstamos activos.</p>
</div>
) : (
<>
<div class="card p-0 overflow-hidden hidden md:block">
<table class="w-full text-sm">
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
<tr>
<th class="p-3 font-medium">Alumno</th>
<th class="p-3 font-medium">Material</th>
<th class="p-3 font-medium">Cant.</th>
<th class="p-3 font-medium">Aprobado</th>
<th class="p-3 font-medium">Devolver antes de</th>
<th class="p-3 font-medium text-right">Acciones</th>
</tr>
</thead>
<tbody>
{filas.map((r: any) => {
const vencido = isVencido(r.fecha_devolucion_estimada);
return (
<tr
class="border-t align-top"
style={`border-color: color-mix(in oklab, var(--color-ink) 10%, transparent); ${vencido ? 'background: color-mix(in oklab, var(--color-danger) 8%, transparent);' : ''}`}
>
<td class="p-3">
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
</td>
<td class="p-3">
<div>{r.material?.nombre ?? '—'}</div>
<div class="text-xs opacity-70">Inv. {r.material?.numero_inventario ?? '—'}</div>
</td>
<td class="p-3" style="font-variant-numeric: tabular-nums;">{r.cantidad}</td>
<td class="p-3 opacity-80">{r.fecha_aprobacion ? fmtFecha.format(new Date(r.fecha_aprobacion)) : '—'}</td>
<td class="p-3">
<span style="font-variant-numeric: tabular-nums;">
{r.fecha_devolucion_estimada ? fmtFecha.format(new Date(r.fecha_devolucion_estimada + 'T00:00:00')) : '—'}
</span>
{vencido && (
<span
class="ml-2 inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold"
style="background: var(--color-danger); color: white;"
>
Vencido
</span>
)}
</td>
<td class="p-3">
<div class="flex justify-end gap-2 flex-wrap">
<VerDetalles client:load prestamoId={r.id} />
<MarcarDevuelto client:load prestamoId={r.id} nombreMaterial={r.material?.nombre ?? '—'} />
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<ul class="md:hidden flex flex-col gap-3">
{filas.map((r: any) => {
const vencido = isVencido(r.fecha_devolucion_estimada);
return (
<li class="card" style={vencido ? 'background: color-mix(in oklab, var(--color-danger) 8%, white);' : ''}>
<div class="flex items-baseline justify-between gap-2">
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
{vencido && (
<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-semibold" style="background: var(--color-danger); color: white;">Vencido</span>
)}
</div>
<div class="text-xs opacity-70 mb-2">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-sm">{r.material?.nombre ?? '—'} <span class="opacity-70">· Inv. {r.material?.numero_inventario ?? '—'}</span></div>
<div class="text-sm mt-1">Cantidad: <strong style="font-variant-numeric: tabular-nums;">{r.cantidad}</strong></div>
<div class="text-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="mt-3 flex gap-2 flex-wrap">
<VerDetalles client:load prestamoId={r.id} />
<MarcarDevuelto client:load prestamoId={r.id} nombreMaterial={r.material?.nombre ?? '—'} />
</div>
</li>
);
})}
</ul>
</>
)}
</div>
</AppLayout>
+151
View File
@@ -0,0 +1,151 @@
---
import AppLayout from '@/layouts/AppLayout.astro';
const supabase = Astro.locals.supabase;
const ALL = ['devuelto', 'rechazado', 'vencido'] as const;
type EstadoHist = typeof ALL[number];
const raw = Astro.url.searchParams.get('estado');
const estadoFiltro = (raw && (ALL as readonly string[]).includes(raw) ? raw : '') as EstadoHist | '';
const estados = estadoFiltro ? [estadoFiltro] : Array.from(ALL);
// ponytail: limit 200, paginar cuando pase de 500 filas
const { data, error } = await supabase
.from('prestamos')
.select(
'id, cantidad, fecha_solicitud, fecha_devolucion_real, estado, notas, alumno:profiles!alumno_id(id, nombre, email, matricula), material:materiales!material_id(id, nombre, numero_inventario)'
)
.in('estado', estados)
.order('fecha_solicitud', { ascending: false })
.limit(200);
const filas = data ?? [];
const fmtFecha = new Intl.DateTimeFormat('es-MX', { dateStyle: 'medium', timeStyle: 'short' });
const path = Astro.url.pathname;
const tabs = [
{ href: '/admin/solicitudes', label: 'Pendientes' },
{ href: '/admin/solicitudes/activos', label: 'En préstamo' },
{ href: '/admin/solicitudes/historial', label: 'Historial' },
];
const estadoLabel: Record<string, string> = {
devuelto: 'Devuelto',
rechazado: 'Rechazado',
vencido: 'Vencido',
};
---
<AppLayout title="Historial de solicitudes — LabPréstamos">
<div class="max-w-6xl">
<header class="mb-4">
<div class="flex items-center gap-3 flex-wrap">
<h1 class="text-2xl md:text-3xl font-semibold">Historial</h1>
<span
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-sm font-semibold"
style="background: color-mix(in oklab, var(--color-ink) 12%, transparent); color: var(--color-ink); font-variant-numeric: tabular-nums;"
aria-label={`${filas.length} registros`}
>
{filas.length}
</span>
</div>
</header>
<nav aria-label="Vistas de solicitudes" class="mb-6 border-b flex gap-1 overflow-x-auto" style="border-color: color-mix(in oklab, var(--color-ink) 12%, transparent);">
{tabs.map((t) => {
const active = path === t.href;
return (
<a
href={t.href}
class:list={['px-4 py-2 text-sm font-medium whitespace-nowrap border-b-2 transition-colors -mb-px', active ? 'text-[color:var(--color-primary)]' : 'opacity-70 hover:opacity-100 border-transparent']}
style={active ? 'border-color: var(--color-primary);' : ''}
aria-current={active ? 'page' : undefined}
>
{t.label}
</a>
);
})}
</nav>
<form method="get" class="mb-4 flex items-end gap-3 flex-wrap">
<div class="flex-1 min-w-[200px] max-w-xs">
<label class="label" for="estado">Filtrar por estado</label>
<select
id="estado"
name="estado"
class="input"
onchange="this.form.submit()"
>
<option value="">Todos</option>
<option value="devuelto" selected={estadoFiltro === 'devuelto'}>Devuelto</option>
<option value="rechazado" selected={estadoFiltro === 'rechazado'}>Rechazado</option>
<option value="vencido" selected={estadoFiltro === 'vencido'}>Vencido</option>
</select>
</div>
<noscript>
<button type="submit" class="btn btn-primary">Aplicar</button>
</noscript>
</form>
{error && (
<div role="alert" class="mb-4 rounded-lg p-3 text-sm" style="background: color-mix(in oklab, var(--color-danger) 12%, transparent); color: var(--color-danger);">
Error cargando historial: {error.message}
</div>
)}
{filas.length === 0 ? (
<div class="card text-center py-12">
<p class="font-medium">No hay registros para este filtro.</p>
</div>
) : (
<>
<div class="card p-0 overflow-hidden hidden md:block">
<table class="w-full text-sm">
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
<tr>
<th class="p-3 font-medium">Alumno</th>
<th class="p-3 font-medium">Material</th>
<th class="p-3 font-medium">Cant.</th>
<th class="p-3 font-medium">Estado</th>
<th class="p-3 font-medium">Solicitado</th>
<th class="p-3 font-medium">Devuelto</th>
</tr>
</thead>
<tbody>
{filas.map((r: any) => (
<tr class="border-t align-top" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);">
<td class="p-3">
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
</td>
<td class="p-3">
<div>{r.material?.nombre ?? '—'}</div>
<div class="text-xs opacity-70">Inv. {r.material?.numero_inventario ?? '—'}</div>
</td>
<td class="p-3" style="font-variant-numeric: tabular-nums;">{r.cantidad}</td>
<td class="p-3">{estadoLabel[r.estado] ?? r.estado}</td>
<td class="p-3 opacity-80">{fmtFecha.format(new Date(r.fecha_solicitud))}</td>
<td class="p-3 opacity-80">{r.fecha_devolucion_real ? fmtFecha.format(new Date(r.fecha_devolucion_real)) : '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
<ul class="md:hidden flex flex-col gap-3">
{filas.map((r: any) => (
<li class="card">
<div class="flex items-baseline justify-between gap-2">
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
<span class="text-xs rounded-full px-2 py-0.5" style="background: color-mix(in oklab, var(--color-ink) 10%, transparent);">{estadoLabel[r.estado] ?? r.estado}</span>
</div>
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-sm mt-2">{r.material?.nombre ?? '—'} · Cantidad <strong style="font-variant-numeric: tabular-nums;">{r.cantidad}</strong></div>
<div class="text-xs opacity-70 mt-2">Solicitado {fmtFecha.format(new Date(r.fecha_solicitud))}</div>
{r.fecha_devolucion_real && <div class="text-xs opacity-70">Devuelto {fmtFecha.format(new Date(r.fecha_devolucion_real))}</div>}
</li>
))}
</ul>
</>
)}
</div>
</AppLayout>
+126
View File
@@ -0,0 +1,126 @@
---
import AppLayout from '@/layouts/AppLayout.astro';
import AccionesSolicitud from '@/components/admin/solicitudes/AccionesSolicitud.tsx';
const supabase = Astro.locals.supabase;
const { data, error } = await supabase
.from('prestamos')
.select(
'id, cantidad, fecha_solicitud, notas, alumno:profiles!alumno_id(id, nombre, email, matricula), material:materiales!material_id(id, nombre, numero_inventario, cantidad_disponible)'
)
.eq('estado', 'pendiente')
.order('fecha_solicitud', { ascending: true });
const filas = data ?? [];
const fmtFecha = new Intl.DateTimeFormat('es-MX', { dateStyle: 'medium', timeStyle: 'short' });
const path = Astro.url.pathname;
const tabs = [
{ href: '/admin/solicitudes', label: 'Pendientes' },
{ href: '/admin/solicitudes/activos', label: 'En préstamo' },
{ href: '/admin/solicitudes/historial', label: 'Historial' },
];
---
<AppLayout title="Solicitudes pendientes — LabPréstamos">
<div class="max-w-6xl">
<header class="mb-4">
<div class="flex items-center gap-3 flex-wrap">
<h1 class="text-2xl md:text-3xl font-semibold">Solicitudes pendientes</h1>
<span
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-sm font-semibold"
style="background: color-mix(in oklab, var(--color-primary) 12%, transparent); color: var(--color-primary); font-variant-numeric: tabular-nums;"
aria-label={`${filas.length} pendientes`}
>
{filas.length}
</span>
</div>
</header>
<nav aria-label="Vistas de solicitudes" class="mb-6 border-b flex gap-1 overflow-x-auto" style="border-color: color-mix(in oklab, var(--color-ink) 12%, transparent);">
{tabs.map((t) => {
const active = path === t.href || (t.href === '/admin/solicitudes' && path === '/admin/solicitudes/');
return (
<a
href={t.href}
class:list={['px-4 py-2 text-sm font-medium whitespace-nowrap border-b-2 transition-colors -mb-px', active ? 'text-[color:var(--color-primary)]' : 'opacity-70 hover:opacity-100 border-transparent']}
style={active ? 'border-color: var(--color-primary);' : ''}
aria-current={active ? 'page' : undefined}
>
{t.label}
</a>
);
})}
</nav>
{error && (
<div role="alert" class="mb-4 rounded-lg p-3 text-sm" style="background: color-mix(in oklab, var(--color-danger) 12%, transparent); color: var(--color-danger);">
Error cargando solicitudes: {error.message}
</div>
)}
{filas.length === 0 ? (
<div class="card text-center py-12">
<div class="text-4xl mb-3" aria-hidden="true">✓</div>
<p class="font-medium">No hay solicitudes por revisar.</p>
<p class="text-sm opacity-70 mt-1">Todo al día.</p>
</div>
) : (
<>
{/* Desktop: tabla */}
<div class="card p-0 overflow-hidden hidden md:block">
<table class="w-full text-sm">
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
<tr>
<th class="p-3 font-medium">Alumno</th>
<th class="p-3 font-medium">Material</th>
<th class="p-3 font-medium">Cantidad</th>
<th class="p-3 font-medium">Solicitado</th>
<th class="p-3 font-medium text-right">Acciones</th>
</tr>
</thead>
<tbody>
{filas.map((r: any) => (
<tr class="border-t align-top" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);">
<td class="p-3">
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
</td>
<td class="p-3">
<div>{r.material?.nombre ?? '—'}</div>
<div class="text-xs opacity-70">
Inv. {r.material?.numero_inventario ?? '—'} · disp. <span style="font-variant-numeric: tabular-nums;">{r.material?.cantidad_disponible ?? 0}</span>
</div>
</td>
<td class="p-3" style="font-variant-numeric: tabular-nums;">{r.cantidad}</td>
<td class="p-3 opacity-80">{fmtFecha.format(new Date(r.fecha_solicitud))}</td>
<td class="p-3">
<div class="flex justify-end">
<AccionesSolicitud client:load prestamo={r} />
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile: tarjetas */}
<ul class="md:hidden flex flex-col gap-3">
{filas.map((r: any) => (
<li class="card">
<div class="flex items-baseline justify-between gap-2">
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
<div class="text-xs opacity-70">{fmtFecha.format(new Date(r.fecha_solicitud))}</div>
</div>
<div class="text-xs opacity-70 mb-2">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-sm">{r.material?.nombre ?? '—'} <span class="opacity-70">· Inv. {r.material?.numero_inventario ?? '—'}</span></div>
<div class="text-sm mt-1">Cantidad: <strong style="font-variant-numeric: tabular-nums;">{r.cantidad}</strong> <span class="opacity-70">· disp. {r.material?.cantidad_disponible ?? 0}</span></div>
<div class="mt-3">
<AccionesSolicitud client:load prestamo={r} />
</div>
</li>
))}
</ul>
</>
)}
</div>
</AppLayout>
+110
View File
@@ -0,0 +1,110 @@
---
import AppLayout from '@/layouts/AppLayout.astro';
import SolicitarModal from '@/components/alumno/SolicitarModal.tsx';
import FiltroCategorias from '@/components/alumno/FiltroCategorias.tsx';
type Material = {
id: number;
nombre: string;
descripcion: string | null;
cantidad_disponible: number;
cantidad_total: number;
numero_inventario: string | null;
categoria: { id: number; nombre: string } | null;
};
const { data, error } = await Astro.locals.supabase
.from('materiales')
.select('id, nombre, descripcion, cantidad_disponible, cantidad_total, numero_inventario, categoria:categorias(id, nombre)')
.eq('estado', 'disponible')
.order('nombre');
const materiales = (data ?? []) as unknown as Material[];
const grupos = new Map<string, { id: number | null; nombre: string; items: Material[] }>();
for (const m of materiales) {
const key = m.categoria ? String(m.categoria.id) : 'sin';
const nombre = m.categoria?.nombre ?? 'Sin categoría';
if (!grupos.has(key)) grupos.set(key, { id: m.categoria?.id ?? null, nombre, items: [] });
grupos.get(key)!.items.push(m);
}
const grupoList = Array.from(grupos.values()).sort((a, b) => a.nombre.localeCompare(b.nombre, 'es'));
const categoriasFiltro = grupoList
.filter((g) => g.id !== null)
.map((g) => ({ id: g.id as number, nombre: g.nombre }));
---
<AppLayout title="Catálogo — LabPréstamos">
<div class="max-w-6xl">
<header class="mb-6">
<h1 class="text-2xl md:text-3xl font-semibold">Catálogo</h1>
<p class="text-sm opacity-70 mt-1">Material disponible en el Laboratorio de Sistemas.</p>
</header>
{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);">
No se pudo cargar el catálogo. Recarga la página o intenta más tarde.
</p>
</div>
)}
{materiales.length === 0 && !error && (
<div class="card text-center">
<h2 class="text-lg font-semibold mb-1">Aún no hay material publicado</h2>
<p class="text-sm opacity-70">Vuelve pronto o contacta al encargado del laboratorio.</p>
</div>
)}
{materiales.length > 0 && (
<>
{categoriasFiltro.length > 0 && (
<div class="mb-6">
<FiltroCategorias categorias={categoriasFiltro} client:idle />
</div>
)}
<p id="catalogo-empty-filter" hidden class="text-sm opacity-70 mb-4">
Ningún material en esta categoría. Prueba con otra.
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{materiales.map((m) => (
<article
class="card flex flex-col gap-3"
data-cat={m.categoria?.id ?? 'sin'}
>
<div class="flex items-start justify-between gap-2">
<h3 class="font-semibold leading-snug">{m.nombre}</h3>
{m.categoria && (
<span class="text-xs px-2 py-0.5 rounded-full whitespace-nowrap" style="background: color-mix(in oklab, var(--color-primary) 10%, transparent); color: var(--color-primary);">
{m.categoria.nombre}
</span>
)}
</div>
{m.descripcion && (
<p class="text-sm opacity-75 line-clamp-2">{m.descripcion}</p>
)}
<dl class="text-xs opacity-70 grid grid-cols-2 gap-1">
<dt class="opacity-60">Inventario</dt>
<dd class="text-right font-mono">{m.numero_inventario ?? '—'}</dd>
<dt class="opacity-60">Disponibles</dt>
<dd class="text-right" style="font-variant-numeric: tabular-nums;">
{m.cantidad_disponible} / {m.cantidad_total}
</dd>
</dl>
<div class="mt-auto">
<SolicitarModal
material={{ id: m.id, nombre: m.nombre, cantidad_disponible: m.cantidad_disponible }}
client:load
/>
</div>
</article>
))}
</div>
</>
)}
</div>
</AppLayout>
+182
View File
@@ -0,0 +1,182 @@
---
import AppLayout from '@/layouts/AppLayout.astro';
type Estado = 'pendiente' | 'aprobado' | 'rechazado' | 'activo' | 'devuelto' | 'vencido';
type Prestamo = {
id: number;
cantidad: number;
estado: Estado;
fecha_solicitud: string;
fecha_aprobacion: string | null;
fecha_devolucion_estimada: string | null;
fecha_devolucion_real: string | null;
notas: string | null;
material: { id: number; nombre: string; numero_inventario: string | null } | null;
};
const user = Astro.locals.user!;
const { data, error } = await Astro.locals.supabase
.from('prestamos')
.select('id, cantidad, estado, fecha_solicitud, fecha_aprobacion, fecha_devolucion_estimada, fecha_devolucion_real, notas, material:materiales(id, nombre, numero_inventario)')
.eq('alumno_id', user.id)
.order('fecha_solicitud', { ascending: false });
const prestamos = (data ?? []) as unknown as Prestamo[];
const activosSet = new Set<Estado>(['pendiente', 'aprobado', 'activo']);
const activos = prestamos.filter((p) => activosSet.has(p.estado));
const historial = prestamos.filter((p) => !activosSet.has(p.estado));
const fmtFecha = new Intl.DateTimeFormat('es-MX', { dateStyle: 'medium' });
const fmt = (d: string | null) => (d ? fmtFecha.format(new Date(d)) : '—');
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-primary)',
activo: 'var(--color-primary)',
devuelto: 'var(--color-ink)',
rechazado: 'var(--color-danger)',
vencido: 'var(--color-danger)',
};
const badgeStyle = (e: Estado) =>
`background: color-mix(in oklab, ${estadoColor[e]} 15%, transparent); color: ${estadoColor[e]};`;
---
<AppLayout title="Mis préstamos — LabPréstamos">
<div class="max-w-4xl">
<header class="mb-6">
<h1 class="text-2xl md:text-3xl font-semibold">Mis préstamos</h1>
<p class="text-sm opacity-70 mt-1">Estado de tus solicitudes actuales e historial.</p>
</header>
{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);">
No se pudieron cargar tus préstamos. Recarga la página o intenta más tarde.
</p>
</div>
)}
{!error && prestamos.length === 0 && (
<div class="card text-center">
<h2 class="text-lg font-semibold mb-1">Aún no has solicitado material</h2>
<p class="text-sm opacity-70 mb-4">Explora el catálogo y envía tu primera solicitud.</p>
<a href="/alumno/catalogo" class="btn btn-primary">Ir al catálogo</a>
</div>
)}
{prestamos.length > 0 && (
<div class="space-y-8">
<section>
<h2 class="text-lg font-semibold mb-3">Activos <span class="text-sm opacity-60" style="font-variant-numeric: tabular-nums;">({activos.length})</span></h2>
{activos.length === 0 ? (
<p class="text-sm opacity-70">Nada activo por ahora.</p>
) : (
<ul class="space-y-3">
{activos.map((p) => (
<li class="card">
<div class="flex items-start justify-between gap-3 flex-wrap">
<div class="min-w-0">
<h3 class="font-semibold leading-snug truncate">{p.material?.nombre ?? 'Material eliminado'}</h3>
<p class="text-xs opacity-60 font-mono mt-0.5">
{p.material?.numero_inventario ?? '—'}
</p>
</div>
<span class="text-xs font-medium px-2 py-1 rounded-full whitespace-nowrap" style={badgeStyle(p.estado)}>
{estadoLabel[p.estado]}
</span>
</div>
<dl class="mt-3 grid grid-cols-2 gap-y-1 text-sm">
<dt class="opacity-60">Cantidad</dt>
<dd class="text-right" style="font-variant-numeric: tabular-nums;">{p.cantidad}</dd>
<dt class="opacity-60">Solicitado</dt>
<dd class="text-right">{fmt(p.fecha_solicitud)}</dd>
{p.fecha_aprobacion && (
<>
<dt class="opacity-60">Aprobado</dt>
<dd class="text-right">{fmt(p.fecha_aprobacion)}</dd>
</>
)}
{p.fecha_devolucion_estimada && (
<>
<dt class="opacity-60">Devolver antes de</dt>
<dd class="text-right">{fmt(p.fecha_devolucion_estimada)}</dd>
</>
)}
</dl>
{p.notas && (
<p class="mt-3 text-sm opacity-75 border-l-2 pl-3" style="border-color: color-mix(in oklab, var(--color-ink) 15%, transparent);">
{p.notas}
</p>
)}
</li>
))}
</ul>
)}
</section>
<section>
<details open={historial.length <= 5}>
<summary class="text-lg font-semibold cursor-pointer list-none flex items-center gap-2">
<span aria-hidden="true"></span>
Historial <span class="text-sm opacity-60" style="font-variant-numeric: tabular-nums;">({historial.length})</span>
</summary>
{historial.length === 0 ? (
<p class="text-sm opacity-70 mt-3">Sin historial todavía.</p>
) : (
<ul class="space-y-3 mt-3">
{historial.map((p) => (
<li class="card" style="opacity: 0.92;">
<div class="flex items-start justify-between gap-3 flex-wrap">
<div class="min-w-0">
<h3 class="font-semibold leading-snug truncate">{p.material?.nombre ?? 'Material eliminado'}</h3>
<p class="text-xs opacity-60 font-mono mt-0.5">
{p.material?.numero_inventario ?? '—'}
</p>
</div>
<span class="text-xs font-medium px-2 py-1 rounded-full whitespace-nowrap" style={badgeStyle(p.estado)}>
{estadoLabel[p.estado]}
</span>
</div>
<dl class="mt-3 grid grid-cols-2 gap-y-1 text-sm">
<dt class="opacity-60">Cantidad</dt>
<dd class="text-right" style="font-variant-numeric: tabular-nums;">{p.cantidad}</dd>
<dt class="opacity-60">Solicitado</dt>
<dd class="text-right">{fmt(p.fecha_solicitud)}</dd>
{p.fecha_devolucion_real && (
<>
<dt class="opacity-60">Devuelto</dt>
<dd class="text-right">{fmt(p.fecha_devolucion_real)}</dd>
</>
)}
</dl>
</li>
))}
</ul>
)}
</details>
</section>
</div>
)}
</div>
</AppLayout>
<style>
details > summary::-webkit-details-marker { display: none; }
details[open] > summary > span[aria-hidden] { transform: rotate(90deg); }
details > summary > span[aria-hidden] {
display: inline-block;
transition: transform 150ms ease-out;
}
</style>
+71
View File
@@ -0,0 +1,71 @@
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 };
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 { error } = await locals.supabase
.from('categorias')
.update({ nombre })
.eq('id', id);
if (error) {
if ((error as { code?: string }).code === '23505') {
return Response.json({ error: 'Ya existe una categoría 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('materiales')
.select('id', { count: 'exact', head: true })
.eq('categoria_id', id);
const { error } = await locals.supabase.from('categorias').delete().eq('id', id);
if (error) {
if ((error as { code?: string }).code === '23503') {
return Response.json(
{ error: `Tiene ${count ?? 'varios'} materiales asociados — reasígnalos primero` },
{ status: 409 },
);
}
return Response.json({ error: 'No se pudo eliminar' }, { status: 500 });
}
return Response.json({ ok: true });
};
+36
View File
@@ -0,0 +1,36 @@
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 };
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 { data, error } = await locals.supabase
.from('categorias')
.insert({ nombre })
.select('id')
.single();
if (error) {
if ((error as { code?: string }).code === '23505') {
return Response.json({ error: 'Ya existe una categoría con ese nombre' }, { status: 409 });
}
return Response.json({ error: 'No se pudo crear la categoría' }, { status: 500 });
}
return Response.json({ id: data.id }, { status: 201 });
};
+140
View File
@@ -0,0 +1,140 @@
import type { APIRoute } from 'astro';
export const prerender = false;
type Patch = {
nombre?: string | null;
categoria_id?: number | null;
descripcion?: string | null;
numero_inventario?: string | null;
estado?: string;
cantidad_total?: number;
cantidad_disponible?: number;
};
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: Record<string, unknown>;
try {
body = await request.json();
} catch {
return Response.json({ error: 'JSON inválido' }, { status: 400 });
}
const patch: Patch = {};
if ('nombre' in body) {
const nombre = typeof body.nombre === 'string' ? body.nombre.trim() : '';
if (!nombre) return Response.json({ error: 'El nombre no puede quedar vacío' }, { status: 400 });
patch.nombre = nombre;
}
if ('categoria_id' in body) {
if (body.categoria_id === null || body.categoria_id === '') {
patch.categoria_id = null;
} else {
const cid = Number(body.categoria_id);
if (!Number.isInteger(cid) || cid <= 0)
return Response.json({ error: 'Categoría inválida' }, { status: 400 });
patch.categoria_id = cid;
}
}
if ('descripcion' in body) {
patch.descripcion =
typeof body.descripcion === 'string' && body.descripcion.trim()
? body.descripcion.trim()
: null;
}
if ('numero_inventario' in body) {
patch.numero_inventario =
typeof body.numero_inventario === 'string' && body.numero_inventario.trim()
? body.numero_inventario.trim()
: null;
}
if ('estado' in body) {
const estado = typeof body.estado === 'string' ? body.estado : '';
if (!['disponible', 'mantenimiento', 'baja'].includes(estado))
return Response.json({ error: 'Estado inválido' }, { status: 400 });
patch.estado = estado;
}
if ('cantidad_total' in body) {
const nuevoTotal = Number(body.cantidad_total);
if (!Number.isInteger(nuevoTotal) || nuevoTotal < 0)
return Response.json({ error: 'Cantidad total inválida' }, { status: 400 });
const { data: actual, error: selErr } = await locals.supabase
.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) {
return Response.json(
{ error: `No puede quedar debajo de lo prestado (${prestados})` },
{ status: 409 },
);
}
patch.cantidad_total = nuevoTotal;
patch.cantidad_disponible = actual.cantidad_disponible + (nuevoTotal - actual.cantidad_total);
}
if (Object.keys(patch).length === 0) {
return Response.json({ error: 'Nada que actualizar' }, { status: 400 });
}
const { error } = await locals.supabase.from('materiales').update(patch).eq('id', id);
if (error) {
if ((error as { code?: string }).code === '23505') {
return Response.json({ error: 'Nº de inventario ya existe' }, { status: 409 });
}
if ((error as { code?: string }).code === '23503') {
return Response.json({ error: 'Categoría inexistente' }, { 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 { error } = await locals.supabase.from('materiales').delete().eq('id', id);
if (error) {
if ((error as { code?: string }).code === '23503') {
return Response.json(
{ error: 'Tiene préstamos asociados — no se puede eliminar' },
{ status: 409 },
);
}
return Response.json({ error: 'No se pudo eliminar' }, { status: 500 });
}
return Response.json({ ok: true });
};
+79
View File
@@ -0,0 +1,79 @@
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;
categoria_id?: unknown;
descripcion?: unknown;
cantidad_total?: unknown;
numero_inventario?: unknown;
estado?: 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 cantidad_total = Number(body.cantidad_total);
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 });
}
const categoria_id =
body.categoria_id === null || body.categoria_id === undefined || body.categoria_id === ''
? null
: Number(body.categoria_id);
if (categoria_id !== null && (!Number.isInteger(categoria_id) || categoria_id <= 0)) {
return Response.json({ error: 'Categoría inválida' }, { status: 400 });
}
const descripcion =
typeof body.descripcion === 'string' && body.descripcion.trim() ? body.descripcion.trim() : null;
const numero_inventario =
typeof body.numero_inventario === 'string' && body.numero_inventario.trim()
? body.numero_inventario.trim()
: null;
const estadoIn = typeof body.estado === 'string' ? body.estado : 'disponible';
if (!['disponible', 'mantenimiento', 'baja'].includes(estadoIn)) {
return Response.json({ error: 'Estado inválido' }, { status: 400 });
}
const { data, error } = await locals.supabase
.from('materiales')
.insert({
nombre,
categoria_id,
descripcion,
cantidad_total,
cantidad_disponible: cantidad_total,
numero_inventario,
estado: estadoIn,
})
.select('id')
.single();
if (error) {
if ((error as { code?: string }).code === '23505') {
return Response.json({ error: 'Nº de inventario ya existe' }, { status: 409 });
}
if ((error as { code?: string }).code === '23503') {
return Response.json({ error: 'Categoría inexistente' }, { status: 409 });
}
return Response.json({ error: 'No se pudo crear el material' }, { status: 500 });
}
return Response.json({ id: data.id }, { status: 201 });
};
@@ -0,0 +1,44 @@
import type { APIRoute } from 'astro';
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
export const POST: APIRoute = async ({ params, request, locals }) => {
if (!locals.user) return json({ error: 'no autenticado' }, 401);
if (locals.profile?.rol !== 'admin') return json({ error: 'no autorizado' }, 403);
const id = Number(params.id);
if (!Number.isInteger(id) || id <= 0) return json({ error: 'id inválido' }, 400);
let body: any;
try { body = await request.json(); } catch { return json({ error: 'JSON inválido' }, 400); }
const fecha = String(body?.fecha_devolucion_estimada ?? '');
if (!ISO_DATE.test(fecha)) return json({ error: 'fecha_devolucion_estimada debe ser YYYY-MM-DD' }, 400);
const hoy = new Date().toISOString().split('T')[0];
if (fecha < hoy) return json({ error: 'la fecha no puede ser pasada' }, 400);
const notas = typeof body?.notas === 'string' && body.notas.trim() ? body.notas.trim() : null;
const update: Record<string, unknown> = {
estado: 'aprobado',
fecha_aprobacion: new Date().toISOString(),
fecha_devolucion_estimada: fecha,
aprobado_por: locals.user.id,
};
if (notas) update.notas = notas;
const { data, error } = await locals.supabase
.from('prestamos')
.update(update)
.eq('id', id)
.eq('estado', 'pendiente')
.select('id')
.maybeSingle();
if (error) return json({ error: error.message }, 500);
if (!data) return json({ error: 'la solicitud ya no está pendiente' }, 409);
return json({ ok: true });
};
@@ -0,0 +1,35 @@
import type { APIRoute } from 'astro';
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
export const GET: APIRoute = async ({ params, locals }) => {
if (!locals.user) return json({ error: 'no autenticado' }, 401);
if (locals.profile?.rol !== 'admin') return json({ error: 'no autorizado' }, 403);
const id = Number(params.id);
if (!Number.isInteger(id) || id <= 0) return json({ error: 'id inválido' }, 400);
const prestamoQ = locals.supabase
.from('prestamos')
.select(
'id, cantidad, estado, fecha_solicitud, fecha_aprobacion, fecha_devolucion_estimada, fecha_devolucion_real, notas, alumno:profiles!alumno_id(id, nombre, email, matricula), material:materiales!material_id(id, nombre, numero_inventario)'
)
.eq('id', id)
.maybeSingle();
// Nombre y orden asumidos del audit_log: select('*') para no romper si el esquema difiere.
const logQ = locals.supabase
.from('audit_log')
.select('*')
.eq('prestamo_id', id)
.order('created_at', { ascending: false })
.limit(20);
const [{ data: prestamo, error: pErr }, { data: audit_log, error: lErr }] = await Promise.all([prestamoQ, logQ]);
if (pErr) return json({ error: pErr.message }, 500);
if (!prestamo) return json({ error: 'no encontrado' }, 404);
return json({ prestamo, audit_log: lErr ? [] : (audit_log ?? []) });
};
@@ -0,0 +1,24 @@
import type { APIRoute } from 'astro';
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
export const POST: APIRoute = async ({ params, locals }) => {
if (!locals.user) return json({ error: 'no autenticado' }, 401);
if (locals.profile?.rol !== 'admin') return json({ error: 'no autorizado' }, 403);
const id = Number(params.id);
if (!Number.isInteger(id) || id <= 0) return json({ error: 'id inválido' }, 400);
const { data, error } = await locals.supabase
.from('prestamos')
.update({ estado: 'devuelto', fecha_devolucion_real: new Date().toISOString() })
.eq('id', id)
.in('estado', ['aprobado', 'activo'])
.select('id')
.maybeSingle();
if (error) return json({ error: error.message }, 500);
if (!data) return json({ error: 'el préstamo no está en curso' }, 409);
return json({ ok: true });
};
@@ -0,0 +1,30 @@
import type { APIRoute } from 'astro';
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
export const POST: APIRoute = async ({ params, request, locals }) => {
if (!locals.user) return json({ error: 'no autenticado' }, 401);
if (locals.profile?.rol !== 'admin') return json({ error: 'no autorizado' }, 403);
const id = Number(params.id);
if (!Number.isInteger(id) || id <= 0) return json({ error: 'id inválido' }, 400);
let body: any;
try { body = await request.json(); } catch { return json({ error: 'JSON inválido' }, 400); }
const motivo = typeof body?.motivo === 'string' ? body.motivo.trim() : '';
if (motivo.length < 5) return json({ error: 'el motivo debe tener al menos 5 caracteres' }, 400);
const { data, error } = await locals.supabase
.from('prestamos')
.update({ estado: 'rechazado', notas: motivo, aprobado_por: locals.user.id })
.eq('id', id)
.eq('estado', 'pendiente')
.select('id')
.maybeSingle();
if (error) return json({ error: error.message }, 500);
if (!data) return json({ error: 'la solicitud ya no está pendiente' }, 409);
return json({ ok: true });
};
+28
View File
@@ -0,0 +1,28 @@
import type { APIRoute } from 'astro';
export const prerender = false;
export const GET: APIRoute = async ({ locals, url }) => {
if (locals.profile?.rol !== 'admin') {
return new Response('Forbidden', { status: 403 });
}
const raw = (url.searchParams.get('q') ?? '').trim();
// ponytail: strip wildcards + comma so .or() parses cleanly; swap for FTS index if search feels slow
const q = raw.replace(/[%_,\\]/g, ' ').slice(0, 64);
if (!q) {
return Response.json([]);
}
const pattern = `%${q}%`;
const { data, error } = await locals.supabase
.from('profiles')
.select('id, nombre, email, matricula')
.or(`nombre.ilike.${pattern},email.ilike.${pattern},matricula.ilike.${pattern}`)
.limit(10);
if (error) return new Response(error.message, { status: 500 });
const items = (data ?? []).map((p) => {
const nombre = p.nombre ?? p.email;
const mat = p.matricula ? ` (${p.matricula})` : '';
return { id: p.id, label: `${nombre}${mat}${p.email}` };
});
return Response.json(items);
};
+90
View File
@@ -0,0 +1,90 @@
import type { APIRoute } from 'astro';
export const prerender = false;
const HEADERS = [
'Fecha solicitud',
'Alumno',
'Matrícula',
'Email',
'Material',
'Nº inventario',
'Cantidad',
'Estado',
'Fecha aprobación',
'Fecha devolución estimada',
'Fecha devolución real',
'Notas',
];
function csvCell(v: unknown): string {
const s = v == null ? '' : String(v);
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
function isoDate(v: string | null | undefined): string {
if (!v) return '';
// Timestamps y dates ambos son ISO-parseables; toISOString().slice(0,10) devuelve YYYY-MM-DD
const d = new Date(v);
return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 10);
}
export const GET: APIRoute = async ({ locals, url }) => {
if (locals.profile?.rol !== 'admin') {
return new Response('Forbidden', { status: 403 });
}
const sp = url.searchParams;
const desde = sp.get('desde');
const hasta = sp.get('hasta');
const estado = sp.get('estado');
const materialId = sp.get('material_id');
const alumnoId = sp.get('alumno_id');
let query = locals.supabase
.from('prestamos')
.select(`
id, cantidad, fecha_solicitud, fecha_aprobacion, fecha_devolucion_estimada, fecha_devolucion_real, estado, notas,
alumno:profiles!alumno_id(nombre, email, matricula),
material:materiales!material_id(nombre, numero_inventario)
`)
.order('fecha_solicitud', { ascending: false })
// ponytail: 5000 evita OOM en export ad-hoc; paginar/streamear si un solo reporte lo excede rutinariamente
.limit(5000);
if (desde) query = query.gte('fecha_solicitud', desde);
if (hasta) query = query.lte('fecha_solicitud', `${hasta}T23:59:59.999Z`);
if (estado && estado !== 'all') query = query.eq('estado', estado);
if (materialId) query = query.eq('material_id', materialId);
if (alumnoId) query = query.eq('alumno_id', alumnoId);
const { data, error } = await query;
if (error) return new Response(error.message, { status: 500 });
const rows = (data ?? []).map((r: any) => [
isoDate(r.fecha_solicitud),
r.alumno?.nombre ?? '',
r.alumno?.matricula ?? '',
r.alumno?.email ?? '',
r.material?.nombre ?? '',
r.material?.numero_inventario ?? '',
r.cantidad,
r.estado,
isoDate(r.fecha_aprobacion),
isoDate(r.fecha_devolucion_estimada),
isoDate(r.fecha_devolucion_real),
r.notas ?? '',
]);
const lines = [HEADERS, ...rows].map((row) => row.map(csvCell).join(','));
// BOM para que Excel abra UTF-8 sin romper acentos
const csv = '' + lines.join('\r\n') + '\r\n';
const today = new Date().toISOString().slice(0, 10);
return new Response(csv, {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="reporte-${today}.csv"`,
'Cache-Control': 'no-store',
},
});
};
@@ -0,0 +1,27 @@
import type { APIRoute } from 'astro';
export const prerender = false;
export const GET: APIRoute = async ({ locals, url }) => {
if (locals.profile?.rol !== 'admin') {
return new Response('Forbidden', { status: 403 });
}
const raw = (url.searchParams.get('q') ?? '').trim();
// ponytail: strip PostgREST wildcards + comma so ilike/or() stay literal; FTS when catálogo crece >2k
const q = raw.replace(/[%_,\\]/g, ' ').slice(0, 64);
if (!q) {
return Response.json([]);
}
const { data, error } = await locals.supabase
.from('materiales')
.select('id, nombre, numero_inventario')
.ilike('nombre', `%${q}%`)
.order('nombre', { ascending: true })
.limit(10);
if (error) return new Response(error.message, { status: 500 });
const items = (data ?? []).map((m) => ({
id: m.id,
label: m.numero_inventario ? `${m.nombre}${m.numero_inventario}` : m.nombre,
}));
return Response.json(items);
};
+23
View File
@@ -0,0 +1,23 @@
import type { APIRoute } from 'astro';
import { serverClient } from '@/lib/supabase';
export const GET: APIRoute = async ({ url, cookies, redirect }) => {
const code = url.searchParams.get('code');
const errorParam = url.searchParams.get('error');
if (errorParam || !code) {
return redirect('/login?error=oauth');
}
const supabase = serverClient(cookies);
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (error) return redirect('/login?error=oauth');
const { data: { user } } = await supabase.auth.getUser();
if (user && !user.email?.toLowerCase().endsWith('@uabc.edu.mx')) {
await supabase.auth.signOut();
return redirect('/login?error=dominio');
}
return redirect('/');
};
+7
View File
@@ -0,0 +1,7 @@
import type { APIRoute } from 'astro';
import { serverClient } from '@/lib/supabase';
export const POST: APIRoute = async ({ cookies, redirect }) => {
await serverClient(cookies).auth.signOut();
return redirect('/login');
};
+58
View File
@@ -0,0 +1,58 @@
import type { APIRoute } from 'astro';
export const prerender = false;
export const POST: APIRoute = async ({ request, locals }) => {
const user = locals.user;
if (!user) {
return Response.json({ error: 'No autenticado' }, { status: 401 });
}
let body: { material_id?: unknown; cantidad?: unknown; notas?: unknown };
try {
body = await request.json();
} catch {
return Response.json({ error: 'JSON inválido' }, { status: 400 });
}
const material_id = Number(body.material_id);
const cantidad = Number(body.cantidad);
const notas = typeof body.notas === 'string' && body.notas.trim() ? body.notas.trim() : null;
if (!Number.isInteger(material_id) || material_id <= 0) {
return Response.json({ error: 'material_id requerido' }, { status: 400 });
}
if (!Number.isInteger(cantidad) || cantidad <= 0) {
return Response.json({ error: 'cantidad debe ser un entero mayor que cero' }, { status: 400 });
}
const supabase = locals.supabase;
const { data: material, error: matErr } = await supabase
.from('materiales')
.select('id, cantidad_disponible, estado')
.eq('id', material_id)
.maybeSingle();
if (matErr) {
return Response.json({ error: 'No se pudo verificar el material' }, { status: 500 });
}
if (!material || material.estado !== 'disponible') {
return Response.json({ error: 'Material no disponible' }, { status: 404 });
}
if (material.cantidad_disponible < cantidad) {
return Response.json({ error: 'Sin stock suficiente' }, { status: 409 });
}
const { data, error } = await supabase
.from('prestamos')
.insert({ alumno_id: user.id, material_id, cantidad, notas })
.select('id')
.single();
if (error) {
return Response.json({ error: 'No se pudo registrar la solicitud' }, { status: 500 });
}
return Response.json({ id: data.id }, { status: 201 });
};
+41 -7
View File
@@ -1,11 +1,45 @@
--- ---
import Welcome from '../components/Welcome.astro'; import AppLayout from '@/layouts/AppLayout.astro';
import Layout from '../layouts/Layout.astro';
// Welcome to Astro! Wondering what to do next? Check out the Astro documentation at https://docs.astro.build const profile = Astro.locals.profile;
// Don't want to use any of this? Delete everything in this file, the `assets`, `components`, and `layouts` directories, and start fresh. const isAdmin = profile?.rol === 'admin';
const nombre = profile?.nombre ?? profile?.email?.split('@')[0] ?? '';
--- ---
<AppLayout title="Inicio — LabPréstamos">
<div class="max-w-3xl">
<header class="mb-8">
<p class="text-sm opacity-60">Bienvenido{nombre ? ',' : ''}</p>
<h1 class="text-2xl md:text-3xl font-semibold">{nombre || 'Laboratorio de Sistemas'}</h1>
</header>
<Layout> <div class="grid gap-4 md:grid-cols-2">
<Welcome /> {isAdmin ? (
</Layout> <>
<a href="/admin/solicitudes" class="card block hover:brightness-105 transition-[filter,transform] duration-150 motion-safe:hover:-translate-y-0.5">
<h2 class="font-semibold text-lg mb-1">Solicitudes</h2>
<p class="text-sm opacity-70">Aprobar o rechazar peticiones de material.</p>
</a>
<a href="/admin/inventario" class="card block hover:brightness-105 transition-[filter,transform] duration-150 motion-safe:hover:-translate-y-0.5">
<h2 class="font-semibold text-lg mb-1">Inventario</h2>
<p class="text-sm opacity-70">Materiales y categorías del laboratorio.</p>
</a>
<a href="/admin/reportes" class="card block hover:brightness-105 transition-[filter,transform] duration-150 motion-safe:hover:-translate-y-0.5 md:col-span-2">
<h2 class="font-semibold text-lg mb-1">Reportes</h2>
<p class="text-sm opacity-70">Historial y exportación de préstamos.</p>
</a>
</>
) : (
<>
<a href="/alumno/catalogo" class="card block hover:brightness-105 transition-[filter,transform] duration-150 motion-safe:hover:-translate-y-0.5">
<h2 class="font-semibold text-lg mb-1">Catálogo</h2>
<p class="text-sm opacity-70">Ver material disponible y solicitar un préstamo.</p>
</a>
<a href="/alumno/mis-prestamos" class="card block hover:brightness-105 transition-[filter,transform] duration-150 motion-safe:hover:-translate-y-0.5">
<h2 class="font-semibold text-lg mb-1">Mis préstamos</h2>
<p class="text-sm opacity-70">Estado de tus solicitudes actuales e historial.</p>
</a>
</>
)}
</div>
</div>
</AppLayout>
+56
View File
@@ -0,0 +1,56 @@
---
import Layout from '@/layouts/Layout.astro';
const url = Astro.url;
const errorParam = url.searchParams.get('error');
const errorMsg = errorParam === 'dominio'
? 'Debes iniciar sesión con tu correo institucional @uabc.edu.mx'
: errorParam === 'oauth'
? 'No se pudo completar la autenticación con Google. Inténtalo de nuevo.'
: null;
const appUrl = import.meta.env.PUBLIC_APP_URL ?? url.origin;
const supabaseUrl = import.meta.env.PUBLIC_SUPABASE_URL;
const redirectTo = `${appUrl}/api/auth/callback`;
const googleAuthUrl = `${supabaseUrl}/auth/v1/authorize?provider=google&redirect_to=${encodeURIComponent(redirectTo)}`;
---
<Layout title="Iniciar sesión — LabPréstamos UABC">
<main id="main" class="min-h-screen grid place-items-center p-4">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<div class="inline-grid place-items-center w-16 h-16 rounded-2xl bg-[color:var(--color-primary)] text-white font-bold text-3xl mb-4 motion-safe:animate-[fadein_.4s_ease-out]">L</div>
<h1 class="text-2xl md:text-3xl font-semibold">Sistema de Préstamos</h1>
<p class="text-sm mt-2 opacity-70">Laboratorio de Sistemas Computacionales · UABC</p>
</div>
<div class="card motion-safe:animate-[fadein_.5s_ease-out]">
<h2 class="text-lg font-semibold mb-1">Inicia sesión</h2>
<p class="text-sm opacity-70 mb-6">Accede con tu cuenta institucional.</p>
<a href={googleAuthUrl} class="btn btn-primary w-full">
<svg width="18" height="18" viewBox="0 0 48 48" aria-hidden="true" focusable="false">
<path fill="#fff" d="M44.5 20H24v8.5h11.7C34.4 33.4 29.7 36 24 36c-6.6 0-12-5.4-12-12s5.4-12 12-12c2.9 0 5.5 1 7.6 2.7l6.2-6.2C33.8 5.1 29.2 3 24 3 12.4 3 3 12.4 3 24s9.4 21 21 21c11 0 20-8 20-21 0-1.4-.1-2.7-.5-4z"/>
</svg>
Continuar con Google
</a>
{errorMsg && (
<div role="alert" class="mt-4 rounded-lg p-3 text-sm" style="background: color-mix(in oklab, var(--color-danger) 12%, transparent); color: var(--color-danger);">
{errorMsg}
</div>
)}
</div>
<p class="text-center text-xs opacity-60 mt-6">
Acceso exclusivo con correo institucional <strong>@uabc.edu.mx</strong>
</p>
</div>
</main>
</Layout>
<style is:global>
@keyframes fadein {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
+77
View File
@@ -0,0 +1,77 @@
@import "tailwindcss";
@import "@fontsource-variable/inter";
@theme {
--color-primary: #00723F;
--color-primary-hover: #005f34;
--color-secondary: #DD971A;
--color-secondary-hover: #c78614;
--color-surface: #F4F7F5;
--color-ink: #2D3748;
--color-danger: #C53030;
--font-sans: "Inter Variable", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--radius-card: 0.5rem;
}
@layer base {
html {
color-scheme: light;
}
body {
background: var(--color-surface);
color: var(--color-ink);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}
@layer components {
.btn {
@apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 font-medium
transition-[filter,background-color,color,border-color] duration-150 ease-out
motion-safe:hover:brightness-110
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--color-primary)]
disabled:opacity-50 disabled:pointer-events-none;
min-height: 44px;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.btn-primary {
background: var(--color-primary);
color: white;
}
.btn-secondary {
background: var(--color-secondary);
color: white;
}
.btn-ghost {
background: transparent;
color: var(--color-ink);
border: 1px solid color-mix(in oklab, var(--color-ink) 15%, transparent);
}
.card {
@apply rounded-lg bg-white p-6;
border: 1px solid color-mix(in oklab, var(--color-ink) 10%, transparent);
}
.input {
@apply w-full rounded-lg bg-white px-3 py-2.5
transition-[border-color,box-shadow] duration-150
focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2
focus-visible:outline-[color:var(--color-primary)];
border: 1px solid color-mix(in oklab, var(--color-ink) 20%, transparent);
min-height: 44px;
touch-action: manipulation;
}
.label {
@apply block text-sm font-medium mb-1;
color: color-mix(in oklab, var(--color-ink) 90%, transparent);
}
h1, h2, h3 { text-wrap: balance; }
p { text-wrap: pretty; }
}
+270
View File
@@ -0,0 +1,270 @@
-- 0001_init.sql
-- Sistema de Prestamos - Laboratorio de Sistemas Computacionales UABC
-- Schema aislado 'prestamos'. Coexiste con otras apps que usan 'public'.
begin;
create schema if not exists prestamos;
grant usage on schema prestamos to anon, authenticated, service_role;
-- ============================================================
-- Perfiles
-- ============================================================
create table prestamos.profiles (
id uuid primary key references auth.users(id) on delete cascade,
email text unique not null,
nombre text,
matricula text,
rol text not null default 'alumno' check (rol in ('alumno','admin')),
created_at timestamptz not null default now()
);
-- ============================================================
-- Categorias e inventario
-- ============================================================
create table prestamos.categorias (
id serial primary key,
nombre text unique not null
);
create table prestamos.materiales (
id serial primary key,
nombre text not null,
categoria_id int references prestamos.categorias(id) on delete restrict,
descripcion text,
cantidad_total int not null check (cantidad_total >= 0),
cantidad_disponible int not null check (cantidad_disponible >= 0),
numero_inventario text unique,
estado text not null default 'disponible' check (estado in ('disponible','mantenimiento','baja')),
created_at timestamptz not null default now(),
constraint disponibilidad_no_excede check (cantidad_disponible <= cantidad_total)
);
-- ============================================================
-- Prestamos
-- ============================================================
create table prestamos.prestamos (
id bigserial primary key,
alumno_id uuid not null references prestamos.profiles(id),
material_id int not null references prestamos.materiales(id),
cantidad int not null check (cantidad > 0),
estado text not null default 'pendiente'
check (estado in ('pendiente','aprobado','rechazado','activo','devuelto','vencido')),
fecha_solicitud timestamptz not null default now(),
fecha_aprobacion timestamptz,
fecha_devolucion_estimada date,
fecha_devolucion_real timestamptz,
aprobado_por uuid references prestamos.profiles(id),
notas text
);
create index prestamos_alumno_idx on prestamos.prestamos(alumno_id);
create index prestamos_estado_idx on prestamos.prestamos(estado);
create index prestamos_material_idx on prestamos.prestamos(material_id);
-- ============================================================
-- Audit log
-- ============================================================
create table prestamos.audit_log (
id bigserial primary key,
prestamo_id bigint references prestamos.prestamos(id) on delete cascade,
actor_id uuid references prestamos.profiles(id),
accion text not null,
estado_anterior text,
estado_nuevo text,
at timestamptz not null default now(),
payload jsonb
);
create index audit_prestamo_idx on prestamos.audit_log(prestamo_id);
create index audit_at_idx on prestamos.audit_log(at desc);
-- ============================================================
-- Helpers
-- ============================================================
create or replace function prestamos.is_admin()
returns boolean
language sql
stable
security definer
set search_path = prestamos, pg_temp
as $$
select coalesce((select rol = 'admin' from prestamos.profiles where id = auth.uid()), false);
$$;
grant execute on function prestamos.is_admin() to anon, authenticated;
-- ============================================================
-- Trigger: crear perfil al registrar usuario en auth.users
-- Rechaza emails no @uabc.edu.mx (segunda linea de defensa)
-- ============================================================
create or replace function prestamos.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = prestamos, pg_temp
as $$
begin
if new.email is null or new.email !~* '@uabc\.edu\.mx$' then
raise exception 'dominio_no_permitido: solo se permite @uabc.edu.mx';
end if;
insert into prestamos.profiles (id, email, nombre)
values (
new.id,
new.email,
coalesce(new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'name')
)
on conflict (id) do nothing;
return new;
end;
$$;
-- ponytail: trigger nombrado con prefijo 'prestamos_' para no colisionar con
-- posibles triggers de otras apps sobre auth.users
drop trigger if exists prestamos_on_auth_user_created on auth.users;
create trigger prestamos_on_auth_user_created
after insert on auth.users
for each row execute function prestamos.handle_new_user();
-- ============================================================
-- Trigger: mantener cantidad_disponible al cambiar estado del prestamo
-- ============================================================
create or replace function prestamos.sync_stock()
returns trigger
language plpgsql
security definer
set search_path = prestamos, pg_temp
as $$
begin
-- transiciones que sacan stock
if new.estado in ('aprobado','activo') and old.estado not in ('aprobado','activo') then
update prestamos.materiales
set cantidad_disponible = cantidad_disponible - new.cantidad
where id = new.material_id;
end if;
-- transiciones que devuelven stock
if new.estado in ('devuelto','rechazado') and old.estado in ('aprobado','activo') then
update prestamos.materiales
set cantidad_disponible = cantidad_disponible + new.cantidad
where id = new.material_id;
end if;
return new;
end;
$$;
drop trigger if exists prestamos_sync_stock on prestamos.prestamos;
create trigger prestamos_sync_stock
after update of estado on prestamos.prestamos
for each row execute function prestamos.sync_stock();
-- ============================================================
-- Trigger: audit log cuando cambia estado
-- ============================================================
create or replace function prestamos.log_estado()
returns trigger
language plpgsql
security definer
set search_path = prestamos, pg_temp
as $$
begin
if new.estado is distinct from old.estado then
insert into prestamos.audit_log (prestamo_id, actor_id, accion, estado_anterior, estado_nuevo)
values (new.id, auth.uid(), 'cambio_estado', old.estado, new.estado);
end if;
return new;
end;
$$;
drop trigger if exists prestamos_log_estado on prestamos.prestamos;
create trigger prestamos_log_estado
after update of estado on prestamos.prestamos
for each row execute function prestamos.log_estado();
-- ============================================================
-- RLS
-- ============================================================
alter table prestamos.profiles enable row level security;
alter table prestamos.categorias enable row level security;
alter table prestamos.materiales enable row level security;
alter table prestamos.prestamos enable row level security;
alter table prestamos.audit_log enable row level security;
-- profiles
create policy profiles_select_self_or_admin on prestamos.profiles
for select to authenticated
using (id = auth.uid() or prestamos.is_admin());
create policy profiles_update_self on prestamos.profiles
for update to authenticated
using (id = auth.uid())
with check (id = auth.uid() and rol = (select rol from prestamos.profiles where id = auth.uid()));
-- el usuario NO puede cambiar su propio rol; admin lo hace via service_role o via Studio
create policy profiles_admin_all on prestamos.profiles
for all to authenticated
using (prestamos.is_admin())
with check (prestamos.is_admin());
-- categorias
create policy categorias_read_all on prestamos.categorias
for select to authenticated using (true);
create policy categorias_admin_write on prestamos.categorias
for all to authenticated
using (prestamos.is_admin())
with check (prestamos.is_admin());
-- materiales
create policy materiales_read_all on prestamos.materiales
for select to authenticated using (true);
create policy materiales_admin_write on prestamos.materiales
for all to authenticated
using (prestamos.is_admin())
with check (prestamos.is_admin());
-- prestamos
create policy prestamos_select_own_or_admin on prestamos.prestamos
for select to authenticated
using (alumno_id = auth.uid() or prestamos.is_admin());
create policy prestamos_insert_own on prestamos.prestamos
for insert to authenticated
with check (alumno_id = auth.uid() and estado = 'pendiente');
create policy prestamos_update_admin on prestamos.prestamos
for update to authenticated
using (prestamos.is_admin())
with check (prestamos.is_admin());
-- audit_log
create policy audit_admin_read on prestamos.audit_log
for select to authenticated using (prestamos.is_admin());
-- INSERT solo via triggers security definer -> no policy publica
-- ============================================================
-- Grants (RLS ya filtra; se otorgan a authenticated para operar)
-- ============================================================
grant select, insert, update, delete on all tables in schema prestamos to authenticated, service_role;
grant usage, select on all sequences in schema prestamos to authenticated, service_role;
alter default privileges in schema prestamos
grant select, insert, update, delete on tables to authenticated, service_role;
grant select on prestamos.audit_log to authenticated; -- redundante pero explicito
-- ============================================================
-- Seed: categorias iniciales
-- ============================================================
insert into prestamos.categorias (nombre) values
('Cables'),
('Herramientas'),
('Componentes electronicos'),
('Equipo de medicion'),
('Kits de desarrollo'),
('Otros')
on conflict (nombre) do nothing;
commit;
+15 -2
View File
@@ -1,5 +1,18 @@
{ {
"extends": "astro/tsconfigs/strict", "extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"], "include": [
"exclude": ["dist"] ".astro/types.d.ts",
"**/*"
],
"exclude": [
"dist"
],
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
} }