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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 styles or using Tailwind](https://docs.astro.build/en/guides/styling/)
|
||||
- [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 3–6.
|
||||
- `.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 3–6 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
@@ -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
@@ -1,5 +1,20 @@
|
||||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
|
||||
import react from '@astrojs/react';
|
||||
import node from '@astrojs/node';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({});
|
||||
export default defineConfig({
|
||||
output: 'server',
|
||||
integrations: [react()],
|
||||
|
||||
adapter: node({
|
||||
mode: 'standalone'
|
||||
}),
|
||||
|
||||
vite: {
|
||||
plugins: [tailwindcss()]
|
||||
}
|
||||
});
|
||||
@@ -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).
|
||||
@@ -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"
|
||||
Generated
+1553
-5
File diff suppressed because it is too large
Load Diff
+13
-2
@@ -12,6 +12,17 @@
|
||||
"astro": "astro"
|
||||
},
|
||||
"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 +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 +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 |
@@ -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`}>
|
||||
Nº 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Vendored
+32
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
+25
-22
@@ -1,23 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>Astro Basics</title>
|
||||
</head>
|
||||
<body>
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
---
|
||||
import '@/styles/global.css';
|
||||
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
interface Props {
|
||||
title?: string;
|
||||
}
|
||||
const { title = 'Sistema de Préstamos — Laboratorio UABC' } = Astro.props;
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<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" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<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 />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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('/');
|
||||
};
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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
@@ -1,11 +1,45 @@
|
||||
---
|
||||
import Welcome from '../components/Welcome.astro';
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import AppLayout from '@/layouts/AppLayout.astro';
|
||||
|
||||
// Welcome to Astro! Wondering what to do next? Check out the Astro documentation at https://docs.astro.build
|
||||
// Don't want to use any of this? Delete everything in this file, the `assets`, `components`, and `layouts` directories, and start fresh.
|
||||
const profile = Astro.locals.profile;
|
||||
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>
|
||||
<Welcome />
|
||||
</Layout>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
{isAdmin ? (
|
||||
<>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
+16
-3
@@ -1,5 +1,18 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
"include": [
|
||||
".astro/types.d.ts",
|
||||
"**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"dist"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user