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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 19:37:49 -07:00
parent 6bbd6d3a6b
commit 1121ab7199
58 changed files with 6281 additions and 252 deletions
-210
View File
@@ -1,210 +0,0 @@
---
import astroLogo from '../assets/astro.svg';
import background from '../assets/background.svg';
---
<div id="container">
<img id="background" src={background.src} alt="" fetchpriority="high" />
<main>
<section id="hero">
<a href="https://astro.build"
><img src={astroLogo.src} width="115" height="48" alt="Astro Homepage" /></a
>
<h1>
To get started, open the <code><pre>src/pages</pre></code> directory in your project.
</h1>
<section id="links">
<a class="button" href="https://docs.astro.build">Read our docs</a>
<a href="https://astro.build/chat"
>Join our Discord <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 127.14 96.36"
><path
fill="currentColor"
d="M107.7 8.07A105.15 105.15 0 0 0 81.47 0a72.06 72.06 0 0 0-3.36 6.83 97.68 97.68 0 0 0-29.11 0A72.37 72.37 0 0 0 45.64 0a105.89 105.89 0 0 0-26.25 8.09C2.79 32.65-1.71 56.6.54 80.21a105.73 105.73 0 0 0 32.17 16.15 77.7 77.7 0 0 0 6.89-11.11 68.42 68.42 0 0 1-10.85-5.18c.91-.66 1.8-1.34 2.66-2a75.57 75.57 0 0 0 64.32 0c.87.71 1.76 1.39 2.66 2a68.68 68.68 0 0 1-10.87 5.19 77 77 0 0 0 6.89 11.1 105.25 105.25 0 0 0 32.19-16.14c2.64-27.38-4.51-51.11-18.9-72.15ZM42.45 65.69C36.18 65.69 31 60 31 53s5-12.74 11.43-12.74S54 46 53.89 53s-5.05 12.69-11.44 12.69Zm42.24 0C78.41 65.69 73.25 60 73.25 53s5-12.74 11.44-12.74S96.23 46 96.12 53s-5.04 12.69-11.43 12.69Z"
></path></svg
>
</a>
</section>
</section>
</main>
<a href="https://astro.build/blog/astro-7/" id="news" class="box">
<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"
><path
d="M24.667 12c1.333 1.414 2 3.192 2 5.334 0 4.62-4.934 5.7-7.334 12C18.444 28.567 18 27.456 18 26c0-4.642 6.667-7.053 6.667-14Zm-5.334-5.333c1.6 1.65 2.4 3.43 2.4 5.333 0 6.602-8.06 7.59-6.4 17.334C13.111 27.787 12 25.564 12 22.666c0-4.434 7.333-8 7.333-16Zm-6-5.333C15.111 3.555 16 5.556 16 7.333c0 8.333-11.333 10.962-5.333 22-3.488-.774-6-4-6-8 0-8.667 8.666-10 8.666-20Z"
fill="#111827"></path></svg
>
<h2>What's New in Astro 7.0?</h2>
<p>
Rust-powered compiler, advanced routing, AI agent support, and more! Click to explore Astro
7.0's new features.
</p>
</a>
</div>
<style>
#background {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
filter: blur(100px);
}
#container {
font-family: Inter, Roboto, 'Helvetica Neue', 'Arial Nova', 'Nimbus Sans', Arial, sans-serif;
height: 100%;
}
main {
height: 100%;
display: flex;
justify-content: center;
}
#hero {
display: flex;
align-items: start;
flex-direction: column;
justify-content: center;
padding: 16px;
}
h1 {
font-size: 22px;
margin-top: 0.25em;
}
#links {
display: flex;
gap: 16px;
}
#links a {
display: flex;
align-items: center;
padding: 10px 12px;
color: #111827;
text-decoration: none;
transition: color 0.2s;
}
#links a:hover {
color: rgb(78, 80, 86);
}
#links a svg {
height: 1em;
margin-left: 8px;
}
#links a.button {
color: white;
background: linear-gradient(83.21deg, #3245ff 0%, #bc52ee 100%);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.12),
inset 0 -2px 0 rgba(0, 0, 0, 0.24);
border-radius: 10px;
}
#links a.button:hover {
color: rgb(230, 230, 230);
box-shadow: none;
}
pre {
font-family:
ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono',
monospace;
font-weight: normal;
background: linear-gradient(14deg, #d83333 0%, #f041ff 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin: 0;
}
h2 {
margin: 0 0 1em;
font-weight: normal;
color: #111827;
font-size: 20px;
}
p {
color: #4b5563;
font-size: 16px;
line-height: 24px;
letter-spacing: -0.006em;
margin: 0;
}
code {
display: inline-block;
background:
linear-gradient(66.77deg, #f3cddd 0%, #f5cee7 100%) padding-box,
linear-gradient(155deg, #d83333 0%, #f041ff 18%, #f5cee7 45%) border-box;
border-radius: 8px;
border: 1px solid transparent;
padding: 6px 8px;
}
.box {
padding: 16px;
background: rgba(255, 255, 255, 1);
border-radius: 16px;
border: 1px solid white;
}
#news {
position: absolute;
bottom: 16px;
right: 16px;
max-width: 300px;
text-decoration: none;
transition: background 0.2s;
backdrop-filter: blur(50px);
}
#news:hover {
background: rgba(255, 255, 255, 0.55);
}
@media screen and (max-height: 368px) {
#news {
display: none;
}
}
@media screen and (max-width: 768px) {
#container {
display: flex;
flex-direction: column;
}
#hero {
display: block;
padding-top: 10%;
}
#links {
flex-wrap: wrap;
}
#links a.button {
padding: 14px 18px;
}
#news {
right: 16px;
left: 16px;
bottom: 2.5rem;
max-width: 100%;
}
h1 {
line-height: 1.5;
}
}
</style>
@@ -0,0 +1,144 @@
import { useEffect, useRef, useState } from 'react';
type Props = {
mode: 'create' | 'edit';
categoria?: { id: number; nombre: string };
};
export default function CategoriaForm({ mode, categoria }: Props) {
const dialogRef = useRef<HTMLDialogElement | null>(null);
const firstFieldRef = useRef<HTMLInputElement | null>(null);
const errorRef = useRef<HTMLParagraphElement | null>(null);
const [nombre, setNombre] = useState(categoria?.nombre ?? '');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const titleId = `cat-form-${mode}-${categoria?.id ?? 'new'}`;
const open = () => {
setError(null);
if (mode === 'create') setNombre('');
dialogRef.current?.showModal();
queueMicrotask(() => firstFieldRef.current?.focus());
};
const close = () => {
if (loading) return;
dialogRef.current?.close();
};
useEffect(() => {
const dlg = dialogRef.current;
if (!dlg) return;
const onClick = (e: MouseEvent) => {
if (e.target === dlg) close();
};
dlg.addEventListener('click', onClick);
return () => dlg.removeEventListener('click', onClick);
});
useEffect(() => {
if (error) errorRef.current?.focus();
}, [error]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (loading) return;
const trimmed = nombre.trim();
if (!trimmed) {
setError('El nombre es obligatorio');
return;
}
setLoading(true);
setError(null);
const url = mode === 'create' ? '/api/admin/categorias' : `/api/admin/categorias/${categoria!.id}`;
const method = mode === 'create' ? 'POST' : 'PATCH';
try {
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nombre: trimmed }),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json?.error ?? 'No se pudo guardar');
dialogRef.current?.close();
location.reload();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error inesperado');
} finally {
setLoading(false);
}
};
const triggerLabel = mode === 'create' ? 'Nueva categoría' : 'Editar';
const triggerClass = mode === 'create' ? 'btn btn-primary' : 'btn btn-ghost';
return (
<>
<button type="button" className={triggerClass} onClick={open}>
{triggerLabel}
</button>
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] backdrop:bg-black/40 w-[min(92vw,24rem)]"
>
<form onSubmit={submit} className="p-5 sm:p-6 flex flex-col gap-4" autoComplete="off">
<div className="flex items-start justify-between gap-3">
<h2 id={titleId} className="text-lg font-semibold">
{mode === 'create' ? 'Nueva categoría' : `Editar: ${categoria?.nombre}`}
</h2>
<button
type="button"
onClick={close}
aria-label="Cerrar"
className="rounded-lg p-1 hover:bg-black/5 transition-colors leading-none text-xl"
>
<span aria-hidden="true">×</span>
</button>
</div>
<div>
<label className="label" htmlFor={`${titleId}-nombre`}>
Nombre
</label>
<input
ref={firstFieldRef}
id={`${titleId}-nombre`}
className="input"
type="text"
value={nombre}
onChange={(e) => setNombre(e.target.value)}
required
autoComplete="off"
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${titleId}-err` : undefined}
/>
</div>
{error && (
<p
ref={errorRef}
id={`${titleId}-err`}
role="alert"
tabIndex={-1}
className="text-sm"
style={{ color: 'var(--color-danger)' }}
>
{error}
</p>
)}
<div className="flex gap-2 justify-end pt-2">
<button type="button" className="btn btn-ghost" onClick={close} disabled={loading}>
Cancelar
</button>
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? 'Guardando…' : 'Guardar'}
</button>
</div>
</form>
</dialog>
</>
);
}
@@ -0,0 +1,130 @@
import { useEffect, useRef, useState } from 'react';
type Props = {
id: number;
nombre: string;
materialesCount: number;
};
export default function EliminarCategoria({ id, nombre, materialesCount }: Props) {
const dialogRef = useRef<HTMLDialogElement | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const titleId = `del-cat-${id}`;
const bloqueado = materialesCount > 0;
const open = () => {
setError(null);
dialogRef.current?.showModal();
};
const close = () => {
if (loading) return;
dialogRef.current?.close();
};
useEffect(() => {
const dlg = dialogRef.current;
if (!dlg) return;
const onClick = (e: MouseEvent) => {
if (e.target === dlg) close();
};
dlg.addEventListener('click', onClick);
return () => dlg.removeEventListener('click', onClick);
});
const submit = async () => {
if (loading) return;
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/admin/categorias/${id}`, { method: 'DELETE' });
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json?.error ?? 'No se pudo eliminar');
dialogRef.current?.close();
location.reload();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error inesperado');
} finally {
setLoading(false);
}
};
return (
<>
<button
type="button"
className="btn btn-ghost transition-colors duration-150"
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
onClick={open}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--color-danger)';
e.currentTarget.style.color = 'white';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = 'var(--color-ink)';
}}
>
Eliminar
</button>
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] backdrop:bg-black/40 w-[min(92vw,28rem)]"
>
<div className="p-5 sm:p-6 flex flex-col gap-4">
<h2 id={titleId} className="text-lg font-semibold">
Eliminar categoría
</h2>
{bloqueado ? (
<>
<p className="text-sm">
No se puede eliminar <strong>{nombre}</strong>: tiene{' '}
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{materialesCount}</span>{' '}
material{materialesCount === 1 ? '' : 'es'} asociado
{materialesCount === 1 ? '' : 's'}. Reasígnalos primero.
</p>
<a
href={`/admin/inventario?cat=${id}`}
className="text-sm underline"
style={{ color: 'var(--color-primary)' }}
>
Ver materiales de esta categoría
</a>
</>
) : (
<p className="text-sm">
¿Seguro que quieres eliminar <strong>{nombre}</strong>?
</p>
)}
{error && (
<p role="alert" className="text-sm" style={{ color: 'var(--color-danger)' }}>
{error}
</p>
)}
<div className="flex gap-2 justify-end pt-2">
<button type="button" className="btn btn-ghost" onClick={close} disabled={loading}>
{bloqueado ? 'Cerrar' : 'Cancelar'}
</button>
{!bloqueado && (
<button
type="button"
className="btn"
style={{ background: 'var(--color-danger)', color: 'white' }}
onClick={submit}
disabled={loading}
>
{loading ? 'Eliminando…' : 'Eliminar'}
</button>
)}
</div>
</div>
</dialog>
</>
);
}
@@ -0,0 +1,105 @@
import { useEffect, useRef, useState } from 'react';
type Props = {
id: number;
nombre: string;
tienePrestamos?: boolean;
};
export default function EliminarMaterial({ id, nombre, tienePrestamos }: Props) {
const dialogRef = useRef<HTMLDialogElement | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const titleId = `del-mat-${id}`;
const open = () => {
setError(null);
dialogRef.current?.showModal();
};
const close = () => {
if (loading) return;
dialogRef.current?.close();
};
useEffect(() => {
const dlg = dialogRef.current;
if (!dlg) return;
const onClick = (e: MouseEvent) => {
if (e.target === dlg) close();
};
dlg.addEventListener('click', onClick);
return () => dlg.removeEventListener('click', onClick);
});
const submit = async () => {
if (loading) return;
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/admin/materiales/${id}`, { method: 'DELETE' });
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json?.error ?? 'No se pudo eliminar');
dialogRef.current?.close();
location.reload();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error inesperado');
} finally {
setLoading(false);
}
};
return (
<>
<button
type="button"
className="btn btn-ghost transition-colors duration-150 hover:!text-white"
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
onClick={open}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-danger)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
Eliminar
</button>
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] backdrop:bg-black/40 w-[min(92vw,26rem)]"
>
<div className="p-5 sm:p-6 flex flex-col gap-4">
<h2 id={titleId} className="text-lg font-semibold">
Eliminar material
</h2>
<p className="text-sm">
¿Seguro que quieres eliminar <strong>{nombre}</strong>? Esta acción no se puede deshacer.
</p>
{tienePrestamos && (
<p className="text-sm" style={{ color: 'var(--color-danger)' }}>
Este material tiene préstamos asociados y no podrá eliminarse.
</p>
)}
{error && (
<p role="alert" className="text-sm" style={{ color: 'var(--color-danger)' }}>
{error}
</p>
)}
<div className="flex gap-2 justify-end pt-2">
<button type="button" className="btn btn-ghost" onClick={close} disabled={loading}>
Cancelar
</button>
<button
type="button"
className="btn"
style={{ background: 'var(--color-danger)', color: 'white' }}
onClick={submit}
disabled={loading}
>
{loading ? 'Eliminando…' : 'Eliminar'}
</button>
</div>
</div>
</dialog>
</>
);
}
@@ -0,0 +1,288 @@
import { useEffect, useRef, useState } from 'react';
type Categoria = { id: number; nombre: string };
type Material = {
id: number;
nombre: string;
categoria_id: number | null;
descripcion: string | null;
cantidad_total: number;
cantidad_disponible: number;
numero_inventario: string | null;
estado: 'disponible' | 'mantenimiento' | 'baja';
};
type Props = {
mode: 'create' | 'edit';
material?: Material;
categorias: Categoria[];
};
export default function MaterialForm({ mode, material, categorias }: Props) {
const dialogRef = useRef<HTMLDialogElement | null>(null);
const firstFieldRef = useRef<HTMLInputElement | null>(null);
const errorRef = useRef<HTMLParagraphElement | null>(null);
const [nombre, setNombre] = useState(material?.nombre ?? '');
const [categoriaId, setCategoriaId] = useState<string>(
material?.categoria_id != null ? String(material.categoria_id) : '',
);
const [descripcion, setDescripcion] = useState(material?.descripcion ?? '');
const [cantidadTotal, setCantidadTotal] = useState<string>(
material ? String(material.cantidad_total) : '0',
);
const [numeroInventario, setNumeroInventario] = useState(material?.numero_inventario ?? '');
const [estado, setEstado] = useState<Material['estado']>(material?.estado ?? 'disponible');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const titleId = `mat-form-${mode}-${material?.id ?? 'new'}`;
const open = () => {
setError(null);
if (mode === 'create') {
setNombre('');
setCategoriaId('');
setDescripcion('');
setCantidadTotal('0');
setNumeroInventario('');
setEstado('disponible');
}
dialogRef.current?.showModal();
queueMicrotask(() => firstFieldRef.current?.focus());
};
const close = () => {
if (loading) return;
dialogRef.current?.close();
};
useEffect(() => {
const dlg = dialogRef.current;
if (!dlg) return;
const onClick = (e: MouseEvent) => {
if (e.target === dlg) close();
};
dlg.addEventListener('click', onClick);
return () => dlg.removeEventListener('click', onClick);
});
useEffect(() => {
if (error) {
errorRef.current?.focus();
}
}, [error]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (loading) return;
setLoading(true);
setError(null);
const payload = {
nombre: nombre.trim(),
categoria_id: categoriaId === '' ? null : Number(categoriaId),
descripcion: descripcion.trim() || null,
cantidad_total: Number(cantidadTotal),
numero_inventario: numeroInventario.trim() || null,
estado,
};
if (!payload.nombre) {
setError('El nombre es obligatorio');
setLoading(false);
return;
}
if (!Number.isInteger(payload.cantidad_total) || payload.cantidad_total < 0) {
setError('La cantidad total debe ser un entero mayor o igual a 0');
setLoading(false);
return;
}
const url =
mode === 'create' ? '/api/admin/materiales' : `/api/admin/materiales/${material!.id}`;
const method = mode === 'create' ? 'POST' : 'PATCH';
try {
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json?.error ?? 'No se pudo guardar');
dialogRef.current?.close();
location.reload();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error inesperado');
} finally {
setLoading(false);
}
};
const triggerLabel = mode === 'create' ? 'Nuevo material' : 'Editar';
const triggerClass = mode === 'create' ? 'btn btn-primary' : 'btn btn-ghost';
return (
<>
<button type="button" className={triggerClass} onClick={open}>
{triggerLabel}
</button>
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] backdrop:bg-black/40 w-[min(92vw,32rem)]"
>
<form onSubmit={submit} className="p-5 sm:p-6 flex flex-col gap-4" autoComplete="off">
<div className="flex items-start justify-between gap-3">
<h2 id={titleId} className="text-lg font-semibold">
{mode === 'create' ? 'Nuevo material' : `Editar: ${material?.nombre}`}
</h2>
<button
type="button"
onClick={close}
aria-label="Cerrar"
className="rounded-lg p-1 hover:bg-black/5 transition-colors leading-none text-xl"
>
<span aria-hidden="true">×</span>
</button>
</div>
<div>
<label className="label" htmlFor={`${titleId}-nombre`}>
Nombre
</label>
<input
ref={firstFieldRef}
id={`${titleId}-nombre`}
className="input"
type="text"
value={nombre}
onChange={(e) => setNombre(e.target.value)}
required
autoComplete="off"
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${titleId}-err` : undefined}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="label" htmlFor={`${titleId}-cat`}>
Categoría
</label>
<select
id={`${titleId}-cat`}
className="input"
value={categoriaId}
onChange={(e) => setCategoriaId(e.target.value)}
>
<option value="">Sin categoría</option>
{categorias.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.nombre}
</option>
))}
</select>
</div>
<div>
<label className="label" htmlFor={`${titleId}-inv`}>
de inventario
</label>
<input
id={`${titleId}-inv`}
className="input"
type="text"
value={numeroInventario}
onChange={(e) => setNumeroInventario(e.target.value)}
autoComplete="off"
placeholder="Opcional…"
/>
</div>
</div>
<div>
<label className="label" htmlFor={`${titleId}-desc`}>
Descripción
</label>
<textarea
id={`${titleId}-desc`}
className="input"
rows={3}
maxLength={500}
value={descripcion}
onChange={(e) => setDescripcion(e.target.value)}
placeholder="Detalles, especificaciones, notas…"
style={{ minHeight: '80px' }}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="label" htmlFor={`${titleId}-total`}>
Cantidad total
</label>
<input
id={`${titleId}-total`}
className="input"
type="number"
min={0}
step={1}
inputMode="numeric"
value={cantidadTotal}
onChange={(e) => setCantidadTotal(e.target.value)}
required
style={{ fontVariantNumeric: 'tabular-nums' }}
/>
{mode === 'edit' && material && (
<p className="text-xs opacity-60 mt-1">
Prestados: {material.cantidad_total - material.cantidad_disponible}
</p>
)}
</div>
<div>
<label className="label" htmlFor={`${titleId}-estado`}>
Estado
</label>
<select
id={`${titleId}-estado`}
className="input"
value={estado}
onChange={(e) => setEstado(e.target.value as Material['estado'])}
>
<option value="disponible">Disponible</option>
<option value="mantenimiento">Mantenimiento</option>
<option value="baja">Baja</option>
</select>
</div>
</div>
{error && (
<p
ref={errorRef}
id={`${titleId}-err`}
role="alert"
tabIndex={-1}
className="text-sm"
style={{ color: 'var(--color-danger)' }}
>
{error}
</p>
)}
<div className="flex gap-2 justify-end pt-2">
<button type="button" className="btn btn-ghost" onClick={close} disabled={loading}>
Cancelar
</button>
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? 'Guardando…' : 'Guardar'}
</button>
</div>
</form>
</dialog>
</>
);
}
@@ -0,0 +1,167 @@
import { useEffect, useId, useRef, useState } from 'react';
export type AutocompleteItem = { id: number | string; label: string };
type Props = {
name: string;
endpoint: string;
label: string;
placeholder?: string;
initialValue?: number | string | null;
initialLabel?: string | null;
};
// ponytail: combobox + fetch de 10 resultados; sustituir por biblioteca si hace falta agrupamiento, keyboard grouping, virtualización, etc.
export function Autocomplete({ name, endpoint, label, placeholder, initialValue, initialLabel }: Props) {
const listId = useId();
const inputId = useId();
const [text, setText] = useState<string>(initialLabel ?? '');
const [value, setValue] = useState<string>(initialValue != null ? String(initialValue) : '');
const [items, setItems] = useState<AutocompleteItem[]>([]);
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1);
const [loading, setLoading] = useState(false);
const rootRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const abortRef = useRef<AbortController | null>(null);
// debounced fetch
useEffect(() => {
if (!text.trim() || text === initialLabel) {
setItems([]);
return;
}
const t = setTimeout(async () => {
abortRef.current?.abort();
const ac = new AbortController();
abortRef.current = ac;
setLoading(true);
try {
const res = await fetch(`${endpoint}?q=${encodeURIComponent(text.trim())}`, { signal: ac.signal });
if (!res.ok) throw new Error(String(res.status));
const data: AutocompleteItem[] = await res.json();
setItems(data);
setOpen(true);
setActive(data.length ? 0 : -1);
} catch (e) {
if ((e as any)?.name !== 'AbortError') {
setItems([]);
setOpen(false);
}
} finally {
setLoading(false);
}
}, 250);
return () => clearTimeout(t);
}, [text, endpoint, initialLabel]);
// click-outside → cerrar
useEffect(() => {
function onDoc(e: MouseEvent) {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
}
document.addEventListener('mousedown', onDoc);
return () => document.removeEventListener('mousedown', onDoc);
}, []);
function pick(it: AutocompleteItem) {
setValue(String(it.id));
setText(it.label);
setOpen(false);
setActive(-1);
}
function clear() {
setValue('');
setText('');
setItems([]);
setOpen(false);
setActive(-1);
inputRef.current?.focus();
}
function onKey(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'ArrowDown') {
e.preventDefault();
if (!open && items.length) setOpen(true);
setActive((a) => (items.length ? (a + 1) % items.length : -1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActive((a) => (items.length ? (a <= 0 ? items.length - 1 : a - 1) : -1));
} else if (e.key === 'Enter') {
if (open && active >= 0 && items[active]) {
e.preventDefault();
pick(items[active]);
}
} else if (e.key === 'Escape') {
setOpen(false);
}
}
return (
<div ref={rootRef} className="relative">
<label htmlFor={inputId} className="label">{label}</label>
<input type="hidden" name={name} value={value} />
<div className="flex gap-2">
<input
ref={inputRef}
id={inputId}
type="text"
className="input"
role="combobox"
aria-autocomplete="list"
aria-expanded={open}
aria-controls={listId}
aria-activedescendant={active >= 0 ? `${listId}-opt-${active}` : undefined}
placeholder={placeholder}
value={text}
onChange={(e) => {
setText(e.target.value);
if (value) setValue(''); // el usuario editó → invalidar la selección previa
}}
onFocus={() => items.length && setOpen(true)}
onKeyDown={onKey}
autoComplete="off"
spellCheck={false}
/>
{(text || value) && (
<button type="button" className="btn btn-ghost px-3" onClick={clear} aria-label="Limpiar">
<span aria-hidden="true">×</span>
</button>
)}
</div>
{open && (
<ul
id={listId}
role="listbox"
className="absolute z-10 mt-1 w-full bg-white rounded-lg shadow-lg max-h-64 overflow-auto"
style={{ border: '1px solid color-mix(in oklab, var(--color-ink) 15%, transparent)' }}
>
{loading && items.length === 0 && (
<li className="px-3 py-2 text-sm opacity-70">Buscando</li>
)}
{!loading && items.length === 0 && (
<li className="px-3 py-2 text-sm opacity-70">Sin resultados</li>
)}
{items.map((it, i) => (
<li
key={it.id}
id={`${listId}-opt-${i}`}
role="option"
aria-selected={i === active}
className="px-3 py-2 text-sm cursor-pointer transition-colors"
style={i === active ? { background: 'color-mix(in oklab, var(--color-primary) 10%, transparent)' } : undefined}
onMouseEnter={() => setActive(i)}
onMouseDown={(e) => {
e.preventDefault();
pick(it);
}}
>
{it.label}
</li>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,20 @@
import { Autocomplete } from './Autocomplete';
type Props = {
name?: string;
initialValue?: string | null;
initialLabel?: string | null;
};
export default function AutocompleteAlumno({ name = 'alumno_id', initialValue, initialLabel }: Props) {
return (
<Autocomplete
name={name}
endpoint="/api/admin/reportes/alumnos"
label="Alumno"
placeholder="Buscar alumno (nombre, matrícula, email)…"
initialValue={initialValue ?? null}
initialLabel={initialLabel ?? null}
/>
);
}
@@ -0,0 +1,20 @@
import { Autocomplete } from './Autocomplete';
type Props = {
name?: string;
initialValue?: number | null;
initialLabel?: string | null;
};
export default function AutocompleteMaterial({ name = 'material_id', initialValue, initialLabel }: Props) {
return (
<Autocomplete
name={name}
endpoint="/api/admin/reportes/materiales"
label="Material"
placeholder="Buscar material…"
initialValue={initialValue ?? null}
initialLabel={initialLabel ?? null}
/>
);
}
@@ -0,0 +1,209 @@
import { useEffect, useRef, useState } from 'react';
type Prestamo = {
id: number;
cantidad: number;
material: { nombre: string; cantidad_disponible: number } | null;
alumno: { nombre: string | null; email: string } | null;
};
const hoy = () => new Date().toISOString().split('T')[0];
const enDias = (d: number) => new Date(Date.now() + d * 86400000).toISOString().split('T')[0];
export default function AccionesSolicitud({ prestamo }: { prestamo: Prestamo }) {
return (
<div className="flex gap-2 flex-wrap">
<AprobarDialog prestamo={prestamo} />
<RechazarDialog prestamo={prestamo} />
</div>
);
}
function useDialog() {
const ref = useRef<HTMLDialogElement>(null);
const open = () => ref.current?.showModal();
const close = () => ref.current?.close();
// Cerrar al hacer click en backdrop
useEffect(() => {
const d = ref.current;
if (!d) return;
const onClick = (e: MouseEvent) => {
if (e.target === d) d.close();
};
d.addEventListener('click', onClick);
return () => d.removeEventListener('click', onClick);
}, []);
return { ref, open, close };
}
function AprobarDialog({ prestamo }: { prestamo: Prestamo }) {
const dlg = useDialog();
const [fecha, setFecha] = useState(enDias(7));
const [notas, setNotas] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const stockInsuficiente = (prestamo.material?.cantidad_disponible ?? 0) < prestamo.cantidad;
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!fecha || fecha < hoy()) {
setError('La fecha de devolución debe ser hoy o posterior.');
return;
}
setLoading(true);
try {
const res = await fetch(`/api/admin/prestamos/${prestamo.id}/aprobar`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ fecha_devolucion_estimada: fecha, notas: notas.trim() || undefined }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.error ?? `Error ${res.status}`);
}
location.reload();
} catch (err: any) {
setError(err.message ?? 'Error al aprobar.');
setLoading(false);
}
};
return (
<>
<button type="button" className="btn btn-primary" onClick={dlg.open}>Aprobar</button>
<dialog ref={dlg.ref} className="rounded-lg p-0 max-w-md w-[calc(100%-2rem)] backdrop:bg-black/40">
<form onSubmit={submit} className="p-6">
<h2 className="text-lg font-semibold">Aprobar solicitud</h2>
<p className="text-sm opacity-70 mt-1">
{prestamo.alumno?.nombre ?? prestamo.alumno?.email} solicita{' '}
<strong>{prestamo.cantidad}</strong> de <strong>{prestamo.material?.nombre ?? '—'}</strong>.
</p>
{stockInsuficiente && (
<div role="alert" className="mt-3 rounded-lg p-2 text-sm" style={{ background: 'color-mix(in oklab, var(--color-danger) 12%, transparent)', color: 'var(--color-danger)' }}>
Stock disponible ({prestamo.material?.cantidad_disponible ?? 0}) menor a lo solicitado.
</div>
)}
<div className="mt-4">
<label className="label" htmlFor={`fecha-${prestamo.id}`}>Devolver antes de</label>
<input
id={`fecha-${prestamo.id}`}
type="date"
className="input"
value={fecha}
min={hoy()}
onChange={(e) => setFecha(e.target.value)}
required
autoFocus
/>
</div>
<div className="mt-3">
<label className="label" htmlFor={`notas-${prestamo.id}`}>Notas (opcional)</label>
<textarea
id={`notas-${prestamo.id}`}
className="input"
rows={3}
value={notas}
onChange={(e) => setNotas(e.target.value)}
placeholder="Observaciones para el alumno o registro interno…"
/>
</div>
{error && (
<div role="alert" className="mt-3 rounded-lg p-2 text-sm" style={{ background: 'color-mix(in oklab, var(--color-danger) 12%, transparent)', color: 'var(--color-danger)' }}>
{error}
</div>
)}
<div className="mt-6 flex justify-end gap-2">
<button type="button" className="btn btn-ghost" onClick={dlg.close} disabled={loading}>Cancelar</button>
<button type="submit" className="btn btn-primary" disabled={loading}>{loading ? 'Aprobando…' : 'Aprobar solicitud'}</button>
</div>
</form>
</dialog>
</>
);
}
function RechazarDialog({ prestamo }: { prestamo: Prestamo }) {
const dlg = useDialog();
const [motivo, setMotivo] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (motivo.trim().length < 5) {
setError('El motivo debe tener al menos 5 caracteres.');
return;
}
setLoading(true);
try {
const res = await fetch(`/api/admin/prestamos/${prestamo.id}/rechazar`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ motivo: motivo.trim() }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.error ?? `Error ${res.status}`);
}
location.reload();
} catch (err: any) {
setError(err.message ?? 'Error al rechazar.');
setLoading(false);
}
};
return (
<>
<button
type="button"
className="btn btn-ghost"
onClick={dlg.open}
style={{ ['--h' as any]: 'var(--color-danger)' }}
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-danger)')}
onMouseLeave={(e) => (e.currentTarget.style.color = '')}
>
Rechazar
</button>
<dialog ref={dlg.ref} className="rounded-lg p-0 max-w-md w-[calc(100%-2rem)] backdrop:bg-black/40">
<form onSubmit={submit} className="p-6">
<h2 className="text-lg font-semibold">Rechazar solicitud</h2>
<p className="text-sm opacity-70 mt-1">
Explica al alumno por qué se rechaza. Este texto queda registrado.
</p>
<div className="mt-4">
<label className="label" htmlFor={`motivo-${prestamo.id}`}>Motivo</label>
<textarea
id={`motivo-${prestamo.id}`}
className="input"
rows={4}
value={motivo}
onChange={(e) => setMotivo(e.target.value)}
required
minLength={5}
autoFocus
placeholder="Ej. Material no disponible por mantenimiento."
/>
</div>
{error && (
<div role="alert" className="mt-3 rounded-lg p-2 text-sm" style={{ background: 'color-mix(in oklab, var(--color-danger) 12%, transparent)', color: 'var(--color-danger)' }}>
{error}
</div>
)}
<div className="mt-6 flex justify-end gap-2">
<button type="button" className="btn btn-ghost" onClick={dlg.close} disabled={loading}>Cancelar</button>
<button
type="submit"
className="btn"
disabled={loading}
style={{ background: 'var(--color-danger)', color: 'white' }}
>
{loading ? 'Rechazando…' : 'Rechazar solicitud'}
</button>
</div>
</form>
</dialog>
</>
);
}
@@ -0,0 +1,59 @@
import { useEffect, useRef, useState } from 'react';
export default function MarcarDevuelto({ prestamoId, nombreMaterial }: { prestamoId: number; nombreMaterial: string }) {
const ref = useRef<HTMLDialogElement>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const d = ref.current;
if (!d) return;
const onClick = (e: MouseEvent) => { if (e.target === d) d.close(); };
d.addEventListener('click', onClick);
return () => d.removeEventListener('click', onClick);
}, []);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
const res = await fetch(`/api/admin/prestamos/${prestamoId}/devolver`, { method: 'POST' });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.error ?? `Error ${res.status}`);
}
location.reload();
} catch (err: any) {
setError(err.message ?? 'Error al registrar la devolución.');
setLoading(false);
}
};
return (
<>
<button type="button" className="btn btn-secondary" onClick={() => ref.current?.showModal()}>
Marcar devuelto
</button>
<dialog ref={ref} className="rounded-lg p-0 max-w-md w-[calc(100%-2rem)] backdrop:bg-black/40">
<form onSubmit={submit} className="p-6">
<h2 className="text-lg font-semibold">Confirmar devolución</h2>
<p className="text-sm opacity-80 mt-2">
¿Confirmas la devolución de <strong>{nombreMaterial}</strong>? El material regresará al inventario disponible.
</p>
{error && (
<div role="alert" className="mt-3 rounded-lg p-2 text-sm" style={{ background: 'color-mix(in oklab, var(--color-danger) 12%, transparent)', color: 'var(--color-danger)' }}>
{error}
</div>
)}
<div className="mt-6 flex justify-end gap-2">
<button type="button" className="btn btn-ghost" onClick={() => ref.current?.close()} disabled={loading}>Cancelar</button>
<button type="submit" className="btn btn-secondary" disabled={loading}>
{loading ? 'Registrando…' : 'Confirmar devolución'}
</button>
</div>
</form>
</dialog>
</>
);
}
@@ -0,0 +1,109 @@
import { useEffect, useRef, useState } from 'react';
type Detalles = {
prestamo: {
id: number;
cantidad: number;
estado: string;
fecha_solicitud: string;
fecha_aprobacion: string | null;
fecha_devolucion_estimada: string | null;
fecha_devolucion_real: string | null;
notas: string | null;
alumno: { nombre: string | null; email: string; matricula: string | null } | null;
material: { nombre: string; numero_inventario: string | null } | null;
};
audit_log: Array<Record<string, any>>;
};
const fmt = new Intl.DateTimeFormat('es-MX', { dateStyle: 'medium', timeStyle: 'short' });
export default function VerDetalles({ prestamoId }: { prestamoId: number }) {
const ref = useRef<HTMLDialogElement>(null);
const [data, setData] = useState<Detalles | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const d = ref.current;
if (!d) return;
const onClick = (e: MouseEvent) => { if (e.target === d) d.close(); };
d.addEventListener('click', onClick);
return () => d.removeEventListener('click', onClick);
}, []);
const open = async () => {
ref.current?.showModal();
if (data) return;
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/admin/prestamos/${prestamoId}/detalles`);
if (!res.ok) throw new Error(`Error ${res.status}`);
setData(await res.json());
} catch (err: any) {
setError(err.message ?? 'No se pudo cargar el detalle.');
} finally {
setLoading(false);
}
};
return (
<>
<button type="button" className="btn btn-ghost" onClick={open}>Detalles</button>
<dialog ref={ref} className="rounded-lg p-0 max-w-lg w-[calc(100%-2rem)] backdrop:bg-black/40">
<div className="p-6">
<div className="flex items-start justify-between gap-4">
<h2 className="text-lg font-semibold">Detalle del préstamo</h2>
<button type="button" className="btn btn-ghost" onClick={() => ref.current?.close()} aria-label="Cerrar">×</button>
</div>
{loading && <p className="mt-4 text-sm opacity-70">Cargando</p>}
{error && (
<div role="alert" className="mt-3 rounded-lg p-2 text-sm" style={{ background: 'color-mix(in oklab, var(--color-danger) 12%, transparent)', color: 'var(--color-danger)' }}>
{error}
</div>
)}
{data && (
<>
<dl className="mt-4 grid grid-cols-3 gap-y-2 text-sm">
<dt className="opacity-70">Alumno</dt>
<dd className="col-span-2">{data.prestamo.alumno?.nombre ?? data.prestamo.alumno?.email}</dd>
<dt className="opacity-70">Material</dt>
<dd className="col-span-2">{data.prestamo.material?.nombre} <span className="opacity-70">· Inv. {data.prestamo.material?.numero_inventario ?? '—'}</span></dd>
<dt className="opacity-70">Cantidad</dt>
<dd className="col-span-2" style={{ fontVariantNumeric: 'tabular-nums' }}>{data.prestamo.cantidad}</dd>
<dt className="opacity-70">Solicitado</dt>
<dd className="col-span-2">{fmt.format(new Date(data.prestamo.fecha_solicitud))}</dd>
{data.prestamo.fecha_aprobacion && (<><dt className="opacity-70">Aprobado</dt><dd className="col-span-2">{fmt.format(new Date(data.prestamo.fecha_aprobacion))}</dd></>)}
{data.prestamo.fecha_devolucion_estimada && (<><dt className="opacity-70">Devolver antes</dt><dd className="col-span-2">{fmt.format(new Date(data.prestamo.fecha_devolucion_estimada + 'T00:00:00'))}</dd></>)}
{data.prestamo.notas && (<><dt className="opacity-70">Notas</dt><dd className="col-span-2 whitespace-pre-wrap">{data.prestamo.notas}</dd></>)}
</dl>
<h3 className="mt-6 mb-2 font-semibold text-sm">Historial de cambios</h3>
{data.audit_log.length === 0 ? (
<p className="text-sm opacity-70">Sin cambios registrados.</p>
) : (
<ul className="text-sm divide-y" style={{ borderColor: 'color-mix(in oklab, var(--color-ink) 10%, transparent)' }}>
{data.audit_log.map((e, i) => {
const fecha = e.created_at ?? e.fecha ?? e.timestamp;
return (
<li key={i} className="py-2 flex items-baseline gap-2 flex-wrap">
<span className="font-medium">{e.accion ?? 'cambio'}</span>
<span className="opacity-80">
{(e.estado_anterior ?? '—')} {(e.estado_nuevo ?? '—')}
</span>
{fecha && <span className="opacity-60 ml-auto text-xs">{fmt.format(new Date(fecha))}</span>}
</li>
);
})}
</ul>
)}
</>
)}
</div>
</dialog>
</>
);
}
@@ -0,0 +1,87 @@
import { useEffect, useState } from 'react';
type Cat = { id: number; nombre: string };
type Props = { categorias: Cat[] };
const ALL = 'all';
function applyFilter(value: string) {
const cards = document.querySelectorAll<HTMLElement>('[data-cat]');
cards.forEach((el) => {
const cat = el.dataset.cat;
const show = value === ALL || cat === value;
el.hidden = !show;
});
const empty = document.getElementById('catalogo-empty-filter');
if (empty) {
const anyVisible = Array.from(cards).some((el) => !el.hidden);
empty.hidden = anyVisible || cards.length === 0;
}
}
export default function FiltroCategorias({ categorias }: Props) {
const [active, setActive] = useState<string>(ALL);
useEffect(() => {
const url = new URL(location.href);
const cat = url.searchParams.get('cat');
const initial = cat && categorias.some((c) => String(c.id) === cat) ? cat : ALL;
setActive(initial);
applyFilter(initial);
}, [categorias]);
const pick = (value: string) => {
setActive(value);
const url = new URL(location.href);
if (value === ALL) url.searchParams.delete('cat');
else url.searchParams.set('cat', value);
history.replaceState(null, '', url.toString());
applyFilter(value);
};
const chipClass = (v: string) =>
v === active
? 'btn'
: 'btn btn-ghost';
const chipStyle = (v: string): React.CSSProperties =>
v === active
? { background: 'var(--color-primary)', color: 'white', minHeight: '40px', paddingBlock: '0.375rem' }
: { minHeight: '40px', paddingBlock: '0.375rem' };
return (
<div
role="tablist"
aria-label="Filtrar por categoría"
className="flex gap-2 overflow-x-auto -mx-4 px-4 pb-1"
style={{ scrollbarWidth: 'thin' }}
>
<button
type="button"
role="tab"
aria-selected={active === ALL}
className={chipClass(ALL)}
style={chipStyle(ALL)}
onClick={() => pick(ALL)}
>
Todas
</button>
{categorias.map((c) => {
const v = String(c.id);
return (
<button
key={c.id}
type="button"
role="tab"
aria-selected={active === v}
className={chipClass(v)}
style={chipStyle(v)}
onClick={() => pick(v)}
>
{c.nombre}
</button>
);
})}
</div>
);
}
+165
View File
@@ -0,0 +1,165 @@
import { useEffect, useRef, useState } from 'react';
type Props = {
material: {
id: number;
nombre: string;
cantidad_disponible: number;
};
};
export default function SolicitarModal({ material }: Props) {
const dialogRef = useRef<HTMLDialogElement | null>(null);
const firstFieldRef = useRef<HTMLInputElement | null>(null);
const [cantidad, setCantidad] = useState<number>(1);
const [notas, setNotas] = useState<string>('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [ok, setOk] = useState(false);
const titleId = `solicitar-title-${material.id}`;
const open = () => {
setError(null);
setOk(false);
setCantidad(1);
setNotas('');
dialogRef.current?.showModal();
queueMicrotask(() => firstFieldRef.current?.focus());
};
const close = () => {
if (loading) return;
dialogRef.current?.close();
};
// Cerrar al click en backdrop
useEffect(() => {
const dlg = dialogRef.current;
if (!dlg) return;
const onClick = (e: MouseEvent) => {
if (e.target === dlg) close();
};
dlg.addEventListener('click', onClick);
return () => dlg.removeEventListener('click', onClick);
});
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (loading) return;
setLoading(true);
setError(null);
try {
const res = await fetch('/api/prestamos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ material_id: material.id, cantidad, notas }),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(json?.error ?? 'No se pudo enviar la solicitud');
}
setOk(true);
setTimeout(() => {
dialogRef.current?.close();
location.reload();
}, 800);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error inesperado');
} finally {
setLoading(false);
}
};
const disabled = material.cantidad_disponible === 0;
return (
<>
<button
type="button"
className="btn btn-primary w-full"
onClick={open}
disabled={disabled}
>
{disabled ? 'Sin stock' : 'Solicitar'}
</button>
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] backdrop:bg-black/40 w-[min(92vw,28rem)]"
>
<form onSubmit={submit} className="p-5 sm:p-6 flex flex-col gap-4">
<div className="flex items-start justify-between gap-3">
<h2 id={titleId} className="text-lg font-semibold">
Solicitar: {material.nombre}
</h2>
<button
type="button"
onClick={close}
aria-label="Cerrar"
className="rounded-lg p-1 hover:bg-black/5 transition-colors leading-none text-xl"
>
<span aria-hidden="true">×</span>
</button>
</div>
<p className="text-sm opacity-70">
Disponibles: <span style={{ fontVariantNumeric: 'tabular-nums' }}>{material.cantidad_disponible}</span>
</p>
<div>
<label className="label" htmlFor={`cant-${material.id}`}>Cantidad</label>
<input
ref={firstFieldRef}
id={`cant-${material.id}`}
className="input"
type="number"
min={1}
max={material.cantidad_disponible}
value={cantidad}
required
inputMode="numeric"
onChange={(e) => setCantidad(Math.max(1, Math.min(material.cantidad_disponible, Number(e.target.value) || 1)))}
style={{ fontVariantNumeric: 'tabular-nums' }}
/>
</div>
<div>
<label className="label" htmlFor={`notas-${material.id}`}>Notas (opcional)</label>
<textarea
id={`notas-${material.id}`}
className="input"
rows={3}
maxLength={500}
value={notas}
onChange={(e) => setNotas(e.target.value)}
placeholder="Motivo, materia, fecha estimada de devolución…"
style={{ minHeight: '96px' }}
/>
</div>
{error && (
<p role="alert" className="text-sm" style={{ color: 'var(--color-danger)' }}>
{error} · Ajusta la cantidad o intenta de nuevo.
</p>
)}
{ok && (
<p role="status" className="text-sm" style={{ color: 'var(--color-primary)' }}>
Solicitud enviada. Actualizando
</p>
)}
<div className="flex gap-2 justify-end pt-2">
<button type="button" className="btn btn-ghost" onClick={close} disabled={loading}>
Cancelar
</button>
<button type="submit" className="btn btn-primary" disabled={loading || ok}>
{loading ? 'Enviando…' : 'Enviar solicitud'}
</button>
</div>
</form>
</dialog>
</>
);
}