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
+140
View File
@@ -0,0 +1,140 @@
import type { APIRoute } from 'astro';
export const prerender = false;
type Patch = {
nombre?: string | null;
categoria_id?: number | null;
descripcion?: string | null;
numero_inventario?: string | null;
estado?: string;
cantidad_total?: number;
cantidad_disponible?: number;
};
const parseId = (raw: string | undefined) => {
const id = Number(raw);
return Number.isInteger(id) && id > 0 ? id : null;
};
export const PATCH: APIRoute = async ({ request, locals, params }) => {
if (locals.profile?.rol !== 'admin') {
return Response.json({ error: 'No autorizado' }, { status: 403 });
}
const id = parseId(params.id);
if (!id) return Response.json({ error: 'ID inválido' }, { status: 400 });
let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
return Response.json({ error: 'JSON inválido' }, { status: 400 });
}
const patch: Patch = {};
if ('nombre' in body) {
const nombre = typeof body.nombre === 'string' ? body.nombre.trim() : '';
if (!nombre) return Response.json({ error: 'El nombre no puede quedar vacío' }, { status: 400 });
patch.nombre = nombre;
}
if ('categoria_id' in body) {
if (body.categoria_id === null || body.categoria_id === '') {
patch.categoria_id = null;
} else {
const cid = Number(body.categoria_id);
if (!Number.isInteger(cid) || cid <= 0)
return Response.json({ error: 'Categoría inválida' }, { status: 400 });
patch.categoria_id = cid;
}
}
if ('descripcion' in body) {
patch.descripcion =
typeof body.descripcion === 'string' && body.descripcion.trim()
? body.descripcion.trim()
: null;
}
if ('numero_inventario' in body) {
patch.numero_inventario =
typeof body.numero_inventario === 'string' && body.numero_inventario.trim()
? body.numero_inventario.trim()
: null;
}
if ('estado' in body) {
const estado = typeof body.estado === 'string' ? body.estado : '';
if (!['disponible', 'mantenimiento', 'baja'].includes(estado))
return Response.json({ error: 'Estado inválido' }, { status: 400 });
patch.estado = estado;
}
if ('cantidad_total' in body) {
const nuevoTotal = Number(body.cantidad_total);
if (!Number.isInteger(nuevoTotal) || nuevoTotal < 0)
return Response.json({ error: 'Cantidad total inválida' }, { status: 400 });
const { data: actual, error: selErr } = await locals.supabase
.from('materiales')
.select('cantidad_total, cantidad_disponible')
.eq('id', id)
.maybeSingle();
if (selErr || !actual)
return Response.json({ error: 'Material no encontrado' }, { status: 404 });
const prestados = actual.cantidad_total - actual.cantidad_disponible;
if (nuevoTotal < prestados) {
return Response.json(
{ error: `No puede quedar debajo de lo prestado (${prestados})` },
{ status: 409 },
);
}
patch.cantidad_total = nuevoTotal;
patch.cantidad_disponible = actual.cantidad_disponible + (nuevoTotal - actual.cantidad_total);
}
if (Object.keys(patch).length === 0) {
return Response.json({ error: 'Nada que actualizar' }, { status: 400 });
}
const { error } = await locals.supabase.from('materiales').update(patch).eq('id', id);
if (error) {
if ((error as { code?: string }).code === '23505') {
return Response.json({ error: 'Nº de inventario ya existe' }, { status: 409 });
}
if ((error as { code?: string }).code === '23503') {
return Response.json({ error: 'Categoría inexistente' }, { status: 409 });
}
return Response.json({ error: 'No se pudo actualizar' }, { status: 500 });
}
return Response.json({ ok: true });
};
export const DELETE: APIRoute = async ({ locals, params }) => {
if (locals.profile?.rol !== 'admin') {
return Response.json({ error: 'No autorizado' }, { status: 403 });
}
const id = parseId(params.id);
if (!id) return Response.json({ error: 'ID inválido' }, { status: 400 });
const { error } = await locals.supabase.from('materiales').delete().eq('id', id);
if (error) {
if ((error as { code?: string }).code === '23503') {
return Response.json(
{ error: 'Tiene préstamos asociados — no se puede eliminar' },
{ status: 409 },
);
}
return Response.json({ error: 'No se pudo eliminar' }, { status: 500 });
}
return Response.json({ ok: true });
};
+79
View File
@@ -0,0 +1,79 @@
import type { APIRoute } from 'astro';
export const prerender = false;
export const POST: APIRoute = async ({ request, locals }) => {
if (locals.profile?.rol !== 'admin') {
return Response.json({ error: 'No autorizado' }, { status: 403 });
}
let body: {
nombre?: unknown;
categoria_id?: unknown;
descripcion?: unknown;
cantidad_total?: unknown;
numero_inventario?: unknown;
estado?: unknown;
};
try {
body = await request.json();
} catch {
return Response.json({ error: 'JSON inválido' }, { status: 400 });
}
const nombre = typeof body.nombre === 'string' ? body.nombre.trim() : '';
if (!nombre) {
return Response.json({ error: 'El nombre es obligatorio' }, { status: 400 });
}
const cantidad_total = Number(body.cantidad_total);
if (!Number.isInteger(cantidad_total) || cantidad_total < 0) {
return Response.json({ error: 'La cantidad total debe ser un entero mayor o igual a 0' }, { status: 400 });
}
const categoria_id =
body.categoria_id === null || body.categoria_id === undefined || body.categoria_id === ''
? null
: Number(body.categoria_id);
if (categoria_id !== null && (!Number.isInteger(categoria_id) || categoria_id <= 0)) {
return Response.json({ error: 'Categoría inválida' }, { status: 400 });
}
const descripcion =
typeof body.descripcion === 'string' && body.descripcion.trim() ? body.descripcion.trim() : null;
const numero_inventario =
typeof body.numero_inventario === 'string' && body.numero_inventario.trim()
? body.numero_inventario.trim()
: null;
const estadoIn = typeof body.estado === 'string' ? body.estado : 'disponible';
if (!['disponible', 'mantenimiento', 'baja'].includes(estadoIn)) {
return Response.json({ error: 'Estado inválido' }, { status: 400 });
}
const { data, error } = await locals.supabase
.from('materiales')
.insert({
nombre,
categoria_id,
descripcion,
cantidad_total,
cantidad_disponible: cantidad_total,
numero_inventario,
estado: estadoIn,
})
.select('id')
.single();
if (error) {
if ((error as { code?: string }).code === '23505') {
return Response.json({ error: 'Nº de inventario ya existe' }, { status: 409 });
}
if ((error as { code?: string }).code === '23503') {
return Response.json({ error: 'Categoría inexistente' }, { status: 409 });
}
return Response.json({ error: 'No se pudo crear el material' }, { status: 500 });
}
return Response.json({ id: data.id }, { status: 201 });
};