1121ab7199
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>
241 lines
10 KiB
Plaintext
241 lines
10 KiB
Plaintext
---
|
|
import AppLayout from '@/layouts/AppLayout.astro';
|
|
import MaterialForm from '@/components/admin/inventario/MaterialForm.tsx';
|
|
import EliminarMaterial from '@/components/admin/inventario/EliminarMaterial.tsx';
|
|
|
|
type Material = {
|
|
id: number;
|
|
nombre: string;
|
|
descripcion: string | null;
|
|
cantidad_total: number;
|
|
cantidad_disponible: number;
|
|
numero_inventario: string | null;
|
|
estado: 'disponible' | 'mantenimiento' | 'baja';
|
|
categoria: { id: number; nombre: string } | null;
|
|
};
|
|
|
|
const q = (Astro.url.searchParams.get('q') ?? '').trim();
|
|
const catRaw = Astro.url.searchParams.get('cat') ?? '';
|
|
const catId = catRaw && /^\d+$/.test(catRaw) ? Number(catRaw) : null;
|
|
const estadoRaw = Astro.url.searchParams.get('estado') ?? '';
|
|
const estado = ['disponible', 'mantenimiento', 'baja'].includes(estadoRaw) ? estadoRaw : '';
|
|
|
|
let query = Astro.locals.supabase
|
|
.from('materiales')
|
|
.select(
|
|
'id, nombre, descripcion, cantidad_total, cantidad_disponible, numero_inventario, estado, categoria:categorias(id, nombre)',
|
|
);
|
|
|
|
if (q) query = query.or(`nombre.ilike.%${q}%,numero_inventario.ilike.%${q}%`);
|
|
if (catId != null) query = query.eq('categoria_id', catId);
|
|
if (estado) query = query.eq('estado', estado);
|
|
|
|
const { data: matData, error: matError } = await query.order('nombre');
|
|
const materiales = (matData ?? []) as unknown as Material[];
|
|
|
|
const { data: catData } = await Astro.locals.supabase
|
|
.from('categorias')
|
|
.select('id, nombre')
|
|
.order('nombre');
|
|
const categorias = (catData ?? []) as { id: number; nombre: string }[];
|
|
|
|
const estadoBadge = (e: Material['estado']) => {
|
|
if (e === 'disponible')
|
|
return {
|
|
label: 'Disponible',
|
|
style: 'background: color-mix(in oklab, var(--color-primary) 12%, transparent); color: var(--color-primary);',
|
|
};
|
|
if (e === 'mantenimiento')
|
|
return {
|
|
label: 'Mantenimiento',
|
|
style: 'background: color-mix(in oklab, var(--color-secondary) 15%, transparent); color: var(--color-secondary-hover);',
|
|
};
|
|
return {
|
|
label: 'Baja',
|
|
style: 'background: color-mix(in oklab, var(--color-ink) 8%, transparent); color: color-mix(in oklab, var(--color-ink) 60%, transparent);',
|
|
};
|
|
};
|
|
---
|
|
<AppLayout title="Inventario — Admin">
|
|
<div class="max-w-6xl">
|
|
<header class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
|
<div>
|
|
<h1 class="text-2xl md:text-3xl font-semibold">Inventario</h1>
|
|
<p class="text-sm opacity-70 mt-1">Materiales del laboratorio.</p>
|
|
</div>
|
|
<MaterialForm mode="create" categorias={categorias} client:load />
|
|
</header>
|
|
|
|
<nav class="flex gap-2 mb-6 border-b" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);" aria-label="Sub-navegación de inventario">
|
|
<a href="/admin/inventario" class="px-4 py-2 text-sm font-medium border-b-2" style="border-color: var(--color-primary); color: var(--color-primary);">
|
|
Materiales
|
|
</a>
|
|
<a href="/admin/inventario/categorias" class="px-4 py-2 text-sm font-medium border-b-2 border-transparent opacity-70 transition-colors duration-150 hover:opacity-100">
|
|
Categorías
|
|
</a>
|
|
</nav>
|
|
|
|
<form method="GET" class="card mb-6 grid grid-cols-1 sm:grid-cols-4 gap-3" role="search">
|
|
<div class="sm:col-span-2">
|
|
<label class="label" for="filtro-q">Buscar</label>
|
|
<input id="filtro-q" name="q" type="search" class="input" value={q} placeholder="Nombre o Nº inventario…" autocomplete="off" />
|
|
</div>
|
|
<div>
|
|
<label class="label" for="filtro-cat">Categoría</label>
|
|
<select id="filtro-cat" name="cat" class="input">
|
|
<option value="">Todas</option>
|
|
{categorias.map((c) => (
|
|
<option value={String(c.id)} selected={catId === c.id}>{c.nombre}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="label" for="filtro-estado">Estado</label>
|
|
<select id="filtro-estado" name="estado" class="input">
|
|
<option value="">Todos</option>
|
|
<option value="disponible" selected={estado === 'disponible'}>Disponible</option>
|
|
<option value="mantenimiento" selected={estado === 'mantenimiento'}>Mantenimiento</option>
|
|
<option value="baja" selected={estado === 'baja'}>Baja</option>
|
|
</select>
|
|
</div>
|
|
<div class="sm:col-span-4 flex gap-2 justify-end">
|
|
{(q || catId != null || estado) && (
|
|
<a href="/admin/inventario" class="btn btn-ghost">Limpiar</a>
|
|
)}
|
|
<button type="submit" class="btn btn-primary">Filtrar</button>
|
|
</div>
|
|
</form>
|
|
|
|
{matError && (
|
|
<div class="card mb-6" role="alert" style="border-color: color-mix(in oklab, var(--color-danger) 30%, transparent);">
|
|
<p class="text-sm" style="color: var(--color-danger);">
|
|
No se pudo cargar el inventario. Recarga la página.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{!matError && materiales.length === 0 && (
|
|
<div class="card text-center">
|
|
<h2 class="text-lg font-semibold mb-1">Sin resultados</h2>
|
|
<p class="text-sm opacity-70">
|
|
{q || catId != null || estado
|
|
? 'Ningún material coincide con los filtros. Ajusta la búsqueda.'
|
|
: 'Aún no hay material registrado. Agrega uno para comenzar.'}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{!matError && materiales.length > 0 && (
|
|
<>
|
|
<div class="hidden md:block card p-0 overflow-hidden">
|
|
<table class="w-full text-sm">
|
|
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
|
|
<tr>
|
|
<th class="px-4 py-3 font-medium">Nombre</th>
|
|
<th class="px-4 py-3 font-medium">Categoría</th>
|
|
<th class="px-4 py-3 font-medium">Nº inventario</th>
|
|
<th class="px-4 py-3 font-medium text-right">Stock</th>
|
|
<th class="px-4 py-3 font-medium">Estado</th>
|
|
<th class="px-4 py-3 font-medium text-right">Acciones</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{materiales.map((m) => {
|
|
const badge = estadoBadge(m.estado);
|
|
return (
|
|
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 8%, transparent);">
|
|
<td class="px-4 py-3">
|
|
<div class="font-medium">{m.nombre}</div>
|
|
{m.descripcion && (
|
|
<div class="text-xs opacity-60 line-clamp-1">{m.descripcion}</div>
|
|
)}
|
|
</td>
|
|
<td class="px-4 py-3 opacity-80">{m.categoria?.nombre ?? '—'}</td>
|
|
<td class="px-4 py-3 font-mono text-xs opacity-80">{m.numero_inventario ?? '—'}</td>
|
|
<td class="px-4 py-3 text-right" style="font-variant-numeric: tabular-nums;">
|
|
{m.cantidad_disponible} / {m.cantidad_total}
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<span class="text-xs px-2 py-0.5 rounded-full whitespace-nowrap" style={badge.style}>
|
|
{badge.label}
|
|
</span>
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<div class="flex gap-2 justify-end">
|
|
<MaterialForm
|
|
mode="edit"
|
|
material={{
|
|
id: m.id,
|
|
nombre: m.nombre,
|
|
categoria_id: m.categoria?.id ?? null,
|
|
descripcion: m.descripcion,
|
|
cantidad_total: m.cantidad_total,
|
|
cantidad_disponible: m.cantidad_disponible,
|
|
numero_inventario: m.numero_inventario,
|
|
estado: m.estado,
|
|
}}
|
|
categorias={categorias}
|
|
client:load
|
|
/>
|
|
<EliminarMaterial id={m.id} nombre={m.nombre} client:load />
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="md:hidden flex flex-col gap-3">
|
|
{materiales.map((m) => {
|
|
const badge = estadoBadge(m.estado);
|
|
return (
|
|
<article class="card flex flex-col gap-3">
|
|
<div class="flex items-start justify-between gap-2">
|
|
<div>
|
|
<h3 class="font-semibold leading-snug">{m.nombre}</h3>
|
|
{m.categoria && (
|
|
<p class="text-xs opacity-70 mt-0.5">{m.categoria.nombre}</p>
|
|
)}
|
|
</div>
|
|
<span class="text-xs px-2 py-0.5 rounded-full whitespace-nowrap" style={badge.style}>
|
|
{badge.label}
|
|
</span>
|
|
</div>
|
|
{m.descripcion && <p class="text-sm opacity-75 line-clamp-2">{m.descripcion}</p>}
|
|
<dl class="text-xs opacity-70 grid grid-cols-2 gap-1">
|
|
<dt class="opacity-60">Inventario</dt>
|
|
<dd class="text-right font-mono">{m.numero_inventario ?? '—'}</dd>
|
|
<dt class="opacity-60">Stock</dt>
|
|
<dd class="text-right" style="font-variant-numeric: tabular-nums;">
|
|
{m.cantidad_disponible} / {m.cantidad_total}
|
|
</dd>
|
|
</dl>
|
|
<div class="flex gap-2 justify-end mt-auto">
|
|
<MaterialForm
|
|
mode="edit"
|
|
material={{
|
|
id: m.id,
|
|
nombre: m.nombre,
|
|
categoria_id: m.categoria?.id ?? null,
|
|
descripcion: m.descripcion,
|
|
cantidad_total: m.cantidad_total,
|
|
cantidad_disponible: m.cantidad_disponible,
|
|
numero_inventario: m.numero_inventario,
|
|
estado: m.estado,
|
|
}}
|
|
categorias={categorias}
|
|
client:load
|
|
/>
|
|
<EliminarMaterial id={m.id} nombre={m.nombre} client:load />
|
|
</div>
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</AppLayout>
|