From 1121ab7199041d35dc354018344ee509edc8ce20 Mon Sep 17 00:00:00 2001 From: Lak-G Date: Fri, 14 Aug 2026 19:37:49 -0700 Subject: [PATCH] =?UTF-8?q?Sistema=20de=20pr=C3=A9stamos=20LabRe=20UABC=20?= =?UTF-8?q?=E2=80=94=20implementaci=C3=B3n=20inicial?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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 --- .dockerignore | 20 + .env.example | 4 + .env.production.example | 5 + AGENTS.md | 99 ++ Dockerfile | 23 + astro.config.mjs | 17 +- deploy/README.md | 82 + docker-compose.yml | 19 + package-lock.json | 1558 ++++++++++++++++- package.json | 15 +- src/assets/astro.svg | 1 - src/assets/background.svg | 1 - src/components/Welcome.astro | 210 --- .../admin/inventario/CategoriaForm.tsx | 144 ++ .../admin/inventario/EliminarCategoria.tsx | 130 ++ .../admin/inventario/EliminarMaterial.tsx | 105 ++ .../admin/inventario/MaterialForm.tsx | 288 +++ .../admin/reportes/Autocomplete.tsx | 167 ++ .../admin/reportes/AutocompleteAlumno.tsx | 20 + .../admin/reportes/AutocompleteMaterial.tsx | 20 + .../admin/solicitudes/AccionesSolicitud.tsx | 209 +++ .../admin/solicitudes/MarcarDevuelto.tsx | 59 + .../admin/solicitudes/VerDetalles.tsx | 109 ++ src/components/alumno/FiltroCategorias.tsx | 87 + src/components/alumno/SolicitarModal.tsx | 165 ++ src/env.d.ts | 32 + src/layouts/AppLayout.astro | 81 + src/layouts/Layout.astro | 47 +- src/lib/supabase.ts | 36 + src/middleware.ts | 46 + src/pages/admin/index.astro | 96 + src/pages/admin/inventario/categorias.astro | 118 ++ src/pages/admin/inventario/index.astro | 240 +++ src/pages/admin/reportes.astro | 397 +++++ src/pages/admin/solicitudes/activos.astro | 152 ++ src/pages/admin/solicitudes/historial.astro | 151 ++ src/pages/admin/solicitudes/index.astro | 126 ++ src/pages/alumno/catalogo.astro | 110 ++ src/pages/alumno/mis-prestamos.astro | 182 ++ src/pages/api/admin/categorias/[id].ts | 71 + src/pages/api/admin/categorias/index.ts | 36 + src/pages/api/admin/materiales/[id].ts | 140 ++ src/pages/api/admin/materiales/index.ts | 79 + src/pages/api/admin/prestamos/[id]/aprobar.ts | 44 + .../api/admin/prestamos/[id]/detalles.ts | 35 + .../api/admin/prestamos/[id]/devolver.ts | 24 + .../api/admin/prestamos/[id]/rechazar.ts | 30 + src/pages/api/admin/reportes/alumnos.ts | 28 + src/pages/api/admin/reportes/export.ts | 90 + src/pages/api/admin/reportes/materiales.ts | 27 + src/pages/api/auth/callback.ts | 23 + src/pages/api/auth/signout.ts | 7 + src/pages/api/prestamos/index.ts | 58 + src/pages/index.astro | 48 +- src/pages/login.astro | 56 + src/styles/global.css | 77 + supabase/migrations/0001_init.sql | 270 +++ tsconfig.json | 19 +- 58 files changed, 6281 insertions(+), 252 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .env.production.example create mode 100644 Dockerfile create mode 100644 deploy/README.md create mode 100644 docker-compose.yml delete mode 100644 src/assets/astro.svg delete mode 100644 src/assets/background.svg delete mode 100644 src/components/Welcome.astro create mode 100644 src/components/admin/inventario/CategoriaForm.tsx create mode 100644 src/components/admin/inventario/EliminarCategoria.tsx create mode 100644 src/components/admin/inventario/EliminarMaterial.tsx create mode 100644 src/components/admin/inventario/MaterialForm.tsx create mode 100644 src/components/admin/reportes/Autocomplete.tsx create mode 100644 src/components/admin/reportes/AutocompleteAlumno.tsx create mode 100644 src/components/admin/reportes/AutocompleteMaterial.tsx create mode 100644 src/components/admin/solicitudes/AccionesSolicitud.tsx create mode 100644 src/components/admin/solicitudes/MarcarDevuelto.tsx create mode 100644 src/components/admin/solicitudes/VerDetalles.tsx create mode 100644 src/components/alumno/FiltroCategorias.tsx create mode 100644 src/components/alumno/SolicitarModal.tsx create mode 100644 src/env.d.ts create mode 100644 src/layouts/AppLayout.astro create mode 100644 src/lib/supabase.ts create mode 100644 src/middleware.ts create mode 100644 src/pages/admin/index.astro create mode 100644 src/pages/admin/inventario/categorias.astro create mode 100644 src/pages/admin/inventario/index.astro create mode 100644 src/pages/admin/reportes.astro create mode 100644 src/pages/admin/solicitudes/activos.astro create mode 100644 src/pages/admin/solicitudes/historial.astro create mode 100644 src/pages/admin/solicitudes/index.astro create mode 100644 src/pages/alumno/catalogo.astro create mode 100644 src/pages/alumno/mis-prestamos.astro create mode 100644 src/pages/api/admin/categorias/[id].ts create mode 100644 src/pages/api/admin/categorias/index.ts create mode 100644 src/pages/api/admin/materiales/[id].ts create mode 100644 src/pages/api/admin/materiales/index.ts create mode 100644 src/pages/api/admin/prestamos/[id]/aprobar.ts create mode 100644 src/pages/api/admin/prestamos/[id]/detalles.ts create mode 100644 src/pages/api/admin/prestamos/[id]/devolver.ts create mode 100644 src/pages/api/admin/prestamos/[id]/rechazar.ts create mode 100644 src/pages/api/admin/reportes/alumnos.ts create mode 100644 src/pages/api/admin/reportes/export.ts create mode 100644 src/pages/api/admin/reportes/materiales.ts create mode 100644 src/pages/api/auth/callback.ts create mode 100644 src/pages/api/auth/signout.ts create mode 100644 src/pages/api/prestamos/index.ts create mode 100644 src/pages/login.astro create mode 100644 src/styles/global.css create mode 100644 supabase/migrations/0001_init.sql diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4b114d1 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..49e2c02 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..f0678ea --- /dev/null +++ b/.env.production.example @@ -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 diff --git a/AGENTS.md b/AGENTS.md index c5ff429..9cdd2ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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, `

Sistema de Préstamos

`, `` 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 `` 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 ` diff --git a/src/components/admin/inventario/CategoriaForm.tsx b/src/components/admin/inventario/CategoriaForm.tsx new file mode 100644 index 0000000..1619e5a --- /dev/null +++ b/src/components/admin/inventario/CategoriaForm.tsx @@ -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(null); + const firstFieldRef = useRef(null); + const errorRef = useRef(null); + const [nombre, setNombre] = useState(categoria?.nombre ?? ''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( + <> + + + +
+
+

+ {mode === 'create' ? 'Nueva categoría' : `Editar: ${categoria?.nombre}`} +

+ +
+ +
+ + setNombre(e.target.value)} + required + autoComplete="off" + aria-invalid={error ? true : undefined} + aria-describedby={error ? `${titleId}-err` : undefined} + /> +
+ + {error && ( + + )} + +
+ + +
+
+
+ + ); +} diff --git a/src/components/admin/inventario/EliminarCategoria.tsx b/src/components/admin/inventario/EliminarCategoria.tsx new file mode 100644 index 0000000..98146c0 --- /dev/null +++ b/src/components/admin/inventario/EliminarCategoria.tsx @@ -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(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( + <> + + + +
+

+ Eliminar categoría +

+ + {bloqueado ? ( + <> +

+ No se puede eliminar {nombre}: tiene{' '} + {materialesCount}{' '} + material{materialesCount === 1 ? '' : 'es'} asociado + {materialesCount === 1 ? '' : 's'}. Reasígnalos primero. +

+
+ Ver materiales de esta categoría → + + + ) : ( +

+ ¿Seguro que quieres eliminar {nombre}? +

+ )} + + {error && ( +

+ {error} +

+ )} + +
+ + {!bloqueado && ( + + )} +
+
+
+ + ); +} diff --git a/src/components/admin/inventario/EliminarMaterial.tsx b/src/components/admin/inventario/EliminarMaterial.tsx new file mode 100644 index 0000000..1561cac --- /dev/null +++ b/src/components/admin/inventario/EliminarMaterial.tsx @@ -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(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( + <> + + + +
+

+ Eliminar material +

+

+ ¿Seguro que quieres eliminar {nombre}? Esta acción no se puede deshacer. +

+ {tienePrestamos && ( +

+ Este material tiene préstamos asociados y no podrá eliminarse. +

+ )} + {error && ( +

+ {error} +

+ )} +
+ + +
+
+
+ + ); +} diff --git a/src/components/admin/inventario/MaterialForm.tsx b/src/components/admin/inventario/MaterialForm.tsx new file mode 100644 index 0000000..868806a --- /dev/null +++ b/src/components/admin/inventario/MaterialForm.tsx @@ -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(null); + const firstFieldRef = useRef(null); + const errorRef = useRef(null); + + const [nombre, setNombre] = useState(material?.nombre ?? ''); + const [categoriaId, setCategoriaId] = useState( + material?.categoria_id != null ? String(material.categoria_id) : '', + ); + const [descripcion, setDescripcion] = useState(material?.descripcion ?? ''); + const [cantidadTotal, setCantidadTotal] = useState( + material ? String(material.cantidad_total) : '0', + ); + const [numeroInventario, setNumeroInventario] = useState(material?.numero_inventario ?? ''); + const [estado, setEstado] = useState(material?.estado ?? 'disponible'); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( + <> + + + +
+
+

+ {mode === 'create' ? 'Nuevo material' : `Editar: ${material?.nombre}`} +

+ +
+ +
+ + setNombre(e.target.value)} + required + autoComplete="off" + aria-invalid={error ? true : undefined} + aria-describedby={error ? `${titleId}-err` : undefined} + /> +
+ +
+
+ + +
+ +
+ + setNumeroInventario(e.target.value)} + autoComplete="off" + placeholder="Opcional…" + /> +
+
+ +
+ +