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:
+18
-20
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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
@@ -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
@@ -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); } }
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user