5 features: buscador catálogo, grid+fotos+unidades, estadísticas separada, badge realtime
Buscador catálogo alumno: - BuscadorCatalogo.tsx con debounce 120ms, normaliza tildes, ?q= en URL - FiltroCategorias usa data-* attributes, coordinación via window.__labreFiltrar - Cards con data-nombre y data-numero-inventario para filtro combinable AND Grid + imágenes + unidades individuales (admin): - Inventario ahora es grid 2/3/4/5 col con fotos aspect-square - MaterialForm: input file con preview, checkbox trackeado_por_unidad - UnidadesManager: agregar/editar/eliminar unidades por material - Reasignar unidad desde detalles de solicitud (aprobada/activa) - Alumno ve etiqueta de unidad asignada en mis-préstamos - Helper materialImg.imgUrl(path) para URL pública del bucket - Auto-submit debounced del filtro de inventario (input + selects) Estadísticas separada: - Nueva ruta /admin/estadisticas con 6 KPIs (movidos de /admin/inventario) - 3 gráficas recharts: top 10 materiales, solicitudes/día, distribución estados - Chart.tsx wrapper con paleta UABC + respeta prefers-reduced-motion - Nav admin: Panel · Solicitudes · Inventario (box) · Estadísticas (stats) · Reportes - admin/index.astro usa todayMX() (fix inconsistencia) Notificaciones pendientes (admin): - Badge SSR con conteo de pendientes sobre ícono Solicitudes (sidebar + dock) - BadgeSolicitudes.tsx: canal Realtime en prestamos.solicitudes, +1 en INSERT con toast, refetch en UPDATE (replica identity default no trae old.estado) - Recharts añadido a package.json
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
---
|
||||
import AppLayout from '@/layouts/AppLayout.astro';
|
||||
import Chart from '@/components/admin/estadisticas/Chart.tsx';
|
||||
import { todayMX } from '@/lib/date';
|
||||
|
||||
// El middleware ya garantiza rol admin en /admin/*.
|
||||
const supabase = Astro.locals.supabase;
|
||||
const dia = todayMX();
|
||||
const countHead = { count: 'exact' as const, head: true };
|
||||
const startISO = dia.startUTC.toISOString();
|
||||
const endISO = dia.endUTC.toISOString();
|
||||
|
||||
// KPIs del día — misma lógica que /admin/inventario legacy.
|
||||
const [
|
||||
{ count: nSolicitadas },
|
||||
{ count: nAprobadas },
|
||||
{ count: nRechazadas },
|
||||
{ count: nDevueltas },
|
||||
{ count: nEnPrestamo },
|
||||
{ count: nVencidos },
|
||||
] = await Promise.all([
|
||||
supabase.from('solicitudes').select('id', countHead).gte('fecha_solicitud', startISO).lt('fecha_solicitud', endISO),
|
||||
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'aprobado').gte('at', startISO).lt('at', endISO),
|
||||
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'rechazado').gte('at', startISO).lt('at', endISO),
|
||||
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'devuelto').gte('at', startISO).lt('at', endISO),
|
||||
supabase.from('solicitudes').select('id', countHead).in('estado', ['aprobado', 'activo']),
|
||||
supabase.from('solicitudes').select('id', countHead).in('estado', ['aprobado', 'activo']).lt('fecha_devolucion_estimada', dia.isoDate),
|
||||
]);
|
||||
|
||||
const kpis = [
|
||||
{ label: 'Solicitadas', value: nSolicitadas ?? 0, tone: 'primary' },
|
||||
{ label: 'Aprobadas', value: nAprobadas ?? 0, tone: 'primary' },
|
||||
{ label: 'Rechazadas', value: nRechazadas ?? 0, tone: 'neutral' },
|
||||
{ label: 'Devoluciones', value: nDevueltas ?? 0, tone: 'primary' },
|
||||
{ label: 'En préstamo', value: nEnPrestamo ?? 0, tone: 'secondary', total: true },
|
||||
{ label: 'Vencidos', value: nVencidos ?? 0, tone: (nVencidos ?? 0) > 0 ? 'danger' : 'neutral', total: true },
|
||||
];
|
||||
|
||||
const toneStyle = (tone: string) => {
|
||||
if (tone === 'primary') return 'color: var(--color-primary);';
|
||||
if (tone === 'secondary') return 'color: var(--color-secondary-hover);';
|
||||
if (tone === 'danger') return 'color: var(--color-danger-text);';
|
||||
return 'color: color-mix(in oklab, var(--color-ink) 65%, transparent);';
|
||||
};
|
||||
|
||||
// Ventana 30 días (rango inclusivo del día actual).
|
||||
const DIAS = 30;
|
||||
const desde = new Date(dia.startUTC.getTime() - (DIAS - 1) * 86400000).toISOString();
|
||||
|
||||
// (1) Top 10 materiales solicitados últimos 30 días.
|
||||
const { data: topMaterialesRaw } = await supabase
|
||||
.from('solicitud_items')
|
||||
.select('cantidad, material:materiales(nombre), solicitud:solicitudes!inner(fecha_solicitud)')
|
||||
.gte('solicitud.fecha_solicitud', desde);
|
||||
|
||||
const topMapa = new Map<string, number>();
|
||||
for (const r of (topMaterialesRaw ?? []) as any[]) {
|
||||
const nom = r.material?.nombre ?? '—';
|
||||
topMapa.set(nom, (topMapa.get(nom) ?? 0) + (r.cantidad ?? 0));
|
||||
}
|
||||
const topMateriales = [...topMapa.entries()]
|
||||
.map(([label, valor]) => ({ label, valor }))
|
||||
.sort((a, b) => b.valor - a.valor)
|
||||
.slice(0, 10);
|
||||
|
||||
// (2) Solicitudes por día últimos 30 días. Rellena huecos con 0.
|
||||
const { data: solicitudesUlt30 } = await supabase
|
||||
.from('solicitudes')
|
||||
.select('fecha_solicitud')
|
||||
.gte('fecha_solicitud', desde)
|
||||
.order('fecha_solicitud', { ascending: true });
|
||||
|
||||
// Genera todas las fechas de la ventana en formato YYYY-MM-DD (zona MX).
|
||||
function toIsoDateMX(d: Date): string {
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'America/Tijuana',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
const conteoPorDia = new Map<string, number>();
|
||||
for (const r of (solicitudesUlt30 ?? []) as { fecha_solicitud: string }[]) {
|
||||
const k = toIsoDateMX(new Date(r.fecha_solicitud));
|
||||
conteoPorDia.set(k, (conteoPorDia.get(k) ?? 0) + 1);
|
||||
}
|
||||
const solicitudesPorDia: { fecha: string; valor: number }[] = [];
|
||||
for (let i = 0; i < DIAS; i++) {
|
||||
const d = new Date(dia.startUTC.getTime() - (DIAS - 1 - i) * 86400000);
|
||||
const iso = toIsoDateMX(d);
|
||||
solicitudesPorDia.push({ fecha: iso, valor: conteoPorDia.get(iso) ?? 0 });
|
||||
}
|
||||
|
||||
// (3) Distribución de estados de todas las solicitudes.
|
||||
const { data: estadosRaw } = await supabase.from('solicitudes').select('estado');
|
||||
const estadoLabelMap: Record<string, string> = {
|
||||
pendiente: 'Pendiente',
|
||||
aprobado: 'Aprobado',
|
||||
activo: 'Activo',
|
||||
devuelto: 'Devuelto',
|
||||
rechazado: 'Rechazado',
|
||||
vencido: 'Vencido',
|
||||
};
|
||||
const estadoMapa = new Map<string, number>();
|
||||
for (const r of (estadosRaw ?? []) as { estado: string | null }[]) {
|
||||
if (!r.estado) continue;
|
||||
estadoMapa.set(r.estado, (estadoMapa.get(r.estado) ?? 0) + 1);
|
||||
}
|
||||
const distribEstados = [...estadoMapa.entries()].map(([k, v]) => ({
|
||||
label: estadoLabelMap[k] ?? k,
|
||||
valor: v,
|
||||
}));
|
||||
---
|
||||
<AppLayout title="Estadísticas — LabPréstamos">
|
||||
<div class="max-w-6xl">
|
||||
<header class="mb-6">
|
||||
<h1 class="text-2xl md:text-3xl font-semibold">Estadísticas</h1>
|
||||
<p class="text-sm mt-1 first-letter:uppercase" style="color: var(--color-pencil);">
|
||||
Actividad del día · <time datetime={dia.isoDate}>{dia.label}</time>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section aria-label="Métricas del día" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 mb-6">
|
||||
{kpis.map((k) => (
|
||||
<div class="card p-4">
|
||||
<div class="text-xs" style="color: var(--color-pencil);">{k.label}{k.total ? ' (total)' : ' hoy'}</div>
|
||||
<div class="text-3xl font-semibold mt-1 leading-none" style={`font-variant-numeric: tabular-nums; ${toneStyle(k.tone)}`}>
|
||||
{k.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section aria-label="Análisis" class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div class="card">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide mb-3">Solicitudes por día · últimos {DIAS}</h3>
|
||||
{solicitudesPorDia.some((d) => d.valor > 0) ? (
|
||||
<Chart tipo="linea" datos={solicitudesPorDia} client:idle />
|
||||
) : (
|
||||
<p class="text-sm py-8 text-center" style="color: var(--color-pencil);">Sin actividad en los últimos {DIAS} días.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide mb-3">Distribución de estados</h3>
|
||||
{distribEstados.length > 0 ? (
|
||||
<Chart tipo="donut" datos={distribEstados} client:idle />
|
||||
) : (
|
||||
<p class="text-sm py-8 text-center" style="color: var(--color-pencil);">Aún no hay solicitudes registradas.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="card lg:col-span-2">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide mb-3">Top 10 materiales · últimos {DIAS} días</h3>
|
||||
{topMateriales.length > 0 ? (
|
||||
<Chart tipo="barras" datos={topMateriales} alto={320} client:idle />
|
||||
) : (
|
||||
<p class="text-sm py-8 text-center" style="color: var(--color-pencil);">Ningún material solicitado en los últimos {DIAS} días.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</AppLayout>
|
||||
@@ -1,13 +1,14 @@
|
||||
---
|
||||
import AppLayout from '@/layouts/AppLayout.astro';
|
||||
import { todayMX } from '@/lib/date';
|
||||
|
||||
const supabase = Astro.locals.supabase;
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const dia = todayMX();
|
||||
|
||||
const [pend, activos, vencidos, agotados, ultimas] = await Promise.all([
|
||||
supabase.from('solicitudes').select('*', { count: 'exact', head: true }).eq('estado', 'pendiente'),
|
||||
supabase.from('solicitudes').select('*', { count: 'exact', head: true }).in('estado', ['aprobado', 'activo']),
|
||||
supabase.from('solicitudes').select('*', { count: 'exact', head: true }).in('estado', ['aprobado', 'activo']).lt('fecha_devolucion_estimada', today),
|
||||
supabase.from('solicitudes').select('*', { count: 'exact', head: true }).in('estado', ['aprobado', 'activo']).lt('fecha_devolucion_estimada', dia.isoDate),
|
||||
supabase.from('materiales').select('*', { count: 'exact', head: true }).eq('cantidad_disponible', 0),
|
||||
supabase
|
||||
.from('solicitudes')
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import AppLayout from '@/layouts/AppLayout.astro';
|
||||
import MaterialForm from '@/components/admin/inventario/MaterialForm.tsx';
|
||||
import EliminarMaterial from '@/components/admin/inventario/EliminarMaterial.tsx';
|
||||
import { todayMX } from '@/lib/date';
|
||||
import { imgUrl } from '@/lib/materialImg';
|
||||
|
||||
type Material = {
|
||||
id: number;
|
||||
@@ -12,48 +12,11 @@ type Material = {
|
||||
cantidad_disponible: number;
|
||||
numero_inventario: string | null;
|
||||
estado: 'disponible' | 'mantenimiento' | 'baja';
|
||||
imagen_path: string | null;
|
||||
trackeado_por_unidad: boolean;
|
||||
categoria: { id: number; nombre: string } | null;
|
||||
};
|
||||
|
||||
// KPIs del día (fecha automática, timezone MX con DST)
|
||||
const dia = todayMX();
|
||||
const supabase = Astro.locals.supabase;
|
||||
const countHead = { count: 'exact' as const, head: true };
|
||||
const startISO = dia.startUTC.toISOString();
|
||||
const endISO = dia.endUTC.toISOString();
|
||||
|
||||
const [
|
||||
{ count: nSolicitadas },
|
||||
{ count: nAprobadas },
|
||||
{ count: nRechazadas },
|
||||
{ count: nDevueltas },
|
||||
{ count: nEnPrestamo },
|
||||
{ count: nVencidos },
|
||||
] = await Promise.all([
|
||||
supabase.from('solicitudes').select('id', countHead).gte('fecha_solicitud', startISO).lt('fecha_solicitud', endISO),
|
||||
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'aprobado').gte('at', startISO).lt('at', endISO),
|
||||
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'rechazado').gte('at', startISO).lt('at', endISO),
|
||||
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'devuelto').gte('at', startISO).lt('at', endISO),
|
||||
supabase.from('solicitudes').select('id', countHead).in('estado', ['aprobado', 'activo']),
|
||||
supabase.from('solicitudes').select('id', countHead).in('estado', ['aprobado', 'activo']).lt('fecha_devolucion_estimada', dia.isoDate),
|
||||
]);
|
||||
|
||||
const kpis = [
|
||||
{ label: 'Solicitadas', value: nSolicitadas ?? 0, tone: 'primary' },
|
||||
{ label: 'Aprobadas', value: nAprobadas ?? 0, tone: 'primary' },
|
||||
{ label: 'Rechazadas', value: nRechazadas ?? 0, tone: 'neutral' },
|
||||
{ label: 'Devoluciones', value: nDevueltas ?? 0, tone: 'primary' },
|
||||
{ label: 'En préstamo', value: nEnPrestamo ?? 0, tone: 'secondary', total: true },
|
||||
{ label: 'Vencidos', value: nVencidos ?? 0, tone: (nVencidos ?? 0) > 0 ? 'danger' : 'neutral', total: true },
|
||||
];
|
||||
|
||||
const toneStyle = (tone: string) => {
|
||||
if (tone === 'primary') return 'color: var(--color-primary);';
|
||||
if (tone === 'secondary') return 'color: var(--color-secondary-hover);';
|
||||
if (tone === 'danger') return 'color: var(--color-danger);';
|
||||
return 'color: color-mix(in oklab, var(--color-ink) 65%, transparent);';
|
||||
};
|
||||
|
||||
const q = (Astro.url.searchParams.get('q') ?? '').trim();
|
||||
const catRaw = Astro.url.searchParams.get('cat') ?? '';
|
||||
const catId = catRaw && /^\d+$/.test(catRaw) ? Number(catRaw) : null;
|
||||
@@ -63,7 +26,7 @@ const estado = ['disponible', 'mantenimiento', 'baja'].includes(estadoRaw) ? est
|
||||
let query = Astro.locals.supabase
|
||||
.from('materiales')
|
||||
.select(
|
||||
'id, nombre, descripcion, cantidad_total, cantidad_disponible, numero_inventario, estado, categoria:categorias(id, nombre)',
|
||||
'id, nombre, descripcion, cantidad_total, cantidad_disponible, numero_inventario, estado, imagen_path, trackeado_por_unidad, categoria:categorias(id, nombre)',
|
||||
);
|
||||
|
||||
if (q) query = query.or(`nombre.ilike.%${q}%,numero_inventario.ilike.%${q}%`);
|
||||
@@ -96,34 +59,16 @@ const estadoBadge = (e: Material['estado']) => {
|
||||
};
|
||||
};
|
||||
---
|
||||
<AppLayout title="Estadísticas — LabPréstamos">
|
||||
<div class="max-w-6xl">
|
||||
<AppLayout title="Inventario — LabPréstamos">
|
||||
<div class="max-w-7xl">
|
||||
<header class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl md:text-3xl font-semibold">Estadísticas</h1>
|
||||
<p class="text-sm opacity-70 mt-1 first-letter:uppercase">
|
||||
Actividad del día · <time datetime={dia.isoDate}>{dia.label}</time>
|
||||
</p>
|
||||
<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>
|
||||
|
||||
<section aria-label="Métricas del día" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 mb-6">
|
||||
{kpis.map((k) => (
|
||||
<div class="card p-4">
|
||||
<div class="text-xs opacity-70">{k.label}{k.total ? ' (total)' : ' hoy'}</div>
|
||||
<div class="text-3xl font-semibold mt-1 leading-none" style={`font-variant-numeric: tabular-nums; ${toneStyle(k.tone)}`}>
|
||||
{k.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<div class="mb-4">
|
||||
<h2 class="text-lg font-semibold">Inventario</h2>
|
||||
<p class="text-sm opacity-70">Materiales del laboratorio.</p>
|
||||
</div>
|
||||
|
||||
<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
|
||||
@@ -133,10 +78,10 @@ const estadoBadge = (e: Material['estado']) => {
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<form method="GET" class="card mb-6 grid grid-cols-1 sm:grid-cols-4 gap-3" role="search">
|
||||
<form id="inv-filter-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" />
|
||||
<input id="filtro-q" name="q" type="search" class="input" value={q} placeholder="Buscar por nombre o Nº de inventario" autocomplete="off" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label" for="filtro-cat">Categoría</label>
|
||||
@@ -164,6 +109,21 @@ const estadoBadge = (e: Material['estado']) => {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script is:inline>
|
||||
(() => {
|
||||
const form = document.getElementById('inv-filter-form');
|
||||
if (!form) return;
|
||||
const q = form.querySelector('input[name="q"]');
|
||||
const selects = form.querySelectorAll('select');
|
||||
let t;
|
||||
q && q.addEventListener('input', () => {
|
||||
clearTimeout(t);
|
||||
t = setTimeout(() => form.submit(), 250);
|
||||
});
|
||||
selects.forEach((s) => s.addEventListener('change', () => form.submit()));
|
||||
})();
|
||||
</script>
|
||||
|
||||
{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-text);">
|
||||
@@ -184,93 +144,44 @@ const estadoBadge = (e: Material['estado']) => {
|
||||
)}
|
||||
|
||||
{!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-[2px] 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="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{materiales.map((m) => {
|
||||
const badge = estadoBadge(m.estado);
|
||||
const src = imgUrl(m.imagen_path);
|
||||
return (
|
||||
<article class="card p-0 flex flex-col overflow-hidden">
|
||||
<div
|
||||
class="aspect-square w-full bg-[color:var(--color-chalk)] grid place-items-center"
|
||||
style="border-bottom: 2px solid var(--color-ink);"
|
||||
>
|
||||
{src ? (
|
||||
<img src={src} alt={m.nombre} class="w-full h-full object-cover" loading="lazy" />
|
||||
) : (
|
||||
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="var(--color-ink)" stroke-width="1.5" aria-hidden="true">
|
||||
<rect x="3" y="4" width="18" height="16" rx="1" />
|
||||
<path d="M3 16l5-5 4 4 3-3 6 6" />
|
||||
<circle cx="8" cy="9" r="1.5" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div class="p-3 flex flex-col gap-2 flex-1">
|
||||
<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-[2px] whitespace-nowrap" style={badge.style}>
|
||||
<h3 class="uppercase font-semibold text-sm leading-snug line-clamp-2">{m.nombre}</h3>
|
||||
<span class="text-xs px-2 py-0.5 rounded-[2px] whitespace-nowrap shrink-0" 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">
|
||||
{m.categoria && (
|
||||
<p class="text-xs opacity-70 -mt-1">{m.categoria.nombre}</p>
|
||||
)}
|
||||
<p class="text-xs" style="font-variant-numeric: tabular-nums;">
|
||||
<span class="font-semibold">{m.cantidad_disponible}</span> / {m.cantidad_total} disponibles
|
||||
{m.trackeado_por_unidad && <span class="opacity-70"> · por unidad</span>}
|
||||
</p>
|
||||
{m.numero_inventario && (
|
||||
<p class="text-xs opacity-60 font-mono truncate">{m.numero_inventario}</p>
|
||||
)}
|
||||
<div class="flex gap-2 mt-auto pt-2">
|
||||
<MaterialForm
|
||||
mode="edit"
|
||||
material={{
|
||||
@@ -282,17 +193,19 @@ const estadoBadge = (e: Material['estado']) => {
|
||||
cantidad_disponible: m.cantidad_disponible,
|
||||
numero_inventario: m.numero_inventario,
|
||||
estado: m.estado,
|
||||
imagen_path: m.imagen_path,
|
||||
trackeado_por_unidad: m.trackeado_por_unidad,
|
||||
}}
|
||||
categorias={categorias}
|
||||
client:load
|
||||
/>
|
||||
<EliminarMaterial id={m.id} nombre={m.nombre} client:load />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
import AppLayout from '@/layouts/AppLayout.astro';
|
||||
import CartProvider from '@/components/alumno/SolicitudCart.tsx';
|
||||
import FiltroCategorias from '@/components/alumno/FiltroCategorias.tsx';
|
||||
import BuscadorCatalogo from '@/components/alumno/BuscadorCatalogo.tsx';
|
||||
|
||||
const qInitial = Astro.url.searchParams.get('q') ?? '';
|
||||
|
||||
type Material = {
|
||||
id: number;
|
||||
@@ -10,12 +13,13 @@ type Material = {
|
||||
cantidad_disponible: number;
|
||||
cantidad_total: number;
|
||||
numero_inventario: string | null;
|
||||
imagen_path: string | null;
|
||||
categoria: { id: number; nombre: string } | null;
|
||||
};
|
||||
|
||||
const { data, error } = await Astro.locals.supabase
|
||||
.from('materiales')
|
||||
.select('id, nombre, descripcion, cantidad_disponible, cantidad_total, numero_inventario, categoria:categorias(id, nombre)')
|
||||
.select('id, nombre, descripcion, cantidad_disponible, cantidad_total, numero_inventario, imagen_path, categoria:categorias(id, nombre)')
|
||||
.eq('estado', 'disponible')
|
||||
.order('nombre');
|
||||
|
||||
@@ -57,11 +61,12 @@ const categoriasFiltro = grupoList
|
||||
|
||||
{materiales.length > 0 && (
|
||||
<>
|
||||
{categoriasFiltro.length > 0 && (
|
||||
<div class="mb-6">
|
||||
<div class="mb-6 flex flex-col gap-3">
|
||||
<BuscadorCatalogo q={qInitial} client:load />
|
||||
{categoriasFiltro.length > 0 && (
|
||||
<FiltroCategorias categorias={categoriasFiltro} client:idle />
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p id="catalogo-empty-filter" hidden class="text-sm opacity-70 mb-4">
|
||||
Ningún material en esta categoría. Prueba con otra.
|
||||
|
||||
@@ -7,6 +7,7 @@ type Item = {
|
||||
cantidad: number;
|
||||
descripcion: string | null;
|
||||
material: { id: number; nombre: string; numero_inventario: string | null } | null;
|
||||
material_unidad: { etiqueta: string } | null;
|
||||
};
|
||||
|
||||
type Solicitud = {
|
||||
@@ -25,7 +26,7 @@ const user = Astro.locals.user!;
|
||||
|
||||
const { data, error } = await Astro.locals.supabase
|
||||
.from('solicitudes')
|
||||
.select('id, estado, fecha_solicitud, fecha_aprobacion, fecha_devolucion_estimada, fecha_devolucion_real, notas, maestro_responsable, items:solicitud_items(cantidad, descripcion, material:materiales(id, nombre, numero_inventario))')
|
||||
.select('id, estado, fecha_solicitud, fecha_aprobacion, fecha_devolucion_estimada, fecha_devolucion_real, notas, maestro_responsable, items:solicitud_items(cantidad, descripcion, material:materiales(id, nombre, numero_inventario), material_unidad:material_unidades(etiqueta))')
|
||||
.eq('alumno_id', user.id)
|
||||
.order('fecha_solicitud', { ascending: false });
|
||||
|
||||
@@ -101,6 +102,9 @@ const badgeStyle = (e: Estado) =>
|
||||
{it.material?.numero_inventario && (
|
||||
<span class="text-xs opacity-60 font-mono ml-1.5">{it.material.numero_inventario}</span>
|
||||
)}
|
||||
{it.material_unidad?.etiqueta && (
|
||||
<div class="text-xs opacity-70">Unidad: {it.material_unidad.etiqueta}</div>
|
||||
)}
|
||||
{it.descripcion && (
|
||||
<p class="text-xs opacity-60 ml-0.5">{it.descripcion}</p>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,8 @@ type Patch = {
|
||||
estado?: string;
|
||||
cantidad_total?: number;
|
||||
cantidad_disponible?: number;
|
||||
imagen_path?: string | null;
|
||||
trackeado_por_unidad?: boolean;
|
||||
};
|
||||
|
||||
const parseId = (raw: string | undefined) => {
|
||||
@@ -72,29 +74,83 @@ export const PATCH: APIRoute = async ({ request, locals, params }) => {
|
||||
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 });
|
||||
if ('imagen_path' in body) {
|
||||
if (body.imagen_path === null) {
|
||||
patch.imagen_path = null;
|
||||
} else if (typeof body.imagen_path === 'string' && body.imagen_path.trim()) {
|
||||
patch.imagen_path = body.imagen_path.trim();
|
||||
} else if (typeof body.imagen_path === 'string') {
|
||||
patch.imagen_path = null;
|
||||
} else {
|
||||
return Response.json({ error: 'imagen_path inválido' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const { data: actual, error: selErr } = await locals.supabase
|
||||
// Toggle trackeado_por_unidad — cargamos estado actual del material una vez si se pide algo que dependa de él
|
||||
let actual:
|
||||
| { cantidad_total: number; cantidad_disponible: number; trackeado_por_unidad: boolean }
|
||||
| null = null;
|
||||
const necesitaActual = 'cantidad_total' in body || 'trackeado_por_unidad' in body;
|
||||
if (necesitaActual) {
|
||||
const { data, error: selErr } = await locals.supabase
|
||||
.from('materiales')
|
||||
.select('cantidad_total, cantidad_disponible')
|
||||
.select('cantidad_total, cantidad_disponible, trackeado_por_unidad')
|
||||
.eq('id', id)
|
||||
.maybeSingle();
|
||||
|
||||
if (selErr || !actual)
|
||||
if (selErr || !data)
|
||||
return Response.json({ error: 'Material no encontrado' }, { status: 404 });
|
||||
actual = data as typeof actual;
|
||||
}
|
||||
|
||||
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 },
|
||||
);
|
||||
if ('trackeado_por_unidad' in body) {
|
||||
const nuevo = body.trackeado_por_unidad === true;
|
||||
if (nuevo !== actual!.trackeado_por_unidad) {
|
||||
if (nuevo) {
|
||||
// Solo permitir activar si aún no hay stock — evita perder datos
|
||||
if (actual!.cantidad_total !== 0) {
|
||||
return Response.json(
|
||||
{ error: 'Para activar rastreo por unidad, la cantidad total debe ser 0. Registra las unidades después.' },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Solo desactivar si no hay unidades registradas
|
||||
const { count } = await locals.supabase
|
||||
.from('material_unidades')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('material_id', id);
|
||||
if ((count ?? 0) > 0) {
|
||||
return Response.json(
|
||||
{ error: 'Para desactivar rastreo por unidad, elimina primero las unidades registradas.' },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
}
|
||||
patch.trackeado_por_unidad = nuevo;
|
||||
}
|
||||
}
|
||||
|
||||
if ('cantidad_total' in body) {
|
||||
// Si el material ya es (o va a quedar) trackeado, cantidad_total lo maneja el trigger; no aceptamos edición manual.
|
||||
const efectivoTrackeado =
|
||||
patch.trackeado_por_unidad ?? actual!.trackeado_por_unidad;
|
||||
if (efectivoTrackeado) {
|
||||
// ignorar silenciosamente cantidad_total en modo trackeado
|
||||
} else {
|
||||
const nuevoTotal = Number(body.cantidad_total);
|
||||
if (!Number.isInteger(nuevoTotal) || nuevoTotal < 0)
|
||||
return Response.json({ error: 'Cantidad total inválida' }, { status: 400 });
|
||||
|
||||
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);
|
||||
}
|
||||
patch.cantidad_total = nuevoTotal;
|
||||
patch.cantidad_disponible = actual.cantidad_disponible + (nuevoTotal - actual.cantidad_total);
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
||||
|
||||
const parseId = (raw: string | undefined) => {
|
||||
const id = Number(raw);
|
||||
return Number.isInteger(id) && id > 0 ? id : null;
|
||||
};
|
||||
|
||||
export const GET: APIRoute = async ({ locals, params, url }) => {
|
||||
if (locals.profile?.rol !== 'admin') return json({ error: 'No autorizado' }, 403);
|
||||
const materialId = parseId(params.id);
|
||||
if (!materialId) return json({ error: 'ID inválido' }, 400);
|
||||
|
||||
let q = locals.supabase
|
||||
.from('material_unidades')
|
||||
.select('id, etiqueta, estado, notas, created_at')
|
||||
.eq('material_id', materialId)
|
||||
.order('id');
|
||||
|
||||
const estado = url.searchParams.get('estado');
|
||||
if (estado && ['disponible', 'prestado', 'mantenimiento', 'baja'].includes(estado)) {
|
||||
q = q.eq('estado', estado);
|
||||
}
|
||||
|
||||
const { data, error } = await q;
|
||||
if (error) return json({ error: 'No se pudo cargar' }, 500);
|
||||
return json({ unidades: data ?? [] });
|
||||
};
|
||||
|
||||
export const POST: APIRoute = async ({ locals, params, request }) => {
|
||||
if (locals.profile?.rol !== 'admin') return json({ error: 'No autorizado' }, 403);
|
||||
const materialId = parseId(params.id);
|
||||
if (!materialId) return json({ error: 'ID inválido' }, 400);
|
||||
|
||||
let body: { etiqueta?: unknown; notas?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return json({ error: 'JSON inválido' }, 400);
|
||||
}
|
||||
|
||||
const etiqueta = typeof body.etiqueta === 'string' ? body.etiqueta.trim() : '';
|
||||
if (!etiqueta) return json({ error: 'La etiqueta es obligatoria' }, 400);
|
||||
const notas =
|
||||
typeof body.notas === 'string' && body.notas.trim() ? body.notas.trim() : null;
|
||||
|
||||
const { data, error } = await locals.supabase
|
||||
.from('material_unidades')
|
||||
.insert({ material_id: materialId, etiqueta, notas })
|
||||
.select('id, etiqueta, estado, notas, created_at')
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
if ((error as { code?: string }).code === '23505') {
|
||||
return json({ error: 'Etiqueta duplicada para este material' }, 409);
|
||||
}
|
||||
if ((error as { code?: string }).code === '23503') {
|
||||
return json({ error: 'Material inexistente' }, 409);
|
||||
}
|
||||
return json({ error: 'No se pudo crear la unidad' }, 500);
|
||||
}
|
||||
|
||||
return json({ unidad: data }, 201);
|
||||
};
|
||||
@@ -14,6 +14,8 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
cantidad_total?: unknown;
|
||||
numero_inventario?: unknown;
|
||||
estado?: unknown;
|
||||
imagen_path?: unknown;
|
||||
trackeado_por_unidad?: unknown;
|
||||
};
|
||||
try {
|
||||
body = await request.json();
|
||||
@@ -26,8 +28,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
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) {
|
||||
const trackeado_por_unidad = body.trackeado_por_unidad === true;
|
||||
|
||||
let cantidad_total = Number(body.cantidad_total);
|
||||
if (trackeado_por_unidad) {
|
||||
// trigger material_unidades sube total/disponible conforme se agreguen unidades
|
||||
cantidad_total = 0;
|
||||
} else 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 });
|
||||
}
|
||||
|
||||
@@ -51,6 +58,9 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
return Response.json({ error: 'Estado inválido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const imagen_path =
|
||||
typeof body.imagen_path === 'string' && body.imagen_path.trim() ? body.imagen_path.trim() : null;
|
||||
|
||||
const { data, error } = await locals.supabase
|
||||
.from('materiales')
|
||||
.insert({
|
||||
@@ -61,6 +71,8 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
cantidad_disponible: cantidad_total,
|
||||
numero_inventario,
|
||||
estado: estadoIn,
|
||||
imagen_path,
|
||||
trackeado_por_unidad,
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
||||
|
||||
const parseId = (raw: string | undefined) => {
|
||||
const id = Number(raw);
|
||||
return Number.isInteger(id) && id > 0 ? id : null;
|
||||
};
|
||||
|
||||
export const PATCH: APIRoute = async ({ locals, params, request }) => {
|
||||
if (locals.profile?.rol !== 'admin') return json({ error: 'No autorizado' }, 403);
|
||||
const unidadId = parseId(params.unidadId);
|
||||
if (!unidadId) return json({ error: 'ID inválido' }, 400);
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return json({ error: 'JSON inválido' }, 400);
|
||||
}
|
||||
|
||||
const patch: Record<string, unknown> = {};
|
||||
|
||||
if ('etiqueta' in body) {
|
||||
const etiqueta = typeof body.etiqueta === 'string' ? body.etiqueta.trim() : '';
|
||||
if (!etiqueta) return json({ error: 'La etiqueta no puede quedar vacía' }, 400);
|
||||
patch.etiqueta = etiqueta;
|
||||
}
|
||||
|
||||
if ('estado' in body) {
|
||||
const estado = typeof body.estado === 'string' ? body.estado : '';
|
||||
if (!['disponible', 'mantenimiento', 'baja'].includes(estado)) {
|
||||
return json({ error: "Estado inválido — 'prestado' lo asigna el sistema" }, 400);
|
||||
}
|
||||
patch.estado = estado;
|
||||
}
|
||||
|
||||
if ('notas' in body) {
|
||||
patch.notas =
|
||||
typeof body.notas === 'string' && body.notas.trim() ? body.notas.trim() : null;
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length === 0) return json({ error: 'Nada que actualizar' }, 400);
|
||||
|
||||
// Si se cambia estado, no permitir tocarlo si la unidad está actualmente 'prestado'
|
||||
if ('estado' in patch) {
|
||||
const { data: cur, error: selErr } = await locals.supabase
|
||||
.from('material_unidades')
|
||||
.select('estado')
|
||||
.eq('id', unidadId)
|
||||
.maybeSingle();
|
||||
if (selErr || !cur) return json({ error: 'Unidad no encontrada' }, 404);
|
||||
if (cur.estado === 'prestado') {
|
||||
return json({ error: 'Unidad prestada — el sistema controla su estado' }, 409);
|
||||
}
|
||||
}
|
||||
|
||||
const { error } = await locals.supabase
|
||||
.from('material_unidades')
|
||||
.update(patch)
|
||||
.eq('id', unidadId);
|
||||
|
||||
if (error) {
|
||||
if ((error as { code?: string }).code === '23505') {
|
||||
return json({ error: 'Etiqueta duplicada para este material' }, 409);
|
||||
}
|
||||
return json({ error: 'No se pudo actualizar' }, 500);
|
||||
}
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
export const DELETE: APIRoute = async ({ locals, params }) => {
|
||||
if (locals.profile?.rol !== 'admin') return json({ error: 'No autorizado' }, 403);
|
||||
const unidadId = parseId(params.unidadId);
|
||||
if (!unidadId) return json({ error: 'ID inválido' }, 400);
|
||||
|
||||
const { data: cur, error: selErr } = await locals.supabase
|
||||
.from('material_unidades')
|
||||
.select('estado')
|
||||
.eq('id', unidadId)
|
||||
.maybeSingle();
|
||||
if (selErr || !cur) return json({ error: 'Unidad no encontrada' }, 404);
|
||||
if (cur.estado === 'prestado') {
|
||||
return json({ error: 'Unidad prestada — no puedes eliminarla' }, 409);
|
||||
}
|
||||
|
||||
const { error } = await locals.supabase.from('material_unidades').delete().eq('id', unidadId);
|
||||
if (error) {
|
||||
if ((error as { code?: string }).code === '23503') {
|
||||
return json({ error: 'Tiene renglones asociados — no se puede eliminar' }, 409);
|
||||
}
|
||||
return json({ error: 'No se pudo eliminar' }, 500);
|
||||
}
|
||||
return json({ ok: true });
|
||||
};
|
||||
@@ -13,7 +13,7 @@ export const GET: APIRoute = async ({ params, locals }) => {
|
||||
const prestamoQ = locals.supabase
|
||||
.from('solicitudes')
|
||||
.select(
|
||||
'id, estado, fecha_solicitud, fecha_aprobacion, fecha_devolucion_estimada, fecha_devolucion_real, notas, maestro_responsable, alumno:profiles!alumno_id(id, nombre, email, matricula), items:solicitud_items(cantidad, descripcion, material:materiales(id, nombre, numero_inventario))'
|
||||
'id, estado, fecha_solicitud, fecha_aprobacion, fecha_devolucion_estimada, fecha_devolucion_real, notas, maestro_responsable, alumno:profiles!alumno_id(id, nombre, email, matricula), items:solicitud_items(id, cantidad, descripcion, material:materiales(id, nombre, numero_inventario, trackeado_por_unidad), material_unidad:material_unidades(id, etiqueta))'
|
||||
)
|
||||
.eq('id', id)
|
||||
.maybeSingle();
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
||||
|
||||
// Traduce excepciones nombradas de prestamos.reasignar_unidad a códigos HTTP.
|
||||
const CODE_MAP: Record<string, number> = {
|
||||
no_autorizado: 403,
|
||||
item_no_existe: 404,
|
||||
solicitud_no_activa: 409,
|
||||
item_sin_unidad: 409,
|
||||
unidad_no_existe: 404,
|
||||
unidad_de_otro_material: 409,
|
||||
unidad_no_disponible: 409,
|
||||
};
|
||||
|
||||
export const POST: APIRoute = async ({ locals, request }) => {
|
||||
if (locals.profile?.rol !== 'admin') return json({ error: 'No autorizado' }, 403);
|
||||
|
||||
let body: { item_id?: unknown; nueva_unidad_id?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return json({ error: 'JSON inválido' }, 400);
|
||||
}
|
||||
|
||||
const itemId = Number(body.item_id);
|
||||
const nuevaId = Number(body.nueva_unidad_id);
|
||||
if (!Number.isInteger(itemId) || itemId <= 0) return json({ error: 'item_id inválido' }, 400);
|
||||
if (!Number.isInteger(nuevaId) || nuevaId <= 0)
|
||||
return json({ error: 'nueva_unidad_id inválido' }, 400);
|
||||
|
||||
const { error } = await locals.supabase.rpc('reasignar_unidad', {
|
||||
p_item_id: itemId,
|
||||
p_nueva_unidad_id: nuevaId,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
const raw = String((error as { message?: string }).message ?? '');
|
||||
for (const key of Object.keys(CODE_MAP)) {
|
||||
if (raw.includes(key)) return json({ error: key }, CODE_MAP[key]);
|
||||
}
|
||||
return json({ error: 'No se pudo reasignar' }, 500);
|
||||
}
|
||||
|
||||
return json({ ok: true });
|
||||
};
|
||||
Reference in New Issue
Block a user