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:
2026-08-22 18:03:38 -07:00
parent 43959056f8
commit 544bbf38a9
23 changed files with 1876 additions and 247 deletions
+72 -16
View File
@@ -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 -2
View File
@@ -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 });
};