UX: motivo requerido, dock móvil, hamburguesa desktop, toaster Sileo, panel de estadísticas
Alumno: - Modal de solicitud: campo "Notas" renombrado a "Motivo del préstamo", ahora requerido, con hint sobre uso (para qué clase o proyecto). Admin: - Bandeja de solicitudes pendientes, activos y devoluciones ahora muestran el motivo del alumno (en tabla desktop y tarjetas móvil). - /admin/inventario se convirtió en "Estadísticas": grid de KPIs del día (solicitadas, aprobadas, rechazadas, devoluciones, en préstamo, vencidos) arriba del inventario existente. Los cambios de estado se cuentan desde audit_log, el rango del día se calcula automáticamente en timezone America/Tijuana con DST via Intl API (src/lib/date.ts). Rediseño móvil: - Sidebar desktop se puede colapsar con botón hamburguesa. Estado persiste en localStorage sin flash (script is:inline bloqueante). - Nav móvil reemplazado por dock píldora flotante con iconos SVG inline (heroicons outline). Respeta safe-area-inset-bottom. Notificaciones estilo Sileo: - Componente Toaster global (React island client:idle) que escucha CustomEvents. Toasts con backdrop-blur, animación de entrada translateY+opacity, auto-dismiss 3.8s, click para cerrar. Helper toastAfterReload deja pendiente en sessionStorage para sobrevivir location.reload() post-acción. Diagnóstico: - /api/whoami devuelve user+profile actuales para depurar sesión. - Endpoint devolver retorna un objeto `debug` en el 403 con email+rol vistos por el server (temporal, para trazar bug del 403). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,121 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import type { ToastPayload, ToastKind } from '@/lib/toast';
|
||||||
|
|
||||||
|
type Item = ToastPayload & { id: number; leaving?: boolean };
|
||||||
|
|
||||||
|
const KIND_META: Record<ToastKind, { color: string; d: string }> = {
|
||||||
|
success: {
|
||||||
|
color: 'var(--color-primary)',
|
||||||
|
d: 'M4.5 12.75l6 6 9-13.5',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
color: 'var(--color-danger)',
|
||||||
|
d: 'M6 18L18 6M6 6l12 12',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
color: 'var(--color-secondary)',
|
||||||
|
d: 'M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Toaster() {
|
||||||
|
const [items, setItems] = useState<Item[]>([]);
|
||||||
|
const nextId = useRef(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function push(payload: ToastPayload) {
|
||||||
|
const id = nextId.current++;
|
||||||
|
const duration = payload.duration ?? 3800;
|
||||||
|
setItems((prev) => [...prev, { ...payload, id, kind: payload.kind ?? 'info' }]);
|
||||||
|
setTimeout(() => dismiss(id, true), duration);
|
||||||
|
}
|
||||||
|
function onToast(e: Event) {
|
||||||
|
push((e as CustomEvent<ToastPayload>).detail);
|
||||||
|
}
|
||||||
|
window.addEventListener('labre:toast', onToast);
|
||||||
|
|
||||||
|
// Drenar toasts pendientes de un reload previo.
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem('labre:toast:queue');
|
||||||
|
if (raw) {
|
||||||
|
sessionStorage.removeItem('labre:toast:queue');
|
||||||
|
(JSON.parse(raw) as ToastPayload[]).forEach(push);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
return () => window.removeEventListener('labre:toast', onToast);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function dismiss(id: number, animate = false) {
|
||||||
|
if (animate) {
|
||||||
|
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, leaving: true } : it)));
|
||||||
|
setTimeout(() => setItems((prev) => prev.filter((it) => it.id !== id)), 250);
|
||||||
|
} else {
|
||||||
|
setItems((prev) => prev.filter((it) => it.id !== id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="region"
|
||||||
|
aria-label="Notificaciones"
|
||||||
|
className="fixed z-[60] left-1/2 -translate-x-1/2 top-4 md:top-6 w-[min(92vw,26rem)] flex flex-col items-stretch gap-2 pointer-events-none"
|
||||||
|
>
|
||||||
|
{items.map((t) => {
|
||||||
|
const meta = KIND_META[t.kind ?? 'info'];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => dismiss(t.id, true)}
|
||||||
|
aria-live="polite"
|
||||||
|
className={
|
||||||
|
'toast-item pointer-events-auto text-left flex items-start gap-3 rounded-2xl px-4 py-3 ' +
|
||||||
|
'bg-white/85 backdrop-blur-xl border shadow-[0_16px_40px_-12px_rgba(0,0,0,0.25)] ' +
|
||||||
|
'motion-safe:transition-[opacity,transform] motion-safe:duration-200 ' +
|
||||||
|
(t.leaving ? 'toast-leave' : 'toast-enter')
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
borderColor: 'color-mix(in oklab, var(--color-ink) 10%, transparent)',
|
||||||
|
color: 'var(--color-ink)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="shrink-0 grid place-items-center w-9 h-9 rounded-full text-white"
|
||||||
|
style={{ background: meta.color }}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d={meta.d} />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block font-semibold text-sm leading-snug">{t.title}</span>
|
||||||
|
{t.description && (
|
||||||
|
<span className="block text-xs opacity-70 mt-0.5 leading-snug">{t.description}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.toast-enter {
|
||||||
|
animation: toast-in 260ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
.toast-leave {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px) scale(0.98);
|
||||||
|
}
|
||||||
|
@keyframes toast-in {
|
||||||
|
from { opacity: 0; transform: translateY(-12px) scale(0.96); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.toast-enter { animation: none; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { toast, toastAfterReload } from '@/lib/toast';
|
||||||
|
|
||||||
type Prestamo = {
|
type Prestamo = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -62,9 +63,16 @@ function AprobarDialog({ prestamo }: { prestamo: Prestamo }) {
|
|||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
throw new Error(body?.error ?? `Error ${res.status}`);
|
throw new Error(body?.error ?? `Error ${res.status}`);
|
||||||
}
|
}
|
||||||
|
toastAfterReload({
|
||||||
|
kind: 'success',
|
||||||
|
title: 'Solicitud aprobada',
|
||||||
|
description: `${prestamo.material?.nombre ?? 'Material'} · ${prestamo.alumno?.nombre ?? prestamo.alumno?.email ?? ''}`,
|
||||||
|
});
|
||||||
location.reload();
|
location.reload();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message ?? 'Error al aprobar.');
|
const msg = err.message ?? 'Error al aprobar.';
|
||||||
|
setError(msg);
|
||||||
|
toast({ kind: 'error', title: 'No se pudo aprobar', description: msg });
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -147,9 +155,16 @@ function RechazarDialog({ prestamo }: { prestamo: Prestamo }) {
|
|||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
throw new Error(body?.error ?? `Error ${res.status}`);
|
throw new Error(body?.error ?? `Error ${res.status}`);
|
||||||
}
|
}
|
||||||
|
toastAfterReload({
|
||||||
|
kind: 'info',
|
||||||
|
title: 'Solicitud rechazada',
|
||||||
|
description: `${prestamo.material?.nombre ?? 'Material'} · ${prestamo.alumno?.nombre ?? prestamo.alumno?.email ?? ''}`,
|
||||||
|
});
|
||||||
location.reload();
|
location.reload();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message ?? 'Error al rechazar.');
|
const msg = err.message ?? 'Error al rechazar.';
|
||||||
|
setError(msg);
|
||||||
|
toast({ kind: 'error', title: 'No se pudo rechazar', description: msg });
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { toast, toastAfterReload } from '@/lib/toast';
|
||||||
|
|
||||||
export default function MarcarDevuelto({ prestamoId, nombreMaterial }: { prestamoId: number; nombreMaterial: string }) {
|
export default function MarcarDevuelto({ prestamoId, nombreMaterial }: { prestamoId: number; nombreMaterial: string }) {
|
||||||
const ref = useRef<HTMLDialogElement>(null);
|
const ref = useRef<HTMLDialogElement>(null);
|
||||||
@@ -23,9 +24,16 @@ export default function MarcarDevuelto({ prestamoId, nombreMaterial }: { prestam
|
|||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
throw new Error(body?.error ?? `Error ${res.status}`);
|
throw new Error(body?.error ?? `Error ${res.status}`);
|
||||||
}
|
}
|
||||||
|
toastAfterReload({
|
||||||
|
kind: 'success',
|
||||||
|
title: 'Devolución registrada',
|
||||||
|
description: `${nombreMaterial} regresa al inventario`,
|
||||||
|
});
|
||||||
location.reload();
|
location.reload();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message ?? 'Error al registrar la devolución.');
|
const msg = err.message ?? 'Error al registrar la devolución.';
|
||||||
|
setError(msg);
|
||||||
|
toast({ kind: 'error', title: 'No se pudo registrar', description: msg });
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { toast, toastAfterReload } from '@/lib/toast';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
material: {
|
material: {
|
||||||
@@ -60,12 +61,19 @@ export default function SolicitarModal({ material }: Props) {
|
|||||||
throw new Error(json?.error ?? 'No se pudo enviar la solicitud');
|
throw new Error(json?.error ?? 'No se pudo enviar la solicitud');
|
||||||
}
|
}
|
||||||
setOk(true);
|
setOk(true);
|
||||||
|
toastAfterReload({
|
||||||
|
kind: 'success',
|
||||||
|
title: 'Solicitud enviada',
|
||||||
|
description: `${material.nombre} · pendiente de aprobación`,
|
||||||
|
});
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
dialogRef.current?.close();
|
dialogRef.current?.close();
|
||||||
location.reload();
|
location.reload();
|
||||||
}, 800);
|
}, 800);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Error inesperado');
|
const msg = err instanceof Error ? err.message : 'Error inesperado';
|
||||||
|
setError(msg);
|
||||||
|
toast({ kind: 'error', title: 'No se pudo enviar', description: msg });
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -126,17 +134,22 @@ export default function SolicitarModal({ material }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label" htmlFor={`notas-${material.id}`}>Notas (opcional)</label>
|
<label className="label" htmlFor={`notas-${material.id}`}>Motivo del préstamo</label>
|
||||||
<textarea
|
<textarea
|
||||||
id={`notas-${material.id}`}
|
id={`notas-${material.id}`}
|
||||||
className="input"
|
className="input"
|
||||||
rows={3}
|
rows={3}
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
value={notas}
|
value={notas}
|
||||||
|
required
|
||||||
onChange={(e) => setNotas(e.target.value)}
|
onChange={(e) => setNotas(e.target.value)}
|
||||||
placeholder="Motivo, materia, fecha estimada de devolución…"
|
placeholder="¿Para qué clase o proyecto lo necesitas?"
|
||||||
style={{ minHeight: '96px' }}
|
style={{ minHeight: '96px' }}
|
||||||
|
aria-describedby={`notas-hint-${material.id}`}
|
||||||
/>
|
/>
|
||||||
|
<p id={`notas-hint-${material.id}`} className="text-xs opacity-60 mt-1">
|
||||||
|
Ejemplo: Clase de Electrónica Analógica · Prof. Gómez · práctica 3.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
+161
-43
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
import Layout from './Layout.astro';
|
import Layout from './Layout.astro';
|
||||||
|
import Toaster from '@/components/Toaster.tsx';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -10,72 +11,189 @@ const profile = Astro.locals.profile;
|
|||||||
const isAdmin = profile?.rol === 'admin';
|
const isAdmin = profile?.rol === 'admin';
|
||||||
const path = Astro.url.pathname;
|
const path = Astro.url.pathname;
|
||||||
const isActive = (href: string) => path === href || path.startsWith(href + '/');
|
const isActive = (href: string) => path === href || path.startsWith(href + '/');
|
||||||
|
|
||||||
|
type NavItem = { href: string; label: string; icon: string };
|
||||||
|
|
||||||
|
// Iconos: paths de heroicons/lucide outline (24x24). Solo el `d` del <path>.
|
||||||
|
const ICONS = {
|
||||||
|
menu: 'M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5',
|
||||||
|
stats: 'M2.25 18L9 11.25l4.306 4.306a11.95 11.95 0 015.814-5.518l2.74-1.22m0 0l-5.94-2.28m5.94 2.28l-2.28 5.941',
|
||||||
|
home: 'M2.25 12l8.954-8.955a1.5 1.5 0 012.122 0L22.28 12M4.5 9.75v10.125A1.125 1.125 0 005.625 21H9.75v-6h4.5v6h4.125A1.125 1.125 0 0019.5 19.875V9.75',
|
||||||
|
grid: 'M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 8.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z',
|
||||||
|
list: 'M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z',
|
||||||
|
inbox: 'M2.25 13.5h3.86a2.25 2.25 0 012.012 1.244l.256.512a2.25 2.25 0 002.013 1.244h3.218a2.25 2.25 0 002.013-1.244l.256-.512a2.25 2.25 0 012.013-1.244h3.859m-19.5.338V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 00-2.15-1.588H6.911a2.25 2.25 0 00-2.15 1.588L2.35 13.177a2.25 2.25 0 00-.1.661z',
|
||||||
|
box: 'M21 8.25c0-2.485-2.099-4.5-4.688-4.5H7.688C5.098 3.75 3 5.765 3 8.25v7.5c0 2.485 2.099 4.5 4.688 4.5h8.624c2.59 0 4.688-2.015 4.688-4.5v-7.5zM3 8.25l9 5.25 9-5.25',
|
||||||
|
chart: 'M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z',
|
||||||
|
logout: 'M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75',
|
||||||
|
dashboard: 'M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5m.75-9l3-3 2.148 2.148A12.061 12.061 0 0116.5 7.605',
|
||||||
|
};
|
||||||
|
|
||||||
|
const alumnoNav: NavItem[] = [
|
||||||
|
{ href: '/', label: 'Inicio', icon: 'home' },
|
||||||
|
{ href: '/alumno/catalogo', label: 'Catálogo', icon: 'grid' },
|
||||||
|
{ href: '/alumno/mis-prestamos', label: 'Mis préstamos', icon: 'list' },
|
||||||
|
];
|
||||||
|
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/reportes', label: 'Reportes', icon: 'chart' },
|
||||||
|
];
|
||||||
|
const nav = isAdmin ? adminNav : alumnoNav;
|
||||||
|
|
||||||
|
const activeCheck = (item: NavItem) => {
|
||||||
|
if (item.href === '/') return path === '/';
|
||||||
|
if (item.href === '/admin') return path === '/admin';
|
||||||
|
return isActive(item.href);
|
||||||
|
};
|
||||||
---
|
---
|
||||||
<Layout title={title}>
|
<Layout title={title}>
|
||||||
<div class="min-h-screen flex flex-col md:flex-row">
|
<!-- No-flash: aplica estado colapsado del sidebar antes de pintar -->
|
||||||
<!-- Sidebar (desktop) / topbar (mobile) -->
|
<script is:inline>
|
||||||
<aside class="md:w-64 md:min-h-screen bg-[color:var(--color-primary)] text-white flex md:flex-col">
|
try {
|
||||||
<a href="/" class="p-4 md:p-6 flex items-center gap-3 md:border-b border-white/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white" aria-label="LabPréstamos — Inicio">
|
if (localStorage.getItem('sidebar') === 'collapsed') {
|
||||||
|
document.documentElement.classList.add('sidebar-collapsed');
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-screen flex flex-col md:block">
|
||||||
|
|
||||||
|
<!-- Sidebar desktop -->
|
||||||
|
<aside id="app-sidebar" class="hidden md:flex md:fixed md:top-0 md:left-0 md:w-64 md:h-screen bg-[color:var(--color-primary)] text-white md:flex-col md:z-40 sidebar-panel transition-transform duration-200 ease-out">
|
||||||
|
<a href="/" class="p-6 flex items-center gap-3 border-b border-white/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white" aria-label="LabPréstamos — Inicio">
|
||||||
<div class="w-9 h-9 rounded-lg bg-[color:var(--color-secondary)] grid place-items-center font-bold" aria-hidden="true">L</div>
|
<div class="w-9 h-9 rounded-lg bg-[color:var(--color-secondary)] grid place-items-center font-bold" aria-hidden="true">L</div>
|
||||||
<div class="hidden md:block">
|
<div>
|
||||||
<div class="font-semibold leading-tight">LabPréstamos</div>
|
<div class="font-semibold leading-tight">LabPréstamos</div>
|
||||||
<div class="text-xs text-white/70">UABC · Lab. Sistemas</div>
|
<div class="text-xs text-white/70">UABC · Lab. Sistemas</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
<nav class="flex-1 p-3 flex flex-col gap-1" aria-label="Navegación principal">
|
||||||
<nav class="flex-1 flex md:flex-col md:p-3 gap-1 overflow-x-auto md:overflow-visible" aria-label="Navegación principal">
|
{nav.map((item) => (
|
||||||
{isAdmin ? (
|
<a
|
||||||
<>
|
href={item.href}
|
||||||
<a href="/admin" class:list={["nav-item", isActive('/admin') && !path.startsWith('/admin/') && "nav-item-active"]}>Panel</a>
|
class:list={[
|
||||||
<a href="/admin/solicitudes" class:list={["nav-item", isActive('/admin/solicitudes') && "nav-item-active"]}>Solicitudes</a>
|
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium whitespace-nowrap transition-colors',
|
||||||
<a href="/admin/inventario" class:list={["nav-item", isActive('/admin/inventario') && "nav-item-active"]}>Inventario</a>
|
activeCheck(item)
|
||||||
<a href="/admin/reportes" class:list={["nav-item", isActive('/admin/reportes') && "nav-item-active"]}>Reportes</a>
|
? 'bg-white/15 text-white'
|
||||||
</>
|
: 'text-white/85 hover:bg-white/10 hover:text-white'
|
||||||
) : (
|
]}
|
||||||
<>
|
aria-current={activeCheck(item) ? 'page' : undefined}
|
||||||
<a href="/" class:list={["nav-item", path === '/' && "nav-item-active"]}>Inicio</a>
|
>
|
||||||
<a href="/alumno/catalogo" class:list={["nav-item", isActive('/alumno/catalogo') && "nav-item-active"]}>Catálogo</a>
|
<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">
|
||||||
<a href="/alumno/mis-prestamos" class:list={["nav-item", isActive('/alumno/mis-prestamos') && "nav-item-active"]}>Mis préstamos</a>
|
<path d={ICONS[item.icon as keyof typeof ICONS]} />
|
||||||
</>
|
</svg>
|
||||||
)}
|
{item.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
<div class="p-3 border-t border-white/10">
|
||||||
<div class="hidden md:block p-3 border-t border-white/10">
|
|
||||||
<div class="px-3 py-2 text-sm">
|
<div class="px-3 py-2 text-sm">
|
||||||
<div class="font-medium truncate">{profile?.nombre ?? profile?.email}</div>
|
<div class="font-medium truncate">{profile?.nombre ?? profile?.email}</div>
|
||||||
<div class="text-white/70 text-xs capitalize">{profile?.rol}</div>
|
<div class="text-white/70 text-xs capitalize">{profile?.rol}</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="POST" action="/api/auth/signout">
|
<form method="POST" action="/api/auth/signout">
|
||||||
<button type="submit" class="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-white/10 transition-colors">
|
<button type="submit" class="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-white/10 transition-colors flex items-center gap-2">
|
||||||
|
<svg width="18" height="18" 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.logout} />
|
||||||
|
</svg>
|
||||||
Cerrar sesión
|
Cerrar sesión
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile signout -->
|
|
||||||
<form method="POST" action="/api/auth/signout" class="md:hidden ml-auto p-2">
|
|
||||||
<button type="submit" aria-label="Cerrar sesión" class="p-2 rounded-lg hover:bg-white/10 transition-colors">
|
|
||||||
<span aria-hidden="true">↩</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main id="main" class="flex-1 p-4 md:p-8">
|
<!-- Header mobile mínimo -->
|
||||||
|
<header class="md:hidden flex items-center justify-between px-4 py-3 bg-[color:var(--color-primary)] text-white">
|
||||||
|
<a href="/" class="flex items-center gap-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white rounded-lg" aria-label="LabPréstamos — Inicio">
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-[color:var(--color-secondary)] grid place-items-center font-bold text-sm" aria-hidden="true">L</div>
|
||||||
|
<span class="font-semibold text-sm">LabPréstamos</span>
|
||||||
|
</a>
|
||||||
|
<div class="text-xs text-white/80 truncate max-w-[45%] text-right">
|
||||||
|
<div class="font-medium truncate">{profile?.nombre ?? profile?.email}</div>
|
||||||
|
<div class="text-white/60 capitalize">{profile?.rol}</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main" class="flex-1 p-4 md:p-8 pb-28 md:pb-8 md:ml-64 sidebar-aware transition-[margin] duration-200 ease-out">
|
||||||
|
<!-- Botón hamburguesa desktop: toggle sidebar -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="sidebar-toggle"
|
||||||
|
aria-label="Alternar menú lateral"
|
||||||
|
aria-controls="app-sidebar"
|
||||||
|
class="hidden md:inline-flex items-center justify-center w-10 h-10 rounded-lg mb-4 text-[color:var(--color-ink)]/80 hover:bg-[color:var(--color-ink)]/5 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--color-primary)]"
|
||||||
|
>
|
||||||
|
<svg width="22" height="22" 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.menu} />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- Dock flotante mobile -->
|
||||||
|
<nav
|
||||||
|
class="md:hidden fixed bottom-4 left-1/2 -translate-x-1/2 z-50 rounded-full px-2 py-2 flex items-center gap-1 backdrop-blur bg-white/95 shadow-[0_10px_30px_-8px_rgba(0,0,0,0.25)] border"
|
||||||
|
style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent); padding-bottom: calc(0.5rem + env(safe-area-inset-bottom));"
|
||||||
|
aria-label="Navegación principal"
|
||||||
|
>
|
||||||
|
{nav.map((item) => {
|
||||||
|
const active = activeCheck(item);
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
aria-label={item.label}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
class:list={[
|
||||||
|
'grid place-items-center rounded-full w-11 h-11 transition-[background-color,color,transform] duration-150 motion-safe:active:scale-95',
|
||||||
|
active
|
||||||
|
? 'bg-[color:var(--color-primary)] text-white'
|
||||||
|
: 'text-[color:var(--color-ink)]/70 hover:text-[color:var(--color-ink)] hover:bg-[color:var(--color-ink)]/5'
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<svg width="22" height="22" 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>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<span class="w-px h-6 mx-1" style="background: color-mix(in oklab, var(--color-ink) 15%, transparent);" aria-hidden="true"></span>
|
||||||
|
<form method="POST" action="/api/auth/signout" class="contents">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
aria-label="Cerrar sesión"
|
||||||
|
class="grid place-items-center rounded-full w-11 h-11 text-[color:var(--color-ink)]/70 hover:text-[color:var(--color-danger)] hover:bg-[color:var(--color-danger)]/10 transition-[background-color,color,transform] duration-150 motion-safe:active:scale-95"
|
||||||
|
>
|
||||||
|
<svg width="22" height="22" 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.logout} />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<Toaster client:idle />
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
<style>
|
<style is:global>
|
||||||
@reference "tailwindcss";
|
@media (min-width: 768px) {
|
||||||
.nav-item {
|
.sidebar-collapsed .sidebar-panel { transform: translateX(-100%); }
|
||||||
@apply block px-3 py-2 md:py-2.5 rounded-lg text-sm font-medium text-white/85
|
.sidebar-collapsed .sidebar-aware { margin-left: 0; }
|
||||||
whitespace-nowrap transition-colors duration-150
|
|
||||||
hover:bg-white/10 hover:text-white
|
|
||||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white;
|
|
||||||
}
|
|
||||||
.nav-item-active {
|
|
||||||
background: rgba(255, 255, 255, 0.15);
|
|
||||||
color: white;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const btn = document.getElementById('sidebar-toggle');
|
||||||
|
const root = document.documentElement;
|
||||||
|
const sync = () => {
|
||||||
|
const collapsed = root.classList.contains('sidebar-collapsed');
|
||||||
|
btn?.setAttribute('aria-expanded', String(!collapsed));
|
||||||
|
};
|
||||||
|
sync();
|
||||||
|
btn?.addEventListener('click', () => {
|
||||||
|
root.classList.toggle('sidebar-collapsed');
|
||||||
|
const collapsed = root.classList.contains('sidebar-collapsed');
|
||||||
|
try { localStorage.setItem('sidebar', collapsed ? 'collapsed' : 'open'); } catch (_) {}
|
||||||
|
sync();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Helpers de fecha con timezone MX. Respeta DST automáticamente
|
||||||
|
// leyendo el offset real vigente para la fecha dada.
|
||||||
|
|
||||||
|
const MX_TZ = 'America/Tijuana';
|
||||||
|
|
||||||
|
function tzOffset(date: Date, tz: string): string {
|
||||||
|
const parts = new Intl.DateTimeFormat('en', {
|
||||||
|
timeZone: tz,
|
||||||
|
timeZoneName: 'longOffset',
|
||||||
|
}).formatToParts(date);
|
||||||
|
const s = parts.find((p) => p.type === 'timeZoneName')?.value ?? 'GMT+00:00';
|
||||||
|
const m = s.match(/GMT([+-]\d{2}:\d{2})/);
|
||||||
|
return m?.[1] ?? '+00:00';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function todayMX(now: Date = new Date()): {
|
||||||
|
label: string;
|
||||||
|
startUTC: Date;
|
||||||
|
endUTC: Date;
|
||||||
|
isoDate: string;
|
||||||
|
} {
|
||||||
|
const isoDate = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: MX_TZ,
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
}).format(now); // YYYY-MM-DD
|
||||||
|
const offset = tzOffset(now, MX_TZ);
|
||||||
|
const startUTC = new Date(`${isoDate}T00:00:00${offset}`);
|
||||||
|
const endUTC = new Date(startUTC.getTime() + 24 * 60 * 60 * 1000);
|
||||||
|
const label = new Intl.DateTimeFormat('es-MX', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
timeZone: MX_TZ,
|
||||||
|
}).format(now);
|
||||||
|
return { label, startUTC, endUTC, isoDate };
|
||||||
|
}
|
||||||
@@ -1,10 +1,21 @@
|
|||||||
import { createServerClient, createBrowserClient, type CookieOptionsWithName } from '@supabase/ssr';
|
import { createServerClient, createBrowserClient, type CookieOptionsWithName } from '@supabase/ssr';
|
||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
import type { AstroCookies } from 'astro';
|
import type { AstroCookies } from 'astro';
|
||||||
|
|
||||||
const SCHEMA = 'prestamos';
|
const SCHEMA = 'prestamos';
|
||||||
|
|
||||||
const SUPABASE_URL = process.env.PUBLIC_SUPABASE_URL ?? import.meta.env.PUBLIC_SUPABASE_URL;
|
const SUPABASE_URL = process.env.PUBLIC_SUPABASE_URL ?? import.meta.env.PUBLIC_SUPABASE_URL;
|
||||||
const SUPABASE_ANON_KEY = process.env.PUBLIC_SUPABASE_ANON_KEY ?? import.meta.env.PUBLIC_SUPABASE_ANON_KEY;
|
const SUPABASE_ANON_KEY = process.env.PUBLIC_SUPABASE_ANON_KEY ?? import.meta.env.PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
const SUPABASE_SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY ?? import.meta.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
|
||||||
|
// ponytail: solo para preview local sin login. Bypass RLS con service_role.
|
||||||
|
// Su uso queda tras un flag de DEV en el middleware.
|
||||||
|
export function serviceClient() {
|
||||||
|
return createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY, {
|
||||||
|
db: { schema: SCHEMA },
|
||||||
|
auth: { persistSession: false, autoRefreshToken: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const cookieOptions: CookieOptionsWithName = {
|
const cookieOptions: CookieOptionsWithName = {
|
||||||
name: 'sb',
|
name: 'sb',
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export type ToastKind = 'success' | 'error' | 'info';
|
||||||
|
|
||||||
|
export type ToastPayload = {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
kind?: ToastKind;
|
||||||
|
duration?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function toast(payload: ToastPayload) {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
window.dispatchEvent(new CustomEvent<ToastPayload>('labre:toast', { detail: payload }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deja un toast pendiente que sobrevive un location.reload().
|
||||||
|
export function toastAfterReload(payload: ToastPayload) {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
const q = JSON.parse(sessionStorage.getItem('labre:toast:queue') ?? '[]');
|
||||||
|
q.push(payload);
|
||||||
|
sessionStorage.setItem('labre:toast:queue', JSON.stringify(q));
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import AppLayout from '@/layouts/AppLayout.astro';
|
import AppLayout from '@/layouts/AppLayout.astro';
|
||||||
import MaterialForm from '@/components/admin/inventario/MaterialForm.tsx';
|
import MaterialForm from '@/components/admin/inventario/MaterialForm.tsx';
|
||||||
import EliminarMaterial from '@/components/admin/inventario/EliminarMaterial.tsx';
|
import EliminarMaterial from '@/components/admin/inventario/EliminarMaterial.tsx';
|
||||||
|
import { todayMX } from '@/lib/date';
|
||||||
|
|
||||||
type Material = {
|
type Material = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -14,6 +15,45 @@ type Material = {
|
|||||||
categoria: { id: number; nombre: string } | null;
|
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('prestamos').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('prestamos').select('id', countHead).in('estado', ['aprobado', 'activo']),
|
||||||
|
supabase.from('prestamos').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 q = (Astro.url.searchParams.get('q') ?? '').trim();
|
||||||
const catRaw = Astro.url.searchParams.get('cat') ?? '';
|
const catRaw = Astro.url.searchParams.get('cat') ?? '';
|
||||||
const catId = catRaw && /^\d+$/.test(catRaw) ? Number(catRaw) : null;
|
const catId = catRaw && /^\d+$/.test(catRaw) ? Number(catRaw) : null;
|
||||||
@@ -56,16 +96,34 @@ const estadoBadge = (e: Material['estado']) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
---
|
---
|
||||||
<AppLayout title="Inventario — Admin">
|
<AppLayout title="Estadísticas — LabPréstamos">
|
||||||
<div class="max-w-6xl">
|
<div class="max-w-6xl">
|
||||||
<header class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
<header class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl md:text-3xl font-semibold">Inventario</h1>
|
<h1 class="text-2xl md:text-3xl font-semibold">Estadísticas</h1>
|
||||||
<p class="text-sm opacity-70 mt-1">Materiales del laboratorio.</p>
|
<p class="text-sm opacity-70 mt-1 first-letter:uppercase">
|
||||||
|
Actividad del día · <time datetime={dia.isoDate}>{dia.label}</time>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<MaterialForm mode="create" categorias={categorias} client:load />
|
<MaterialForm mode="create" categorias={categorias} client:load />
|
||||||
</header>
|
</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">
|
<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);">
|
<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
|
Materiales
|
||||||
|
|||||||
@@ -94,6 +94,11 @@ const tabs = [
|
|||||||
<td class="p-3">
|
<td class="p-3">
|
||||||
<div>{r.material?.nombre ?? '—'}</div>
|
<div>{r.material?.nombre ?? '—'}</div>
|
||||||
<div class="text-xs opacity-70">Inv. {r.material?.numero_inventario ?? '—'}</div>
|
<div class="text-xs opacity-70">Inv. {r.material?.numero_inventario ?? '—'}</div>
|
||||||
|
{r.notas && (
|
||||||
|
<div class="text-xs mt-1.5 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);">
|
||||||
|
<span class="opacity-60">Motivo:</span> {r.notas}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td class="p-3" style="font-variant-numeric: tabular-nums;">{r.cantidad}</td>
|
<td class="p-3" style="font-variant-numeric: tabular-nums;">{r.cantidad}</td>
|
||||||
<td class="p-3 opacity-80">{r.fecha_aprobacion ? fmtFecha.format(new Date(r.fecha_aprobacion)) : '—'}</td>
|
<td class="p-3 opacity-80">{r.fecha_aprobacion ? fmtFecha.format(new Date(r.fecha_aprobacion)) : '—'}</td>
|
||||||
@@ -138,6 +143,11 @@ const tabs = [
|
|||||||
<div class="text-sm">{r.material?.nombre ?? '—'} <span class="opacity-70">· Inv. {r.material?.numero_inventario ?? '—'}</span></div>
|
<div class="text-sm">{r.material?.nombre ?? '—'} <span class="opacity-70">· Inv. {r.material?.numero_inventario ?? '—'}</span></div>
|
||||||
<div class="text-sm mt-1">Cantidad: <strong style="font-variant-numeric: tabular-nums;">{r.cantidad}</strong></div>
|
<div class="text-sm mt-1">Cantidad: <strong style="font-variant-numeric: tabular-nums;">{r.cantidad}</strong></div>
|
||||||
<div class="text-sm mt-1 opacity-80">Devolver antes de <span style="font-variant-numeric: tabular-nums;">{r.fecha_devolucion_estimada ? fmtFecha.format(new Date(r.fecha_devolucion_estimada + 'T00:00:00')) : '—'}</span></div>
|
<div class="text-sm mt-1 opacity-80">Devolver antes de <span style="font-variant-numeric: tabular-nums;">{r.fecha_devolucion_estimada ? fmtFecha.format(new Date(r.fecha_devolucion_estimada + 'T00:00:00')) : '—'}</span></div>
|
||||||
|
{r.notas && (
|
||||||
|
<p class="text-sm mt-2 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);">
|
||||||
|
<span class="opacity-60">Motivo:</span> {r.notas}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div class="mt-3 flex gap-2 flex-wrap">
|
<div class="mt-3 flex gap-2 flex-wrap">
|
||||||
<VerDetalles client:load prestamoId={r.id} />
|
<VerDetalles client:load prestamoId={r.id} />
|
||||||
<MarcarDevuelto client:load prestamoId={r.id} nombreMaterial={r.material?.nombre ?? '—'} />
|
<MarcarDevuelto client:load prestamoId={r.id} nombreMaterial={r.material?.nombre ?? '—'} />
|
||||||
|
|||||||
@@ -89,6 +89,11 @@ const tabs = [
|
|||||||
<div class="text-xs opacity-70">
|
<div class="text-xs opacity-70">
|
||||||
Inv. {r.material?.numero_inventario ?? '—'} · disp. <span style="font-variant-numeric: tabular-nums;">{r.material?.cantidad_disponible ?? 0}</span>
|
Inv. {r.material?.numero_inventario ?? '—'} · disp. <span style="font-variant-numeric: tabular-nums;">{r.material?.cantidad_disponible ?? 0}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{r.notas && (
|
||||||
|
<div class="text-xs mt-1.5 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);">
|
||||||
|
<span class="opacity-60">Motivo:</span> {r.notas}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td class="p-3" style="font-variant-numeric: tabular-nums;">{r.cantidad}</td>
|
<td class="p-3" style="font-variant-numeric: tabular-nums;">{r.cantidad}</td>
|
||||||
<td class="p-3 opacity-80">{fmtFecha.format(new Date(r.fecha_solicitud))}</td>
|
<td class="p-3 opacity-80">{fmtFecha.format(new Date(r.fecha_solicitud))}</td>
|
||||||
@@ -114,6 +119,11 @@ const tabs = [
|
|||||||
<div class="text-xs opacity-70 mb-2">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
|
<div class="text-xs opacity-70 mb-2">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
|
||||||
<div class="text-sm">{r.material?.nombre ?? '—'} <span class="opacity-70">· Inv. {r.material?.numero_inventario ?? '—'}</span></div>
|
<div class="text-sm">{r.material?.nombre ?? '—'} <span class="opacity-70">· Inv. {r.material?.numero_inventario ?? '—'}</span></div>
|
||||||
<div class="text-sm mt-1">Cantidad: <strong style="font-variant-numeric: tabular-nums;">{r.cantidad}</strong> <span class="opacity-70">· disp. {r.material?.cantidad_disponible ?? 0}</span></div>
|
<div class="text-sm mt-1">Cantidad: <strong style="font-variant-numeric: tabular-nums;">{r.cantidad}</strong> <span class="opacity-70">· disp. {r.material?.cantidad_disponible ?? 0}</span></div>
|
||||||
|
{r.notas && (
|
||||||
|
<p class="text-sm mt-2 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);">
|
||||||
|
<span class="opacity-60">Motivo:</span> {r.notas}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<AccionesSolicitud client:load prestamo={r} />
|
<AccionesSolicitud client:load prestamo={r} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ const json = (body: unknown, status = 200) =>
|
|||||||
|
|
||||||
export const POST: APIRoute = async ({ params, locals }) => {
|
export const POST: APIRoute = async ({ params, locals }) => {
|
||||||
if (!locals.user) return json({ error: 'no autenticado' }, 401);
|
if (!locals.user) return json({ error: 'no autenticado' }, 401);
|
||||||
if (locals.profile?.rol !== 'admin') return json({ error: 'no autorizado' }, 403);
|
if (locals.profile?.rol !== 'admin') {
|
||||||
|
return json({
|
||||||
|
error: 'no autorizado',
|
||||||
|
debug: { email: locals.user.email, profile_rol: locals.profile?.rol ?? null },
|
||||||
|
}, 403);
|
||||||
|
}
|
||||||
|
|
||||||
const id = Number(params.id);
|
const id = Number(params.id);
|
||||||
if (!Number.isInteger(id) || id <= 0) return json({ error: 'id inválido' }, 400);
|
if (!Number.isInteger(id) || id <= 0) return json({ error: 'id inválido' }, 400);
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ locals }) => {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
user: locals.user ? { id: locals.user.id, email: locals.user.email } : null,
|
||||||
|
profile: locals.profile,
|
||||||
|
}, null, 2),
|
||||||
|
{ headers: { 'content-type': 'application/json' } },
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user