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,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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
Reference in New Issue
Block a user