1121ab7199
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>
289 lines
9.1 KiB
TypeScript
289 lines
9.1 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
}
|