Files
labre-web/src/components/admin/inventario/MaterialForm.tsx
T
LakG 1121ab7199 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>
2026-08-14 19:37:49 -07:00

289 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
</>
);
}