Rediseño visual UABC + Mulish + auditoría de BD (P0/P1)

Rediseño visual:
- Nueva identidad UABC/editorial (DESIGN.md), tipografía Mulish, tokens
  verde/dorado/negro sobre bone-white, patrón de puntos verde en canvas,
  radios 8/12/30/9999, sin sombras, hairline 1px, tarjetas con leve
  transparencia + blur suave + borde verde muy sutil.
- 45 archivos .astro/.tsx alineados al nuevo sistema (Track A/B/C/D/E).
- Limpieza post-critique: eliminado triple CTA en home alumno, .card
  anidados en admin, aria-live decorativo, 26 aliases legacy en global.css,
  border-l verde en nav (patrón AI-slop), classList.remove obsoletos.

Auditoría de llamadas a BD (para 20-50 usuarios concurrentes):
- P0-1: middleware con cache in-memory de profile (30s) + is_banned (30s)
  por uid; exclusión de assets estáticos (/_astro/*, /audio/*, favicon).
  Invalidación en signout, PATCH /profile, baneos.
- P0-2: eliminado count pendientes duplicado en AppLayout; badge se
  hidrata client-side (BadgeSolicitudes con fetch inicial).
- P0-3: migración 0008 REPLICA IDENTITY FULL en solicitudes + delta
  preciso en BadgeSolicitudes (con fallback a refetch).
- P1: admin/index dedupe (4 queries → 2 con recientes.filter), catalogo
  reusa profile del middleware (3 queries → 1), inventario paralelo,
  estadisticas con cache 60s en RAM (stats-cache.ts), migración 0009
  con 3 índices (fecha, vencidos parcial, audit compuesto).

Fixes P0 de UI post-critique:
- estadisticas.astro: ReferenceError DIAS restaurado.
- solicitudes (3 vistas): error rendering sanitizado (no expone mensaje
  Postgres crudo al admin).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014xaxs8cGcQe9NwbMqA1gvn
This commit is contained in:
2026-09-21 09:42:12 -07:00
parent a41d50ca62
commit ac3e7061ac
53 changed files with 2132 additions and 1576 deletions
+18 -20
View File
@@ -3,17 +3,20 @@ import type { ToastPayload, ToastKind } from '@/lib/toast';
type Item = ToastPayload & { id: number; leaving?: boolean };
const KIND_META: Record<ToastKind, { color: string; d: string }> = {
const KIND_META: Record<ToastKind, { bg: string; text: string; d: string }> = {
success: {
color: 'var(--color-positive)',
bg: 'bg-verde-institucional',
text: 'text-bone-white',
d: 'M4.5 12.75l6 6 9-13.5',
},
error: {
color: 'var(--color-danger-text)',
bg: 'bg-negro-institucional',
text: 'text-bone-white',
d: 'M6 18L18 6M6 6l12 12',
},
info: {
color: 'var(--color-secondary)',
bg: 'bg-azul-institucional',
text: 'text-bone-white',
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',
},
};
@@ -50,7 +53,7 @@ export default function Toaster() {
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);
setTimeout(() => setItems((prev) => prev.filter((it) => it.id !== id)), 200);
} else {
setItems((prev) => prev.filter((it) => it.id !== id));
}
@@ -71,29 +74,23 @@ export default function Toaster() {
onClick={() => dismiss(t.id, true)}
aria-live="polite"
className={
'toast-item pointer-events-auto text-left flex items-start gap-3 rounded-[2px] px-4 py-3 bg-white ' +
'toast-item pointer-events-auto text-left flex items-start gap-3 rounded-[12px] px-4 py-3 bg-bone-white text-graphite border border-hairline-gray shadow-none ' +
'motion-safe:transition-[opacity,transform] motion-safe:duration-200 ' +
(t.leaving ? 'toast-leave' : 'toast-enter')
}
style={{
border: '2px solid var(--color-ink)',
boxShadow: 'var(--shadow-hard)',
color: 'var(--color-ink)',
}}
>
<span
aria-hidden="true"
className="shrink-0 grid place-items-center w-9 h-9 rounded-[2px]"
style={{ background: meta.color, border: '2px solid var(--color-ink)' }}
className={`shrink-0 grid place-items-center w-8 h-8 rounded-full ${meta.bg} ${meta.text}`}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--color-ink)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<svg width="16" height="16" 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>
<span className="block font-medium text-sm text-graphite leading-snug">{t.title}</span>
{t.description && (
<span className="block text-xs opacity-70 mt-0.5 leading-snug">{t.description}</span>
<span className="block text-xs text-graphite/70 mt-0.5 leading-snug">{t.description}</span>
)}
</span>
</button>
@@ -102,18 +99,19 @@ export default function Toaster() {
<style>{`
.toast-enter {
animation: toast-in 260ms cubic-bezier(0.22, 1, 0.36, 1) both;
animation: toast-in 200ms ease-out both;
}
.toast-leave {
opacity: 0;
transform: translateY(-8px) scale(0.98);
transform: translateY(-4px);
}
@keyframes toast-in {
from { opacity: 0; transform: translateY(-12px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.toast-enter { animation: none; }
.toast-leave { opacity: 0; }
}
`}</style>
</div>
+59 -7
View File
@@ -4,11 +4,17 @@ import { toast } from '@/lib/toast';
function setBadge(n: number) {
const v = Math.max(0, n | 0);
// Badge pill visual (nav): oculto si 0.
document.querySelectorAll<HTMLElement>('[data-badge="pendientes"]').forEach((el) => {
el.textContent = String(v);
if (v > 0) el.removeAttribute('hidden');
else el.setAttribute('hidden', '');
el.setAttribute('aria-label', `${v} solicitudes pendientes`);
el.classList.add('bg-verde-institucional', 'text-bone-white', 'rounded-full');
});
// Contadores de texto plano (KPIs) — visibles siempre, sin estilo pill.
document.querySelectorAll<HTMLElement>('[data-count="pendientes"]').forEach((el) => {
el.textContent = String(v);
});
}
@@ -18,18 +24,26 @@ function readBadge(): number {
return Number.isFinite(n) ? n : 0;
}
export default function BadgeSolicitudes() {
export default function BadgeSolicitudes({ count, className }: { count?: number; className?: string } = {}) {
useEffect(() => {
document.querySelectorAll<HTMLElement>('[data-badge="pendientes"]').forEach((el) => {
el.classList.add('bg-verde-institucional', 'text-bone-white', 'rounded-full');
});
const supabase = browserClient();
const refetch = async () => {
const { count } = await supabase
const { count: c } = await supabase
.from('solicitudes')
.select('*', { count: 'exact', head: true })
.eq('estado', 'pendiente');
setBadge(count ?? 0);
setBadge(c ?? 0);
};
// Fetch inicial: el SSR ya no envía count (se removió de AppLayout para
// ahorrar 1 query/pageview). El badge arranca en 0 y se hidrata acá.
refetch();
const channel = supabase
.channel('solicitudes-pendientes')
.on(
@@ -43,11 +57,26 @@ export default function BadgeSolicitudes() {
},
)
.on(
// ponytail: refetch en UPDATE porque payload.old no trae estado en replica identity default;
// upgrade a delta cuando la tabla tenga replica identity full.
// Delta preciso: REPLICA IDENTITY FULL (migración 0008) hace que
// payload.old traiga el estado anterior — evitamos el refetch en cada UPDATE.
'postgres_changes',
{ event: 'UPDATE', schema: 'prestamos', table: 'solicitudes' },
() => { refetch(); },
(payload) => {
const oldEstado = (payload.old as { estado?: string } | null)?.estado;
const newEstado = (payload.new as { estado?: string } | null)?.estado;
// Fallback: si el WAL no envió estado en `old` (tabla aún en REPLICA IDENTITY DEFAULT),
// caemos al refetch original — un poco más caro pero correcto.
if (oldEstado === undefined) {
refetch();
return;
}
if (oldEstado === 'pendiente' && newEstado !== 'pendiente') {
setBadge(Math.max(0, readBadge() - 1));
} else if (oldEstado !== 'pendiente' && newEstado === 'pendiente') {
setBadge(readBadge() + 1);
}
// Si ninguno de los dos es 'pendiente', el UPDATE no afecta al count — no hacer nada.
},
)
.subscribe();
@@ -56,5 +85,28 @@ export default function BadgeSolicitudes() {
};
}, []);
return null;
if (count !== undefined) {
return (
<span
data-badge="pendientes"
hidden={count === 0}
aria-label={`${count} solicitudes pendientes`}
className={`inline-flex items-center justify-center min-w-[18px] h-[18px] px-1.5 rounded-full font-sans font-medium text-[11px] leading-none bg-verde-institucional text-bone-white ${className ?? ''}`}
>
{count}
</span>
);
}
return (
<style>{`
[data-badge="pendientes"] {
background-color: var(--color-verde-institucional) !important;
color: var(--color-bone-white) !important;
border-radius: 9999px !important;
min-width: 18px !important;
height: 18px !important;
}
`}</style>
);
}
+109 -52
View File
@@ -21,50 +21,67 @@ type Linea = { tipo: 'linea'; datos: { fecha: string; valor: number }[]; alto?:
type Donut = { tipo: 'donut'; datos: { label: string; valor: number }[]; alto?: number };
type Props = Barras | Linea | Donut;
const INK = '#383838';
const PENCIL = '#a1a1a1';
const PRIMARY = '#00723F';
const SECONDARY = '#DD971A';
const DANGER = '#a83224';
const POSITIVE = '#38c1b0';
const ATTENTION = '#ffde00';
// PALETA UABC (TRACK E):
// Primario: #00723F (verde-institucional)
// Secundarios: #DD971A (dorado-uabc), #50B848 (verde-claro), #20419A (azul-institucional), #024731 (verde-jardin)
// Alerta: #000000 (negro-institucional)
// Superficie: #fffefb (bone-white)
// Texto: #333833 (graphite)
// Bordes: #e5e7eb (hairline-gray)
const MONO: React.CSSProperties = {
fontFamily: "'JetBrains Mono Variable', ui-monospace, monospace",
fontSize: 11,
color: INK,
};
const VERDE_INSTITUCIONAL = '#00723F';
const DORADO_UABC = '#DD971A';
const VERDE_CLARO = '#50B848';
const AZUL_INSTITUCIONAL = '#20419A';
const VERDE_JARDIN = '#024731';
const NEGRO_INSTITUCIONAL = '#000000';
const BONE_WHITE = '#fffefb';
const GRAPHITE = '#333833';
const HAIRLINE_GRAY = '#e5e7eb';
const PRIMARY = VERDE_INSTITUCIONAL;
const ALERT = NEGRO_INSTITUCIONAL;
// Paleta rotativa para el donut. Coloreado por label cuando aplique.
const DONUT_BY_LABEL: Record<string, string> = {
pendiente: ATTENTION,
aprobado: PRIMARY,
activo: PRIMARY,
devuelto: POSITIVE,
rechazado: PENCIL,
vencido: DANGER,
pendiente: DORADO_UABC,
aprobado: VERDE_INSTITUCIONAL,
activo: VERDE_CLARO,
devuelto: VERDE_JARDIN,
rechazado: GRAPHITE,
vencido: ALERT,
};
const DONUT_FALLBACK = [PRIMARY, SECONDARY, POSITIVE, ATTENTION, PENCIL, DANGER];
const DONUT_FALLBACK = [
VERDE_INSTITUCIONAL,
DORADO_UABC,
VERDE_CLARO,
AZUL_INSTITUCIONAL,
VERDE_JARDIN,
ALERT,
];
function donutColor(label: string, i: number): string {
return DONUT_BY_LABEL[label.toLowerCase()] ?? DONUT_FALLBACK[i % DONUT_FALLBACK.length];
}
const TOOLTIP_CONTENT_STYLE: React.CSSProperties = {
backgroundColor: '#fffefb',
border: '1px solid #e5e7eb',
borderRadius: 8,
padding: '8px 12px',
color: '#333833',
boxShadow: 'none',
fontFamily: "'Azeret Mono', monospace",
fontSize: 12,
};
function TooltipBox({ active, payload, label }: any) {
if (!active || !payload?.length) return null;
return (
<div
style={{
background: 'white',
border: `2px solid ${INK}`,
borderRadius: 2,
padding: '6px 10px',
...MONO,
}}
>
{label !== undefined && <div style={{ fontWeight: 600 }}>{label}</div>}
<div style={TOOLTIP_CONTENT_STYLE}>
{label !== undefined && <div style={{ fontWeight: 500, color: VERDE_INSTITUCIONAL, marginBottom: 4 }}>{label}</div>}
{payload.map((p: any, i: number) => (
<div key={i}>
<div key={i} style={{ fontVariantNumeric: 'tabular-nums' }}>
{p.name}: <strong>{p.value}</strong>
</div>
))}
@@ -86,8 +103,11 @@ function usePrefersReducedMotion(): boolean {
function fmtFechaCorta(iso: string): string {
// 'YYYY-MM-DD' → 'DD/MM'
const [, m, d] = iso.split('-');
return `${d}/${m}`;
const parts = iso.split('-');
if (parts.length >= 3) {
return `${parts[2]}/${parts[1]}`;
}
return iso;
}
export default function Chart(props: Props) {
@@ -103,21 +123,40 @@ export default function Chart(props: Props) {
);
return (
<ResponsiveContainer width="100%" height={alto}>
<BarChart data={datos} layout="vertical" margin={{ top: 8, right: 24, left: 8, bottom: 8 }}>
<CartesianGrid stroke={PENCIL} strokeDasharray="2 3" horizontal={false} />
<XAxis type="number" allowDecimals={false} stroke={INK} tick={MONO as any} />
<BarChart data={datos} layout="vertical" margin={{ top: 8, right: 28, left: 8, bottom: 8 }}>
<CartesianGrid stroke="#e5e7eb" strokeDasharray="2 3" horizontal={false} />
<XAxis
type="number"
allowDecimals={false}
stroke="#e5e7eb"
tick={{ fill: '#333833', fontSize: 11, fontFamily: "'Azeret Mono', monospace", fontVariantNumeric: 'tabular-nums' } as any}
/>
<YAxis
type="category"
dataKey="label"
width={100}
stroke={INK}
tick={{ ...MONO, fontSize: 10 } as any}
stroke="#e5e7eb"
tick={{ fill: '#333833', fontSize: 11, fontFamily: "'Azeret Mono', monospace" } as any}
interval={0}
tickFormatter={(v: string) => (v.length > 14 ? v.slice(0, 13) + '…' : v)}
/>
<Tooltip content={<TooltipBox />} cursor={{ fill: 'rgba(56,56,56,0.06)' }} />
<Bar dataKey="valor" fill={PRIMARY} stroke={INK} strokeWidth={1.5} animationDuration={anim} name="Solicitado">
<LabelList dataKey="valor" position="right" style={MONO as any} />
<Tooltip
content={<TooltipBox />}
cursor={{ fill: 'rgba(0, 114, 63, 0.05)' }}
contentStyle={TOOLTIP_CONTENT_STYLE}
/>
<Bar
dataKey="valor"
fill={PRIMARY}
radius={[0, 8, 8, 0]}
animationDuration={anim}
name="Solicitado"
>
<LabelList
dataKey="valor"
position="right"
style={{ fill: '#333833', fontSize: 11, fontFamily: "'Azeret Mono', monospace", fontVariantNumeric: 'tabular-nums' } as any}
/>
</Bar>
</BarChart>
</ResponsiveContainer>
@@ -129,17 +168,30 @@ export default function Chart(props: Props) {
return (
<ResponsiveContainer width="100%" height={alto}>
<LineChart data={datos} margin={{ top: 8, right: 16, left: 0, bottom: 8 }}>
<CartesianGrid stroke={PENCIL} strokeDasharray="2 3" />
<XAxis dataKey="fechaCorta" stroke={INK} tick={MONO as any} interval="preserveStartEnd" />
<YAxis allowDecimals={false} stroke={INK} tick={MONO as any} />
<Tooltip content={<TooltipBox />} cursor={{ stroke: INK, strokeWidth: 1, strokeDasharray: '2 3' }} />
<CartesianGrid stroke="#e5e7eb" strokeDasharray="2 3" />
<XAxis
dataKey="fechaCorta"
stroke="#e5e7eb"
tick={{ fill: '#333833', fontSize: 11, fontFamily: "'Azeret Mono', monospace", fontVariantNumeric: 'tabular-nums' } as any}
interval="preserveStartEnd"
/>
<YAxis
allowDecimals={false}
stroke="#e5e7eb"
tick={{ fill: '#333833', fontSize: 11, fontFamily: "'Azeret Mono', monospace", fontVariantNumeric: 'tabular-nums' } as any}
/>
<Tooltip
content={<TooltipBox />}
cursor={{ stroke: '#e5e7eb', strokeWidth: 1, strokeDasharray: '2 3' }}
contentStyle={TOOLTIP_CONTENT_STYLE}
/>
<Line
type="monotone"
dataKey="valor"
stroke={PRIMARY}
strokeWidth={2}
dot={{ fill: PRIMARY, stroke: INK, strokeWidth: 1.5, r: 3 }}
activeDot={{ fill: SECONDARY, stroke: INK, strokeWidth: 1.5, r: 5 }}
dot={{ fill: PRIMARY, stroke: BONE_WHITE, strokeWidth: 1, r: 3 }}
activeDot={{ fill: DORADO_UABC, stroke: PRIMARY, strokeWidth: 1, r: 5 }}
animationDuration={anim}
name="Solicitudes"
/>
@@ -153,15 +205,15 @@ export default function Chart(props: Props) {
return (
<ResponsiveContainer width="100%" height={alto}>
<PieChart margin={{ top: 8, right: 8, left: 8, bottom: 8 }}>
<Tooltip content={<TooltipBox />} />
<Tooltip content={<TooltipBox />} contentStyle={TOOLTIP_CONTENT_STYLE} />
<Pie
data={datos}
dataKey="valor"
nameKey="label"
innerRadius="55%"
outerRadius="85%"
stroke={INK}
strokeWidth={1.5}
stroke={BONE_WHITE}
strokeWidth={1}
isAnimationActive={!reduced}
animationDuration={anim}
label={false}
@@ -175,11 +227,16 @@ export default function Chart(props: Props) {
layout="horizontal"
verticalAlign="bottom"
align="center"
iconType="square"
wrapperStyle={{ ...MONO, marginTop: 8 }}
iconType="circle"
wrapperStyle={{
fontFamily: "'Azeret Mono', monospace",
fontSize: 12,
color: '#333833',
marginTop: 12,
}}
formatter={(value: string, entry: any) => {
const v = entry?.payload?.valor;
return v != null ? `${value} (${v})` : value;
return <span style={{ color: '#333833', marginRight: 8, fontFamily: "'Azeret Mono', monospace" }}>{v != null ? `${value} (${v})` : value}</span>;
}}
/>
</PieChart>
@@ -81,20 +81,23 @@ export default function CategoriaForm({ mode, categoria }: Props) {
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(92vw,24rem)]"
className="card bg-bone-white rounded-[12px] p-0 border border-hairline-gray backdrop:bg-negro-institucional/40 w-[min(92vw,24rem)]"
style={{ boxShadow: 'none' }}
>
<form onSubmit={submit} className="p-5 sm:p-6 flex flex-col gap-4" autoComplete="off">
<form onSubmit={submit} className="p-6 flex flex-col gap-4" autoComplete="off">
<div className="flex items-start justify-between gap-3">
<h2 id={titleId} className="text-lg font-semibold">
<h2 id={titleId} className="text-xl text-graphite font-normal">
{mode === 'create' ? 'Nueva categoría' : `Editar: ${categoria?.nombre}`}
</h2>
<button
type="button"
onClick={close}
aria-label="Cerrar"
className="rounded-lg p-1 hover:bg-black/5 transition-colors leading-none text-xl"
className="rounded-[8px] p-1.5 hover:bg-linen text-graphite transition-colors leading-none"
>
<span aria-hidden="true">×</span>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
@@ -122,8 +125,7 @@ export default function CategoriaForm({ mode, categoria }: Props) {
id={`${titleId}-err`}
role="alert"
tabIndex={-1}
className="text-sm"
style={{ color: 'var(--color-danger-text)' }}
className="text-sm text-negro-institucional"
>
{error}
</p>
@@ -54,17 +54,9 @@ export default function EliminarCategoria({ id, nombre, materialesCount }: Props
<>
<button
type="button"
className="btn btn-ghost transition-colors duration-150"
className="btn btn-ghost"
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
onClick={open}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--color-danger)';
e.currentTarget.style.color = 'white';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = 'var(--color-ink)';
}}
>
Eliminar
</button>
@@ -72,37 +64,37 @@ export default function EliminarCategoria({ id, nombre, materialesCount }: Props
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(92vw,28rem)]"
className="card bg-bone-white rounded-[12px] p-0 border border-hairline-gray backdrop:bg-negro-institucional/40 w-[min(92vw,28rem)]"
style={{ boxShadow: 'none' }}
>
<div className="p-5 sm:p-6 flex flex-col gap-4">
<h2 id={titleId} className="text-lg font-semibold">
<div className="p-6 flex flex-col gap-4">
<h2 id={titleId} className="text-xl text-graphite font-normal">
Eliminar categoría
</h2>
{bloqueado ? (
<>
<p className="text-sm">
No se puede eliminar <strong>{nombre}</strong>: tiene{' '}
<p className="text-sm text-graphite">
No se puede eliminar <strong className="font-medium text-graphite">{nombre}</strong>: tiene{' '}
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{materialesCount}</span>{' '}
material{materialesCount === 1 ? '' : 'es'} asociado
{materialesCount === 1 ? '' : 's'}. Reasígnalos primero.
</p>
<a
href={`/admin/inventario?cat=${id}`}
className="text-sm underline"
style={{ color: 'var(--color-primary)' }}
className="text-sm text-verde-institucional hover:text-verde-claro underline"
>
Ver materiales de esta categoría →
</a>
</>
) : (
<p className="text-sm">
¿Seguro que quieres eliminar <strong>{nombre}</strong>?
<p className="text-sm text-graphite">
¿Seguro que quieres eliminar <strong className="font-medium text-graphite">{nombre}</strong>?
</p>
)}
{error && (
<p role="alert" className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
<p role="alert" className="text-sm text-negro-institucional">
{error}
</p>
)}
@@ -114,8 +106,7 @@ export default function EliminarCategoria({ id, nombre, materialesCount }: Props
{!bloqueado && (
<button
type="button"
className="btn"
style={{ background: 'var(--color-danger)', color: 'var(--color-ink)' }}
className="btn btn-danger"
onClick={submit}
disabled={loading}
>
@@ -55,8 +55,8 @@ export default function EliminarMaterial({ id, nombre, tienePrestamos, compact }
<button
type="button"
className={
'btn btn-ghost hover:!bg-[color:var(--color-danger)] hover:!text-[color:var(--color-ink)]' +
(compact ? ' w-full text-sm px-2' : '')
'btn btn-ghost' +
(compact ? ' w-full text-xs px-2' : '')
}
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
onClick={open}
@@ -67,22 +67,23 @@ export default function EliminarMaterial({ id, nombre, tienePrestamos, compact }
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(92vw,26rem)]"
className="card bg-bone-white rounded-[12px] p-0 border border-hairline-gray backdrop:bg-negro-institucional/40 w-[min(92vw,26rem)]"
style={{ boxShadow: 'none' }}
>
<div className="p-5 sm:p-6 flex flex-col gap-4">
<h2 id={titleId} className="text-lg font-semibold">
<div className="p-6 flex flex-col gap-4">
<h2 id={titleId} className="text-xl text-graphite font-normal">
Eliminar material
</h2>
<p className="text-sm">
¿Seguro que quieres eliminar <strong>{nombre}</strong>? Esta acción no se puede deshacer.
<p className="text-sm text-graphite">
¿Seguro que quieres eliminar <strong className="font-medium text-graphite">{nombre}</strong>? Esta acción no se puede deshacer.
</p>
{tienePrestamos && (
<p className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
<p className="text-sm text-negro-institucional">
Este material tiene préstamos asociados y no podrá eliminarse.
</p>
)}
{error && (
<p role="alert" className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
<p role="alert" className="text-sm text-negro-institucional">
{error}
</p>
)}
@@ -92,8 +93,7 @@ export default function EliminarMaterial({ id, nombre, tienePrestamos, compact }
</button>
<button
type="button"
className="btn"
style={{ background: 'var(--color-danger)', color: 'var(--color-ink)' }}
className="btn btn-danger"
onClick={submit}
disabled={loading}
>
@@ -186,7 +186,7 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr
const triggerLabel = mode === 'create' ? 'Nuevo material' : 'Editar';
const triggerClass =
(mode === 'create' ? 'btn btn-primary' : 'btn btn-ghost') +
(compact ? ' w-full text-sm px-2' : '');
(compact ? ' w-full text-xs px-2' : '');
const triggerStyle = compact
? { minHeight: '36px', paddingBlock: '0.25rem' as const }
: undefined;
@@ -202,18 +202,19 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(94vw,36rem)]"
className="card bg-bone-white rounded-[12px] p-0 border border-hairline-gray backdrop:bg-negro-institucional/40 w-[min(94vw,36rem)] max-h-[90vh] overflow-y-auto"
style={{ boxShadow: 'none' }}
>
<form onSubmit={submit} className="p-5 sm:p-6 flex flex-col gap-4" autoComplete="off">
<form onSubmit={submit} className="p-6 flex flex-col gap-4" autoComplete="off">
<div className="flex items-start justify-between gap-3">
<h2 id={titleId} className="text-lg font-semibold">
<h2 id={titleId} className="text-xl text-graphite font-normal">
{mode === 'create' ? 'Nuevo material' : `Editar: ${material?.nombre}`}
</h2>
<button
type="button"
onClick={close}
aria-label="Cerrar"
className="rounded-lg p-1.5 hover:bg-black/5 transition-colors"
className="rounded-[8px] p-1.5 hover:bg-linen text-graphite transition-colors"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
@@ -226,13 +227,12 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr
<label className="label" htmlFor={`${titleId}-file`}>Foto</label>
<div className="flex items-start gap-3">
<div
className="w-24 h-24 shrink-0 grid place-items-center bg-[color:var(--color-chalk)]"
style={{ border: '2px solid var(--color-ink)' }}
className="w-24 h-24 shrink-0 grid place-items-center bg-linen rounded-[12px] border border-hairline-gray overflow-hidden"
>
{currentImg ? (
<img src={currentImg} alt="" className="w-full h-full object-cover" />
) : (
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" className="stroke-graphite" strokeWidth="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" />
@@ -251,7 +251,7 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr
{(file || imagenPath) && (
<button
type="button"
className="btn btn-ghost self-start"
className="btn btn-ghost self-start text-xs"
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
onClick={() => {
setFile(null);
@@ -326,16 +326,16 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr
/>
</div>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<label className="flex items-start gap-2 text-sm cursor-pointer text-graphite">
<input
type="checkbox"
checked={trackeado}
onChange={(e) => setTrackeado(e.target.checked)}
className="mt-1"
className="mt-1 accent-verde-institucional"
/>
<span>
<span className="font-medium">Rastrear cada unidad por separado</span>
<span className="block opacity-70 text-xs">
<span className="block text-graphite/70 text-xs">
Ej. Laptop-01, Laptop-02… El stock se calcula del número de unidades registradas abajo.
</span>
</span>
@@ -359,10 +359,10 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr
style={{ fontVariantNumeric: 'tabular-nums' }}
/>
{trackeado && (
<p className="text-xs opacity-70 mt-1">Se calcula del número de unidades.</p>
<p className="text-xs text-graphite/70 mt-1">Se calcula del número de unidades.</p>
)}
{!trackeado && mode === 'edit' && material && (
<p className="text-xs opacity-60 mt-1">
<p className="text-xs text-graphite/60 mt-1">
Prestados: {material.cantidad_total - material.cantidad_disponible}
</p>
)}
@@ -389,8 +389,7 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr
id={`${titleId}-err`}
role="alert"
tabIndex={-1}
className="text-sm"
style={{ color: 'var(--color-danger-text)' }}
className="text-sm text-negro-institucional"
>
{error}
</p>
@@ -408,10 +407,7 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr
{/* Gestor de unidades — solo edit + trackeado */}
{mode === 'edit' && material && material.trackeado_por_unidad && (
<div
className="border-t-2 p-5 sm:p-6"
style={{ borderColor: 'var(--color-ink)', background: 'var(--color-chalk)' }}
>
<div className="border-t border-hairline-gray p-6 bg-linen rounded-b-[12px]">
<UnidadesManager materialId={material.id} />
</div>
)}
@@ -88,7 +88,7 @@ export default function UnidadesManager({ materialId }: { materialId: number })
return (
<div>
<h3 className="text-sm font-semibold uppercase tracking-wider mb-3">Unidades individuales</h3>
<h3 className="eyebrow mb-3">Unidades individuales</h3>
<form onSubmit={crear} className="flex gap-2 mb-4">
<input
@@ -110,9 +110,9 @@ export default function UnidadesManager({ materialId }: { materialId: number })
</form>
{loading ? (
<p className="text-sm opacity-70">Cargando…</p>
<p className="text-sm text-graphite/70">Cargando…</p>
) : unidades.length === 0 ? (
<p className="text-sm opacity-70">Sin unidades registradas.</p>
<p className="text-sm text-graphite/70">Sin unidades registradas.</p>
) : (
<div className="flex flex-col gap-2">
{unidades.map((u) => {
@@ -120,7 +120,7 @@ export default function UnidadesManager({ materialId }: { materialId: number })
return (
<div
key={u.id}
className="card p-3 grid grid-cols-1 sm:grid-cols-[1fr_auto_auto] gap-2 items-center"
className="card-flat p-3.5 grid grid-cols-1 sm:grid-cols-[1fr_auto_auto] gap-2 items-center bg-bone-white border border-hairline-gray rounded-[12px]"
>
<input
type="text"
@@ -151,7 +151,7 @@ export default function UnidadesManager({ materialId }: { materialId: number })
</select>
<button
type="button"
className="btn btn-ghost"
className="btn btn-danger text-xs px-3"
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
disabled={bloqueada}
onClick={() => eliminar(u)}
@@ -64,27 +64,28 @@ export default function EliminarMaestro({ id, nombre, alumnosCount }: Props) {
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(92vw,28rem)]"
className="card bg-bone-white rounded-[12px] p-0 border border-hairline-gray backdrop:bg-negro-institucional/40 w-[min(92vw,28rem)]"
style={{ boxShadow: 'none' }}
>
<div className="p-5 sm:p-6 flex flex-col gap-4">
<h2 id={titleId} className="text-lg font-semibold">
<div className="p-6 flex flex-col gap-4">
<h2 id={titleId} className="text-xl text-graphite font-normal">
Eliminar maestro
</h2>
{bloqueado ? (
<p className="text-sm">
No se puede eliminar <strong>{nombre}</strong>: tiene{' '}
<p className="text-sm text-graphite">
No se puede eliminar <strong className="font-medium text-graphite">{nombre}</strong>: tiene{' '}
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{alumnosCount}</span>{' '}
alumno{alumnosCount === 1 ? '' : 's'} como tutor. Reasígnalos primero desde su perfil.
</p>
) : (
<p className="text-sm">
¿Seguro que quieres eliminar <strong>{nombre}</strong>?
<p className="text-sm text-graphite">
¿Seguro que quieres eliminar <strong className="font-medium text-graphite">{nombre}</strong>?
</p>
)}
{error && (
<p role="alert" className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
<p role="alert" className="text-sm text-negro-institucional">
{error}
</p>
)}
@@ -96,8 +97,7 @@ export default function EliminarMaestro({ id, nombre, alumnosCount }: Props) {
{!bloqueado && (
<button
type="button"
className="btn"
style={{ background: 'var(--color-danger)', color: 'var(--color-ink)' }}
className="btn btn-danger"
onClick={submit}
disabled={loading}
>
+15 -12
View File
@@ -87,20 +87,23 @@ export default function MaestroForm({ mode, maestro }: Props) {
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(92vw,24rem)]"
className="card bg-bone-white rounded-[12px] p-0 border border-hairline-gray backdrop:bg-negro-institucional/40 w-[min(92vw,24rem)]"
style={{ boxShadow: 'none' }}
>
<form onSubmit={submit} className="p-5 sm:p-6 flex flex-col gap-4" autoComplete="off">
<form onSubmit={submit} className="p-6 flex flex-col gap-4" autoComplete="off">
<div className="flex items-start justify-between gap-3">
<h2 id={titleId} className="text-lg font-semibold">
<h2 id={titleId} className="text-xl text-graphite font-normal">
{mode === 'create' ? 'Nuevo maestro' : `Editar: ${maestro?.nombre}`}
</h2>
<button
type="button"
onClick={close}
aria-label="Cerrar"
className="rounded-lg p-1 hover:bg-black/5 transition-colors leading-none text-xl"
className="rounded-[8px] p-1.5 hover:bg-linen text-graphite transition-colors leading-none"
>
<span aria-hidden="true">×</span>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
@@ -122,23 +125,24 @@ export default function MaestroForm({ mode, maestro }: Props) {
/>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<label className="flex items-center gap-2 text-sm cursor-pointer text-graphite">
<input
type="checkbox"
checked={activo}
onChange={(e) => setActivo(e.target.checked)}
className="accent-verde-institucional"
/>
Activo (visible en el catálogo de maestros)
<span>Activo <span className="text-graphite/70 text-xs">(visible en el catálogo de maestros)</span></span>
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<label className="flex items-start gap-2 text-sm cursor-pointer text-graphite">
<input
type="checkbox"
className="mt-0.5"
className="mt-0.5 accent-verde-institucional"
checked={esTutor}
onChange={(e) => setEsTutor(e.target.checked)}
/>
<span>Es tutor <span className="opacity-70">(aparece en la lista de tutores del perfil de alumnos)</span></span>
<span>Es tutor <span className="text-graphite/70 text-xs">(aparece en la lista de tutores del perfil de alumnos)</span></span>
</label>
{error && (
@@ -147,8 +151,7 @@ export default function MaestroForm({ mode, maestro }: Props) {
id={`${titleId}-err`}
role="alert"
tabIndex={-1}
className="text-sm"
style={{ color: 'var(--color-danger-text)' }}
className="text-sm text-negro-institucional"
>
{error}
</p>
+34 -24
View File
@@ -107,7 +107,7 @@ export function Autocomplete({ name, endpoint, label, placeholder, initialValue,
ref={inputRef}
id={inputId}
type="text"
className="input"
className="input rounded-[8px]"
role="combobox"
aria-autocomplete="list"
aria-expanded={open}
@@ -119,13 +119,18 @@ export function Autocomplete({ name, endpoint, label, placeholder, initialValue,
setText(e.target.value);
if (value) setValue(''); // el usuario editó → invalidar la selección previa
}}
onFocus={() => items.length && setOpen(true)}
onFocus={() => items.length > 0 && setOpen(true)}
onKeyDown={onKey}
autoComplete="off"
spellCheck={false}
/>
{(text || value) && (
<button type="button" className="btn btn-ghost px-3" onClick={clear} aria-label="Limpiar">
<button
type="button"
className="btn btn-ghost px-3"
onClick={clear}
aria-label="Limpiar"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
@@ -136,32 +141,37 @@ export function Autocomplete({ name, endpoint, label, placeholder, initialValue,
<ul
id={listId}
role="listbox"
className="absolute z-10 mt-1 w-full bg-white rounded-lg max-h-64 overflow-auto"
style={{ border: '1px solid color-mix(in oklab, var(--color-ink) 15%, transparent)' }}
className="absolute z-50 mt-1 w-full bg-bone-white border border-hairline-gray rounded-[8px] max-h-64 overflow-auto shadow-none p-1"
>
{loading && items.length === 0 && (
<li className="px-3 py-2 text-sm opacity-70">Buscando…</li>
<li className="px-3 py-2 text-sm text-graphite opacity-70">Buscando…</li>
)}
{!loading && items.length === 0 && (
<li className="px-3 py-2 text-sm opacity-70">Sin resultados</li>
<li className="px-3 py-2 text-sm text-graphite opacity-70">Sin resultados</li>
)}
{items.map((it, i) => (
<li
key={it.id}
id={`${listId}-opt-${i}`}
role="option"
aria-selected={i === active}
className="px-3 py-2 text-sm cursor-pointer transition-colors"
style={i === active ? { background: 'color-mix(in oklab, var(--color-primary) 10%, transparent)' } : undefined}
onMouseEnter={() => setActive(i)}
onMouseDown={(e) => {
e.preventDefault();
pick(it);
}}
>
{it.label}
</li>
))}
{items.map((it, i) => {
const isSelected = String(it.id) === value;
const isFocused = i === active;
return (
<li
key={it.id}
id={`${listId}-opt-${i}`}
role="option"
aria-selected={isFocused || isSelected}
className={`px-3 py-2 text-sm cursor-pointer transition-colors rounded-[8px] ${
isFocused || isSelected
? 'bg-linen text-verde-institucional font-medium'
: 'text-graphite hover:bg-linen'
}`}
onMouseDown={(e) => {
e.preventDefault();
pick(it);
}}
>
{it.label}
</li>
);
})}
</ul>
)}
</div>
@@ -82,24 +82,24 @@ function AprobarDialog({ prestamo }: { prestamo: Prestamo }) {
return (
<>
<button type="button" className="btn btn-primary" onClick={dlg.open}>Aprobar</button>
<dialog ref={dlg.ref} className="rounded-lg p-0 max-w-md w-[calc(100%-2rem)] bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40">
<form onSubmit={submit} className="p-6">
<h2 className="text-lg font-semibold">Aprobar solicitud</h2>
<p className="text-sm opacity-70 mt-1">
<dialog ref={dlg.ref} className="card rounded-[12px] max-w-md w-[calc(100%-2rem)] backdrop:bg-black/40 shadow-none">
<form onSubmit={submit} className="p-0">
<h2 className="text-xl md:text-2xl font-light text-graphite">Aprobar solicitud</h2>
<p className="text-sm text-graphite/70 mt-1">
{prestamo.alumno?.nombre ?? prestamo.alumno?.email} solicita, para {prestamo.maestro_responsable}:
</p>
<ul className="text-sm mt-1 list-disc pl-5">
<ul className="text-sm mt-2 list-disc pl-5 space-y-1">
{prestamo.items.map((i, idx) => (
<li key={idx}>
<strong>{i.cantidad}</strong> de <strong>{i.material?.nombre ?? '—'}</strong>
{(i.material?.cantidad_disponible ?? 0) < i.cantidad && (
<span style={{ color: 'var(--color-danger-text)' }}> — stock insuficiente ({i.material?.cantidad_disponible ?? 0} disp.)</span>
<span className="text-dorado-uabc font-medium"> — stock insuficiente ({i.material?.cantidad_disponible ?? 0} disp.)</span>
)}
</li>
))}
</ul>
{stockInsuficiente && (
<div role="alert" className="mt-3 rounded-lg p-2 text-sm" style={{ background: 'color-mix(in oklab, var(--color-danger) 12%, transparent)', color: 'var(--color-danger-text)' }}>
<div role="alert" className="mt-3 rounded-[12px] p-3 text-sm bg-negro-institucional text-bone-white">
Uno o más materiales no tienen stock suficiente.
</div>
)}
@@ -128,7 +128,7 @@ function AprobarDialog({ prestamo }: { prestamo: Prestamo }) {
/>
</div>
{error && (
<div role="alert" className="mt-3 rounded-lg p-2 text-sm" style={{ background: 'color-mix(in oklab, var(--color-danger) 12%, transparent)', color: 'var(--color-danger-text)' }}>
<div role="alert" className="mt-3 rounded-[12px] p-3 text-sm bg-negro-institucional text-bone-white">
{error}
</div>
)}
@@ -184,18 +184,18 @@ function RechazarDialog({ prestamo }: { prestamo: Prestamo }) {
<>
<button
type="button"
className="btn btn-ghost hover:!text-[color:var(--color-danger)]"
className="btn btn-danger"
onClick={dlg.open}
>
Rechazar
</button>
<dialog ref={dlg.ref} className="rounded-lg p-0 max-w-md w-[calc(100%-2rem)] bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40">
<form onSubmit={submit} className="p-6">
<h2 className="text-lg font-semibold">Rechazar solicitud</h2>
<p className="text-sm opacity-70 mt-1">
<dialog ref={dlg.ref} className="card rounded-[12px] max-w-md w-[calc(100%-2rem)] backdrop:bg-black/40 shadow-none">
<form onSubmit={submit} className="p-0">
<h2 className="text-xl md:text-2xl font-light text-graphite">Rechazar solicitud</h2>
<p className="text-sm text-graphite/70 mt-1">
{resumenNombres(prestamo.items)} · {prestamo.alumno?.nombre ?? prestamo.alumno?.email}
</p>
<p className="text-sm opacity-70 mt-1">
<p className="text-sm text-graphite/70 mt-1">
Explica al alumno por qué se rechaza. Este texto queda registrado.
</p>
<div className="mt-4">
@@ -213,7 +213,7 @@ function RechazarDialog({ prestamo }: { prestamo: Prestamo }) {
/>
</div>
{error && (
<div role="alert" className="mt-3 rounded-lg p-2 text-sm" style={{ background: 'color-mix(in oklab, var(--color-danger) 12%, transparent)', color: 'var(--color-danger-text)' }}>
<div role="alert" className="mt-3 rounded-[12px] p-3 text-sm bg-negro-institucional text-bone-white">
{error}
</div>
)}
@@ -221,9 +221,8 @@ function RechazarDialog({ prestamo }: { prestamo: Prestamo }) {
<button type="button" className="btn btn-ghost" onClick={dlg.close} disabled={loading}>Cancelar</button>
<button
type="submit"
className="btn"
className="btn btn-danger"
disabled={loading}
style={{ background: 'var(--color-danger)', color: 'var(--color-ink)' }}
>
{loading ? 'Rechazando…' : 'Rechazar solicitud'}
</button>
@@ -40,23 +40,23 @@ export default function MarcarDevuelto({ prestamoId, resumenMateriales }: { pres
return (
<>
<button type="button" className="btn btn-secondary" onClick={() => ref.current?.showModal()}>
<button type="button" className="btn btn-primary" onClick={() => ref.current?.showModal()}>
Marcar devuelto
</button>
<dialog ref={ref} className="rounded-lg p-0 max-w-md w-[calc(100%-2rem)] bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40">
<form onSubmit={submit} className="p-6">
<h2 className="text-lg font-semibold">Confirmar devolución</h2>
<p className="text-sm opacity-80 mt-2">
¿Confirmas la devolución de <strong>{resumenMateriales}</strong>? El material regresará al inventario disponible.
<dialog ref={ref} className="card rounded-[12px] max-w-md w-[calc(100%-2rem)] backdrop:bg-black/40 shadow-none">
<form onSubmit={submit} className="p-0">
<h2 className="text-xl md:text-2xl font-light text-graphite">Confirmar devolución</h2>
<p className="text-sm text-graphite/80 mt-2">
¿Confirmas la devolución de <strong className="text-graphite">{resumenMateriales}</strong>? El material regresará al inventario disponible.
</p>
{error && (
<div role="alert" className="mt-3 rounded-lg p-2 text-sm" style={{ background: 'color-mix(in oklab, var(--color-danger) 12%, transparent)', color: 'var(--color-danger-text)' }}>
<div role="alert" className="mt-3 rounded-[12px] p-3 text-sm bg-negro-institucional text-bone-white">
{error}
</div>
)}
<div className="mt-6 flex justify-end gap-2">
<button type="button" className="btn btn-ghost" onClick={() => ref.current?.close()} disabled={loading}>Cancelar</button>
<button type="submit" className="btn btn-secondary" disabled={loading}>
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? 'Registrando…' : 'Confirmar devolución'}
</button>
</div>
+109 -79
View File
@@ -94,8 +94,7 @@ function ReasignarUnidad({
return (
<button
type="button"
className="btn btn-ghost"
style={{ minHeight: '30px', paddingBlock: '0.15rem', fontSize: '0.75rem' }}
className="btn btn-ghost text-xs py-1 px-2.5 min-h-[28px]"
onClick={abrir}
>
Cambiar unidad
@@ -103,10 +102,9 @@ function ReasignarUnidad({
);
}
return (
<div className="flex flex-wrap items-center gap-1 mt-1">
<div className="flex flex-wrap items-center gap-1.5 mt-1">
<select
className="input"
style={{ minHeight: '32px', paddingBlock: '0.2rem', fontSize: '0.85rem' }}
className="input text-xs py-1 px-2 min-h-[32px] w-auto"
value={selected}
onChange={(e) => setSelected(e.target.value)}
disabled={loading || saving}
@@ -118,8 +116,7 @@ function ReasignarUnidad({
</select>
<button
type="button"
className="btn btn-primary"
style={{ minHeight: '32px', paddingBlock: '0.2rem', fontSize: '0.85rem' }}
className="btn btn-primary text-xs py-1 px-2.5 min-h-[32px]"
onClick={confirmar}
disabled={!selected || saving}
>
@@ -127,8 +124,7 @@ function ReasignarUnidad({
</button>
<button
type="button"
className="btn btn-ghost"
style={{ minHeight: '32px', paddingBlock: '0.2rem', fontSize: '0.85rem' }}
className="btn btn-ghost text-xs py-1 px-2.5 min-h-[32px]"
onClick={() => setOpen(false)}
disabled={saving}
>
@@ -170,84 +166,118 @@ export default function VerDetalles({ prestamoId }: { prestamoId: number }) {
const canReasignar = data && ['aprobado', 'activo'].includes(data.prestamo.estado);
const getChipCls = (st: string) => {
const map: Record<string, string> = {
pendiente: 'chip-pendiente',
aprobado: 'chip-aprobado',
activo: 'chip-activo',
devuelto: 'chip-devuelto',
rechazado: 'chip-rechazado',
vencido: 'chip-vencido',
};
return map[st] ?? 'chip-pendiente';
};
return (
<>
<button type="button" className="btn btn-ghost" onClick={open}>Detalles</button>
<dialog ref={ref} className="rounded-lg p-0 max-w-lg w-[calc(100%-2rem)] bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40">
<div className="p-6">
<div className="flex items-start justify-between gap-4">
<h2 className="text-lg font-semibold">Detalle del préstamo</h2>
<button type="button" className="rounded-lg p-1.5 hover:bg-black/5 transition-colors" onClick={() => ref.current?.close()} aria-label="Cerrar">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<button type="button" className="btn btn-ghost" onClick={open}>Ver detalles</button>
<dialog ref={ref} className="card rounded-[12px] max-w-lg w-[calc(100%-2rem)] backdrop:bg-black/40 shadow-none">
<div className="flex items-start justify-between gap-4">
<h2 className="text-xl md:text-2xl font-light text-graphite">Detalle del préstamo</h2>
<button
type="button"
className="rounded-full p-2 text-graphite/70 hover:text-graphite hover:bg-linen transition-colors"
onClick={() => ref.current?.close()}
aria-label="Cerrar"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{loading && <p className="mt-4 text-sm text-graphite/70">Cargando…</p>}
{error && (
<div role="alert" className="mt-3 rounded-[12px] p-3 text-sm bg-negro-institucional text-bone-white">
{error}
</div>
)}
{loading && <p className="mt-4 text-sm opacity-70">Cargando…</p>}
{error && (
<div role="alert" className="mt-3 rounded-lg p-2 text-sm" style={{ background: 'color-mix(in oklab, var(--color-danger) 12%, transparent)', color: 'var(--color-danger-text)' }}>
{error}
</div>
)}
{data && (
<>
<dl className="mt-4 grid grid-cols-3 gap-y-2 text-sm">
<dt className="opacity-70">Alumno</dt>
<dd className="col-span-2">{data.prestamo.alumno?.nombre ?? data.prestamo.alumno?.email}</dd>
<dt className="opacity-70">Maestro</dt>
<dd className="col-span-2 break-words">{data.prestamo.maestro_responsable}</dd>
<dt className="opacity-70">Materiales</dt>
<dd className="col-span-2">
<ul className="list-disc pl-4 space-y-2">
{data.prestamo.items.map((i) => (
<li key={i.id}>
<strong style={{ fontVariantNumeric: 'tabular-nums' }}>{i.cantidad}</strong>× {i.material?.nombre ?? '—'}{' '}
<span className="opacity-70">· Inv. {i.material?.numero_inventario ?? '—'}</span>
{i.material_unidad?.etiqueta && (
<div className="text-xs">
<span className="opacity-70">Unidad: </span>
<span className="font-semibold">{i.material_unidad.etiqueta}</span>
</div>
)}
{i.descripcion && <div className="text-xs opacity-70">{i.descripcion}</div>}
{data && (
<>
<dl className="mt-4 grid grid-cols-3 gap-y-3 text-sm">
<dt className="text-graphite/70">Alumno</dt>
<dd className="col-span-2 text-graphite font-medium">{data.prestamo.alumno?.nombre ?? data.prestamo.alumno?.email}</dd>
<dt className="text-graphite/70">Maestro</dt>
<dd className="col-span-2 break-words text-graphite">{data.prestamo.maestro_responsable}</dd>
<dt className="text-graphite/70">Materiales</dt>
<dd className="col-span-2">
<ul className="border-y border-hairline-gray my-2 divide-y divide-hairline-gray">
{data.prestamo.items.map((i) => (
<li key={i.id} className="py-2.5 first:pt-2 last:pb-2 border-b border-hairline-gray last:border-b-0">
<div className="flex items-baseline justify-between gap-2">
<div>
<strong style={{ fontVariantNumeric: 'tabular-nums' }}>{i.cantidad}×</strong>{' '}
<span className="font-medium text-graphite">{i.material?.nombre ?? '—'}</span>
<span className="text-xs text-graphite/60 ml-2">Inv. {i.material?.numero_inventario ?? '—'}</span>
</div>
{canReasignar && i.material_unidad && (
<ReasignarUnidad solicitudId={data.prestamo.id} item={i} />
)}
</li>
))}
</ul>
</dd>
<dt className="opacity-70">Solicitado</dt>
<dd className="col-span-2">{fmt.format(new Date(data.prestamo.fecha_solicitud))}</dd>
{data.prestamo.fecha_aprobacion && (<><dt className="opacity-70">Aprobado</dt><dd className="col-span-2">{fmt.format(new Date(data.prestamo.fecha_aprobacion))}</dd></>)}
{data.prestamo.fecha_devolucion_estimada && (<><dt className="opacity-70">Devolver antes</dt><dd className="col-span-2">{fmt.format(new Date(data.prestamo.fecha_devolucion_estimada + 'T00:00:00'))}</dd></>)}
{data.prestamo.notas && (<><dt className="opacity-70">Notas</dt><dd className="col-span-2 whitespace-pre-wrap">{data.prestamo.notas}</dd></>)}
</dl>
<h3 className="mt-6 mb-2 font-semibold text-sm">Historial de cambios</h3>
{data.audit_log.length === 0 ? (
<p className="text-sm opacity-70">Sin cambios registrados.</p>
) : (
<ul className="text-sm divide-y" style={{ borderColor: 'color-mix(in oklab, var(--color-ink) 10%, transparent)' }}>
{data.audit_log.map((e, i) => {
const fecha = e.at ?? e.created_at ?? e.fecha ?? e.timestamp;
return (
<li key={i} className="py-2 flex items-baseline gap-2 flex-wrap">
<span className="font-medium">{e.accion ?? 'cambio'}</span>
<span className="opacity-80">
{(e.estado_anterior ?? '—')} → {(e.estado_nuevo ?? '—')}
</span>
{fecha && <span className="opacity-60 ml-auto text-xs">{fmt.format(new Date(fecha))}</span>}
</li>
);
})}
</div>
{i.material_unidad?.etiqueta && (
<div className="text-xs text-graphite/70 mt-1">
Unidad: <span className="font-medium text-graphite">{i.material_unidad.etiqueta}</span>
</div>
)}
{i.descripcion && <div className="text-xs text-graphite/60 mt-0.5">{i.descripcion}</div>}
</li>
))}
</ul>
)}
</>
)}
</div>
</dd>
<dt className="text-graphite/70">Solicitado</dt>
<dd className="col-span-2 text-graphite">{fmt.format(new Date(data.prestamo.fecha_solicitud))}</dd>
{data.prestamo.fecha_aprobacion && (<><dt className="text-graphite/70">Aprobado</dt><dd className="col-span-2 text-graphite">{fmt.format(new Date(data.prestamo.fecha_aprobacion))}</dd></>)}
{data.prestamo.fecha_devolucion_estimada && (<><dt className="text-graphite/70">Devolver antes</dt><dd className="col-span-2 text-graphite">{fmt.format(new Date(data.prestamo.fecha_devolucion_estimada + 'T00:00:00'))}</dd></>)}
{data.prestamo.notas && (<><dt className="text-graphite/70">Notas</dt><dd className="col-span-2 text-graphite whitespace-pre-wrap">{data.prestamo.notas}</dd></>)}
</dl>
<h3 className="mt-6 mb-3 text-base font-medium text-graphite">Historial de cambios</h3>
{data.audit_log.length === 0 ? (
<p className="text-sm text-graphite/70">Sin cambios registrados.</p>
) : (
<div className="relative pl-4 border-l border-hairline-gray space-y-3 my-2">
{data.audit_log.map((e, i) => {
const fecha = e.at ?? e.created_at ?? e.fecha ?? e.timestamp;
const estadoNuevo = e.estado_nuevo;
const estadoAnt = e.estado_anterior;
return (
<div key={i} className="relative">
<div className="absolute -left-[21px] top-1.5 w-2 h-2 rounded-full bg-verde-institucional" />
<div className="flex items-center justify-between gap-2 flex-wrap text-sm">
<span className="font-medium text-graphite capitalize">{e.accion ?? 'Cambio'}</span>
{fecha && <span className="text-xs text-graphite/60">{fmt.format(new Date(fecha))}</span>}
</div>
{(estadoAnt || estadoNuevo) && (
<div className="flex items-center gap-1.5 mt-1 text-xs">
{estadoAnt && (
<>
<span className={`chip ${getChipCls(estadoAnt)}`}>{estadoAnt}</span>
<span className="text-graphite/40">→</span>
</>
)}
{estadoNuevo && (
<span className={`chip ${getChipCls(estadoNuevo)}`}>{estadoNuevo}</span>
)}
</div>
)}
</div>
);
})}
</div>
)}
</>
)}
</dialog>
</>
);
+41 -14
View File
@@ -11,6 +11,7 @@ export default function BanearForm({ profile, adminSelf }: Props) {
const firstFieldRef = useRef<HTMLTextAreaElement | null>(null);
const errorRef = useRef<HTMLParagraphElement | null>(null);
const [razon, setRazon] = useState('');
const [expiresAt, setExpiresAt] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -21,6 +22,7 @@ export default function BanearForm({ profile, adminSelf }: Props) {
if (adminSelf) return;
setError(null);
setRazon('');
setExpiresAt('');
dialogRef.current?.showModal();
queueMicrotask(() => firstFieldRef.current?.focus());
};
@@ -54,10 +56,18 @@ export default function BanearForm({ profile, adminSelf }: Props) {
setLoading(true);
setError(null);
try {
const payload: { profile_id: string; razon: string; expires_at?: string } = {
profile_id: profile.id,
razon: trimmed,
};
if (expiresAt) {
payload.expires_at = new Date(expiresAt + 'T23:59:59').toISOString();
}
const res = await fetch('/api/admin/baneos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile_id: profile.id, razon: trimmed }),
body: JSON.stringify(payload),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json?.error ?? 'No se pudo banear');
@@ -79,11 +89,10 @@ export default function BanearForm({ profile, adminSelf }: Props) {
<>
<button
type="button"
className="btn btn-ghost"
className="btn btn-danger text-xs px-3"
style={{
minHeight: '36px',
paddingBlock: '0.25rem',
color: adminSelf ? undefined : 'var(--color-danger-text)',
opacity: adminSelf ? 0.5 : 1,
cursor: adminSelf ? 'not-allowed' : undefined,
}}
@@ -98,24 +107,27 @@ export default function BanearForm({ profile, adminSelf }: Props) {
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(92vw,28rem)]"
className="card bg-bone-white rounded-[12px] p-0 border border-hairline-gray backdrop:bg-negro-institucional/40 w-[min(92vw,28rem)]"
style={{ boxShadow: 'none' }}
>
<form onSubmit={submit} className="p-5 sm:p-6 flex flex-col gap-4" autoComplete="off">
<form onSubmit={submit} className="p-6 flex flex-col gap-4" autoComplete="off">
<div className="flex items-start justify-between gap-3">
<h2 id={titleId} className="text-lg font-semibold">
<h2 id={titleId} className="text-xl text-graphite font-normal">
Banear a {label}
</h2>
<button
type="button"
onClick={close}
aria-label="Cerrar"
className="rounded-lg p-1 hover:bg-black/5 transition-colors leading-none text-xl"
className="rounded-[8px] p-1.5 hover:bg-linen text-graphite transition-colors leading-none"
>
<span aria-hidden="true">×</span>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<p className="text-sm" style={{ color: 'var(--color-pencil)' }}>
<p className="text-sm text-graphite/70">
El usuario no podrá acceder al sistema hasta ser desbaneado.
</p>
@@ -137,19 +149,35 @@ export default function BanearForm({ profile, adminSelf }: Props) {
aria-describedby={error ? `${titleId}-err` : undefined}
placeholder="Explica el motivo — el usuario lo verá en su pantalla de bloqueo."
/>
<p className="text-xs mt-1" style={{ color: 'var(--color-pencil)' }}>
<p className="text-xs text-graphite/60 mt-1">
{razon.length}/500
</p>
</div>
<div>
<label className="label" htmlFor={`${titleId}-expires`}>
Fecha de expiración (opcional)
</label>
<input
id={`${titleId}-expires`}
type="date"
className="input"
value={expiresAt}
min={new Date().toISOString().split('T')[0]}
onChange={(e) => setExpiresAt(e.target.value)}
/>
<p className="text-xs text-graphite/60 mt-1">
Opcional. Si se omite, el baneo será indefinido hasta desbanear manualmente.
</p>
</div>
{error && (
<p
ref={errorRef}
id={`${titleId}-err`}
role="alert"
tabIndex={-1}
className="text-sm"
style={{ color: 'var(--color-danger-text)' }}
className="text-sm text-negro-institucional"
>
{error}
</p>
@@ -161,8 +189,7 @@ export default function BanearForm({ profile, adminSelf }: Props) {
</button>
<button
type="submit"
className="btn"
style={{ background: 'var(--color-danger)', color: 'var(--color-ink)' }}
className="btn btn-danger"
disabled={loading}
>
{loading ? 'Baneando…' : 'Banear'}
@@ -37,7 +37,7 @@ export default function DesbanearButton({ baneoId, nombre }: Props) {
<>
<button
type="button"
className="btn btn-primary"
className="btn btn-secondary text-xs px-3"
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
onClick={submit}
disabled={loading}
@@ -48,17 +48,18 @@ export default function DesbanearButton({ baneoId, nombre }: Props) {
<dialog
ref={errorDialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(92vw,26rem)]"
className="card bg-bone-white rounded-[12px] p-0 border border-hairline-gray backdrop:bg-negro-institucional/40 w-[min(92vw,26rem)]"
style={{ boxShadow: 'none' }}
>
<div className="p-5 sm:p-6 flex flex-col gap-4">
<h2 id={titleId} className="text-lg font-semibold">
<div className="p-6 flex flex-col gap-4">
<h2 id={titleId} className="text-xl text-graphite font-normal">
No se pudo desbanear
</h2>
<p className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
<p className="text-sm text-negro-institucional">
{error}
</p>
<div className="flex justify-end pt-2">
<button type="button" className="btn btn-primary" onClick={() => errorDialogRef.current?.close()}>
<button type="button" className="btn btn-ghost" onClick={() => errorDialogRef.current?.close()}>
Entendido
</button>
</div>
+42 -9
View File
@@ -6,32 +6,65 @@ type Props = {
};
export default function AgregarMaterial({ material }: Props) {
const { addItem, has } = useCart();
const { items, addItem, updateCantidad, removeItem, has } = useCart();
const sinStock = material.cantidad_disponible === 0;
const enCarrito = has(material.id);
if (enCarrito) {
const item = items.find((i) => i.material_id === material.id);
const cantidad = item?.cantidad ?? 1;
return (
<button type="button" className="btn btn-secondary w-full" disabled>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M4.5 12.75l6 6 9-13.5" />
</svg>
En tu solicitud
</button>
<div className="flex items-center justify-between gap-2 w-full">
<div className="flex items-center gap-1">
<button
type="button"
className="btn btn-ghost rounded-full w-8 h-8 p-0 flex items-center justify-center shrink-0"
aria-label={`Disminuir cantidad de ${material.nombre}`}
onClick={() => {
if (cantidad > 1) {
updateCantidad(material.id, cantidad - 1);
} else {
removeItem(material.id);
toast({ kind: 'info', title: 'Eliminado de tu solicitud', description: material.nombre });
}
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M5 12h14" />
</svg>
</button>
<span className="font-sans text-sm font-medium text-graphite min-w-[1.75rem] text-center" style={{ fontVariantNumeric: 'tabular-nums' }}>
{cantidad}
</span>
<button
type="button"
className="btn btn-ghost rounded-full w-8 h-8 p-0 flex items-center justify-center shrink-0"
disabled={cantidad >= material.cantidad_disponible}
aria-label={`Aumentar cantidad de ${material.nombre}`}
onClick={() => updateCantidad(material.id, cantidad + 1)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M12 5v14M5 12h14" />
</svg>
</button>
</div>
<span className="text-xs text-graphite/70 truncate">En tu solicitud</span>
</div>
);
}
return (
<button
type="button"
className="btn btn-primary w-full"
className="btn btn-secondary w-full"
disabled={sinStock}
onClick={() => {
addItem(material);
toast({ kind: 'success', title: 'Agregado a tu solicitud', description: material.nombre });
}}
>
{sinStock ? 'Sin stock' : 'Agregar'}
{sinStock ? 'Sin stock' : 'Agregar al carrito'}
</button>
);
}
+10 -5
View File
@@ -82,25 +82,30 @@ export default function BuscadorCatalogo({ q: qInitial = '' }: { q?: string }) {
<label className="label sr-only" htmlFor="buscador-catalogo">
Buscar material
</label>
<div className="relative">
<div className="relative flex items-center">
<span className="absolute left-3.5 pointer-events-none text-graphite/50 flex items-center" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="stroke-graphite">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
</span>
<input
id="buscador-catalogo"
className="input"
className="input pl-11 pr-10 rounded-[8px]"
type="search"
placeholder="Buscar por nombre o número de inventario…"
value={q}
onChange={(e) => onChange(e.target.value)}
autoComplete="off"
style={{ paddingRight: q ? '2.5rem' : undefined }}
/>
{q && (
<button
type="button"
onClick={clear}
aria-label="Limpiar búsqueda"
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded-[2px] hover:bg-black/5 transition-colors"
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 rounded-full hover:bg-linen text-graphite transition-colors"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
+2 -9
View File
@@ -50,13 +50,8 @@ export default function FiltroCategorias({ categorias }: Props) {
const chipClass = (v: string) =>
v === active
? 'btn'
: 'btn btn-ghost';
const chipStyle = (v: string): React.CSSProperties =>
v === active
? { background: 'var(--color-primary)', color: 'white', minHeight: '40px', paddingBlock: '0.375rem' }
: { minHeight: '40px', paddingBlock: '0.375rem' };
? 'bg-verde-institucional text-bone-white rounded-[8px] px-3 py-1 border border-verde-institucional inline-flex items-center whitespace-nowrap text-xs font-medium cursor-pointer transition-colors'
: 'chip cursor-pointer hover:bg-linen hover:border-dorado-uabc transition-colors';
return (
<div
@@ -70,7 +65,6 @@ export default function FiltroCategorias({ categorias }: Props) {
role="tab"
aria-selected={active === ALL}
className={chipClass(ALL)}
style={chipStyle(ALL)}
data-cat-value={ALL}
data-cat-active={active === ALL ? 'true' : undefined}
onClick={() => pick(ALL)}
@@ -86,7 +80,6 @@ export default function FiltroCategorias({ categorias }: Props) {
role="tab"
aria-selected={active === v}
className={chipClass(v)}
style={chipStyle(v)}
data-cat-value={v}
data-cat-active={active === v ? 'true' : undefined}
onClick={() => pick(v)}
+39 -51
View File
@@ -14,14 +14,6 @@ type Material = {
categoria: { id: number; nombre: string } | null;
};
// El catálogo sólo lista materiales con estado 'disponible' (ver catalogo.astro),
// así que el chip siempre coincide con el mismo estilo que usa el inventario del admin.
const DISPONIBLE_BADGE_STYLE = {
background: 'color-mix(in oklab, var(--color-positive) 18%, white)',
color: 'var(--color-ink)',
border: '1.5px solid var(--color-positive)',
} as const;
type CartItem = {
material_id: number;
nombre: string;
@@ -238,7 +230,7 @@ export default function CartProvider({
return (
<CartContext.Provider value={value}>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
{materiales.map((m) => {
const src = imgUrl(m.imagen_path);
return (
@@ -249,34 +241,32 @@ export default function CartProvider({
data-nombre={m.nombre}
data-numero-inventario={m.numero_inventario ?? ''}
>
<div
className="relative aspect-square w-full bg-[color:var(--color-chalk)] grid place-items-center"
style={{ borderBottom: '2px solid var(--color-ink)' }}
>
<div className="relative aspect-square w-full rounded-t-[12px] overflow-hidden bg-linen flex items-center justify-center border-b border-hairline-gray">
{src ? (
<img src={src} alt={m.nombre} className="w-full h-full object-cover" loading="lazy" />
<img src={src} alt={m.nombre} className="w-full h-full aspect-square object-cover rounded-t-[12px]" loading="lazy" />
) : (
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="var(--color-ink)" strokeWidth={1.5} aria-hidden="true">
<rect x="3" y="4" width="18" height="16" rx="1" />
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} className="text-graphite/40" aria-hidden="true">
<rect x="3" y="4" width="18" height="16" rx="2" />
<path d="M3 16l5-5 4 4 3-3 6 6" />
<circle cx="8" cy="9" r="1.5" />
</svg>
)}
<span
className="absolute bottom-1.5 right-1.5 text-xs px-2 py-0.5 rounded-[2px] whitespace-nowrap shadow-[var(--shadow-hard-sm)]"
style={DISPONIBLE_BADGE_STYLE}
>
<span className="absolute bottom-2 right-2 badge">
Disponible
</span>
</div>
<div className="p-4 flex flex-col gap-3 flex-1">
<h3 className="font-semibold leading-snug">{m.nombre}</h3>
{m.categoria && <p className="text-xs opacity-70 -mt-1">{m.categoria.nombre}</p>}
<div className="p-6 flex flex-col gap-2 flex-1">
<span className="eyebrow text-dorado-uabc">{m.categoria?.nombre ?? 'Sin categoría'}</span>
<h3 className="text-[16px] font-medium text-graphite leading-snug">
{m.nombre}
</h3>
{m.descripcion && <p className="text-sm opacity-75 line-clamp-2">{m.descripcion}</p>}
{m.descripcion && (
<p className="text-sm text-graphite/75 line-clamp-2">{m.descripcion}</p>
)}
<dl className="text-xs opacity-70 grid grid-cols-2 gap-1">
<dl className="text-xs text-graphite/70 grid grid-cols-2 gap-1 my-2">
<dt className="opacity-60">Inventario</dt>
<dd className="text-right font-mono">{m.numero_inventario ?? '—'}</dd>
<dt className="opacity-60">Disponibles</dt>
@@ -285,7 +275,7 @@ export default function CartProvider({
</dd>
</dl>
<div className="mt-auto">
<div className="mt-auto pt-2">
<AgregarMaterial material={{ id: m.id, nombre: m.nombre, cantidad_disponible: m.cantidad_disponible }} />
</div>
</div>
@@ -297,8 +287,7 @@ export default function CartProvider({
{items.length > 0 && (
<button
type="button"
className="btn btn-primary fixed bottom-24 right-4 md:bottom-6 md:right-6 z-40"
style={{ boxShadow: 'var(--shadow-hard)' }}
className="btn btn-primary fixed bottom-24 right-4 md:bottom-6 md:right-6 z-40 px-6 py-3"
onClick={openCheckout}
>
Ver solicitud ({items.length})
@@ -308,44 +297,43 @@ export default function CartProvider({
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(92vw,32rem)]"
className="card bg-bone-white rounded-[12px] p-6 text-graphite border border-hairline-gray backdrop:bg-negro-institucional/60 w-[min(92vw,32rem)] m-auto shadow-none"
>
<form onSubmit={submit} className="p-5 sm:p-6 flex flex-col gap-4">
<div className="flex items-start justify-between gap-3">
<h2 id={titleId} className="text-lg font-semibold">
<form onSubmit={submit} className="flex flex-col gap-5">
<div className="flex items-center justify-between gap-3 pb-3 border-b border-hairline-gray">
<h2 id={titleId} className="text-xl font-normal text-graphite">
Tu solicitud
</h2>
<button
type="button"
onClick={closeCheckout}
aria-label="Cerrar"
className="rounded-lg p-1.5 hover:bg-black/5 transition-colors"
className="btn btn-ghost rounded-full w-8 h-8 p-0 flex items-center justify-center shrink-0"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{items.length === 0 ? (
<p className="text-sm opacity-70">Tu solicitud está vacía. Cierra esta ventana y agrega material del catálogo.</p>
<p className="text-sm text-graphite/70">Tu solicitud está vacía. Cierra esta ventana y agrega material del catálogo.</p>
) : (
<ul className="flex flex-col gap-3 max-h-64 overflow-y-auto -mx-1 px-1">
{items.map((item) => (
<li
key={item.material_id}
className="flex items-start gap-2 pb-3"
style={{ borderBottom: '1.5px solid color-mix(in oklab, var(--color-ink) 15%, transparent)' }}
className="flex items-start gap-3 pb-3 border-b border-hairline-gray"
>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{item.nombre}</p>
<p className="font-sans font-medium text-sm text-graphite truncate">{item.nombre}</p>
<div className="flex items-center gap-2 mt-1.5">
<label className="sr-only" htmlFor={`cant-cart-${item.material_id}`}>
Cantidad de {item.nombre}
</label>
<input
id={`cant-cart-${item.material_id}`}
className="input"
className="input rounded-[8px]"
type="number"
min={1}
max={item.cantidad_disponible}
@@ -354,14 +342,14 @@ export default function CartProvider({
onChange={(e) => updateCantidad(item.material_id, Number(e.target.value) || 1)}
style={{ width: '4.5rem', minHeight: '36px', paddingBlock: '0.25rem', fontVariantNumeric: 'tabular-nums' }}
/>
<span className="text-xs opacity-60">de {item.cantidad_disponible}</span>
<span className="text-xs text-graphite/70">de {item.cantidad_disponible}</span>
</div>
<label className="sr-only" htmlFor={`desc-cart-${item.material_id}`}>
Nota para {item.nombre}
</label>
<input
id={`desc-cart-${item.material_id}`}
className="input mt-2"
className="input rounded-[8px] mt-2"
type="text"
maxLength={200}
value={item.descripcion}
@@ -374,9 +362,9 @@ export default function CartProvider({
type="button"
onClick={() => removeItem(item.material_id)}
aria-label={`Quitar ${item.nombre}`}
className="rounded-lg p-1.5 hover:bg-black/5 transition-colors shrink-0"
className="btn btn-ghost rounded-full w-8 h-8 p-0 flex items-center justify-center shrink-0"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
@@ -391,7 +379,7 @@ export default function CartProvider({
<select
ref={firstFieldRef}
id="maestro-select"
className="input"
className="input rounded-[8px]"
value={maestroId}
onChange={(e) => setMaestroId(e.target.value)}
aria-describedby="maestro-hint"
@@ -403,7 +391,7 @@ export default function CartProvider({
<option key={m.id} value={String(m.id)}>{m.nombre}</option>
))}
</select>
<p id="maestro-hint" className="text-xs opacity-70 mt-1">
<p id="maestro-hint" className="text-xs text-graphite/70 mt-1">
{tutorNombre
? <>Si no eliges nadie se usará tu tutor: <strong>{tutorNombre}</strong>.</>
: 'Debes elegir un maestro; no tienes tutor guardado.'}
@@ -415,7 +403,7 @@ export default function CartProvider({
<label className="label" htmlFor="notas-cart">Motivo del préstamo</label>
<textarea
id="notas-cart"
className="input"
className="input rounded-[8px]"
rows={3}
maxLength={100}
value={notas}
@@ -424,25 +412,25 @@ export default function CartProvider({
style={{ minHeight: '96px' }}
aria-describedby="notas-cart-hint"
/>
<p id="notas-cart-hint" className="text-xs opacity-60 mt-1">
<p id="notas-cart-hint" className="text-xs text-graphite/70 mt-1">
Ejemplo: Clase de Electrónica Analógica · Prof. Gómez. (Opcional, máx. 100 caracteres)
</p>
</div>
{error && (
<p role="alert" className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
<p role="alert" className="text-sm text-negro-institucional">
{error} · Ajusta la solicitud e intenta de nuevo.
</p>
)}
{ok && (
<p role="status" className="text-sm" style={{ color: 'var(--color-positive)' }}>
<p role="status" className="text-sm text-verde-institucional font-medium">
Solicitud enviada. Actualizando…
</p>
)}
{!perfilCompleto ? (
<div className="card p-3">
<p className="text-sm mb-3">Necesitas completar tu perfil (matrícula + tutor) para crear vales.</p>
<div className="card p-4 rounded-[12px] bg-linen border border-hairline-gray">
<p className="text-sm text-graphite mb-3">Necesitas completar tu perfil (matrícula + tutor) para crear vales.</p>
<a href="/perfil" className="btn btn-primary">Completar perfil</a>
</div>
) : (
+10 -15
View File
@@ -6,31 +6,26 @@ type Props = {
className?: string;
};
// Contraste rápido: fondo HSL con L=55% → texto blanco pasa AA con casi todos los hues.
// Mantenemos blanco fijo para consistencia visual.
export default function Avatar({ info, size = 40, className = '' }: Props) {
const style: React.CSSProperties = {
width: size,
height: size,
border: '2px solid var(--color-ink)',
borderRadius: 2,
display: 'grid',
placeItems: 'center',
overflow: 'hidden',
background: info.src ? 'white' : info.hueColor,
color: 'white',
fontWeight: 700,
fontSize: Math.max(11, Math.floor(size * 0.4)),
lineHeight: 1,
flexShrink: 0,
fontSize: Math.max(12, Math.floor(size * 0.38)),
};
return (
<div className={className} style={style} aria-hidden="true">
<div
className={`rounded-full overflow-hidden shrink-0 grid place-items-center border border-hairline-gray select-none leading-none ${
info.src ? 'bg-bone-white' : 'bg-linen text-verde-institucional font-medium'
} ${className}`}
style={style}
aria-hidden="true"
>
{info.src ? (
<img
src={info.src}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
className="w-full h-full object-cover rounded-full"
referrerPolicy="no-referrer"
/>
) : (
+30 -19
View File
@@ -123,35 +123,46 @@ export default function PerfilForm({
<form onSubmit={submit} className="flex flex-col gap-5" autoComplete="off">
<div>
<label className="label" htmlFor="p-foto">Foto</label>
<div className="flex items-center gap-3">
<Avatar info={previewInfo} size={72} />
<div className="flex-1 flex flex-col gap-2">
<div className="flex items-center gap-4">
<Avatar info={previewInfo} size={96} className="w-24 h-24 rounded-full" />
<div className="flex-1 flex flex-col items-start gap-2">
<input
ref={fileInputRef}
id="p-foto"
className="input"
className="hidden"
type="file"
accept="image/*"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
{(file || fotoPath) && (
<div className="flex gap-2 flex-wrap items-center">
<button
type="button"
className="btn btn-ghost self-start"
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
onClick={() => {
setFile(null);
if (fileInputRef.current) fileInputRef.current.value = '';
setFotoPath(null);
}}
className="btn btn-ghost"
onClick={() => fileInputRef.current?.click()}
>
Quitar foto
Subir foto
</button>
{(file || fotoPath) && (
<button
type="button"
className="btn btn-ghost text-negro-institucional"
onClick={() => {
setFile(null);
if (fileInputRef.current) fileInputRef.current.value = '';
setFotoPath(null);
}}
>
Quitar foto
</button>
)}
</div>
{file && (
<span className="text-xs text-graphite/70 truncate max-w-xs">{file.name}</span>
)}
</div>
</div>
{!file && !fotoPath && googleAvatarUrl && (
<p className="text-xs opacity-70 mt-2">Mostrando tu foto de Google. Sube una para reemplazarla.</p>
<p className="text-xs text-graphite/70 mt-2">Mostrando tu foto de Google. Sube una para reemplazarla.</p>
)}
</div>
@@ -159,7 +170,7 @@ export default function PerfilForm({
<label className="label" htmlFor="p-mat">{matriculaLabel}</label>
<input
id="p-mat"
className="input"
className="input rounded-[8px]"
type="text"
value={matricula}
onChange={(e) => setMatricula(e.target.value)}
@@ -174,7 +185,7 @@ export default function PerfilForm({
<label className="label" htmlFor="p-sem">Semestre</label>
<select
id="p-sem"
className="input"
className="input rounded-[8px]"
value={semestre}
onChange={(e) => setSemestre(e.target.value)}
>
@@ -191,7 +202,7 @@ export default function PerfilForm({
<label className="label" htmlFor="p-tutor">Tutor</label>
<select
id="p-tutor"
className="input"
className="input rounded-[8px]"
value={tutorId}
onChange={(e) => setTutorId(e.target.value)}
>
@@ -200,12 +211,12 @@ export default function PerfilForm({
<option key={m.id} value={String(m.id)}>{m.nombre}</option>
))}
</select>
<p className="text-xs opacity-70 mt-1">Se rellenará por defecto al crear vales.</p>
<p className="text-xs text-graphite/70 mt-1">Se rellenará por defecto al crear vales.</p>
</div>
)}
{error && (
<p role="alert" className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
<p role="alert" className="text-sm text-negro-institucional">
{error}
</p>
)}
+38 -47
View File
@@ -32,14 +32,10 @@ const avatar = profile
const perfilIncompleto = profile?.rol === 'alumno' && (!profile.matricula || !profile.tutor_id);
let pendientesCount = 0;
if (isAdmin) {
const { count } = await Astro.locals.supabase
.from('solicitudes')
.select('*', { count: 'exact', head: true })
.eq('estado', 'pendiente');
pendientesCount = count ?? 0;
}
// El count de pendientes lo hidrata BadgeSolicitudes (client-side) al montarse.
// Renderizar 0 en SSR evita 1 hit a DB por cada pageview de admin — el badge
// aparece ~200ms después de la carga, trade-off aceptable.
const pendientesCount = 0;
const path = Astro.url.pathname;
const isActive = (href: string) => path === href || path.startsWith(href + '/');
@@ -94,14 +90,14 @@ const activeCheck = (item: NavItem) => {
<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-white text-[color:var(--color-ink)] md:flex-col md:z-40 sidebar-panel transition-transform duration-200 ease-out" style="border-right: 2px solid var(--color-ink);">
<a href="/" class="p-6 flex items-center gap-3 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--color-ink)]" aria-label="LabPréstamos — Inicio" style="border-bottom: 2px solid var(--color-ink);">
<div class="w-9 h-9 rounded-[2px] bg-[color:var(--color-primary)] grid place-items-center p-2" style="border: 2px solid var(--color-ink);" aria-hidden="true">
<BrandMark class="w-full h-full text-white" />
<aside id="app-sidebar" class="hidden md:flex md:fixed md:top-0 md:left-0 md:w-64 md:h-screen bg-bone-white text-graphite border-r border-hairline-gray 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-hairline-gray focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-verde-institucional" aria-label="LabPréstamos — Inicio">
<div class="w-9 h-9 rounded-[8px] bg-linen text-verde-institucional grid place-items-center p-2" aria-hidden="true">
<BrandMark class="w-full h-full" />
</div>
<div>
<div class="font-semibold leading-tight uppercase tracking-wide text-sm">LabPréstamos</div>
<div class="text-xs" style="color: var(--color-pencil);">UABC · Lab. Sistemas</div>
<div class="font-medium leading-tight text-graphite text-sm">LabPréstamos</div>
<div class="text-xs text-graphite/60">UABC · Lab. Sistemas</div>
</div>
</a>
<nav class="flex-1 p-3 flex flex-col gap-1.5" aria-label="Navegación principal">
@@ -109,10 +105,11 @@ const activeCheck = (item: NavItem) => {
<a
href={item.href}
class:list={[
'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)]'
'relative flex items-center gap-3 px-3 py-2.5 rounded-[8px] text-sm whitespace-nowrap motion-safe:transition-colors motion-safe:duration-150',
activeCheck(item)
? 'bg-linen text-verde-institucional font-medium'
: 'text-graphite hover:bg-linen hover:text-verde-institucional'
]}
style={activeCheck(item) ? 'border: 2px solid var(--color-ink);' : 'border: 2px solid transparent;'}
aria-current={activeCheck(item) ? 'page' : undefined}
>
<span class="relative inline-grid place-items-center">
@@ -124,8 +121,7 @@ const activeCheck = (item: NavItem) => {
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);"
class="absolute -top-2 -right-3 min-w-[18px] h-[18px] px-1.5 grid place-items-center rounded-full font-sans font-medium text-[11px] leading-none bg-verde-institucional text-bone-white"
>{pendientesCount}</span>
)}
</span>
@@ -133,26 +129,25 @@ const activeCheck = (item: NavItem) => {
</a>
))}
</nav>
<div class="p-3" style="border-top: 2px solid var(--color-ink);">
<a href="/perfil" class="flex items-center gap-3 px-2 py-2 rounded-[2px] hover:bg-[color:var(--color-chalk)] transition-colors">
<div class="p-3 border-t border-hairline-gray">
<a href="/perfil" class="flex items-center gap-3 px-2 py-2 rounded-[8px] hover:bg-linen transition-colors">
{avatar && <Avatar client:load info={avatar} size={36} />}
<div class="min-w-0 flex-1">
<div class="text-sm font-medium truncate">{profile?.nombre ?? profile?.email}</div>
<div class="text-xs uppercase tracking-wide flex items-center gap-1.5" style="color: var(--color-pencil);">
<div class="text-sm font-medium text-graphite truncate">{profile?.nombre ?? profile?.email}</div>
<div class="text-xs text-graphite/60 flex items-center gap-1.5">
<span>Ver perfil</span>
{perfilIncompleto && (
<span
aria-label="Perfil incompleto"
title="Perfil incompleto"
class="inline-block w-2 h-2 rounded-full motion-safe:animate-pulse"
style="background: var(--color-danger);"
class="inline-block w-2 h-2 rounded-full bg-negro-institucional motion-safe:animate-pulse"
></span>
)}
</div>
</div>
</a>
<form method="POST" action="/api/auth/signout" class="mt-1">
<button type="submit" class="w-full text-left px-3 py-2 rounded-[2px] text-sm uppercase tracking-wide hover:bg-[color:var(--color-chalk)] transition-colors flex items-center gap-2">
<form method="POST" action="/api/auth/signout" class="mt-2">
<button type="submit" class="btn btn-ghost w-full justify-start text-xs">
<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>
@@ -163,20 +158,19 @@ const activeCheck = (item: NavItem) => {
</aside>
<!-- Header mobile mínimo -->
<header class="md:hidden flex items-center justify-between px-4 py-3 bg-white" style="border-bottom: 2px solid var(--color-ink);">
<a href="/" class="flex items-center gap-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--color-ink)] rounded-[2px]" aria-label="LabPréstamos — Inicio">
<div class="w-8 h-8 rounded-[2px] bg-[color:var(--color-primary)] grid place-items-center p-1.5" style="border: 2px solid var(--color-ink);" aria-hidden="true">
<header class="md:hidden flex items-center justify-between px-4 py-3 bg-bone-white border-b border-hairline-gray">
<a href="/" class="flex items-center gap-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-verde-institucional rounded-[8px]" aria-label="LabPréstamos — Inicio">
<div class="w-8 h-8 rounded-[8px] bg-linen text-verde-institucional grid place-items-center p-1.5" aria-hidden="true">
<BrandMark class="w-full h-full" />
</div>
<span class="font-semibold text-sm uppercase tracking-wide">LabPréstamos</span>
<span class="font-medium text-sm text-graphite">LabPréstamos</span>
</a>
<a href="/perfil" class="relative flex items-center gap-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--color-ink)] rounded-[2px]" aria-label="Mi perfil">
<a href="/perfil" class="relative flex items-center gap-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-verde-institucional rounded-full" aria-label="Mi perfil">
{avatar && <Avatar client:load info={avatar} size={36} />}
{perfilIncompleto && (
<span
aria-hidden="true"
class="absolute -top-0.5 -right-0.5 w-2.5 h-2.5 rounded-full motion-safe:animate-pulse"
style="background: var(--color-danger); border: 1.5px solid var(--color-ink);"
class="absolute -top-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-negro-institucional motion-safe:animate-pulse"
></span>
)}
</a>
@@ -189,8 +183,7 @@ const activeCheck = (item: NavItem) => {
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-[2px] mb-4 hover:bg-[color:var(--color-chalk)] transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--color-ink)]"
style="border: 2px solid var(--color-ink);"
class="hidden md:inline-flex items-center justify-center w-10 h-10 rounded-[8px] mb-4 border border-hairline-gray bg-bone-white text-graphite hover:bg-linen hover:text-verde-institucional transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-verde-institucional"
>
<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.menu} />
@@ -199,10 +192,10 @@ const activeCheck = (item: NavItem) => {
<slot />
</main>
<!-- Dock mobile: barra inferior fija, plana (sin sombra, como el nav de MotherDuck) -->
<!-- Dock móvil: barra inferior fija, plana (sin sombra, sin blur) -->
<nav
class="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-white flex items-stretch justify-around"
style="border-top: 2px solid var(--color-ink); padding-bottom: env(safe-area-inset-bottom);"
class="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-bone-white border-t border-hairline-gray flex items-stretch justify-around"
style="padding-bottom: env(safe-area-inset-bottom);"
aria-label="Navegación principal"
>
{nav.map((item) => {
@@ -212,14 +205,13 @@ const activeCheck = (item: NavItem) => {
href={item.href}
aria-label={item.label}
aria-current={active ? 'page' : undefined}
class="relative flex-1 grid place-items-center py-2.5"
class="relative flex-1 grid place-items-center py-2"
>
<span
class:list={[
'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' : ''
'relative grid place-items-center rounded-[8px] w-10 h-10 transition-[background-color,transform] duration-100 motion-safe:active:scale-95',
active ? 'text-verde-institucional font-medium bg-linen' : 'text-graphite hover:bg-linen hover:text-verde-institucional'
]}
style={active ? 'border: 2px solid var(--color-ink);' : ''}
>
<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]} />
@@ -229,8 +221,7 @@ const activeCheck = (item: NavItem) => {
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);"
class="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 grid place-items-center rounded-full font-sans font-medium text-[11px] leading-none bg-verde-institucional text-bone-white"
>{pendientesCount}</span>
)}
</span>
@@ -241,9 +232,9 @@ const activeCheck = (item: NavItem) => {
<button
type="submit"
aria-label="Cerrar sesión"
class="w-full grid place-items-center py-2.5"
class="w-full grid place-items-center py-2"
>
<span class="grid place-items-center rounded-[2px] w-11 h-11 transition-[background-color,transform] duration-100 motion-safe:active:scale-95 hover:bg-[color:var(--color-danger)]/15">
<span class="grid place-items-center rounded-[8px] w-10 h-10 text-graphite hover:bg-linen hover:text-verde-institucional transition-[background-color,transform] duration-100 motion-safe:active:scale-95">
<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.logout} />
</svg>
+15 -17
View File
@@ -11,28 +11,26 @@ const { title = 'Sistema de Préstamos — Laboratorio UABC' } = Astro.props;
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#f4efea" />
<meta name="theme-color" content="#00723F" />
<meta name="color-scheme" content="light" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Mulish:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
</head>
<body>
<!--
THESIS: Trade the institutional-green bitácora look for MotherDuck's crayon-terminal-on-cream
neobrutalism, per a fully pinned user brief — hard edges and offset shadow instead of soft,
rounded institutional polish.
OWN-WORLD: Cream Paper canvas, Frost White cards, Charcoal Ink borders/text everywhere, Sky
Crayon as the sole filled-action color, Coral Sketch for danger, Mint Sketch for positive
status, Duck Bill Orange as sparse accent, JetBrains Mono throughout, 2px radius everywhere,
-6px/6px hard offset shadow with press-in hover on buttons and modals.
STORY: alumno/admin still request, approve, and return lab equipment; the interface now reads
like a terminal readout instead of a soft green form.
FIRST VIEWPORT: login card — cream page, white bordered card, charcoal offset shadow, uppercase
mono headline, sky-blue press button.
FORM: user-pinned brief (MotherDuck DESIGN.md) — concept roll skipped per "the brief wins."
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the
verdict, DESIGN.md, and every shipping raster carrying its provenance.
THESIS: Identidad editorial institucional UABC en clave monoespaciada — verde institucional
#00723F como único interruptor de acción, tipografía Azeret Mono unificada (300/500), dos
lienzos alternos (Bone White / Negro Institucional), sin sombras, hairlines 1px.
OWN-WORLD: Bone White (#fffefb) canvas, Verde Institucional (#00723F) para CTAs y estados,
Dorado UABC (#DD971A) como acento secundario, radios 8/12/30/9999 solamente, ninguna otra
familia tipográfica.
STORY: mismo sistema de préstamos — la interfaz se lee como una hoja de especificación
técnica en vez de un panel de marketing.
FORM: user-pinned brief (UABC DESIGN.md) — concept roll skipped, brief gana.
-->
<a href="#main" class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 btn btn-primary z-50">
Saltar al contenido
@@ -48,14 +46,14 @@ const { title = 'Sistema de Préstamos — Laboratorio UABC' } = Astro.props;
<style is:global>
#page-loader {
position: fixed; inset: 0; z-index: 9999;
background: color-mix(in oklab, var(--color-surface) 60%, transparent);
background: color-mix(in oklab, var(--color-bone-white) 60%, transparent);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: grid; place-items: center;
}
#page-loader[hidden] { display: none; }
.page-loader-spinner {
color: var(--color-ink);
color: var(--color-graphite);
animation: labre-spin 0.9s linear infinite;
}
@keyframes labre-spin { to { transform: rotate(360deg); } }
+144
View File
@@ -0,0 +1,144 @@
// Cache in-memory de las estadísticas admin — 60s TTL.
// Motivo: /admin/estadisticas dispara 9 queries por visita; a 20-50 usuarios
// concurrentes con un admin abriendo la pestaña varias veces al día, absorbemos
// >90% de los hits con este cache.
// El cache es por día (isoDate) — cuando cambia el día, la key cambia y se re-computa.
import type { SupabaseClient } from '@supabase/supabase-js';
const TTL_MS = 60_000;
export type EstadisticasBundle = {
kpis: {
nSolicitadas: number;
nAprobadas: number;
nRechazadas: number;
nDevueltas: number;
nEnPrestamo: number;
nVencidos: number;
};
topMateriales: { label: string; valor: number }[];
solicitudesPorDia: { fecha: string; valor: number }[];
distribEstados: { label: string; valor: number }[];
};
type Entry = { at: number; data: EstadisticasBundle };
const cache = new Map<string, Entry>();
export function invalidateEstadisticas() {
cache.clear();
}
export async function getEstadisticas(
supabase: SupabaseClient,
dia: { isoDate: string; startUTC: Date; endUTC: Date },
): Promise<EstadisticasBundle> {
const key = dia.isoDate;
const now = Date.now();
const hit = cache.get(key);
if (hit && now - hit.at < TTL_MS) return hit.data;
const countHead = { count: 'exact' as const, head: true };
const startISO = dia.startUTC.toISOString();
const endISO = dia.endUTC.toISOString();
const DIAS = 30;
const desde = new Date(dia.startUTC.getTime() - (DIAS - 1) * 86400000).toISOString();
// 9 queries en paralelo — mismo shape que la implementación inline anterior.
const [
{ count: nSolicitadas },
{ count: nAprobadas },
{ count: nRechazadas },
{ count: nDevueltas },
{ count: nEnPrestamo },
{ count: nVencidos },
{ data: topMaterialesRaw },
{ data: solicitudesUlt30 },
{ data: estadosRaw },
] = 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),
supabase
.from('solicitud_items')
.select('cantidad, material:materiales(nombre), solicitud:solicitudes!inner(fecha_solicitud)')
.gte('solicitud.fecha_solicitud', desde),
supabase
.from('solicitudes')
.select('fecha_solicitud')
.gte('fecha_solicitud', desde)
.order('fecha_solicitud', { ascending: true }),
supabase.from('solicitudes').select('estado'),
]);
// Top 10 materiales (agrupado en memoria).
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);
// Solicitudes por día (30 días, rellena huecos con 0).
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 });
}
// Distribución de estados.
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,
}));
const data: EstadisticasBundle = {
kpis: {
nSolicitadas: nSolicitadas ?? 0,
nAprobadas: nAprobadas ?? 0,
nRechazadas: nRechazadas ?? 0,
nDevueltas: nDevueltas ?? 0,
nEnPrestamo: nEnPrestamo ?? 0,
nVencidos: nVencidos ?? 0,
},
topMateriales,
solicitudesPorDia,
distribEstados,
};
cache.set(key, { at: now, data });
return data;
}
+64 -19
View File
@@ -6,7 +6,33 @@ const PUBLIC_ROUTES = ['/login', '/api/auth/signin', '/api/auth/callback', '/api
// Rutas siempre accesibles para un user baneado (sino, loop de rewrite)
const BANNED_ALLOWED = ['/banned', '/api/auth/signout'];
// Caches en memoria del proceso — TTL corto, mono-container. Se invalidan en signout
// y al actualizar profile. Reducen 2-3 hits a Postgres por request a ~0.05 en promedio.
const PROFILE_TTL_MS = 30_000;
const BANNED_TTL_MS = 30_000;
type CachedProfile = { profile: any; exp: number };
type CachedBanned = { banned: boolean; exp: number };
const profileCache = new Map<string, CachedProfile>();
const bannedCache = new Map<string, CachedBanned>();
export function invalidateUserCaches(uid: string) {
profileCache.delete(uid);
bannedCache.delete(uid);
}
export const onRequest = defineMiddleware(async (context, next) => {
// Skip assets estáticos y audio — Astro los sirve por el mismo pipeline y no necesitan auth.
const path = context.url.pathname;
if (
path.startsWith('/_astro/') ||
path.startsWith('/audio/') ||
path === '/favicon.svg' ||
path === '/favicon.ico' ||
path === '/robots.txt'
) {
return next();
}
const supabase = serverClient(context.cookies);
context.locals.supabase = supabase;
@@ -21,27 +47,36 @@ export const onRequest = defineMiddleware(async (context, next) => {
context.locals.profile = null;
if (user) {
const select = 'id, email, nombre, matricula, rol, semestre, tutor_id, foto_path';
let { data: profile } = await supabase
.from('profiles')
.select(select)
.eq('id', user.id)
.maybeSingle();
const now = Date.now();
const cached = profileCache.get(user.id);
let profile: any = cached && cached.exp > now ? cached.profile : null;
// Self-heal: el trigger prestamos_on_auth_user_created coexiste con otro
// trigger del proyecto vecino en auth.users y no siempre dispara. Si el
// user existe pero no hay profile, lo creamos aquí con los datos del OAuth.
if (!profile && user.email?.toLowerCase().endsWith(UABC_DOMAIN)) {
const nombre =
(user.user_metadata?.full_name as string | undefined) ??
(user.user_metadata?.name as string | undefined) ??
null;
const { data: creado } = await supabase
if (!profile) {
const select = 'id, email, nombre, matricula, rol, semestre, tutor_id, foto_path';
const { data } = await supabase
.from('profiles')
.upsert({ id: user.id, email: user.email, nombre }, { onConflict: 'id' })
.select(select)
.eq('id', user.id)
.maybeSingle();
profile = creado ?? null;
profile = data ?? null;
// Self-heal: el trigger prestamos_on_auth_user_created coexiste con otro
// trigger del proyecto vecino en auth.users y no siempre dispara. Si el
// user existe pero no hay profile, lo creamos aquí con los datos del OAuth.
if (!profile && user.email?.toLowerCase().endsWith(UABC_DOMAIN)) {
const nombre =
(user.user_metadata?.full_name as string | undefined) ??
(user.user_metadata?.name as string | undefined) ??
null;
const { data: creado } = await supabase
.from('profiles')
.upsert({ id: user.id, email: user.email, nombre }, { onConflict: 'id' })
.select(select)
.maybeSingle();
profile = creado ?? null;
}
if (profile) profileCache.set(user.id, { profile, exp: now + PROFILE_TTL_MS });
}
context.locals.profile = profile ?? null;
@@ -60,9 +95,19 @@ export const onRequest = defineMiddleware(async (context, next) => {
// Ban guard: si el user está baneado (y no es admin), toda ruta cae a /banned
// salvo la propia /banned y el signout. RPC is_banned respeta expires_at.
// Cache 30s por uid — se invalida al banear/desbanear desde los endpoints admin.
if (user && context.locals.profile && context.locals.profile.rol !== 'admin') {
const { data: banned } = await supabase.rpc('is_banned', { p_uid: user.id });
if (banned === true && !BANNED_ALLOWED.includes(pathname)) {
const now = Date.now();
const cached = bannedCache.get(user.id);
let banned: boolean;
if (cached && cached.exp > now) {
banned = cached.banned;
} else {
const { data } = await supabase.rpc('is_banned', { p_uid: user.id });
banned = data === true;
bannedCache.set(user.id, { banned, exp: now + BANNED_TTL_MS });
}
if (banned && !BANNED_ALLOWED.includes(pathname)) {
return context.rewrite('/banned');
}
}
+10 -6
View File
@@ -6,13 +6,17 @@ Astro.response.status = 403;
---
<Layout title="Acceso denegado — LabPréstamos">
<main id="main" class="min-h-screen grid place-items-center p-4">
<div class="w-full max-w-md text-center">
<div class="inline-grid place-items-center w-16 h-16 rounded-[2px] mb-4 p-4" style="background: var(--color-danger); border: 2px solid var(--color-ink); box-shadow: var(--shadow-hard);">
<BrandMark class="w-full h-full" style="color: var(--color-ink);" />
<div class="w-full max-w-md">
<div class="card text-center">
<div class="inline-grid place-items-center w-16 h-16 rounded-[12px] bg-linen text-verde-institucional mb-4 p-4" aria-hidden="true">
<BrandMark class="w-full h-full" />
</div>
<h1 class="mb-2">Acceso denegado</h1>
<p class="text-sm text-graphite/70">Esta sección es solo para administradores del laboratorio.</p>
<div class="mt-6">
<a href="/" class="btn btn-primary inline-flex">Volver al inicio</a>
</div>
</div>
<h1 class="text-2xl font-semibold uppercase tracking-wide">Acceso denegado</h1>
<p class="text-sm mt-2" style="color: var(--color-pencil);">Esta sección es solo para administradores del laboratorio.</p>
<a href="/" class="btn btn-primary mt-6 inline-flex">Volver al inicio</a>
</div>
</main>
</Layout>
+10 -6
View File
@@ -6,13 +6,17 @@ Astro.response.status = 404;
---
<Layout title="Página no encontrada — LabPréstamos">
<main id="main" class="min-h-screen grid place-items-center p-4">
<div class="w-full max-w-md text-center">
<div class="inline-grid place-items-center w-16 h-16 rounded-[2px] bg-[color:var(--color-primary)] mb-4 p-4" style="border: 2px solid var(--color-ink); box-shadow: var(--shadow-hard);">
<BrandMark class="w-full h-full text-white" />
<div class="w-full max-w-md">
<div class="card text-center">
<div class="inline-grid place-items-center w-16 h-16 rounded-[12px] bg-linen text-verde-institucional mb-4 p-4" aria-hidden="true">
<BrandMark class="w-full h-full" />
</div>
<h1 class="mb-2">Página no encontrada</h1>
<p class="text-sm text-graphite/70">La dirección no existe o cambió de lugar.</p>
<div class="mt-6">
<a href="/" class="btn btn-primary inline-flex">Volver al inicio</a>
</div>
</div>
<h1 class="text-2xl font-semibold uppercase tracking-wide">Página no encontrada</h1>
<p class="text-sm mt-2" style="color: var(--color-pencil);">La dirección no existe o cambió de lugar.</p>
<a href="/" class="btn btn-primary mt-6 inline-flex">Volver al inicio</a>
</div>
</main>
</Layout>
+43 -116
View File
@@ -2,161 +2,88 @@
import AppLayout from '@/layouts/AppLayout.astro';
import Chart from '@/components/admin/estadisticas/Chart.tsx';
import { todayMX } from '@/lib/date';
import { getEstadisticas } from '@/lib/stats-cache';
// 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),
]);
// Cache 60s en RAM (getEstadisticas absorbe >90% de visitas repetidas).
const { kpis: k, topMateriales, solicitudesPorDia, distribEstados } = await getEstadisticas(supabase, dia);
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).
// Debe coincidir con la ventana en stats-cache.ts (rango de las 3 gráficas).
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,
}));
const vencidosCount = k.nVencidos;
const kpis = [
{ label: 'Solicitadas', value: k.nSolicitadas, total: false, isVencidos: false },
{ label: 'Aprobadas', value: k.nAprobadas, total: false, isVencidos: false },
{ label: 'Rechazadas', value: k.nRechazadas, total: false, isVencidos: false },
{ label: 'Devoluciones', value: k.nDevueltas, total: false, isVencidos: false },
{ label: 'En préstamo', value: k.nEnPrestamo, total: true, isVencidos: false },
{ label: 'Vencidos', value: vencidosCount, total: true, isVencidos: true },
];
---
<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);">
<h1 class="text-3xl md:text-4xl">Estadísticas</h1>
<p class="text-sm mt-1 opacity-70">
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}
{kpis.map((k) => {
const isVencidosAlert = k.isVencidos && k.value > 0;
return (
<div
class:list={[
'flex flex-col justify-between',
isVencidosAlert ? 'card-dark' : 'card',
]}
>
<div class:list={['eyebrow', isVencidosAlert ? 'text-amarillo-dorado' : 'text-dorado-uabc']}>
{k.label}{k.total ? ' (total)' : ' hoy'}
</div>
<div
class:list={[
'text-3xl font-light mt-2 leading-none tabular-nums',
isVencidosAlert ? 'text-bone-white' : 'text-graphite',
]}
>
{k.value}
</div>
</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>
<h3 class="mb-4">Solicitudes por día · últimos {DIAS} días</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>
<p class="text-sm py-8 text-center text-graphite/60">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>
<h3 class="mb-4">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>
<p class="text-sm py-8 text-center text-graphite/60">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>
<h3 class="mb-4">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>
<p class="text-sm py-8 text-center text-graphite/60">Ningún material solicitado en los últimos {DIAS} días.</p>
)}
</div>
</section>
+57 -58
View File
@@ -23,8 +23,13 @@ type Sol = {
items: Item[];
};
const [pend, vencidos, pendientes2, ultimas] = await Promise.all([
supabase.from('solicitudes').select('*', { count: 'exact', head: true }).eq('estado', 'pendiente'),
// 2 queries en paralelo (antes eran 4):
// - count vencidos: KPI del header.
// - "recientes": trae las 8 más recientes; alimenta a la vez "Requieren tu atención" (pendientes)
// y "Actividad reciente" (todas las últimas 5). Trade-off: si en las últimas 8 hay <2 pendientes,
// se muestran las que haya — dashboard, no auditoría.
// El count de pendientes lo hidrata BadgeSolicitudes client-side (KPI del header lo lee del DOM del badge).
const [vencidos, recientes] = await Promise.all([
supabase
.from('solicitudes')
.select('*', { count: 'exact', head: true })
@@ -33,18 +38,13 @@ const [pend, vencidos, pendientes2, ultimas] = await Promise.all([
supabase
.from('solicitudes')
.select('id, estado, fecha_solicitud, alumno:profiles!alumno_id(nombre, email), items:solicitud_items(cantidad, material:materiales(nombre))')
.eq('estado', 'pendiente')
.order('fecha_solicitud', { ascending: false })
.limit(2),
supabase
.from('solicitudes')
.select('id, estado, fecha_solicitud, alumno:profiles!alumno_id(nombre, email), items:solicitud_items(cantidad, material:materiales(nombre))')
.order('fecha_solicitud', { ascending: false })
.limit(5),
.limit(8),
]);
const pendientesData = (pendientes2.data ?? []) as unknown as Sol[];
const ultimasData = (ultimas.data ?? []) as unknown as Sol[];
const recientesData = (recientes.data ?? []) as unknown as Sol[];
const pendientesData = recientesData.filter((s) => s.estado === 'pendiente').slice(0, 2);
const ultimasData = recientesData.slice(0, 5);
const estadoLabel: Record<Estado, string> = {
pendiente: 'Pendiente',
@@ -54,16 +54,14 @@ const estadoLabel: Record<Estado, string> = {
rechazado: 'Rechazado',
vencido: 'Vencido',
};
const estadoColor: Record<Estado, string> = {
pendiente: 'var(--color-ink)',
aprobado: 'var(--color-positive)',
activo: 'var(--color-positive)',
devuelto: 'var(--color-ink)',
rechazado: 'var(--color-danger)',
vencido: 'var(--color-danger)',
const estadoChipClass: Record<Estado, string> = {
pendiente: 'chip-pendiente',
aprobado: 'chip-aprobado',
activo: 'chip-activo',
devuelto: 'chip-devuelto',
rechazado: 'chip-rechazado',
vencido: 'chip-vencido',
};
const badgeStyle = (e: Estado) =>
`background: color-mix(in oklab, ${estadoColor[e]} 18%, white); color: var(--color-ink); border: 1.5px solid ${estadoColor[e]};`;
const resumenItems = (items: Item[]) => {
const preview = items.slice(0, 3).map((i) => `${i.cantidad}× ${i.material?.nombre ?? '—'}`).join(', ');
@@ -71,50 +69,53 @@ const resumenItems = (items: Item[]) => {
return extra > 0 ? `${preview} (+${extra})` : preview;
};
const kpiPend = pend.count ?? 0;
const kpiVenc = vencidos.count ?? 0;
---
<AppLayout title="Panel — LabPréstamos">
<div class="max-w-5xl mx-auto">
<header class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
<h1 class="font-mono uppercase text-3xl md:text-5xl font-semibold leading-tight" style="letter-spacing: -0.01em;">
<h1>
{saludo}, {nombreCorto}
</h1>
<div class="flex gap-3 shrink-0">
<a href="/admin/solicitudes" class="card-flat px-4 py-3 text-center">
<div class="text-2xl font-semibold leading-none" style={`font-variant-numeric: tabular-nums; color: ${kpiPend > 0 ? 'var(--color-primary)' : 'var(--color-ink)'};`}>{kpiPend}</div>
<div class="mt-1 text-xs uppercase tracking-wide" style="color: var(--color-pencil);">Pendientes</div>
<a href="/admin/solicitudes" class="card text-center block min-w-[120px] transition-colors hover:border-dorado-uabc">
<div data-count="pendientes" class="text-3xl md:text-4xl font-light" style="font-variant-numeric: tabular-nums;">—</div>
<div class="mt-1 text-xs eyebrow text-dorado-uabc">Pendientes</div>
</a>
<a href="/admin/solicitudes/activos" class="card-flat px-4 py-3 text-center">
<div class="text-2xl font-semibold leading-none" style={`font-variant-numeric: tabular-nums; color: ${kpiVenc > 0 ? 'var(--color-danger)' : 'var(--color-ink)'};`}>{kpiVenc}</div>
<div class="mt-1 text-xs uppercase tracking-wide" style="color: var(--color-pencil);">Vencidos</div>
<a
href="/admin/solicitudes/activos"
class:list={[
'text-center block min-w-[120px] transition-colors',
kpiVenc > 0 ? 'card-dark' : 'card'
]}
>
<div class="text-3xl md:text-4xl font-light" style="font-variant-numeric: tabular-nums;">{kpiVenc}</div>
<div class:list={['mt-1 text-xs eyebrow', kpiVenc > 0 ? 'text-bone-white' : 'text-graphite/70']}>Vencidos</div>
</a>
</div>
</header>
<section aria-labelledby="atencion-h" class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 id="atencion-h" class="text-lg font-semibold uppercase tracking-wide">Requieren tu atención</h2>
<section aria-labelledby="atencion-h" class="card mb-10">
<div class="flex items-baseline justify-between mb-4">
<h2 id="atencion-h" class="text-xl md:text-2xl font-light">Requieren tu atención</h2>
{pendientesData.length > 0 && (
<a href="/admin/solicitudes" class="text-sm underline decoration-[color:var(--color-ink)] underline-offset-4">Ver todas</a>
<a href="/admin/solicitudes" class="btn btn-ghost text-xs py-1 px-3 min-h-[36px]">Ver todas</a>
)}
</div>
{pendientesData.length === 0 ? (
<div class="card text-center">
<p class="text-sm opacity-70">Todo al día. No hay solicitudes pendientes.</p>
</div>
<p class="text-sm text-graphite/70 text-center py-6">Todo al día. No hay solicitudes pendientes.</p>
) : (
<div class="grid gap-3 md:grid-cols-2">
{pendientesData.map((s) => (
<a href="/admin/solicitudes" class="card-link">
<a href="/admin/solicitudes" class="card-link block">
<div class="flex items-start justify-between gap-3 mb-2">
<h3 class="font-semibold leading-tight">{s.alumno?.nombre ?? s.alumno?.email ?? '—'}</h3>
<span class="badge shrink-0" style={badgeStyle(s.estado)}>{estadoLabel[s.estado]}</span>
<h3 class="text-base font-medium leading-tight">{s.alumno?.nombre ?? s.alumno?.email ?? '—'}</h3>
<span class:list={['chip shrink-0', estadoChipClass[s.estado]]}>{estadoLabel[s.estado]}</span>
</div>
<p class="text-sm leading-snug mb-3" style="color: var(--color-pencil);">{resumenItems(s.items ?? [])}</p>
<div class="flex items-center justify-between text-xs">
<span style="color: var(--color-pencil);">{fmtFechaRelativa(s.fecha_solicitud)}</span>
<span class="underline decoration-[color:var(--color-ink)] underline-offset-4">Revisar</span>
<p class="text-sm leading-snug mb-4 text-graphite/70">{resumenItems(s.items ?? [])}</p>
<div class="flex items-center justify-between text-xs pt-3 border-t border-hairline-gray">
<span class="text-graphite/60">{fmtFechaRelativa(s.fecha_solicitud)}</span>
<span class="btn btn-primary text-xs py-1 px-3 min-h-[32px]">Revisar</span>
</div>
</a>
))}
@@ -122,21 +123,19 @@ const kpiVenc = vencidos.count ?? 0;
)}
</section>
<section aria-labelledby="ultimas-h">
<div class="flex items-baseline justify-between mb-3">
<h2 id="ultimas-h" class="text-lg font-semibold uppercase tracking-wide">Actividad reciente</h2>
<a href="/admin/solicitudes/historial" class="text-sm underline decoration-[color:var(--color-ink)] underline-offset-4">Historial</a>
<section aria-labelledby="ultimas-h" class="card">
<div class="flex items-baseline justify-between mb-4">
<h2 id="ultimas-h" class="text-xl md:text-2xl font-light">Actividad reciente</h2>
<a href="/admin/solicitudes/historial" class="btn btn-ghost text-xs py-1 px-3 min-h-[36px]">Historial</a>
</div>
{ultimasData.length === 0 ? (
<div class="card text-center">
<p class="text-sm opacity-70">Aún no hay solicitudes registradas.</p>
</div>
<p class="text-sm text-graphite/70 text-center py-6">Aún no hay solicitudes registradas.</p>
) : (
<>
<div class="hidden md:block card-flat p-0 overflow-hidden">
<div class="hidden md:block p-0 overflow-hidden rounded-[8px] border border-hairline-gray">
<table class="w-full text-sm">
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
<thead class="text-left bg-linen text-graphite border-b border-hairline-gray">
<tr>
<th class="p-3 font-medium">Alumno</th>
<th class="p-3 font-medium">Material</th>
@@ -146,8 +145,8 @@ const kpiVenc = vencidos.count ?? 0;
</thead>
<tbody>
{ultimasData.map((r) => (
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);">
<td class="p-3">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</td>
<tr class="border-b border-hairline-gray last:border-b-0">
<td class="p-3 font-medium text-graphite">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</td>
<td class="p-3">
<div class="flex flex-col gap-0.5">
{(r.items ?? []).map((i) => (
@@ -156,9 +155,9 @@ const kpiVenc = vencidos.count ?? 0;
</div>
</td>
<td class="p-3">
<span class="badge" style={badgeStyle(r.estado)}>{estadoLabel[r.estado]}</span>
<span class:list={['chip', estadoChipClass[r.estado]]}>{estadoLabel[r.estado]}</span>
</td>
<td class="p-3 opacity-80">{fmtFechaRelativa(r.fecha_solicitud)}</td>
<td class="p-3 text-graphite/80">{fmtFechaRelativa(r.fecha_solicitud)}</td>
</tr>
))}
</tbody>
@@ -170,10 +169,10 @@ const kpiVenc = vencidos.count ?? 0;
<article class="card">
<div class="flex items-start justify-between gap-3 mb-2">
<div class="min-w-0">
<h3 class="font-semibold leading-tight truncate">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</h3>
<p class="text-xs mt-1" style="color: var(--color-pencil);">{fmtFechaRelativa(r.fecha_solicitud)}</p>
<h3 class="text-base font-medium leading-tight truncate">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</h3>
<p class="text-xs text-graphite/60 mt-1">{fmtFechaRelativa(r.fecha_solicitud)}</p>
</div>
<span class="badge shrink-0" style={badgeStyle(r.estado)}>{estadoLabel[r.estado]}</span>
<span class:list={['chip shrink-0', estadoChipClass[r.estado]]}>{estadoLabel[r.estado]}</span>
</div>
<div class="text-sm leading-snug">
{(r.items ?? []).map((i) => (
+32 -22
View File
@@ -24,27 +24,37 @@ const categorias = ((data ?? []) as unknown as CategoriaRow[]).map((c) => ({
<div class="max-w-4xl">
<header class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
<div>
<h1 class="text-2xl md:text-3xl font-semibold">Categorías</h1>
<p class="text-sm opacity-70 mt-1">Organiza los materiales por tipo.</p>
<h1 class="text-3xl">Categorías</h1>
<p class="text-sm text-graphite/70 mt-1">Organiza los materiales por tipo.</p>
</div>
<CategoriaForm mode="create" client:load />
</header>
<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 border-transparent opacity-70 transition-colors duration-150 hover:opacity-100">
<nav class="flex gap-2 mb-6 border-b border-hairline-gray" aria-label="Sub-navegación de inventario">
<a
href="/admin/inventario"
class="px-4 py-2 text-sm font-medium rounded-[8px] transition-colors border-b-2 -mb-px border-transparent text-graphite hover:text-verde-institucional"
>
Materiales
</a>
<a href="/admin/inventario/categorias" 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/categorias"
class="px-4 py-2 text-sm font-medium rounded-[8px] transition-colors border-b-2 -mb-px border-verde-institucional text-verde-institucional"
aria-current="page"
>
Categorías
</a>
<a href="/admin/maestros" class="px-4 py-2 text-sm font-medium border-b-2 border-transparent opacity-70 transition-colors duration-150 hover:opacity-100">
<a
href="/admin/maestros"
class="px-4 py-2 text-sm font-medium rounded-[8px] transition-colors border-b-2 -mb-px border-transparent text-graphite hover:text-verde-institucional"
>
Maestros
</a>
</nav>
{error && (
<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);">
<div class="card mb-6" role="alert">
<p class="text-sm text-negro-institucional">
No se pudieron cargar las categorías. Recarga la página.
</p>
</div>
@@ -52,8 +62,8 @@ const categorias = ((data ?? []) as unknown as CategoriaRow[]).map((c) => ({
{!error && categorias.length === 0 && (
<div class="card text-center">
<h2 class="text-lg font-semibold mb-1">Sin categorías aún</h2>
<p class="text-sm opacity-70">Crea la primera para agrupar tus materiales.</p>
<h2 class="text-lg mb-1">Sin categorías aún</h2>
<p class="text-sm text-graphite/70">Crea la primera para agrupar tus materiales.</p>
</div>
)}
@@ -61,24 +71,24 @@ const categorias = ((data ?? []) as unknown as CategoriaRow[]).map((c) => ({
<>
<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);">
<thead class="text-left bg-linen border-b border-hairline-gray">
<tr>
<th class="px-4 py-3 font-medium">Nombre</th>
<th class="px-4 py-3 font-medium text-right"># Materiales</th>
<th class="px-4 py-3 font-medium text-right">Acciones</th>
<th class="px-4 py-3 font-medium text-graphite">Nombre</th>
<th class="px-4 py-3 font-medium text-graphite text-right"># Materiales</th>
<th class="px-4 py-3 font-medium text-graphite text-right">Acciones</th>
</tr>
</thead>
<tbody>
{categorias.map((c) => (
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 8%, transparent);">
<td class="px-4 py-3 font-medium">{c.nombre}</td>
<tr class="border-b border-hairline-gray last:border-b-0">
<td class="px-4 py-3 font-medium text-graphite">{c.nombre}</td>
<td class="px-4 py-3 text-right" style="font-variant-numeric: tabular-nums;">
{c.count > 0 ? (
<a href={`/admin/inventario?cat=${c.id}`} class="underline" style="color: var(--color-primary);">
<a href={`/admin/inventario?cat=${c.id}`} class="text-verde-institucional hover:text-verde-claro underline">
{c.count}
</a>
) : (
<span class="opacity-60">0</span>
<span class="text-graphite/50">0</span>
)}
</td>
<td class="px-4 py-3">
@@ -95,12 +105,12 @@ const categorias = ((data ?? []) as unknown as CategoriaRow[]).map((c) => ({
<div class="md:hidden flex flex-col gap-3">
{categorias.map((c) => (
<article class="card flex items-start justify-between gap-3">
<article class="card flex items-start justify-between gap-3 p-4">
<div>
<h3 class="font-semibold">{c.nombre}</h3>
<p class="text-xs opacity-70 mt-1">
<h3 class="font-medium text-graphite">{c.nombre}</h3>
<p class="text-xs text-graphite/70 mt-1">
{c.count > 0 ? (
<a href={`/admin/inventario?cat=${c.id}`} class="underline" style="color: var(--color-primary);">
<a href={`/admin/inventario?cat=${c.id}`} class="text-verde-institucional hover:text-verde-claro underline">
{c.count} material{c.count === 1 ? '' : 'es'}
</a>
) : (
+37 -38
View File
@@ -23,39 +23,38 @@ const catId = catRaw && /^\d+$/.test(catRaw) ? Number(catRaw) : null;
const estadoRaw = Astro.url.searchParams.get('estado') ?? '';
const estado = ['disponible', 'mantenimiento', 'baja'].includes(estadoRaw) ? estadoRaw : '';
let query = Astro.locals.supabase
let matQuery = Astro.locals.supabase
.from('materiales')
.select(
'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}%`);
if (catId != null) query = query.eq('categoria_id', catId);
if (estado) query = query.eq('estado', estado);
if (q) matQuery = matQuery.or(`nombre.ilike.%${q}%,numero_inventario.ilike.%${q}%`);
if (catId != null) matQuery = matQuery.eq('categoria_id', catId);
if (estado) matQuery = matQuery.eq('estado', estado);
const { data: matData, error: matError } = await query.order('nombre');
const materiales = (matData ?? []) as unknown as Material[];
const { data: catData } = await Astro.locals.supabase
.from('categorias')
.select('id, nombre')
.order('nombre');
const categorias = (catData ?? []) as { id: number; nombre: string }[];
const [matRes, catRes] = await Promise.all([
matQuery.order('nombre'),
Astro.locals.supabase.from('categorias').select('id, nombre').order('nombre'),
]);
const materiales = (matRes.data ?? []) as unknown as Material[];
const matError = matRes.error;
const categorias = (catRes.data ?? []) as { id: number; nombre: string }[];
const estadoBadge = (e: Material['estado']) => {
if (e === 'disponible')
return {
label: 'Disponible',
style: 'background: color-mix(in oklab, var(--color-positive) 18%, white); color: var(--color-ink); border: 1.5px solid var(--color-positive);',
className: 'bg-bone-white text-verde-institucional border border-verde-institucional',
};
if (e === 'mantenimiento')
return {
label: 'Mantenimiento',
style: 'background: color-mix(in oklab, var(--color-secondary) 18%, white); color: var(--color-ink); border: 1.5px solid var(--color-secondary);',
className: 'bg-bone-white text-dorado-uabc border border-dorado-uabc',
};
return {
label: 'Baja',
style: 'background: var(--color-chalk); color: var(--color-ink); border: 1.5px solid var(--color-graphite);',
className: 'bg-negro-institucional text-bone-white border border-negro-institucional',
};
};
---
@@ -63,19 +62,22 @@ const estadoBadge = (e: Material['estado']) => {
<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">Inventario</h1>
<p class="text-sm opacity-70 mt-1">Materiales del laboratorio.</p>
<h1 class="text-3xl">Inventario</h1>
<p class="text-sm text-graphite/70 mt-1">Materiales del laboratorio.</p>
</div>
<MaterialForm mode="create" categorias={categorias} client:load />
</header>
<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);">
<nav class="flex gap-2 mb-6 border-b border-hairline-gray" aria-label="Sub-navegación de inventario">
<a href="/admin/inventario" class="px-4 py-2 text-sm font-medium rounded-[8px] transition-colors border-b-2 -mb-px border-verde-institucional text-verde-institucional" aria-current="page">
Materiales
</a>
<a href="/admin/inventario/categorias" class="px-4 py-2 text-sm font-medium border-b-2 border-transparent opacity-70 transition-colors duration-150 hover:opacity-100">
<a href="/admin/inventario/categorias" class="px-4 py-2 text-sm font-medium rounded-[8px] transition-colors border-b-2 -mb-px border-transparent text-graphite hover:text-verde-institucional">
Categorías
</a>
<a href="/admin/maestros" class="px-4 py-2 text-sm font-medium rounded-[8px] transition-colors border-b-2 -mb-px border-transparent text-graphite hover:text-verde-institucional">
Maestros
</a>
</nav>
<form id="inv-filter-form" method="GET" class="card mb-6 grid grid-cols-1 sm:grid-cols-4 gap-3" role="search">
@@ -110,8 +112,8 @@ const estadoBadge = (e: Material['estado']) => {
</form>
{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);">
<div class="card mb-6" role="alert">
<p class="text-sm text-negro-institucional">
No se pudo cargar el inventario. Recarga la página.
</p>
</div>
@@ -119,8 +121,8 @@ const estadoBadge = (e: Material['estado']) => {
{!matError && materiales.length === 0 && (
<div class="card text-center">
<h2 class="text-lg font-semibold mb-1">Sin resultados</h2>
<p class="text-sm opacity-70">
<h2 class="text-lg mb-1">Sin resultados</h2>
<p class="text-sm text-graphite/70">
{q || catId != null || estado
? 'Ningún material coincide con los filtros. Ajusta la búsqueda.'
: 'Aún no hay material registrado. Agrega uno para comenzar.'}
@@ -135,34 +137,31 @@ const estadoBadge = (e: Material['estado']) => {
const src = imgUrl(m.imagen_path);
return (
<article class="card p-0 flex flex-col overflow-hidden">
<div
class="relative aspect-square w-full bg-[color:var(--color-chalk)] grid place-items-center"
style="border-bottom: 2px solid var(--color-ink);"
>
<div class="relative aspect-square w-full bg-linen rounded-t-[12px] overflow-hidden grid place-items-center border-b border-hairline-gray">
{src ? (
<img src={src} alt={m.nombre} class="w-full h-full object-cover" loading="lazy" />
<img src={src} alt={m.nombre} class="w-full h-full aspect-square object-cover rounded-t-[12px]" 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">
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" class="stroke-graphite" 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>
)}
<span class="absolute bottom-1.5 right-1.5 text-xs px-2 py-0.5 rounded-[2px] whitespace-nowrap shadow-[var(--shadow-hard-sm)]" style={badge.style}>
<span class={`absolute bottom-2 right-2 text-xs px-2.5 py-0.5 rounded-[8px] font-medium whitespace-nowrap ${badge.className}`}>
{badge.label}
</span>
</div>
<div class="p-3 flex flex-col gap-2 flex-1">
<h3 class="uppercase font-semibold text-sm leading-snug line-clamp-2">{m.nombre}</h3>
<div class="p-4 flex flex-col gap-2 flex-1">
<h3 class="font-medium text-sm leading-snug line-clamp-2 text-graphite">{m.nombre}</h3>
{m.categoria && (
<p class="text-xs opacity-70 -mt-1">{m.categoria.nombre}</p>
<p class="text-xs text-graphite/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 class="text-xs text-graphite" style="font-variant-numeric: tabular-nums;">
<span class="font-medium text-graphite">{m.cantidad_disponible}</span> / {m.cantidad_total} disponibles
{m.trackeado_por_unidad && <span class="text-graphite/60"> · por unidad</span>}
</p>
{m.numero_inventario && (
<p class="text-xs opacity-60 font-mono truncate">{m.numero_inventario}</p>
<p class="text-xs text-graphite/60 font-mono truncate">{m.numero_inventario}</p>
)}
<div class="grid grid-cols-2 gap-2 mt-auto pt-2">
<MaterialForm
+47 -31
View File
@@ -28,27 +28,37 @@ const maestros = ((data ?? []) as unknown as MaestroRow[]).map((m) => ({
<div class="max-w-4xl">
<header class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
<div>
<h1 class="text-2xl md:text-3xl font-semibold">Maestros</h1>
<p class="text-sm opacity-70 mt-1">Catálogo de tutores que los alumnos eligen en su perfil.</p>
<h1 class="text-3xl">Maestros</h1>
<p class="text-sm text-graphite/70 mt-1">Catálogo de tutores que los alumnos eligen en su perfil.</p>
</div>
<MaestroForm mode="create" client:load />
</header>
<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 border-transparent opacity-70 transition-colors duration-150 hover:opacity-100">
<nav class="flex gap-2 mb-6 border-b border-hairline-gray" aria-label="Sub-navegación de inventario">
<a
href="/admin/inventario"
class="px-4 py-2 text-sm font-medium rounded-[8px] transition-colors border-b-2 -mb-px border-transparent text-graphite hover:text-verde-institucional"
>
Materiales
</a>
<a href="/admin/inventario/categorias" class="px-4 py-2 text-sm font-medium border-b-2 border-transparent opacity-70 transition-colors duration-150 hover:opacity-100">
<a
href="/admin/inventario/categorias"
class="px-4 py-2 text-sm font-medium rounded-[8px] transition-colors border-b-2 -mb-px border-transparent text-graphite hover:text-verde-institucional"
>
Categorías
</a>
<a href="/admin/maestros" 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/maestros"
class="px-4 py-2 text-sm font-medium rounded-[8px] transition-colors border-b-2 -mb-px border-verde-institucional text-verde-institucional"
aria-current="page"
>
Maestros
</a>
</nav>
{error && (
<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);">
<div class="card mb-6" role="alert">
<p class="text-sm text-negro-institucional">
No se pudieron cargar los maestros. Recarga la página.
</p>
</div>
@@ -56,8 +66,8 @@ const maestros = ((data ?? []) as unknown as MaestroRow[]).map((m) => ({
{!error && maestros.length === 0 && (
<div class="card text-center">
<h2 class="text-lg font-semibold mb-1">Sin maestros aún</h2>
<p class="text-sm opacity-70">Agrega el primero para que los alumnos puedan elegirlo como tutor.</p>
<h2 class="text-lg mb-1">Sin maestros aún</h2>
<p class="text-sm text-graphite/70">Agrega el primero para que los alumnos puedan elegirlo como tutor.</p>
</div>
)}
@@ -65,37 +75,35 @@ const maestros = ((data ?? []) as unknown as MaestroRow[]).map((m) => ({
<>
<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);">
<thead class="text-left bg-linen border-b border-hairline-gray">
<tr>
<th class="px-4 py-3 font-medium">Nombre</th>
<th class="px-4 py-3 font-medium">Estado</th>
<th class="px-4 py-3 font-medium text-center">Tutor</th>
<th class="px-4 py-3 font-medium text-right"># Alumnos</th>
<th class="px-4 py-3 font-medium text-right">Acciones</th>
<th class="px-4 py-3 font-medium text-graphite">Nombre</th>
<th class="px-4 py-3 font-medium text-graphite">Estado</th>
<th class="px-4 py-3 font-medium text-graphite text-center">Tutor</th>
<th class="px-4 py-3 font-medium text-graphite text-right"># Alumnos</th>
<th class="px-4 py-3 font-medium text-graphite text-right">Acciones</th>
</tr>
</thead>
<tbody>
{maestros.map((m) => (
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 8%, transparent);">
<td class="px-4 py-3 font-medium">{m.nombre}</td>
<tr class="border-b border-hairline-gray last:border-b-0">
<td class="px-4 py-3 font-medium text-graphite">{m.nombre}</td>
<td class="px-4 py-3">
{m.activo ? (
<span class="text-xs uppercase tracking-wide" style="color: var(--color-primary);">Activo</span>
) : (
<span class="text-xs uppercase tracking-wide opacity-60">Inactivo</span>
)}
<span class={`badge text-xs rounded-[8px] ${m.activo ? 'bg-bone-white text-verde-institucional border-verde-institucional' : 'bg-linen text-graphite/60 border-hairline-gray'}`}>
{m.activo ? 'Activo' : 'Inactivo'}
</span>
</td>
<td class="px-4 py-3 text-center">
{m.es_tutor ? (
<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-label="Sí" role="img" style="display:inline-block; color: var(--color-primary);">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" class="inline-block text-verde-institucional" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-label="Sí" role="img">
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<span class="opacity-40" aria-label="No">—</span>
<span class="text-graphite/40" aria-label="No">—</span>
)}
</td>
<td class="px-4 py-3 text-right" style="font-variant-numeric: tabular-nums;">
{m.count > 0 ? m.count : <span class="opacity-60">0</span>}
{m.count > 0 ? m.count : <span class="text-graphite/50">0</span>}
</td>
<td class="px-4 py-3">
<div class="flex gap-2 justify-end">
@@ -111,12 +119,20 @@ const maestros = ((data ?? []) as unknown as MaestroRow[]).map((m) => ({
<div class="md:hidden flex flex-col gap-3">
{maestros.map((m) => (
<article class="card flex items-start justify-between gap-3">
<article class="card flex items-start justify-between gap-3 p-4">
<div>
<h3 class="font-semibold">{m.nombre}</h3>
<p class="text-xs opacity-70 mt-1">
{m.activo ? 'Activo' : 'Inactivo'} · {m.es_tutor ? 'Tutor' : 'No tutor'} · {m.count} alumno{m.count === 1 ? '' : 's'}
</p>
<h3 class="font-medium text-graphite">{m.nombre}</h3>
<div class="flex items-center gap-2 mt-1 flex-wrap">
<span class={`badge text-xs rounded-[8px] ${m.activo ? 'bg-bone-white text-verde-institucional border-verde-institucional' : 'bg-linen text-graphite/60 border-hairline-gray'}`}>
{m.activo ? 'Activo' : 'Inactivo'}
</span>
{m.es_tutor && (
<span class="badge text-xs rounded-[8px] bg-linen text-dorado-uabc border-dorado-uabc">Tutor</span>
)}
<span class="text-xs text-graphite/70">
{m.count} alumno{m.count === 1 ? '' : 's'}
</span>
</div>
</div>
<div class="flex flex-col gap-2 shrink-0">
<MaestroForm mode="edit" maestro={{ id: m.id, nombre: m.nombre, activo: m.activo, es_tutor: m.es_tutor }} client:load />
+93 -88
View File
@@ -128,24 +128,34 @@ const exportFilename = `reporte-${today}.csv`;
<AppLayout title="Reportes — LabPréstamos">
<div class="max-w-6xl">
<header class="mb-6">
<h1 class="text-2xl md:text-3xl font-semibold">Reportes</h1>
<h1 class="text-3xl md:text-4xl">Reportes</h1>
<p class="text-sm opacity-70 mt-1">Historial filtrable de préstamos y lista de vencidos.</p>
</header>
<nav class="mb-6 border-b" style="border-color: color-mix(in oklab, var(--color-ink) 12%, transparent);" aria-label="Vistas de reportes">
<ul class="flex gap-1">
<nav class="mb-6 border-b border-hairline-gray" aria-label="Vistas de reportes">
<ul class="flex gap-2 -mb-px">
<li>
<a
href="?vista=historial"
aria-current={vista === 'historial' ? 'page' : undefined}
class:list={['tab', vista === 'historial' && 'tab-active']}
class:list={[
'inline-block px-4 py-2 text-sm font-medium rounded-[8px] transition-colors',
vista === 'historial'
? 'text-verde-institucional border-b-2 border-verde-institucional'
: 'text-graphite hover:text-verde-institucional border-b-2 border-transparent',
]}
>Historial</a>
</li>
<li>
<a
href="?vista=vencidos"
aria-current={vista === 'vencidos' ? 'page' : undefined}
class:list={['tab', vista === 'vencidos' && 'tab-active']}
class:list={[
'inline-block px-4 py-2 text-sm font-medium rounded-[8px] transition-colors',
vista === 'vencidos'
? 'text-verde-institucional border-b-2 border-verde-institucional'
: 'text-graphite hover:text-verde-institucional border-b-2 border-transparent',
]}
>Vencidos</a>
</li>
</ul>
@@ -194,11 +204,11 @@ const exportFilename = `reporte-${today}.csv`;
<div class="mt-5 flex flex-col md:flex-row md:items-center gap-3 md:justify-between">
<div class="flex flex-col md:flex-row gap-2">
<button type="submit" class="btn btn-primary">Filtrar</button>
<button type="submit" class="btn btn-secondary">Filtrar</button>
<a href="/admin/reportes?vista=historial" class="btn btn-ghost">Limpiar</a>
</div>
<a
class="btn btn-secondary w-full md:w-auto"
class="btn btn-primary w-full md:w-auto"
href={exportHref}
download={exportFilename}
>
@@ -210,15 +220,14 @@ const exportFilename = `reporte-${today}.csv`;
{hitLimit && (
<div
role="status"
class="mb-4 rounded-lg p-3 text-sm"
style="background: color-mix(in oklab, var(--color-secondary) 12%, transparent); border: 1px solid color-mix(in oklab, var(--color-secondary) 30%, transparent);"
class="mb-4 rounded-[8px] p-4 text-sm bg-linen border border-hairline-gray text-graphite"
>
Se muestran los {HARD_LIMIT} más recientes — refina los filtros o exporta a CSV.
</div>
)}
{filas.length === 0 ? (
<div class="card text-center">
<div class="card text-center py-10">
<p class="opacity-75">Sin resultados para los filtros seleccionados.</p>
<p class="text-sm opacity-60 mt-1">Ajusta los filtros o exporta todo el historial.</p>
</div>
@@ -228,37 +237,39 @@ const exportFilename = `reporte-${today}.csv`;
<div class="card p-0 overflow-hidden hidden md:block">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
<thead class="text-left bg-linen text-graphite border-b border-hairline-gray">
<tr>
<th class="p-3 font-medium">Fecha solicitud</th>
<th class="p-3 font-medium">Alumno</th>
<th class="p-3 font-medium">Maestro responsable</th>
<th class="p-3 font-medium">Material</th>
<th class="p-3 font-medium">Estado</th>
<th class="p-3 font-medium">Fecha devolución</th>
<th class="p-4 font-medium">Fecha solicitud</th>
<th class="p-4 font-medium">Alumno</th>
<th class="p-4 font-medium">Maestro responsable</th>
<th class="p-4 font-medium">Material</th>
<th class="p-4 font-medium">Estado</th>
<th class="p-4 font-medium">Fecha devolución</th>
</tr>
</thead>
<tbody>
{filas.map((r: any) => (
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);">
<td class="p-3 opacity-90 whitespace-nowrap">{fmt(r.fecha_solicitud)}</td>
<td class="p-3">
<div class="font-medium">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</div>
{r.alumno?.matricula && <div class="text-xs opacity-70">{r.alumno.matricula}</div>}
<tr class="border-b border-hairline-gray hover:bg-linen/50 transition-colors">
<td class="p-4 opacity-90 whitespace-nowrap tabular-nums">{fmt(r.fecha_solicitud)}</td>
<td class="p-4">
<div class="font-medium text-graphite">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</div>
{r.alumno?.matricula && <div class="text-xs opacity-70 tabular-nums">{r.alumno.matricula}</div>}
</td>
<td class="p-3 opacity-90">{r.maestro_responsable ?? '—'}</td>
<td class="p-3">
<td class="p-4 opacity-90">{r.maestro_responsable ?? '—'}</td>
<td class="p-4">
{(r.items ?? []).map((i: any) => (
<div>
<span style="font-variant-numeric: tabular-nums;">{i.cantidad}×</span> {i.material?.nombre ?? '—'}
<span class="tabular-nums">{i.cantidad}×</span> {i.material?.nombre ?? '—'}
{i.material?.numero_inventario && (
<span class="text-xs opacity-70"> ({i.material.numero_inventario})</span>
)}
</div>
))}
</td>
<td class="p-3">{estadoLabel[r.estado] ?? r.estado}</td>
<td class="p-3 opacity-90 whitespace-nowrap">
<td class="p-4">
<span class={`chip chip-${r.estado}`}>{estadoLabel[r.estado] ?? r.estado}</span>
</td>
<td class="p-4 opacity-90 whitespace-nowrap tabular-nums">
{fmt(r.fecha_devolucion_real ?? r.fecha_devolucion_estimada)}
{!r.fecha_devolucion_real && r.fecha_devolucion_estimada && (
<span class="text-xs opacity-60"> (est.)</span>
@@ -268,9 +279,9 @@ const exportFilename = `reporte-${today}.csv`;
))}
</tbody>
<tfoot>
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 15%, transparent); background: color-mix(in oklab, var(--color-ink) 3%, transparent);">
<td class="p-3 text-sm font-medium" colspan="6">
Total: <span style="font-variant-numeric: tabular-nums;">{total}</span> {total === 1 ? 'préstamo' : 'préstamos'}
<tr class="bg-linen text-graphite border-t border-hairline-gray">
<td class="p-4 text-sm font-medium" colspan="6">
Total: <span class="tabular-nums font-medium">{total}</span> {total === 1 ? 'préstamo' : 'préstamos'}
</td>
</tr>
</tfoot>
@@ -283,29 +294,37 @@ const exportFilename = `reporte-${today}.csv`;
{filas.map((r: any) => (
<li class="card">
<div class="flex items-baseline justify-between gap-3">
<span class="text-xs opacity-70">{fmt(r.fecha_solicitud)}</span>
<span class="text-xs font-medium">{estadoLabel[r.estado] ?? r.estado}</span>
<span class="text-xs opacity-70 tabular-nums">{fmt(r.fecha_solicitud)}</span>
<span class={`chip chip-${r.estado}`}>{estadoLabel[r.estado] ?? r.estado}</span>
</div>
<div class="mt-1 space-y-0.5">
<div class="mt-2 space-y-0.5">
{(r.items ?? []).map((i: any) => (
<div class="font-medium">
<span style="font-variant-numeric: tabular-nums;">{i.cantidad}×</span> {i.material?.nombre ?? '—'}
<div class="font-medium text-graphite">
<span class="tabular-nums">{i.cantidad}×</span> {i.material?.nombre ?? '—'}
{i.material?.numero_inventario && (
<span class="text-xs opacity-70"> ({i.material.numero_inventario})</span>
)}
</div>
))}
</div>
<div class="text-sm opacity-80">
<div class="text-sm opacity-80 mt-1">
{r.alumno?.nombre ?? r.alumno?.email ?? '—'}
{r.alumno?.matricula && <span class="opacity-60"> · {r.alumno.matricula}</span>}
{r.alumno?.matricula && <span class="opacity-60 tabular-nums"> · {r.alumno.matricula}</span>}
</div>
<div class="text-xs opacity-70">Maestro: {r.maestro_responsable ?? '—'}</div>
<div class="text-xs opacity-70 mt-1">Maestro: {r.maestro_responsable ?? '—'}</div>
<div class="mt-2 flex items-center justify-end text-sm">
<span class="opacity-80">
<span class="opacity-80 tabular-nums">
Dev.: {fmt(r.fecha_devolucion_real ?? r.fecha_devolucion_estimada)}
{!r.fecha_devolucion_real && r.fecha_devolucion_estimada && (
<span class="text-xs opacity-60"> (est.)</span>
)}
</span>
</div>
</li>
))}
<li class="text-center text-sm opacity-70 pt-1">Total: {total} {total === 1 ? 'préstamo' : 'préstamos'}</li>
<li class="text-center text-sm opacity-70 pt-1">
Total: <span class="tabular-nums font-medium">{total}</span> {total === 1 ? 'préstamo' : 'préstamos'}
</li>
</ul>
</>
)}
@@ -315,7 +334,7 @@ const exportFilename = `reporte-${today}.csv`;
{vista === 'vencidos' && (
<>
{vencidos.length === 0 ? (
<div class="card text-center">
<div class="card text-center py-10">
<p class="opacity-75">No hay préstamos vencidos. Todo al día.</p>
</div>
) : (
@@ -323,39 +342,38 @@ const exportFilename = `reporte-${today}.csv`;
<div class="card p-0 overflow-hidden hidden md:block">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
<thead class="text-left bg-linen text-graphite border-b border-hairline-gray">
<tr>
<th class="p-3 font-medium">Alumno</th>
<th class="p-3 font-medium">Material</th>
<th class="p-3 font-medium">Debía devolver el</th>
<th class="p-3 font-medium">Días de atraso</th>
<th class="p-4 font-medium">Alumno</th>
<th class="p-4 font-medium">Material</th>
<th class="p-4 font-medium">Debía devolver el</th>
<th class="p-4 font-medium">Días de atraso</th>
</tr>
</thead>
<tbody>
{vencidos.map((r: any) => {
const dias = diasAtraso(r.fecha_devolucion_estimada);
return (
<tr
class="border-t"
style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent); background: color-mix(in oklab, var(--color-danger) 8%, transparent);"
>
<td class="p-3">
<div class="font-medium">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</div>
{r.alumno?.matricula && <div class="text-xs opacity-70">{r.alumno.matricula}</div>}
<tr class="border-b border-hairline-gray hover:bg-linen/50 transition-colors">
<td class="p-4">
<div class="font-medium text-graphite">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</div>
{r.alumno?.matricula && <div class="text-xs opacity-70 tabular-nums">{r.alumno.matricula}</div>}
</td>
<td class="p-3">
<td class="p-4">
{(r.items ?? []).map((i: any) => (
<div>
<span style="font-variant-numeric: tabular-nums;">{i.cantidad}×</span> {i.material?.nombre ?? '—'}
<span class="tabular-nums">{i.cantidad}×</span> {i.material?.nombre ?? '—'}
{i.material?.numero_inventario && (
<span class="text-xs opacity-70"> ({i.material.numero_inventario})</span>
)}
</div>
))}
</td>
<td class="p-3 whitespace-nowrap">{fmt(r.fecha_devolucion_estimada)}</td>
<td class="p-3 font-medium" style={`font-variant-numeric: tabular-nums; color: var(--color-danger-text);`}>
{dias} {dias === 1 ? 'día' : 'días'}
<td class="p-4 whitespace-nowrap tabular-nums">{fmt(r.fecha_devolucion_estimada)}</td>
<td class="p-4">
<span class="chip chip-vencido tabular-nums">
{dias} {dias === 1 ? 'día' : 'días'}
</span>
</td>
</tr>
);
@@ -369,23 +387,30 @@ const exportFilename = `reporte-${today}.csv`;
{vencidos.map((r: any) => {
const dias = diasAtraso(r.fecha_devolucion_estimada);
return (
<li class="card" style="background: color-mix(in oklab, var(--color-danger) 8%, transparent);">
<div class="space-y-0.5">
<li class="card">
<div class="flex items-baseline justify-between gap-3">
<span class="text-xs opacity-70">Atraso</span>
<span class="chip chip-vencido tabular-nums">
{dias} {dias === 1 ? 'día' : 'días'}
</span>
</div>
<div class="mt-2 space-y-0.5">
{(r.items ?? []).map((i: any) => (
<div class="font-medium">
<span style="font-variant-numeric: tabular-nums;">{i.cantidad}×</span> {i.material?.nombre ?? '—'}
<div class="font-medium text-graphite">
<span class="tabular-nums">{i.cantidad}×</span> {i.material?.nombre ?? '—'}
{i.material?.numero_inventario && (
<span class="text-xs opacity-70"> ({i.material.numero_inventario})</span>
)}
</div>
))}
</div>
<div class="text-sm opacity-80">
<div class="text-sm opacity-80 mt-1">
{r.alumno?.nombre ?? r.alumno?.email ?? '—'}
{r.alumno?.matricula && <span class="opacity-60"> · {r.alumno.matricula}</span>}
{r.alumno?.matricula && <span class="opacity-60 tabular-nums"> · {r.alumno.matricula}</span>}
</div>
<div class="mt-2 flex items-center justify-between text-sm">
<span>Debía devolver el {fmt(r.fecha_devolucion_estimada)}</span>
<span class="font-medium" style="font-variant-numeric: tabular-nums; color: var(--color-danger-text);">
{dias} {dias === 1 ? 'día' : 'días'}
</span>
<span class="opacity-75">Debía devolver el</span>
<span class="tabular-nums font-medium">{fmt(r.fecha_devolucion_estimada)}</span>
</div>
</li>
);
@@ -397,23 +422,3 @@ const exportFilename = `reporte-${today}.csv`;
)}
</div>
</AppLayout>
<style>
@reference "tailwindcss";
.tab {
@apply inline-block px-4 py-2.5 text-sm font-medium rounded-t-lg
transition-[background-color,color,border-color] duration-150
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--color-primary)];
color: color-mix(in oklab, var(--color-ink) 70%, transparent);
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.tab:hover {
color: var(--color-ink);
background: color-mix(in oklab, var(--color-ink) 4%, transparent);
}
.tab-active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
</style>
+41 -45
View File
@@ -32,12 +32,11 @@ const tabs = [
---
<AppLayout title="Préstamos activos — LabPréstamos">
<div class="max-w-6xl">
<header class="mb-4">
<header class="mb-6">
<div class="flex items-center gap-3 flex-wrap">
<h1 class="text-2xl md:text-3xl font-semibold">En préstamo</h1>
<h1 class="text-2xl md:text-3xl font-light">En préstamo</h1>
<span
class="inline-flex items-center rounded-[2px] px-2.5 py-0.5 text-sm font-semibold"
style="background: var(--color-positive); color: var(--color-ink); border: 1.5px solid var(--color-ink); font-variant-numeric: tabular-nums;"
class="badge"
aria-label={`${filas.length} préstamos activos`}
>
{filas.length}
@@ -45,14 +44,16 @@ const tabs = [
</div>
</header>
<nav aria-label="Vistas de solicitudes" class="mb-6 border-b flex gap-1 overflow-x-auto" style="border-color: color-mix(in oklab, var(--color-ink) 12%, transparent);">
<nav aria-label="Vistas de solicitudes" class="mb-6 border-b border-hairline-gray flex gap-2 overflow-x-auto">
{tabs.map((t) => {
const active = path === t.href;
return (
<a
href={t.href}
class:list={['px-4 py-2 text-sm font-medium whitespace-nowrap border-b-2 transition-colors -mb-px', active ? 'text-[color:var(--color-primary)]' : 'opacity-70 hover:opacity-100 border-transparent']}
style={active ? 'border-color: var(--color-primary);' : ''}
class:list={[
'px-4 py-2 text-sm font-medium whitespace-nowrap transition-colors border-b -mb-px',
active ? 'border-verde-institucional text-verde-institucional' : 'border-transparent text-graphite/70 hover:text-verde-institucional'
]}
aria-current={active ? 'page' : undefined}
>
{t.label}
@@ -61,24 +62,25 @@ const tabs = [
})}
</nav>
{error && (
<div role="alert" class="mb-4 rounded-lg p-3 text-sm" style="background: color-mix(in oklab, var(--color-danger) 20%, white); color: var(--color-ink); border: 1.5px solid var(--color-danger);">
Error cargando préstamos: {error.message}
{error ? (
<div role="alert" class="card text-center py-10">
<p class="font-medium text-graphite">No se pudo cargar la lista.</p>
<p class="text-sm text-graphite/70 mt-1">Recarga la página o contacta a soporte si persiste.</p>
</div>
)}
{filas.length === 0 ? (
) : filas.length === 0 ? (
<div class="card text-center py-12">
<p class="font-medium">No hay préstamos activos.</p>
<p class="font-medium text-graphite">No hay préstamos activos.</p>
<p class="text-sm text-graphite/70 mt-1">Todos los materiales están devueltos.</p>
</div>
) : (
<>
<div class="card p-0 overflow-hidden hidden md:block">
<table class="w-full text-sm">
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
<thead class="text-left bg-linen text-graphite border-b border-hairline-gray">
<tr>
<th class="p-3 font-medium">Alumno</th>
<th class="p-3 font-medium">Material</th>
<th class="p-3 font-medium">Estado</th>
<th class="p-3 font-medium">Aprobado</th>
<th class="p-3 font-medium">Devolver antes de</th>
<th class="p-3 font-medium text-right">Acciones</th>
@@ -88,46 +90,40 @@ const tabs = [
{filas.map((r: any) => {
const vencido = isVencido(r.fecha_devolucion_estimada);
return (
<tr
class="border-t align-top"
style={`border-color: color-mix(in oklab, var(--color-ink) 10%, transparent); ${vencido ? 'background: color-mix(in oklab, var(--color-danger) 8%, transparent);' : ''}`}
>
<tr class="border-b border-hairline-gray last:border-b-0 align-top">
<td class="p-3">
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-xs opacity-70 mt-1 break-words">Maestro: {r.maestro_responsable}</div>
<div class="font-medium text-graphite">{r.alumno?.nombre ?? '—'}</div>
<div class="text-xs text-graphite/70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-xs text-graphite/70 mt-1 break-words">Maestro: {r.maestro_responsable}</div>
</td>
<td class="p-3">
<div class="flex flex-col gap-1.5">
{(r.items ?? []).map((i: any) => (
<div>
<div>{i.cantidad}× {i.material?.nombre ?? '—'}</div>
<div class="text-xs opacity-70">Inv. {i.material?.numero_inventario ?? '—'}</div>
<div class="text-xs text-graphite/70">Inv. {i.material?.numero_inventario ?? '—'}</div>
</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);">
<div class="text-xs mt-1.5 text-graphite/80 border-l border-hairline-gray pl-2">
<span class="opacity-60">Motivo:</span> <span class="break-words">{truncate(r.notas, 50)}</span>
</div>
)}
</td>
<td class="p-3 opacity-80">{r.fecha_aprobacion ? fmtFecha.format(new Date(r.fecha_aprobacion)) : '—'}</td>
<td class="p-3">
<span class:list={['chip', vencido ? 'chip-vencido' : 'chip-aprobado']}>
{vencido ? 'Vencido' : 'En préstamo'}
</span>
</td>
<td class="p-3 text-graphite/80">{r.fecha_aprobacion ? fmtFecha.format(new Date(r.fecha_aprobacion)) : '—'}</td>
<td class="p-3 text-graphite/80">
<span style="font-variant-numeric: tabular-nums;">
{r.fecha_devolucion_estimada ? fmtFecha.format(new Date(r.fecha_devolucion_estimada + 'T00:00:00')) : '—'}
</span>
{vencido && (
<span
class="ml-2 inline-flex items-center rounded-[2px] px-2 py-0.5 text-xs font-semibold"
style="background: var(--color-danger); color: var(--color-ink); border: 1.5px solid var(--color-ink);"
>
Vencido
</span>
)}
</td>
<td class="p-3">
<div class="flex justify-end gap-2 flex-wrap">
<div class="flex items-center justify-end gap-2 flex-wrap">
<VerDetalles client:load prestamoId={r.id} />
<MarcarDevuelto client:load prestamoId={r.id} resumenMateriales={resumenMateriales(r.items)} />
</div>
@@ -143,30 +139,30 @@ const tabs = [
{filas.map((r: any) => {
const vencido = isVencido(r.fecha_devolucion_estimada);
return (
<li class="card" style={vencido ? 'background: color-mix(in oklab, var(--color-danger) 8%, white);' : ''}>
<li class="card">
<div class="flex items-baseline justify-between gap-2">
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
{vencido && (
<span class="inline-flex rounded-[2px] px-2 py-0.5 text-xs font-semibold" style="background: var(--color-danger); color: var(--color-ink); border: 1.5px solid var(--color-ink);">Vencido</span>
)}
<div class="font-medium text-graphite">{r.alumno?.nombre ?? '—'}</div>
<span class:list={['chip shrink-0', vencido ? 'chip-vencido' : 'chip-aprobado']}>
{vencido ? 'Vencido' : 'En préstamo'}
</span>
</div>
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-xs opacity-70 mb-2 break-words">Maestro: {r.maestro_responsable}</div>
<div class="text-xs text-graphite/70 mt-0.5">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-xs text-graphite/70 mb-2 break-words">Maestro: {r.maestro_responsable}</div>
<div class="flex flex-col gap-1.5">
{(r.items ?? []).map((i: any) => (
<div class="text-sm">
<strong style="font-variant-numeric: tabular-nums;">{i.cantidad}×</strong> {i.material?.nombre ?? '—'}
<span class="opacity-70"> · Inv. {i.material?.numero_inventario ?? '—'}</span>
<span class="text-graphite/70"> · Inv. {i.material?.numero_inventario ?? '—'}</span>
</div>
))}
</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-xs text-graphite/70 mt-2">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);">
<p class="text-sm mt-2 text-graphite/80 border-l border-hairline-gray pl-2">
<span class="opacity-60">Motivo:</span> <span class="break-words">{truncate(r.notas, 50)}</span>
</p>
)}
<div class="mt-3 flex gap-2 flex-wrap">
<div class="mt-3 pt-3 border-t border-hairline-gray flex items-center gap-2 flex-wrap">
<VerDetalles client:load prestamoId={r.id} />
<MarcarDevuelto client:load prestamoId={r.id} resumenMateriales={resumenMateriales(r.items)} />
</div>
+46 -32
View File
@@ -34,15 +34,20 @@ const estadoLabel: Record<string, string> = {
rechazado: 'Rechazado',
vencido: 'Vencido',
};
const chipPorEstado: Record<string, string> = {
devuelto: 'chip-devuelto',
rechazado: 'chip-rechazado',
vencido: 'chip-vencido',
};
---
<AppLayout title="Historial de solicitudes — LabPréstamos">
<div class="max-w-6xl">
<header class="mb-4">
<header class="mb-6">
<div class="flex items-center gap-3 flex-wrap">
<h1 class="text-2xl md:text-3xl font-semibold">Historial</h1>
<h1 class="text-2xl md:text-3xl font-light">Historial</h1>
<span
class="inline-flex items-center rounded-[2px] px-2.5 py-0.5 text-sm font-semibold"
style="background: var(--color-chalk); color: var(--color-ink); border: 1.5px solid var(--color-ink); font-variant-numeric: tabular-nums;"
class="badge"
aria-label={`${filas.length} registros`}
>
{filas.length}
@@ -50,14 +55,16 @@ const estadoLabel: Record<string, string> = {
</div>
</header>
<nav aria-label="Vistas de solicitudes" class="mb-6 border-b flex gap-1 overflow-x-auto" style="border-color: color-mix(in oklab, var(--color-ink) 12%, transparent);">
<nav aria-label="Vistas de solicitudes" class="mb-6 border-b border-hairline-gray flex gap-2 overflow-x-auto">
{tabs.map((t) => {
const active = path === t.href;
return (
<a
href={t.href}
class:list={['px-4 py-2 text-sm font-medium whitespace-nowrap border-b-2 transition-colors -mb-px', active ? 'text-[color:var(--color-primary)]' : 'opacity-70 hover:opacity-100 border-transparent']}
style={active ? 'border-color: var(--color-primary);' : ''}
class:list={[
'px-4 py-2 text-sm font-medium whitespace-nowrap transition-colors border-b -mb-px',
active ? 'border-verde-institucional text-verde-institucional' : 'border-transparent text-graphite/70 hover:text-verde-institucional'
]}
aria-current={active ? 'page' : undefined}
>
{t.label}
@@ -66,7 +73,7 @@ const estadoLabel: Record<string, string> = {
})}
</nav>
<form method="get" class="mb-4 flex items-end gap-3 flex-wrap">
<form method="get" class="mb-6 flex items-end gap-3 flex-wrap">
<div class="flex-1 min-w-[200px] max-w-xs">
<label class="label" for="estado">Filtrar por estado</label>
<select
@@ -86,21 +93,20 @@ const estadoLabel: Record<string, string> = {
</noscript>
</form>
{error && (
<div role="alert" class="mb-4 rounded-lg p-3 text-sm" style="background: color-mix(in oklab, var(--color-danger) 20%, white); color: var(--color-ink); border: 1.5px solid var(--color-danger);">
Error cargando historial: {error.message}
{error ? (
<div role="alert" class="card text-center py-10">
<p class="font-medium text-graphite">No se pudo cargar la lista.</p>
<p class="text-sm text-graphite/70 mt-1">Recarga la página o contacta a soporte si persiste.</p>
</div>
)}
{filas.length === 0 ? (
) : filas.length === 0 ? (
<div class="card text-center py-12">
<p class="font-medium">No hay registros para este filtro.</p>
<p class="font-medium text-graphite">No hay registros para este filtro.</p>
</div>
) : (
<>
<div class="card p-0 overflow-hidden hidden md:block">
<table class="w-full text-sm">
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
<thead class="text-left bg-linen text-graphite border-b border-hairline-gray">
<tr>
<th class="p-3 font-medium">Alumno</th>
<th class="p-3 font-medium">Material</th>
@@ -111,25 +117,29 @@ const estadoLabel: Record<string, string> = {
</thead>
<tbody>
{filas.map((r: any) => (
<tr class="border-t align-top" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);">
<tr class="border-b border-hairline-gray last:border-b-0 align-top">
<td class="p-3">
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-xs opacity-70 mt-1 break-words">Maestro: {r.maestro_responsable}</div>
<div class="font-medium text-graphite">{r.alumno?.nombre ?? '—'}</div>
<div class="text-xs text-graphite/70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-xs text-graphite/70 mt-1 break-words">Maestro: {r.maestro_responsable}</div>
</td>
<td class="p-3">
<div class="flex flex-col gap-1.5">
{(r.items ?? []).map((i: any) => (
<div>
<div>{i.cantidad}× {i.material?.nombre ?? '—'}</div>
<div class="text-xs opacity-70">Inv. {i.material?.numero_inventario ?? '—'}</div>
<div class="text-xs text-graphite/70">Inv. {i.material?.numero_inventario ?? '—'}</div>
</div>
))}
</div>
</td>
<td class="p-3">{estadoLabel[r.estado] ?? r.estado}</td>
<td class="p-3 opacity-80">{fmtFecha.format(new Date(r.fecha_solicitud))}</td>
<td class="p-3 opacity-80">{r.fecha_devolucion_real ? fmtFecha.format(new Date(r.fecha_devolucion_real)) : '—'}</td>
<td class="p-3">
<span class:list={['chip', chipPorEstado[r.estado] ?? 'chip-devuelto']}>
{estadoLabel[r.estado] ?? r.estado}
</span>
</td>
<td class="p-3 text-graphite/80">{fmtFecha.format(new Date(r.fecha_solicitud))}</td>
<td class="p-3 text-graphite/80">{r.fecha_devolucion_real ? fmtFecha.format(new Date(r.fecha_devolucion_real)) : '—'}</td>
</tr>
))}
</tbody>
@@ -140,18 +150,22 @@ const estadoLabel: Record<string, string> = {
{filas.map((r: any) => (
<li class="card">
<div class="flex items-baseline justify-between gap-2">
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
<span class="text-xs rounded-[2px] px-2 py-0.5" style="background: color-mix(in oklab, var(--color-ink) 10%, transparent);">{estadoLabel[r.estado] ?? r.estado}</span>
<div class="font-medium text-graphite">{r.alumno?.nombre ?? '—'}</div>
<span class:list={['chip shrink-0', chipPorEstado[r.estado] ?? 'chip-devuelto']}>
{estadoLabel[r.estado] ?? r.estado}
</span>
</div>
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-xs opacity-70 break-words">Maestro: {r.maestro_responsable}</div>
<div class="flex flex-col gap-1 mt-2">
<div class="text-xs text-graphite/70 mt-0.5">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-xs text-graphite/70 mb-2 break-words">Maestro: {r.maestro_responsable}</div>
<div class="flex flex-col gap-1.5">
{(r.items ?? []).map((i: any) => (
<div class="text-sm">{i.cantidad}× {i.material?.nombre ?? '—'}</div>
<div class="text-sm">
<strong style="font-variant-numeric: tabular-nums;">{i.cantidad}×</strong> {i.material?.nombre ?? '—'}
</div>
))}
</div>
<div class="text-xs opacity-70 mt-2">Solicitado {fmtFecha.format(new Date(r.fecha_solicitud))}</div>
{r.fecha_devolucion_real && <div class="text-xs opacity-70">Devuelto {fmtFecha.format(new Date(r.fecha_devolucion_real))}</div>}
<div class="text-xs text-graphite/60 mt-2">Solicitado {fmtFecha.format(new Date(r.fecha_solicitud))}</div>
{r.fecha_devolucion_real && <div class="text-xs text-graphite/60 mt-0.5">Devuelto {fmtFecha.format(new Date(r.fecha_devolucion_real))}</div>}
</li>
))}
</ul>
+42 -32
View File
@@ -1,6 +1,7 @@
---
import AppLayout from '@/layouts/AppLayout.astro';
import AccionesSolicitud from '@/components/admin/solicitudes/AccionesSolicitud.tsx';
import VerDetalles from '@/components/admin/solicitudes/VerDetalles.tsx';
import { truncate } from '@/lib/text';
const supabase = Astro.locals.supabase;
@@ -23,12 +24,11 @@ const tabs = [
---
<AppLayout title="Solicitudes pendientes — LabPréstamos">
<div class="max-w-6xl">
<header class="mb-4">
<header class="mb-6">
<div class="flex items-center gap-3 flex-wrap">
<h1 class="text-2xl md:text-3xl font-semibold">Solicitudes pendientes</h1>
<h1 class="text-2xl md:text-3xl font-light">Solicitudes pendientes</h1>
<span
class="inline-flex items-center rounded-[2px] px-2.5 py-0.5 text-sm font-semibold"
style="background: var(--color-primary); color: white; border: 1.5px solid var(--color-ink); font-variant-numeric: tabular-nums;"
class="badge"
aria-label={`${filas.length} pendientes`}
>
{filas.length}
@@ -36,14 +36,16 @@ const tabs = [
</div>
</header>
<nav aria-label="Vistas de solicitudes" class="mb-6 border-b flex gap-1 overflow-x-auto" style="border-color: color-mix(in oklab, var(--color-ink) 12%, transparent);">
<nav aria-label="Vistas de solicitudes" class="mb-6 border-b border-hairline-gray flex gap-2 overflow-x-auto">
{tabs.map((t) => {
const active = path === t.href || (t.href === '/admin/solicitudes' && path === '/admin/solicitudes/');
return (
<a
href={t.href}
class:list={['px-4 py-2 text-sm font-medium whitespace-nowrap border-b-2 transition-colors -mb-px', active ? 'text-[color:var(--color-primary)]' : 'opacity-70 hover:opacity-100 border-transparent']}
style={active ? 'border-color: var(--color-primary);' : ''}
class:list={[
'px-4 py-2 text-sm font-medium whitespace-nowrap transition-colors border-b -mb-px',
active ? 'border-verde-institucional text-verde-institucional' : 'border-transparent text-graphite/70 hover:text-verde-institucional'
]}
aria-current={active ? 'page' : undefined}
>
{t.label}
@@ -52,61 +54,65 @@ const tabs = [
})}
</nav>
{error && (
<div role="alert" class="mb-4 rounded-lg p-3 text-sm" style="background: color-mix(in oklab, var(--color-danger) 20%, white); color: var(--color-ink); border: 1.5px solid var(--color-danger);">
Error cargando solicitudes: {error.message}
{error ? (
<div role="alert" class="card text-center py-10">
<p class="font-medium text-graphite">No se pudo cargar la lista.</p>
<p class="text-sm text-graphite/70 mt-1">Recarga la página o contacta a soporte si persiste.</p>
</div>
)}
{filas.length === 0 ? (
) : filas.length === 0 ? (
<div class="card text-center py-12">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="mx-auto mb-3" aria-hidden="true">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="mx-auto mb-3 text-verde-institucional" aria-hidden="true">
<path d="M4.5 12.75l6 6 9-13.5" />
</svg>
<p class="font-medium">No hay solicitudes por revisar.</p>
<p class="text-sm opacity-70 mt-1">Todo al día.</p>
<p class="font-medium text-graphite">No hay solicitudes por revisar.</p>
<p class="text-sm text-graphite/70 mt-1">Todo al día.</p>
</div>
) : (
<>
{/* Desktop: tabla */}
<div class="card p-0 overflow-hidden hidden md:block">
<table class="w-full text-sm">
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
<thead class="text-left bg-linen text-graphite border-b border-hairline-gray">
<tr>
<th class="p-3 font-medium">Alumno</th>
<th class="p-3 font-medium">Material</th>
<th class="p-3 font-medium">Estado</th>
<th class="p-3 font-medium">Solicitado</th>
<th class="p-3 font-medium text-right">Acciones</th>
</tr>
</thead>
<tbody>
{filas.map((r: any) => (
<tr class="border-t align-top" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);">
<tr class="border-b border-hairline-gray last:border-b-0 align-top">
<td class="p-3">
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-xs opacity-70 mt-1 break-words">Maestro: {r.maestro_responsable}</div>
<div class="font-medium text-graphite">{r.alumno?.nombre ?? '—'}</div>
<div class="text-xs text-graphite/70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-xs text-graphite/70 mt-1 break-words">Maestro: {r.maestro_responsable}</div>
</td>
<td class="p-3">
<div class="flex flex-col gap-1.5">
{(r.items ?? []).map((i: any) => (
<div>
<div>{i.cantidad}× {i.material?.nombre ?? '—'}</div>
<div class="text-xs opacity-70">
<div class="text-xs text-graphite/70">
Inv. {i.material?.numero_inventario ?? '—'} · disp. <span style="font-variant-numeric: tabular-nums;">{i.material?.cantidad_disponible ?? 0}</span>
</div>
</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);">
<div class="text-xs mt-1.5 text-graphite/80 border-l border-hairline-gray pl-2">
<span class="opacity-60">Motivo:</span> <span class="break-words">{truncate(r.notas, 50)}</span>
</div>
)}
</td>
<td class="p-3 opacity-80">{fmtFecha.format(new Date(r.fecha_solicitud))}</td>
<td class="p-3">
<div class="flex justify-end">
<span class="chip chip-pendiente">Pendiente</span>
</td>
<td class="p-3 text-graphite/80">{fmtFecha.format(new Date(r.fecha_solicitud))}</td>
<td class="p-3">
<div class="flex items-center justify-end gap-2 flex-wrap">
<VerDetalles client:load prestamoId={r.id} />
<AccionesSolicitud client:load prestamo={r} />
</div>
</td>
@@ -121,25 +127,29 @@ const tabs = [
{filas.map((r: any) => (
<li class="card">
<div class="flex items-baseline justify-between gap-2">
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
<div class="text-xs opacity-70">{fmtFecha.format(new Date(r.fecha_solicitud))}</div>
<div class="font-medium text-graphite">{r.alumno?.nombre ?? '—'}</div>
<span class="chip chip-pendiente">Pendiente</span>
</div>
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-xs opacity-70 mb-2 break-words">Maestro: {r.maestro_responsable}</div>
<div class="text-xs text-graphite/70 mt-0.5">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-xs text-graphite/70 mb-2 break-words">Maestro: {r.maestro_responsable}</div>
<div class="flex flex-col gap-1.5">
{(r.items ?? []).map((i: any) => (
<div class="text-sm">
<strong style="font-variant-numeric: tabular-nums;">{i.cantidad}×</strong> {i.material?.nombre ?? '—'}
<span class="opacity-70"> · Inv. {i.material?.numero_inventario ?? '—'} · disp. {i.material?.cantidad_disponible ?? 0}</span>
<span class="text-graphite/70"> · Inv. {i.material?.numero_inventario ?? '—'} · disp. {i.material?.cantidad_disponible ?? 0}</span>
</div>
))}
</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);">
<p class="text-sm mt-2 text-graphite/80 border-l border-hairline-gray pl-2">
<span class="opacity-60">Motivo:</span> <span class="break-words">{truncate(r.notas, 50)}</span>
</p>
)}
<div class="mt-3">
<div class="text-xs text-graphite/60 mt-2">
Solicitado {fmtFecha.format(new Date(r.fecha_solicitud))}
</div>
<div class="mt-3 pt-3 border-t border-hairline-gray flex items-center gap-2 flex-wrap">
<VerDetalles client:load prestamoId={r.id} />
<AccionesSolicitud client:load prestamo={r} />
</div>
</li>
+30 -33
View File
@@ -53,13 +53,13 @@ const trunc = (s: string, n = 80) => (s.length > n ? s.slice(0, n - 1) + '…' :
<AppLayout title="Usuarios — Admin">
<div class="max-w-5xl">
<header class="mb-6">
<h1 class="text-2xl md:text-3xl font-semibold">Usuarios</h1>
<p class="text-sm opacity-70 mt-1">Perfiles de todos los usuarios. Banea a quien haga mal uso del sistema.</p>
<h1 class="text-3xl">Usuarios</h1>
<p class="text-sm text-graphite/70 mt-1">Perfiles de todos los usuarios. Banea a quien haga mal uso del sistema.</p>
</header>
{perr && (
<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);">
<div class="card mb-6" role="alert">
<p class="text-sm text-negro-institucional">
No se pudieron cargar los usuarios. Recarga la página.
</p>
</div>
@@ -67,8 +67,8 @@ const trunc = (s: string, n = 80) => (s.length > n ? s.slice(0, n - 1) + '…' :
{!perr && usuarios.length === 0 && (
<div class="card text-center">
<h2 class="text-lg font-semibold mb-1">Sin usuarios aún</h2>
<p class="text-sm opacity-70">Se listarán aquí en cuanto alguien inicie sesión.</p>
<h2 class="text-lg mb-1">Sin usuarios aún</h2>
<p class="text-sm text-graphite/70">Se listarán aquí en cuanto alguien inicie sesión.</p>
</div>
)}
@@ -77,43 +77,41 @@ const trunc = (s: string, n = 80) => (s.length > n ? s.slice(0, n - 1) + '…' :
{/* Desktop */}
<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);">
<thead class="text-left bg-linen border-b border-hairline-gray">
<tr>
<th class="px-4 py-3 font-medium">Nombre</th>
<th class="px-4 py-3 font-medium">Email</th>
<th class="px-4 py-3 font-medium">Rol</th>
<th class="px-4 py-3 font-medium">Estado</th>
<th class="px-4 py-3 font-medium text-right">Acciones</th>
<th class="px-4 py-3 font-medium text-graphite">Nombre</th>
<th class="px-4 py-3 font-medium text-graphite">Email</th>
<th class="px-4 py-3 font-medium text-graphite">Rol</th>
<th class="px-4 py-3 font-medium text-graphite">Estado</th>
<th class="px-4 py-3 font-medium text-graphite text-right">Acciones</th>
</tr>
</thead>
<tbody>
{usuarios.map((u) => {
const isSelf = u.id === meId;
return (
<tr class="border-t align-top" style="border-color: color-mix(in oklab, var(--color-ink) 8%, transparent);">
<tr class="border-b border-hairline-gray last:border-b-0 align-top">
<td class="px-4 py-3">
<div class="font-medium break-words">{u.nombre ?? '—'}</div>
{u.matricula && <div class="text-xs opacity-60 mt-0.5" style="font-variant-numeric: tabular-nums;">{u.matricula}</div>}
<div class="font-medium text-graphite break-words">{u.nombre ?? '—'}</div>
{u.matricula && <div class="text-xs text-graphite/60 mt-0.5 font-mono">{u.matricula}</div>}
</td>
<td class="px-4 py-3 break-all text-xs">{u.email}</td>
<td class="px-4 py-3 break-all text-xs text-graphite/80">{u.email}</td>
<td class="px-4 py-3">
<span
class="text-xs uppercase tracking-wide px-2 py-0.5 rounded-[2px]"
style={`border: 1.5px solid var(--color-ink); ${u.rol === 'admin' ? 'background: var(--color-secondary);' : ''}`}
class={`badge text-xs rounded-[8px] ${u.rol === 'admin' ? 'bg-linen text-graphite border-hairline-gray font-medium' : 'bg-bone-white text-graphite border-hairline-gray'}`}
>{rolLabel(u.rol)}</span>
</td>
<td class="px-4 py-3">
{u.baneo ? (
<div class="flex flex-col gap-1">
<div class="flex flex-col gap-1 items-start">
<span
class="text-xs uppercase tracking-wide px-2 py-0.5 rounded-[2px] w-fit"
style="background: var(--color-danger); color: var(--color-ink); border: 1.5px solid var(--color-ink);"
class="chip bg-negro-institucional text-bone-white rounded-[8px] text-xs px-2.5 py-0.5 font-medium border-0"
>Baneado</span>
<span class="text-xs opacity-70 break-words">{trunc(u.baneo.razon)}</span>
<span class="text-[11px] opacity-50">desde {fmtDate(u.baneo.banned_at)}</span>
<span class="text-xs text-graphite/70 break-words">{trunc(u.baneo.razon)}</span>
<span class="text-[11px] text-graphite/50">desde {fmtDate(u.baneo.banned_at)}</span>
</div>
) : (
<span class="text-xs uppercase tracking-wide" style="color: var(--color-primary);">Activo</span>
<span class="badge bg-bone-white text-verde-institucional border-verde-institucional text-xs font-medium rounded-[8px]">Activo</span>
)}
</td>
<td class="px-4 py-3">
@@ -141,29 +139,28 @@ const trunc = (s: string, n = 80) => (s.length > n ? s.slice(0, n - 1) + '…' :
{usuarios.map((u) => {
const isSelf = u.id === meId;
return (
<article class="card flex flex-col gap-3">
<article class="card flex flex-col gap-3 p-4">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<h3 class="font-semibold break-words">{u.nombre ?? '—'}</h3>
<p class="text-xs opacity-70 break-all">{u.email}</p>
<p class="text-xs opacity-60 mt-1">
<h3 class="font-medium text-graphite break-words">{u.nombre ?? '—'}</h3>
<p class="text-xs text-graphite/70 break-all">{u.email}</p>
<p class="text-xs text-graphite/60 mt-1">
{rolLabel(u.rol)}{u.matricula ? ` · ${u.matricula}` : ''}
</p>
</div>
{u.baneo ? (
<span
class="text-xs uppercase tracking-wide px-2 py-0.5 rounded-[2px] shrink-0"
style="background: var(--color-danger); color: var(--color-ink); border: 1.5px solid var(--color-ink);"
class="chip bg-negro-institucional text-bone-white rounded-[8px] text-xs px-2.5 py-0.5 font-medium shrink-0 border-0"
>Baneado</span>
) : (
<span class="text-xs uppercase tracking-wide shrink-0" style="color: var(--color-primary);">Activo</span>
<span class="badge bg-bone-white text-verde-institucional border-verde-institucional text-xs font-medium shrink-0 rounded-[8px]">Activo</span>
)}
</div>
{u.baneo && (
<div class="text-xs opacity-80 break-words">
<div class="text-xs text-graphite/80 break-words">
<strong>Razón:</strong> {trunc(u.baneo.razon, 120)}
<div class="opacity-60 mt-1">desde {fmtDate(u.baneo.banned_at)}</div>
<div class="text-graphite/60 mt-1">desde {fmtDate(u.baneo.banned_at)}</div>
</div>
)}
+18 -31
View File
@@ -7,33 +7,15 @@ import BuscadorCatalogo from '@/components/alumno/BuscadorCatalogo.tsx';
const qInitial = Astro.url.searchParams.get('q') ?? '';
const supabase = Astro.locals.supabase;
const userId = Astro.locals.user?.id;
const rol = Astro.locals.profile?.rol ?? 'alumno';
const profile = Astro.locals.profile;
const rol = profile?.rol ?? 'alumno';
const esDocente = rol === 'docente';
// Trae matricula + tutor_id directo (el middleware sólo hidrata columnas antiguas).
let matricula: string | null = null;
let tutorId: number | null = null;
if (userId) {
const { data: p } = await supabase
.from('profiles')
.select('matricula, tutor_id')
.eq('id', userId)
.maybeSingle();
matricula = p?.matricula ?? null;
tutorId = (p as { tutor_id?: number | null } | null)?.tutor_id ?? null;
}
// El middleware ya trae matricula y tutor_id en Astro.locals.profile — no requery.
const matricula = profile?.matricula ?? null;
const tutorId = profile?.tutor_id ?? null;
let tutorNombre: string | null = null;
if (tutorId) {
const { data } = await supabase.from('maestros').select('nombre').eq('id', tutorId).maybeSingle();
tutorNombre = data?.nombre ?? null;
}
// Docente: solo requiere matrícula (número de empleado). Alumno: matrícula + tutor.
const perfilCompleto = esDocente ? !!matricula : !!(matricula && tutorId);
// Lista completa de maestros activos para el combobox del checkout (solo alumno lo usa).
// Una sola query para maestros activos; el nombre del tutor sale del mismo array.
type MaestroOpt = { id: number; nombre: string };
let maestros: MaestroOpt[] = [];
if (!esDocente) {
@@ -44,6 +26,10 @@ if (!esDocente) {
.order('nombre');
maestros = (ms ?? []) as MaestroOpt[];
}
const tutorNombre = tutorId ? (maestros.find((m) => m.id === tutorId)?.nombre ?? null) : null;
// Docente: solo requiere matrícula (número de empleado). Alumno: matrícula + tutor.
const perfilCompleto = esDocente ? !!matricula : !!(matricula && tutorId);
type Material = {
id: number;
@@ -79,13 +65,14 @@ const categoriasFiltro = grupoList
<AppLayout title="Catálogo — LabPréstamos">
<div class="max-w-6xl">
<header class="mb-6">
<h1 class="text-2xl md:text-3xl font-semibold">Catálogo</h1>
<p class="text-sm opacity-70 mt-1">Material disponible en el Laboratorio de Sistemas.</p>
<span class="eyebrow mb-1">Inventario disponible</span>
<h1 class="text-3xl md:text-4xl">Catálogo</h1>
<p class="text-sm text-graphite/70 mt-1">Material disponible en el Laboratorio de Sistemas.</p>
</header>
{error && (
<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);">
<div class="card mb-6 border border-hairline-gray" role="alert">
<p class="text-sm text-negro-institucional">
No se pudo cargar el catálogo. Recarga la página o intenta más tarde.
</p>
</div>
@@ -93,8 +80,8 @@ const categoriasFiltro = grupoList
{materiales.length === 0 && !error && (
<div class="card text-center">
<h2 class="text-lg font-semibold mb-1">Aún no hay material publicado</h2>
<p class="text-sm opacity-70">Vuelve pronto o contacta al encargado del laboratorio.</p>
<h2 class="text-xl mb-1">Aún no hay material publicado</h2>
<p class="text-sm text-graphite/70">Vuelve pronto o contacta al encargado del laboratorio.</p>
</div>
)}
@@ -107,7 +94,7 @@ const categoriasFiltro = grupoList
)}
</div>
<p id="catalogo-empty-filter" hidden class="text-sm opacity-70 mb-4">
<p id="catalogo-empty-filter" hidden class="text-sm text-graphite/70 mb-4">
Ningún material en esta categoría. Prueba con otra.
</p>
+106 -101
View File
@@ -48,37 +48,35 @@ const estadoLabel: Record<Estado, string> = {
vencido: 'Vencido',
};
const estadoColor: Record<Estado, string> = {
pendiente: 'var(--color-ink)',
aprobado: 'var(--color-positive)',
activo: 'var(--color-positive)',
devuelto: 'var(--color-ink)',
rechazado: 'var(--color-danger)',
vencido: 'var(--color-danger)',
const chipClass: Record<Estado, string> = {
pendiente: 'chip chip-pendiente',
aprobado: 'chip chip-aprobado',
activo: 'chip chip-activo',
devuelto: 'chip chip-devuelto',
rechazado: 'chip chip-rechazado',
vencido: 'chip chip-vencido',
};
const badgeStyle = (e: Estado) =>
`background: color-mix(in oklab, ${estadoColor[e]} 18%, white); color: var(--color-ink); border: 1.5px solid ${estadoColor[e]};`;
---
<AppLayout title="Mis préstamos — LabPréstamos">
<div class="max-w-4xl">
<header class="mb-6">
<h1 class="text-2xl md:text-3xl font-semibold">Mis préstamos</h1>
<p class="text-sm opacity-70 mt-1">Estado de tus solicitudes actuales e historial.</p>
<span class="eyebrow mb-1">Mis solicitudes</span>
<h1 class="text-3xl md:text-4xl">Mis préstamos</h1>
<p class="text-sm text-graphite/70 mt-1">Estado de tus solicitudes actuales e historial.</p>
</header>
{error && (
<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);">
<div class="card mb-6 border border-hairline-gray p-6" role="alert">
<p class="text-sm text-negro-institucional">
No se pudieron cargar tus préstamos. Recarga la página o intenta más tarde.
</p>
</div>
)}
{!error && prestamos.length === 0 && (
<div class="card text-center">
<h2 class="text-lg font-semibold mb-1">Aún no has solicitado material</h2>
<p class="text-sm opacity-70 mb-4">Explora el catálogo y envía tu primera solicitud.</p>
<div class="card text-center p-8">
<h2 class="text-xl mb-1">Aún no has solicitado material</h2>
<p class="text-sm text-graphite/70 mb-4">Explora el catálogo y envía tu primera solicitud.</p>
<a href="/alumno/catalogo" class="btn btn-primary">Ir al catálogo</a>
</div>
)}
@@ -86,106 +84,117 @@ const badgeStyle = (e: Estado) =>
{prestamos.length > 0 && (
<div class="space-y-8">
<section>
<h2 class="text-lg font-semibold mb-3">Activos <span class="text-sm opacity-60" style="font-variant-numeric: tabular-nums;">({activos.length})</span></h2>
{activos.length === 0 ? (
<p class="text-sm opacity-70">Nada activo por ahora.</p>
) : (
<ul class="space-y-3">
{activos.map((p) => (
<li class="card">
<div class="flex items-start justify-between gap-3 flex-wrap">
<ul class="min-w-0 space-y-1">
{p.items.map((it) => (
<li class="leading-snug">
<span class="font-semibold" style="font-variant-numeric: tabular-nums;">{it.cantidad}×</span>{' '}
<span class="font-semibold">{it.material?.nombre ?? 'Material eliminado'}</span>
{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>
)}
</li>
))}
</ul>
<span class="text-xs font-medium px-2 py-1 rounded-[2px] whitespace-nowrap" style={badgeStyle(p.estado)}>
{estadoLabel[p.estado]}
</span>
</div>
<dl class="mt-3 grid grid-cols-2 gap-y-1 text-sm">
<dt class="opacity-60">Maestro responsable</dt>
<dd class="text-right truncate">{p.maestro_responsable}</dd>
<dt class="opacity-60">Solicitado</dt>
<dd class="text-right">{fmt(p.fecha_solicitud)}</dd>
{p.fecha_aprobacion && (
<>
<dt class="opacity-60">Aprobado</dt>
<dd class="text-right">{fmt(p.fecha_aprobacion)}</dd>
</>
)}
{p.fecha_devolucion_estimada && (
<>
<dt class="opacity-60">Devolver antes de</dt>
<dd class="text-right">{fmt(p.fecha_devolucion_estimada)}</dd>
</>
)}
</dl>
{p.notas && (
<p class="mt-3 text-sm opacity-75 border-l-2 pl-3" style="border-color: color-mix(in oklab, var(--color-ink) 15%, transparent);">
{p.notas}
</p>
)}
</li>
))}
</ul>
)}
</section>
<section>
<details open={historial.length <= 5}>
<summary class="text-lg font-semibold cursor-pointer list-none flex items-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<details open class="group">
<summary class="text-2xl cursor-pointer list-none flex items-center gap-2 select-none font-light">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="transition-transform duration-150 group-open:rotate-90">
<path d="M9 6l6 6-6 6" />
</svg>
Historial <span class="text-sm opacity-60" style="font-variant-numeric: tabular-nums;">({historial.length})</span>
<span>
Activos <span class="text-base text-graphite/60 font-sans tabular-nums font-normal">({activos.length})</span>
</span>
</summary>
{historial.length === 0 ? (
<p class="text-sm opacity-70 mt-3">Sin historial todavía.</p>
{activos.length === 0 ? (
<div class="card p-6 text-sm text-graphite/70 mt-4">Nada activo por ahora.</div>
) : (
<ul class="space-y-3 mt-3">
{historial.map((p) => (
<li class="card" style="opacity: 0.92;">
<ul class="space-y-4 mt-4">
{activos.map((p) => (
<li class="card p-6">
<div class="flex items-start justify-between gap-3 flex-wrap">
<ul class="min-w-0 space-y-1">
{p.items.map((it) => (
<li class="leading-snug">
<span class="font-semibold" style="font-variant-numeric: tabular-nums;">{it.cantidad}×</span>{' '}
<span class="font-semibold">{it.material?.nombre ?? 'Material eliminado'}</span>
<span class="font-medium text-graphite tabular-nums">{it.cantidad}×</span>{' '}
<span class="font-medium text-graphite">{it.material?.nombre ?? 'Material eliminado'}</span>
{it.material?.numero_inventario && (
<span class="text-xs opacity-60 font-mono ml-1.5">{it.material.numero_inventario}</span>
<span class="text-xs text-graphite/60 font-mono ml-1.5">{it.material.numero_inventario}</span>
)}
{it.material_unidad?.etiqueta && (
<div class="text-xs text-graphite/70">Unidad: {it.material_unidad.etiqueta}</div>
)}
{it.descripcion && (
<p class="text-xs opacity-60 ml-0.5">{it.descripcion}</p>
<p class="text-xs text-graphite/60 ml-0.5">{it.descripcion}</p>
)}
</li>
))}
</ul>
<span class="text-xs font-medium px-2 py-1 rounded-[2px] whitespace-nowrap" style={badgeStyle(p.estado)}>
<span class={chipClass[p.estado]}>
{estadoLabel[p.estado]}
</span>
</div>
<dl class="mt-3 grid grid-cols-2 gap-y-1 text-sm">
<dt class="opacity-60">Maestro responsable</dt>
<dd class="text-right truncate">{p.maestro_responsable}</dd>
<dt class="opacity-60">Solicitado</dt>
<dd class="text-right">{fmt(p.fecha_solicitud)}</dd>
<dl class="mt-4 pt-3 border-t border-hairline-gray grid grid-cols-2 gap-y-1.5 text-sm">
<dt class="text-graphite/60">Maestro responsable</dt>
<dd class="text-right truncate font-medium text-graphite">{p.maestro_responsable}</dd>
<dt class="text-graphite/60">Solicitado</dt>
<dd class="text-right text-graphite">{fmt(p.fecha_solicitud)}</dd>
{p.fecha_aprobacion && (
<>
<dt class="text-graphite/60">Aprobado</dt>
<dd class="text-right text-graphite">{fmt(p.fecha_aprobacion)}</dd>
</>
)}
{p.fecha_devolucion_estimada && (
<>
<dt class="text-graphite/60">Devolver antes de</dt>
<dd class="text-right text-graphite">{fmt(p.fecha_devolucion_estimada)}</dd>
</>
)}
</dl>
{p.notas && (
<p class="mt-3 text-sm text-graphite/80 border-l border-hairline-gray pl-3 italic bg-linen p-2 rounded-[8px]">
{p.notas}
</p>
)}
</li>
))}
</ul>
)}
</details>
</section>
<section>
<details open={historial.length <= 5} class="group">
<summary class="text-2xl cursor-pointer list-none flex items-center gap-2 select-none font-light">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="transition-transform duration-150 group-open:rotate-90">
<path d="M9 6l6 6-6 6" />
</svg>
<span>
Historial <span class="text-base text-graphite/60 font-sans tabular-nums font-normal">({historial.length})</span>
</span>
</summary>
{historial.length === 0 ? (
<p class="text-sm text-graphite/70 mt-3">Sin historial todavía.</p>
) : (
<ul class="space-y-4 mt-4">
{historial.map((p) => (
<li class="card p-6">
<div class="flex items-start justify-between gap-3 flex-wrap">
<ul class="min-w-0 space-y-1">
{p.items.map((it) => (
<li class="leading-snug">
<span class="font-medium text-graphite tabular-nums">{it.cantidad}×</span>{' '}
<span class="font-medium text-graphite">{it.material?.nombre ?? 'Material eliminado'}</span>
{it.material?.numero_inventario && (
<span class="text-xs text-graphite/60 font-mono ml-1.5">{it.material.numero_inventario}</span>
)}
{it.descripcion && (
<p class="text-xs text-graphite/60 ml-0.5">{it.descripcion}</p>
)}
</li>
))}
</ul>
<span class={chipClass[p.estado]}>
{estadoLabel[p.estado]}
</span>
</div>
<dl class="mt-4 pt-3 border-t border-hairline-gray grid grid-cols-2 gap-y-1.5 text-sm">
<dt class="text-graphite/60">Maestro responsable</dt>
<dd class="text-right truncate font-medium text-graphite">{p.maestro_responsable}</dd>
<dt class="text-graphite/60">Solicitado</dt>
<dd class="text-right text-graphite">{fmt(p.fecha_solicitud)}</dd>
{p.fecha_devolucion_real && (
<>
<dt class="opacity-60">Devuelto</dt>
<dd class="text-right">{fmt(p.fecha_devolucion_real)}</dd>
<dt class="text-graphite/60">Devuelto</dt>
<dd class="text-right text-graphite">{fmt(p.fecha_devolucion_real)}</dd>
</>
)}
</dl>
@@ -202,8 +211,4 @@ const badgeStyle = (e: Estado) =>
<style>
details > summary::-webkit-details-marker { display: none; }
details[open] > summary > svg { transform: rotate(90deg); }
details > summary > svg {
transition: transform 150ms ease-out;
}
</style>
+3 -1
View File
@@ -1,4 +1,5 @@
import type { APIRoute } from 'astro';
import { invalidateUserCaches } from '@/middleware';
export const prerender = false;
@@ -17,7 +18,7 @@ export const POST: APIRoute = async ({ locals, params }) => {
.update({ unbanned_at: new Date().toISOString(), unbanned_by: locals.user!.id })
.eq('id', id)
.is('unbanned_at', null)
.select('id');
.select('id, profile_id');
if (error) {
return Response.json({ error: 'No se pudo desbanear' }, { status: 500 });
@@ -26,5 +27,6 @@ export const POST: APIRoute = async ({ locals, params }) => {
return Response.json({ error: 'Baneo no encontrado o ya inactivo' }, { status: 404 });
}
if (data[0]?.profile_id) invalidateUserCaches(data[0].profile_id);
return Response.json({ ok: true });
};
+2
View File
@@ -1,4 +1,5 @@
import type { APIRoute } from 'astro';
import { invalidateUserCaches } from '@/middleware';
export const prerender = false;
@@ -63,5 +64,6 @@ export const POST: APIRoute = async ({ request, locals }) => {
return Response.json({ error: 'No se pudo crear el baneo' }, { status: 500 });
}
invalidateUserCaches(profile_id);
return Response.json({ id: data.id }, { status: 201 });
};
+4 -1
View File
@@ -1,7 +1,10 @@
import type { APIRoute } from 'astro';
import { serverClient } from '@/lib/supabase';
import { invalidateUserCaches } from '@/middleware';
export const POST: APIRoute = async ({ cookies, redirect }) => {
export const POST: APIRoute = async ({ cookies, redirect, locals }) => {
const uid = locals.user?.id;
await serverClient(cookies).auth.signOut();
if (uid) invalidateUserCaches(uid);
return redirect('/login');
};
+2
View File
@@ -1,4 +1,5 @@
import type { APIRoute } from 'astro';
import { invalidateUserCaches } from '@/middleware';
export const prerender = false;
@@ -80,5 +81,6 @@ export const PATCH: APIRoute = async ({ request, locals }) => {
return Response.json({ error: 'no se pudo actualizar' }, { status: 500 });
}
invalidateUserCaches(user.id);
return Response.json({ ok: true, profile: data });
};
+14 -14
View File
@@ -39,40 +39,40 @@ Astro.response.status = 403;
<main id="main" class="min-h-screen grid place-items-center p-4">
<div class="w-full max-w-lg">
<div class="text-center mb-6">
<div class="inline-grid place-items-center w-16 h-16 rounded-[2px] p-4 mb-4" style="background: var(--color-danger); border: 2px solid var(--color-ink); box-shadow: var(--shadow-hard);">
<BrandMark class="w-full h-full" style="color: var(--color-ink);" />
<div class="inline-grid place-items-center w-16 h-16 rounded-[12px] bg-linen text-verde-institucional p-4 mb-4" aria-hidden="true">
<BrandMark class="w-full h-full" />
</div>
<h1 class="text-2xl md:text-3xl font-semibold uppercase tracking-wide">Cuenta bloqueada</h1>
<p class="text-sm mt-2" style="color: var(--color-pencil);">Tu acceso al sistema de préstamos fue suspendido.</p>
<h1>Cuenta bloqueada</h1>
<p class="text-sm mt-2 text-graphite/70">Tu acceso al sistema de préstamos fue suspendido.</p>
</div>
<div class="card-raised">
<div class="card">
<dl class="flex flex-col gap-4 text-sm">
<div>
<dt class="text-xs uppercase tracking-wide mb-1" style="color: var(--color-pencil);">Motivo</dt>
<dd class="whitespace-pre-wrap break-words">{ban.razon}</dd>
<dt class="eyebrow mb-1">Motivo</dt>
<dd class="whitespace-pre-wrap break-words text-graphite font-medium">{ban.razon}</dd>
</div>
<div class="grid sm:grid-cols-2 gap-4">
<div>
<dt class="text-xs uppercase tracking-wide mb-1" style="color: var(--color-pencil);">Fecha del bloqueo</dt>
<dd>{bannedAtStr}</dd>
<dt class="eyebrow mb-1">Fecha del bloqueo</dt>
<dd class="text-graphite">{bannedAtStr}</dd>
</div>
{expiresStr && (
<div>
<dt class="text-xs uppercase tracking-wide mb-1" style="color: var(--color-pencil);">Expira</dt>
<dd>{expiresStr}</dd>
<dt class="eyebrow mb-1">Expira</dt>
<dd class="text-graphite">{expiresStr}</dd>
</div>
)}
{byName && (
<div>
<dt class="text-xs uppercase tracking-wide mb-1" style="color: var(--color-pencil);">Bloqueado por</dt>
<dd>{byName}</dd>
<dt class="eyebrow mb-1">Bloqueado por</dt>
<dd class="text-graphite">{byName}</dd>
</div>
)}
</div>
</dl>
<p class="text-sm mt-6" style="color: var(--color-pencil);">
<p class="text-sm mt-6 text-graphite/70">
Si crees que esto es un error, contacta al Laboratorio de Sistemas Computacionales.
</p>
+63 -38
View File
@@ -22,16 +22,40 @@ type Item = { cantidad: number; material: { nombre: string } | null };
type Actual = { id: number; estado: Estado; fecha_solicitud: string; items: Item[] } | null;
let actual: Actual = null;
let activosCount = 0;
let pendientesCount = 0;
let devueltosCount = 0;
if (user) {
const { data } = await supabase
.from('solicitudes')
.select('id, estado, fecha_solicitud, items:solicitud_items(cantidad, material:materiales(nombre))')
.eq('alumno_id', user.id)
.in('estado', ['pendiente', 'aprobado', 'activo'])
.order('fecha_solicitud', { ascending: false })
.limit(1)
.maybeSingle();
actual = (data as unknown as Actual) ?? null;
const [actualRes, activosRes, pendRes, devRes] = await Promise.all([
supabase
.from('solicitudes')
.select('id, estado, fecha_solicitud, items:solicitud_items(cantidad, material:materiales(nombre))')
.eq('alumno_id', user.id)
.in('estado', ['pendiente', 'aprobado', 'activo'])
.order('fecha_solicitud', { ascending: false })
.limit(1)
.maybeSingle(),
supabase
.from('solicitudes')
.select('*', { count: 'exact', head: true })
.eq('alumno_id', user.id)
.in('estado', ['aprobado', 'activo']),
supabase
.from('solicitudes')
.select('*', { count: 'exact', head: true })
.eq('alumno_id', user.id)
.eq('estado', 'pendiente'),
supabase
.from('solicitudes')
.select('*', { count: 'exact', head: true })
.eq('alumno_id', user.id)
.eq('estado', 'devuelto'),
]);
actual = (actualRes.data as unknown as Actual) ?? null;
activosCount = activosRes.count ?? 0;
pendientesCount = pendRes.count ?? 0;
devueltosCount = devRes.count ?? 0;
}
const estadoLabel: Record<Estado, string> = {
@@ -42,16 +66,6 @@ const estadoLabel: Record<Estado, string> = {
devuelto: 'Devuelto',
vencido: 'Vencido',
};
const estadoColor: Record<Estado, string> = {
pendiente: 'var(--color-ink)',
aprobado: 'var(--color-positive)',
activo: 'var(--color-positive)',
devuelto: 'var(--color-ink)',
rechazado: 'var(--color-danger)',
vencido: 'var(--color-danger)',
};
const badgeStyle = (e: Estado) =>
`background: color-mix(in oklab, ${estadoColor[e]} 18%, white); color: var(--color-ink); border: 1.5px solid ${estadoColor[e]};`;
const items = actual?.items ?? [];
const preview = items.slice(0, 3);
@@ -60,46 +74,57 @@ const extra = items.length - preview.length;
<AppLayout title="Inicio — LabPréstamos">
<div class="max-w-2xl mx-auto">
<header class="mb-8">
<h1 class="font-mono uppercase text-3xl md:text-5xl font-semibold leading-tight" style="letter-spacing: -0.01em;">
<h1>
{saludo}, {nombreCorto}
</h1>
</header>
<section class="card-raised mb-8">
<p class="text-xs uppercase tracking-wide mb-1" style="color: var(--color-pencil);">Nuevo préstamo</p>
<h2 class="text-xl md:text-2xl font-semibold mb-5">¿Qué vas a pedir hoy?</h2>
<a href="/alumno/catalogo" class="btn btn-primary">Ir al catálogo</a>
</section>
<div class="grid grid-cols-3 gap-4 mb-8">
<div class="card p-5">
<span class="eyebrow mb-1">Activos</span>
<div class="text-2xl md:text-3xl text-graphite font-light" style="font-variant-numeric: tabular-nums;">{activosCount}</div>
</div>
<div class="card p-5">
<span class="eyebrow mb-1 text-dorado-uabc">Pendientes</span>
<div class="text-2xl md:text-3xl text-graphite font-light" style="font-variant-numeric: tabular-nums;">{pendientesCount}</div>
</div>
<div class="card p-5">
<span class="eyebrow mb-1">Devueltos</span>
<div class="text-2xl md:text-3xl text-graphite font-light" style="font-variant-numeric: tabular-nums;">{devueltosCount}</div>
</div>
</div>
{actual ? (
<section aria-labelledby="actual-h">
<div class="flex items-center justify-between gap-3 mb-3">
<h2 id="actual-h" class="text-lg font-semibold uppercase tracking-wide">Tu préstamo actual</h2>
<span class="badge" style={badgeStyle(actual.estado)}>{estadoLabel[actual.estado]}</span>
<h2 id="actual-h" class="text-xl">Tu préstamo actual</h2>
<span class:list={['chip', `chip-${actual.estado}`]}>{estadoLabel[actual.estado]}</span>
</div>
<a href="/alumno/mis-prestamos" class="card-link">
<ul class="space-y-1 mb-4">
<ul class="space-y-1.5 mb-4">
{preview.map((it) => (
<li class="leading-snug">
<span class="font-semibold" style="font-variant-numeric: tabular-nums;">{it.cantidad}×</span>{' '}
<span class="font-semibold">{it.material?.nombre ?? 'Material eliminado'}</span>
<li class="leading-snug text-graphite">
<span class="font-medium" style="font-variant-numeric: tabular-nums;">{it.cantidad}×</span>{' '}
<span class="font-medium text-graphite">{it.material?.nombre ?? 'Material eliminado'}</span>
</li>
))}
{extra > 0 && (
<li class="text-sm opacity-70">(y {extra} más)</li>
<li class="text-sm text-graphite/60">(y {extra} más)</li>
)}
</ul>
<div class="flex items-center justify-between gap-3 text-sm">
<span style="color: var(--color-pencil);">Solicitado {fmtFechaRelativa(actual.fecha_solicitud)}</span>
<span class="underline decoration-[color:var(--color-ink)] underline-offset-4">Ver detalles</span>
<span class="text-graphite/60">Solicitado {fmtFechaRelativa(actual.fecha_solicitud)}</span>
<span class="text-verde-institucional font-medium">Ver detalles</span>
</div>
</a>
<div class="mt-6">
<a href="/alumno/catalogo" class="btn btn-primary">Nuevo préstamo</a>
</div>
</section>
) : (
<section class="card text-center">
<h2 class="text-lg font-semibold mb-1">Aún no tienes préstamos activos</h2>
<p class="text-sm opacity-70 mb-5">¿Vamos por algo del laboratorio?</p>
<a href="/alumno/catalogo" class="btn btn-primary">Ir al catálogo</a>
<section class="text-center py-8">
<p class="text-graphite/70 mb-5">Sin préstamos activos.</p>
<a href="/alumno/catalogo" class="btn btn-primary inline-flex">Nuevo préstamo</a>
</section>
)}
</div>
+11 -11
View File
@@ -16,33 +16,33 @@ const googleAuthUrl = '/api/auth/signin';
<main id="main" class="min-h-screen grid place-items-center p-4">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<div class="inline-grid place-items-center w-16 h-16 rounded-[2px] bg-[color:var(--color-primary)] p-4 mb-4 motion-safe:animate-[fadein_.4s_ease-out]" style="border: 2px solid var(--color-ink); box-shadow: var(--shadow-hard);">
<BrandMark class="w-full h-full text-white" />
<div class="inline-grid place-items-center w-16 h-16 rounded-[12px] bg-linen text-verde-institucional p-4 mb-4 motion-safe:animate-[fadein_.4s_ease-out]" aria-hidden="true">
<BrandMark class="w-full h-full" />
</div>
<h1 class="text-2xl md:text-3xl font-semibold uppercase tracking-wide">Sistema de Préstamos</h1>
<p class="text-sm mt-2" style="color: var(--color-pencil);">Laboratorio de Sistemas Computacionales · UABC</p>
<h1>Sistema de Préstamos</h1>
<p class="text-sm mt-2 text-graphite/70">Laboratorio de Sistemas Computacionales · UABC</p>
</div>
<div class="card-raised motion-safe:animate-[fadein_.5s_ease-out]">
<h2 class="text-lg font-semibold mb-1 uppercase tracking-wide">Inicia sesión</h2>
<p class="text-sm mb-6" style="color: var(--color-pencil);">Accede con tu cuenta institucional.</p>
<div class="card motion-safe:animate-[fadein_.5s_ease-out]">
<h2 class="text-xl mb-1">Inicia sesión</h2>
<p class="text-sm mb-6 text-graphite/70">Accede con tu cuenta institucional.</p>
<a href={googleAuthUrl} class="btn btn-primary w-full">
<svg width="18" height="18" viewBox="0 0 48 48" aria-hidden="true" focusable="false">
<path fill="white" d="M44.5 20H24v8.5h11.7C34.4 33.4 29.7 36 24 36c-6.6 0-12-5.4-12-12s5.4-12 12-12c2.9 0 5.5 1 7.6 2.7l6.2-6.2C33.8 5.1 29.2 3 24 3 12.4 3 3 12.4 3 24s9.4 21 21 21c11 0 20-8 20-21 0-1.4-.1-2.7-.5-4z"/>
<path fill="currentColor" d="M44.5 20H24v8.5h11.7C34.4 33.4 29.7 36 24 36c-6.6 0-12-5.4-12-12s5.4-12 12-12c2.9 0 5.5 1 7.6 2.7l6.2-6.2C33.8 5.1 29.2 3 24 3 12.4 3 3 12.4 3 24s9.4 21 21 21c11 0 20-8 20-21 0-1.4-.1-2.7-.5-4z"/>
</svg>
Continuar con Google
</a>
{errorMsg && (
<div role="alert" class="mt-4 rounded-[2px] p-3 text-sm" style="background: color-mix(in oklab, var(--color-danger) 20%, transparent); border: 1.5px solid var(--color-danger); color: var(--color-ink);">
<div role="alert" class="mt-4 rounded-[8px] p-4 text-sm bg-linen border border-negro-institucional text-graphite">
{errorMsg}
</div>
)}
</div>
<p class="text-center text-xs mt-6" style="color: var(--color-pencil);">
Acceso exclusivo con correo institucional <strong style="color: var(--color-ink);">@uabc.edu.mx</strong>
<p class="text-center text-xs mt-6 text-graphite/70">
Acceso exclusivo con correo institucional <strong class="text-graphite font-medium">@uabc.edu.mx</strong>
</p>
</div>
</main>
+5 -5
View File
@@ -29,18 +29,18 @@ const copyIntro =
<main id="main" class="min-h-screen grid place-items-center p-4">
<div class="w-full max-w-lg">
<div class="text-center mb-6">
<div class="inline-grid place-items-center w-16 h-16 rounded-[2px] bg-[color:var(--color-primary)] p-4 mb-4" style="border: 2px solid var(--color-ink); box-shadow: var(--shadow-hard);">
<BrandMark class="w-full h-full text-white" />
<div class="inline-grid place-items-center w-16 h-16 rounded-[12px] bg-linen text-verde-institucional p-4 mb-4" aria-hidden="true">
<BrandMark class="w-full h-full" />
</div>
<h1 class="text-2xl md:text-3xl font-semibold uppercase tracking-wide">
<h1>
Bienvenido, {primerNombre}
</h1>
<p class="text-sm mt-2" style="color: var(--color-pencil);">
<p class="text-sm mt-2 text-graphite/70">
{copyIntro}
</p>
</div>
<div class="card-raised">
<div class="card">
<PerfilForm
client:load
mode="onboarding"
+10 -9
View File
@@ -31,22 +31,23 @@ const info = avatarInfo({
});
---
<AppLayout title="Mi perfil — LabPréstamos">
<div class="max-w-md mx-auto">
<header class="flex items-center gap-4 mb-6">
<Avatar client:load info={info} size={80} />
<div class="min-w-0">
<h1 class="text-xl md:text-2xl font-semibold uppercase tracking-wide leading-tight truncate">
<div class="max-w-xl mx-auto space-y-6">
<header class="card p-6 flex items-center gap-5">
<Avatar client:load info={info} size={80} className="w-20 h-20 rounded-full" />
<div class="min-w-0 flex-1">
<span class="eyebrow mb-1">Mi perfil</span>
<h1 class="text-2xl md:text-3xl leading-tight truncate">
{profile.nombre ?? profile.email.split('@')[0]}
</h1>
<p class="text-sm truncate" style="color: var(--color-pencil);">{profile.email}</p>
<span class="badge mt-2" style={`background: ${profile.rol === 'admin' ? 'var(--color-secondary)' : 'var(--color-chalk)'};`}>
<p class="text-sm text-graphite/70 truncate">{profile.email}</p>
<span class="badge mt-2">
{rolLabel}
</span>
</div>
</header>
<section class="card-raised">
<h2 class="text-sm uppercase tracking-wide font-semibold mb-4">Datos del perfil</h2>
<section class="card p-6">
<h2 class="text-xl md:text-2xl mb-4">Datos del perfil</h2>
<PerfilForm
client:load
userId={user.id}
+248 -95
View File
@@ -1,44 +1,49 @@
@import "tailwindcss";
@import "@fontsource-variable/jetbrains-mono";
@theme {
/* MotherDuck-derived system (shadow/radius/type mechanics); identity colors are UABC's own */
--color-cream: #f4efea;
--color-frost: #ffffff;
--color-chalk: #f8f8f7;
--color-ice: #ebf9ff;
/* UABC — Identidad editorial institucional (DESIGN.md) */
--color-verde-institucional: #00723F;
--color-verde-claro: #50B848;
--color-dorado-uabc: #DD971A;
--color-amarillo-dorado: #FEBE10;
--color-verde-jardin: #024731;
--color-dorado-oscuro: #B08208;
--color-azul-institucional: #20419A;
--color-bone-white: #fffefb;
--color-linen: #f3f2ee;
--color-hairline-gray: #e5e7eb;
--color-graphite: #333833;
--color-negro-institucional: #000000;
--color-ink: #383838;
--color-pencil: #a1a1a1;
--color-graphite: #818181;
--font-sans: 'Mulish', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: 'Mulish', ui-sans-serif, system-ui, sans-serif;
--font-serif: 'Mulish', ui-sans-serif, system-ui, sans-serif;
/* UABC institutional green/gold — dark enough that primary needs white text, not charcoal */
--color-primary: #00723F;
--color-primary-hover: #005f34;
--color-secondary: #DD971A;
--color-secondary-hover: #c78614;
--color-danger: #f38e84;
--color-danger-hover: #ec7469;
/* AA-contrast text variant of Coral Sketch — the fill color reads ~2:1 on white, too light for body text */
--color-danger-text: #a83224;
--color-positive: #38c1b0;
--color-attention: #ffde00;
--text-micro: 10px;
--text-caption: 12px;
--text-body-sm: 14px;
--text-body: 16px;
--text-ui: 16px;
--text-heading: 24px;
--text-heading-lg: 32px;
--text-display: 72px;
--color-surface: var(--color-cream);
/* Radii — tokens 8/12/30/9999 solamente */
--radius-tags: 8px;
--radius-cards: 12px;
--radius-nav: 30px;
--radius-pill: 9999px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 12px;
--radius-xl: 12px;
--radius-2xl: 30px;
--radius-3xl: 30px;
--radius-full: 9999px;
--font-sans: "JetBrains Mono Variable", ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, monospace;
--radius-card: 2px;
--shadow-hard: -6px 6px 0 0 var(--color-ink);
--shadow-hard-sm: -3px 3px 0 0 var(--color-ink);
/* Every Tailwind radius utility collapses to the system's 2px — non-negotiable per brief */
--radius-sm: 2px;
--radius-md: 2px;
--radius-lg: 2px;
--radius-xl: 2px;
--radius-2xl: 2px;
--radius-3xl: 2px;
/* Sin sombras — elevación por color de superficie + hairline border */
--shadow-hard: none;
--shadow-hard-sm: none;
}
@layer base {
@@ -46,116 +51,264 @@
color-scheme: light;
}
body {
background: var(--color-cream);
color: var(--color-ink);
background-color: var(--color-bone-white);
background-image: radial-gradient(circle, color-mix(in oklab, var(--color-verde-institucional) 12%, transparent) 1.5px, transparent 1.5px);
background-size: 22px 22px;
background-position: 0 0;
color: var(--color-graphite);
font-family: var(--font-sans);
letter-spacing: 0.02em;
font-weight: 700;
font-size: 16px;
line-height: 1.5;
letter-spacing: -0.1px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
:focus-visible {
outline: 2px solid var(--color-ink);
outline: 2px solid var(--color-verde-institucional);
outline-offset: 2px;
}
::selection {
background: var(--color-primary);
color: white;
background: var(--color-verde-institucional);
color: var(--color-bone-white);
}
/* Tabular data keeps real square corners even where a nested control forgets to ask */
table {
font-variant-numeric: tabular-nums;
}
h1, h2, h3, h4 {
font-family: var(--font-sans);
font-weight: 700;
color: var(--color-graphite);
text-wrap: balance;
}
h1 { font-size: 32px; line-height: 1.25; letter-spacing: -0.3px; }
h2 { font-size: 24px; line-height: 1.25; letter-spacing: -0.2px; }
h3 { font-size: 16px; line-height: 1.5; letter-spacing: 0; font-weight: 700; }
h4 { font-size: 14px; line-height: 1.5; letter-spacing: 0; font-weight: 700; }
p { text-wrap: pretty; }
a { color: var(--color-verde-institucional); text-decoration: none; }
a:hover { color: var(--color-verde-claro); }
}
@layer components {
.btn {
@apply inline-flex items-center justify-center gap-2 rounded-[2px] px-4 py-2.5 font-medium
motion-safe:transition-transform motion-safe:duration-100 motion-safe:ease-out
disabled:opacity-50 disabled:pointer-events-none disabled:translate-x-0 disabled:translate-y-0;
@apply inline-flex items-center justify-center gap-2 px-6 py-2.5
motion-safe:transition-colors motion-safe:duration-200 motion-safe:ease-out
disabled:opacity-50 disabled:pointer-events-none;
border-radius: var(--radius-pill);
min-height: 44px;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
border: 2px solid var(--color-ink);
color: var(--color-ink);
box-shadow: var(--shadow-hard);
}
.btn:hover,
.btn:focus-visible {
transform: translate(-6px, 6px);
box-shadow: 0 0 0 0 transparent;
}
.btn:active {
transform: translate(-3px, 3px);
box-shadow: var(--shadow-hard-sm);
border: 1px solid transparent;
color: var(--color-graphite);
background: transparent;
font-family: var(--font-sans);
font-size: 16px;
font-weight: 700;
letter-spacing: 0;
line-height: 1.5;
box-shadow: none;
text-decoration: none;
}
.btn-primary {
background: var(--color-primary);
color: white;
background: var(--color-verde-institucional);
color: var(--color-bone-white);
border-color: var(--color-verde-institucional);
}
.btn-primary:hover,
.btn-primary:focus-visible {
background: var(--color-verde-claro);
border-color: var(--color-verde-claro);
}
.btn-secondary {
background: var(--color-secondary);
color: var(--color-ink);
background: transparent;
color: var(--color-graphite);
border-color: var(--color-hairline-gray);
}
.btn-secondary:hover,
.btn-secondary:focus-visible {
background: var(--color-linen);
border-color: var(--color-dorado-uabc);
}
.btn-ghost {
background: transparent;
color: var(--color-ink);
border-color: color-mix(in oklab, var(--color-ink) 35%, transparent);
box-shadow: none;
color: var(--color-graphite);
border-color: var(--color-hairline-gray);
}
.btn-ghost:hover,
.btn-ghost:focus-visible {
transform: none;
background: var(--color-chalk);
border-color: var(--color-ink);
background: var(--color-linen);
border-color: var(--color-hairline-gray);
}
.btn-danger {
background: var(--color-negro-institucional);
color: var(--color-bone-white);
border-color: var(--color-negro-institucional);
}
.btn-danger:hover,
.btn-danger:focus-visible {
background: var(--color-graphite);
border-color: var(--color-graphite);
}
.card {
@apply rounded-[2px] bg-white p-6;
border: 2px solid var(--color-ink);
}
.card-flat {
@apply rounded-[2px] bg-white p-6;
border: 1.5px solid var(--color-ink);
background: color-mix(in oklab, var(--color-linen) 72%, transparent);
border: 1px solid color-mix(in oklab, var(--color-verde-institucional) 18%, transparent);
border-radius: var(--radius-cards);
padding: 24px;
backdrop-filter: blur(6px) saturate(1.1);
-webkit-backdrop-filter: blur(6px) saturate(1.1);
}
.card-flat,
.card-raised {
@apply rounded-[2px] bg-white p-6;
border: 2px solid var(--color-ink);
box-shadow: var(--shadow-hard);
background: color-mix(in oklab, var(--color-linen) 82%, transparent);
border: 1px solid color-mix(in oklab, var(--color-verde-institucional) 22%, transparent);
border-radius: var(--radius-cards);
padding: 24px;
backdrop-filter: blur(8px) saturate(1.1);
-webkit-backdrop-filter: blur(8px) saturate(1.1);
}
.card-link {
@apply rounded-[2px] bg-white p-6 block
motion-safe:transition-transform motion-safe:duration-100 motion-safe:ease-out;
border: 2px solid var(--color-ink);
box-shadow: var(--shadow-hard);
@apply block motion-safe:transition-colors motion-safe:duration-200 motion-safe:ease-out;
background: color-mix(in oklab, var(--color-linen) 72%, transparent);
border: 1px solid color-mix(in oklab, var(--color-verde-institucional) 18%, transparent);
border-radius: var(--radius-cards);
padding: 24px;
text-decoration: none;
color: var(--color-graphite);
backdrop-filter: blur(6px) saturate(1.1);
-webkit-backdrop-filter: blur(6px) saturate(1.1);
}
.card-link:hover,
.card-link:focus-visible {
transform: translate(-6px, 6px);
box-shadow: 0 0 0 0 transparent;
background: color-mix(in oklab, var(--color-linen) 88%, transparent);
border-color: color-mix(in oklab, var(--color-verde-institucional) 40%, transparent);
}
/* Legacy tinted variants → collapse to Linen card */
.card-sage,
.card-keylime,
.card-mint,
.card-slate {
background: var(--color-linen);
border: 1px solid var(--color-hairline-gray);
border-radius: var(--radius-cards);
padding: 24px;
color: var(--color-graphite);
}
.card-dark {
background: var(--color-negro-institucional);
border: 1px solid var(--color-negro-institucional);
border-radius: var(--radius-cards);
padding: 24px;
color: var(--color-bone-white);
}
.input {
@apply w-full rounded-[2px] bg-white px-3 py-2.5
transition-[border-color] duration-100
focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2
focus-visible:outline-[color:var(--color-ink)];
border: 2px solid var(--color-ink);
@apply w-full px-4 py-3
transition-[border-color,box-shadow] duration-200
focus:outline-none;
background: var(--color-bone-white);
border: 1px solid var(--color-hairline-gray);
border-radius: var(--radius-tags);
color: var(--color-graphite);
min-height: 44px;
touch-action: manipulation;
font-family: var(--font-sans);
font-weight: 700;
font-size: 16px;
letter-spacing: -0.1px;
}
.input:focus {
border-color: var(--color-primary);
.input:focus,
.input:focus-visible {
border-color: var(--color-verde-institucional);
box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-verde-institucional) 25%, transparent);
}
.label {
@apply block text-sm font-medium mb-1 uppercase;
letter-spacing: 0.05em;
font-size: 0.75rem;
color: var(--color-ink);
@apply block mb-2;
font-family: var(--font-sans);
font-size: 12px;
font-weight: 700;
color: var(--color-graphite);
letter-spacing: 0;
text-transform: none;
}
.eyebrow {
@apply inline-block;
font-family: var(--font-sans);
font-size: 10px;
font-weight: 700;
color: var(--color-verde-institucional);
letter-spacing: 0.2px;
text-transform: uppercase;
}
.badge {
@apply inline-flex items-center rounded-[2px] px-2 py-0.5 text-xs font-semibold uppercase whitespace-nowrap;
letter-spacing: 0.03em;
border: 1.5px solid var(--color-ink);
@apply inline-flex items-center px-3 py-1 whitespace-nowrap;
border-radius: var(--radius-tags);
background: var(--color-bone-white);
color: var(--color-graphite);
border: 1px solid var(--color-hairline-gray);
font-size: 12px;
font-weight: 700;
letter-spacing: 0;
}
/* Status chips — tag pill (8px), no full pill; borde de acento por estado */
.chip {
@apply inline-flex items-center whitespace-nowrap;
border-radius: var(--radius-tags);
padding: 4px 10px;
font-size: 12px;
font-weight: 700;
font-family: var(--font-sans);
letter-spacing: 0;
background: var(--color-bone-white);
color: var(--color-graphite);
border: 1px solid var(--color-hairline-gray);
}
.chip-pendiente {
color: var(--color-dorado-uabc);
border-color: var(--color-dorado-uabc);
}
.chip-aprobado,
.chip-activo,
.chip-devuelto {
color: var(--color-verde-institucional);
border-color: var(--color-verde-institucional);
}
.chip-rechazado,
.chip-vencido {
color: var(--color-bone-white);
background: var(--color-negro-institucional);
border-color: var(--color-negro-institucional);
}
/* Patrones decorativos de círculos — utility para superficies puntuales */
.pattern-dots {
background-image: radial-gradient(circle, color-mix(in oklab, var(--color-verde-institucional) 18%, transparent) 1.5px, transparent 1.5px);
background-size: 22px 22px;
}
.pattern-dots-dorado {
background-image: radial-gradient(circle, color-mix(in oklab, var(--color-dorado-uabc) 25%, transparent) 1.5px, transparent 1.5px);
background-size: 22px 22px;
}
.pattern-dots-dense {
background-image: radial-gradient(circle, color-mix(in oklab, var(--color-verde-institucional) 22%, transparent) 2px, transparent 2px);
background-size: 14px 14px;
}
.pattern-dots-on-dark {
background-image: radial-gradient(circle, color-mix(in oklab, var(--color-bone-white) 18%, transparent) 1.5px, transparent 1.5px);
background-size: 22px 22px;
}
/* Nav flotante translúcido (patrón del brief) */
.nav-pill {
background: color-mix(in oklab, var(--color-dorado-oscuro) 18%, transparent);
border-radius: var(--radius-nav);
padding: 8px 16px;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
h1, h2, h3 { text-wrap: balance; }