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,96 @@
|
||||
---
|
||||
import AppLayout from '@/layouts/AppLayout.astro';
|
||||
|
||||
const supabase = Astro.locals.supabase;
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
const [pend, activos, vencidos, agotados, ultimas] = await Promise.all([
|
||||
supabase.from('prestamos').select('*', { count: 'exact', head: true }).eq('estado', 'pendiente'),
|
||||
supabase.from('prestamos').select('*', { count: 'exact', head: true }).in('estado', ['aprobado', 'activo']),
|
||||
supabase.from('prestamos').select('*', { count: 'exact', head: true }).in('estado', ['aprobado', 'activo']).lt('fecha_devolucion_estimada', today),
|
||||
supabase.from('materiales').select('*', { count: 'exact', head: true }).eq('cantidad_disponible', 0),
|
||||
supabase
|
||||
.from('prestamos')
|
||||
.select('id, cantidad, fecha_solicitud, estado, alumno:profiles!alumno_id(nombre, email), material:materiales!material_id(nombre)')
|
||||
.order('fecha_solicitud', { ascending: false })
|
||||
.limit(5),
|
||||
]);
|
||||
|
||||
const fmtFecha = new Intl.DateTimeFormat('es-MX', { dateStyle: 'medium', timeStyle: 'short' });
|
||||
|
||||
const kpis = [
|
||||
{ label: 'Pendientes', value: pend.count ?? 0, href: '/admin/solicitudes', tone: 'primary' },
|
||||
{ label: 'En préstamo', value: activos.count ?? 0, href: '/admin/solicitudes/activos', tone: 'secondary' },
|
||||
{ label: 'Vencidos', value: vencidos.count ?? 0, href: '/admin/solicitudes/activos', tone: 'danger' },
|
||||
{ label: 'Agotados', value: agotados.count ?? 0, href: '/admin/inventario', tone: 'ink' },
|
||||
];
|
||||
|
||||
const estadoLabel: Record<string, string> = {
|
||||
pendiente: 'Pendiente',
|
||||
aprobado: 'En préstamo',
|
||||
activo: 'En préstamo',
|
||||
devuelto: 'Devuelto',
|
||||
rechazado: 'Rechazado',
|
||||
vencido: 'Vencido',
|
||||
};
|
||||
---
|
||||
<AppLayout title="Panel — LabPréstamos">
|
||||
<div class="max-w-6xl">
|
||||
<header class="mb-6">
|
||||
<h1 class="text-2xl md:text-3xl font-semibold">Panel</h1>
|
||||
<p class="text-sm opacity-70 mt-1">Resumen del laboratorio.</p>
|
||||
</header>
|
||||
|
||||
<section aria-label="Indicadores" class="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-4 mb-8">
|
||||
{kpis.map((k) => (
|
||||
<a
|
||||
href={k.href}
|
||||
class="card block group focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--color-primary)] motion-safe:hover:-translate-y-0.5 transition-transform duration-150"
|
||||
>
|
||||
<div
|
||||
class="text-4xl font-semibold leading-none"
|
||||
style={`font-variant-numeric: tabular-nums; color: var(--color-${k.tone === 'ink' ? 'ink' : k.tone});`}
|
||||
>
|
||||
{k.value}
|
||||
</div>
|
||||
<div class="mt-2 text-sm opacity-70 group-hover:opacity-100 transition-opacity">{k.label}</div>
|
||||
</a>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="ultimas-h">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 id="ultimas-h" class="text-lg font-semibold">Últimas 5 solicitudes</h2>
|
||||
<a href="/admin/solicitudes" class="text-sm underline decoration-[color:var(--color-primary)] underline-offset-4">Ver todas</a>
|
||||
</div>
|
||||
<div class="card p-0 overflow-hidden">
|
||||
{ultimas.data && ultimas.data.length > 0 ? (
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
|
||||
<tr>
|
||||
<th class="p-3 font-medium">Alumno</th>
|
||||
<th class="p-3 font-medium">Material</th>
|
||||
<th class="p-3 font-medium">Cant.</th>
|
||||
<th class="p-3 font-medium">Estado</th>
|
||||
<th class="p-3 font-medium">Solicitado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ultimas.data.map((r: any) => (
|
||||
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);">
|
||||
<td class="p-3">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</td>
|
||||
<td class="p-3">{r.material?.nombre ?? '—'}</td>
|
||||
<td class="p-3" style="font-variant-numeric: tabular-nums;">{r.cantidad}</td>
|
||||
<td class="p-3">{estadoLabel[r.estado] ?? r.estado}</td>
|
||||
<td class="p-3 opacity-80">{fmtFecha.format(new Date(r.fecha_solicitud))}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p class="p-6 opacity-70 text-sm">Aún no hay solicitudes registradas.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</AppLayout>
|
||||
Reference in New Issue
Block a user