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