Sistema de préstamos LabRe UABC — implementación inicial
Sistema web para gestión de préstamos de material del Laboratorio de Sistemas Computacionales de la UABC. - Backend: Supabase self-hosted, schema aislado `prestamos` con RLS, triggers de stock y audit log (supabase/migrations/0001_init.sql). - Auth: Google OAuth restringido a @uabc.edu.mx, verificado en middleware y como segunda línea en trigger de DB. - Frontend: Astro 7 (SSR con adapter Node) + React islands + Tailwind v4 con paleta UABC (primary #00723F, secondary #DD971A) bajo regla 60/30/10. - Interfaz alumno mobile-first: catálogo con filtro por categorías, solicitud de préstamos, historial personal. - Interfaz admin desktop-first: panel con KPIs, bandeja de solicitudes (aprobar/rechazar/devolver), CRUD de inventario y categorías, reportes filtrables con export CSV nativo. - Modales con `<dialog>` nativo, cero librerías de UI adicionales. - Deploy: Dockerfile multi-stage node:22-alpine + docker-compose para publicar bajo prestamos.buglabs.dev vía Cloudflare Tunnel. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user