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
+39 -7
View File
@@ -1,6 +1,7 @@
---
import Layout from './Layout.astro';
import Toaster from '@/components/Toaster.tsx';
import BadgeSolicitudes from '@/components/admin/BadgeSolicitudes.tsx';
import BrandMark from '@/components/BrandMark.astro';
interface Props {
@@ -10,6 +11,15 @@ const { title } = Astro.props;
const profile = Astro.locals.profile;
const isAdmin = profile?.rol === 'admin';
let pendientesCount = 0;
if (isAdmin) {
const { count } = await Astro.locals.supabase
.from('solicitudes')
.select('*', { count: 'exact', head: true })
.eq('estado', 'pendiente');
pendientesCount = count ?? 0;
}
const path = Astro.url.pathname;
const isActive = (href: string) => path === href || path.startsWith(href + '/');
@@ -37,7 +47,8 @@ const alumnoNav: NavItem[] = [
const adminNav: NavItem[] = [
{ href: '/admin', label: 'Panel', icon: 'dashboard' },
{ href: '/admin/solicitudes', label: 'Solicitudes', icon: 'inbox' },
{ href: '/admin/inventario', label: 'Estadísticas', icon: 'stats' },
{ href: '/admin/inventario', label: 'Inventario', icon: 'box' },
{ href: '/admin/estadisticas', label: 'Estadísticas', icon: 'stats' },
{ href: '/admin/reportes', label: 'Reportes', icon: 'chart' },
];
const nav = isAdmin ? adminNav : alumnoNav;
@@ -76,15 +87,26 @@ const activeCheck = (item: NavItem) => {
<a
href={item.href}
class:list={[
'flex items-center gap-3 px-3 py-2.5 rounded-[2px] text-sm font-medium uppercase tracking-wide whitespace-nowrap transition-colors',
'relative flex items-center gap-3 px-3 py-2.5 rounded-[2px] text-sm font-medium uppercase tracking-wide whitespace-nowrap transition-colors',
activeCheck(item) ? 'bg-[color:var(--color-primary)] text-white' : 'hover:bg-[color:var(--color-chalk)]'
]}
style={activeCheck(item) ? 'border: 2px solid var(--color-ink);' : 'border: 2px solid transparent;'}
aria-current={activeCheck(item) ? 'page' : undefined}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d={ICONS[item.icon as keyof typeof ICONS]} />
</svg>
<span class="relative inline-grid place-items-center">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d={ICONS[item.icon as keyof typeof ICONS]} />
</svg>
{item.href === '/admin/solicitudes' && (
<span
data-badge="pendientes"
hidden={pendientesCount === 0}
aria-label={`${pendientesCount} solicitudes pendientes`}
class="absolute -top-2 -right-3 min-w-[18px] h-[18px] px-1 grid place-items-center rounded-[2px] font-mono font-bold text-[11px] leading-none"
style="background: var(--color-danger); color: var(--color-ink); border: 2px solid var(--color-ink);"
>{pendientesCount}</span>
)}
</span>
{item.label}
</a>
))}
@@ -149,11 +171,11 @@ const activeCheck = (item: NavItem) => {
href={item.href}
aria-label={item.label}
aria-current={active ? 'page' : undefined}
class="flex-1 grid place-items-center py-2.5"
class="relative flex-1 grid place-items-center py-2.5"
>
<span
class:list={[
'grid place-items-center rounded-[2px] w-11 h-11 transition-[background-color,transform] duration-100 motion-safe:active:scale-95',
'relative grid place-items-center rounded-[2px] w-11 h-11 transition-[background-color,transform] duration-100 motion-safe:active:scale-95',
active ? 'bg-[color:var(--color-primary)] text-white' : ''
]}
style={active ? 'border: 2px solid var(--color-ink);' : ''}
@@ -161,6 +183,15 @@ const activeCheck = (item: NavItem) => {
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d={ICONS[item.icon as keyof typeof ICONS]} />
</svg>
{item.href === '/admin/solicitudes' && (
<span
data-badge="pendientes"
hidden={pendientesCount === 0}
aria-label={`${pendientesCount} solicitudes pendientes`}
class="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 grid place-items-center rounded-[2px] font-mono font-bold text-[11px] leading-none"
style="background: var(--color-danger); color: var(--color-ink); border: 2px solid var(--color-ink);"
>{pendientesCount}</span>
)}
</span>
</a>
);
@@ -181,6 +212,7 @@ const activeCheck = (item: NavItem) => {
</nav>
<Toaster client:idle />
{isAdmin && <BadgeSolicitudes client:idle />}
</div>
</Layout>