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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014xaxs8cGcQe9NwbMqA1gvn
This commit is contained in:
2026-09-21 09:42:12 -07:00
parent a41d50ca62
commit ac3e7061ac
53 changed files with 2132 additions and 1576 deletions
+18 -20
View File
@@ -3,17 +3,20 @@ import type { ToastPayload, ToastKind } from '@/lib/toast';
type Item = ToastPayload & { id: number; leaving?: boolean };
const KIND_META: Record<ToastKind, { color: string; d: string }> = {
const KIND_META: Record<ToastKind, { bg: string; text: string; d: string }> = {
success: {
color: 'var(--color-positive)',
bg: 'bg-verde-institucional',
text: 'text-bone-white',
d: 'M4.5 12.75l6 6 9-13.5',
},
error: {
color: 'var(--color-danger-text)',
bg: 'bg-negro-institucional',
text: 'text-bone-white',
d: 'M6 18L18 6M6 6l12 12',
},
info: {
color: 'var(--color-secondary)',
bg: 'bg-azul-institucional',
text: 'text-bone-white',
d: 'M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z',
},
};
@@ -50,7 +53,7 @@ export default function Toaster() {
function dismiss(id: number, animate = false) {
if (animate) {
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, leaving: true } : it)));
setTimeout(() => setItems((prev) => prev.filter((it) => it.id !== id)), 250);
setTimeout(() => setItems((prev) => prev.filter((it) => it.id !== id)), 200);
} else {
setItems((prev) => prev.filter((it) => it.id !== id));
}
@@ -71,29 +74,23 @@ export default function Toaster() {
onClick={() => dismiss(t.id, true)}
aria-live="polite"
className={
'toast-item pointer-events-auto text-left flex items-start gap-3 rounded-[2px] px-4 py-3 bg-white ' +
'toast-item pointer-events-auto text-left flex items-start gap-3 rounded-[12px] px-4 py-3 bg-bone-white text-graphite border border-hairline-gray shadow-none ' +
'motion-safe:transition-[opacity,transform] motion-safe:duration-200 ' +
(t.leaving ? 'toast-leave' : 'toast-enter')
}
style={{
border: '2px solid var(--color-ink)',
boxShadow: 'var(--shadow-hard)',
color: 'var(--color-ink)',
}}
>
<span
aria-hidden="true"
className="shrink-0 grid place-items-center w-9 h-9 rounded-[2px]"
style={{ background: meta.color, border: '2px solid var(--color-ink)' }}
className={`shrink-0 grid place-items-center w-8 h-8 rounded-full ${meta.bg} ${meta.text}`}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--color-ink)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d={meta.d} />
</svg>
</span>
<span className="min-w-0 flex-1">
<span className="block font-semibold text-sm leading-snug">{t.title}</span>
<span className="block font-medium text-sm text-graphite leading-snug">{t.title}</span>
{t.description && (
<span className="block text-xs opacity-70 mt-0.5 leading-snug">{t.description}</span>
<span className="block text-xs text-graphite/70 mt-0.5 leading-snug">{t.description}</span>
)}
</span>
</button>
@@ -102,18 +99,19 @@ export default function Toaster() {
<style>{`
.toast-enter {
animation: toast-in 260ms cubic-bezier(0.22, 1, 0.36, 1) both;
animation: toast-in 200ms ease-out both;
}
.toast-leave {
opacity: 0;
transform: translateY(-8px) scale(0.98);
transform: translateY(-4px);
}
@keyframes toast-in {
from { opacity: 0; transform: translateY(-12px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.toast-enter { animation: none; }
.toast-leave { opacity: 0; }
}
`}</style>
</div>
+59 -7
View File
@@ -4,11 +4,17 @@ import { toast } from '@/lib/toast';
function setBadge(n: number) {
const v = Math.max(0, n | 0);
// Badge pill visual (nav): oculto si 0.
document.querySelectorAll<HTMLElement>('[data-badge="pendientes"]').forEach((el) => {
el.textContent = String(v);
if (v > 0) el.removeAttribute('hidden');
else el.setAttribute('hidden', '');
el.setAttribute('aria-label', `${v} solicitudes pendientes`);
el.classList.add('bg-verde-institucional', 'text-bone-white', 'rounded-full');
});
// Contadores de texto plano (KPIs) — visibles siempre, sin estilo pill.
document.querySelectorAll<HTMLElement>('[data-count="pendientes"]').forEach((el) => {
el.textContent = String(v);
});
}
@@ -18,18 +24,26 @@ function readBadge(): number {
return Number.isFinite(n) ? n : 0;
}
export default function BadgeSolicitudes() {
export default function BadgeSolicitudes({ count, className }: { count?: number; className?: string } = {}) {
useEffect(() => {
document.querySelectorAll<HTMLElement>('[data-badge="pendientes"]').forEach((el) => {
el.classList.add('bg-verde-institucional', 'text-bone-white', 'rounded-full');
});
const supabase = browserClient();
const refetch = async () => {
const { count } = await supabase
const { count: c } = await supabase
.from('solicitudes')
.select('*', { count: 'exact', head: true })
.eq('estado', 'pendiente');
setBadge(count ?? 0);
setBadge(c ?? 0);
};
// Fetch inicial: el SSR ya no envía count (se removió de AppLayout para
// ahorrar 1 query/pageview). El badge arranca en 0 y se hidrata acá.
refetch();
const channel = supabase
.channel('solicitudes-pendientes')
.on(
@@ -43,11 +57,26 @@ export default function BadgeSolicitudes() {
},
)
.on(
// ponytail: refetch en UPDATE porque payload.old no trae estado en replica identity default;
// upgrade a delta cuando la tabla tenga replica identity full.
// Delta preciso: REPLICA IDENTITY FULL (migración 0008) hace que
// payload.old traiga el estado anterior — evitamos el refetch en cada UPDATE.
'postgres_changes',
{ event: 'UPDATE', schema: 'prestamos', table: 'solicitudes' },
() => { refetch(); },
(payload) => {
const oldEstado = (payload.old as { estado?: string } | null)?.estado;
const newEstado = (payload.new as { estado?: string } | null)?.estado;
// Fallback: si el WAL no envió estado en `old` (tabla aún en REPLICA IDENTITY DEFAULT),
// caemos al refetch original — un poco más caro pero correcto.
if (oldEstado === undefined) {
refetch();
return;
}
if (oldEstado === 'pendiente' && newEstado !== 'pendiente') {
setBadge(Math.max(0, readBadge() - 1));
} else if (oldEstado !== 'pendiente' && newEstado === 'pendiente') {
setBadge(readBadge() + 1);
}
// Si ninguno de los dos es 'pendiente', el UPDATE no afecta al count — no hacer nada.
},
)
.subscribe();
@@ -56,5 +85,28 @@ export default function BadgeSolicitudes() {
};
}, []);
return null;
if (count !== undefined) {
return (
<span
data-badge="pendientes"
hidden={count === 0}
aria-label={`${count} solicitudes pendientes`}
className={`inline-flex items-center justify-center min-w-[18px] h-[18px] px-1.5 rounded-full font-sans font-medium text-[11px] leading-none bg-verde-institucional text-bone-white ${className ?? ''}`}
>
{count}
</span>
);
}
return (
<style>{`
[data-badge="pendientes"] {
background-color: var(--color-verde-institucional) !important;
color: var(--color-bone-white) !important;
border-radius: 9999px !important;
min-width: 18px !important;
height: 18px !important;
}
`}</style>
);
}
+109 -52
View File
@@ -21,50 +21,67 @@ type Linea = { tipo: 'linea'; datos: { fecha: string; valor: number }[]; alto?:
type Donut = { tipo: 'donut'; datos: { label: string; valor: number }[]; alto?: number };
type Props = Barras | Linea | Donut;
const INK = '#383838';
const PENCIL = '#a1a1a1';
const PRIMARY = '#00723F';
const SECONDARY = '#DD971A';
const DANGER = '#a83224';
const POSITIVE = '#38c1b0';
const ATTENTION = '#ffde00';
// PALETA UABC (TRACK E):
// Primario: #00723F (verde-institucional)
// Secundarios: #DD971A (dorado-uabc), #50B848 (verde-claro), #20419A (azul-institucional), #024731 (verde-jardin)
// Alerta: #000000 (negro-institucional)
// Superficie: #fffefb (bone-white)
// Texto: #333833 (graphite)
// Bordes: #e5e7eb (hairline-gray)
const MONO: React.CSSProperties = {
fontFamily: "'JetBrains Mono Variable', ui-monospace, monospace",
fontSize: 11,
color: INK,
};
const VERDE_INSTITUCIONAL = '#00723F';
const DORADO_UABC = '#DD971A';
const VERDE_CLARO = '#50B848';
const AZUL_INSTITUCIONAL = '#20419A';
const VERDE_JARDIN = '#024731';
const NEGRO_INSTITUCIONAL = '#000000';
const BONE_WHITE = '#fffefb';
const GRAPHITE = '#333833';
const HAIRLINE_GRAY = '#e5e7eb';
const PRIMARY = VERDE_INSTITUCIONAL;
const ALERT = NEGRO_INSTITUCIONAL;
// Paleta rotativa para el donut. Coloreado por label cuando aplique.
const DONUT_BY_LABEL: Record<string, string> = {
pendiente: ATTENTION,
aprobado: PRIMARY,
activo: PRIMARY,
devuelto: POSITIVE,
rechazado: PENCIL,
vencido: DANGER,
pendiente: DORADO_UABC,
aprobado: VERDE_INSTITUCIONAL,
activo: VERDE_CLARO,
devuelto: VERDE_JARDIN,
rechazado: GRAPHITE,
vencido: ALERT,
};
const DONUT_FALLBACK = [PRIMARY, SECONDARY, POSITIVE, ATTENTION, PENCIL, DANGER];
const DONUT_FALLBACK = [
VERDE_INSTITUCIONAL,
DORADO_UABC,
VERDE_CLARO,
AZUL_INSTITUCIONAL,
VERDE_JARDIN,
ALERT,
];
function donutColor(label: string, i: number): string {
return DONUT_BY_LABEL[label.toLowerCase()] ?? DONUT_FALLBACK[i % DONUT_FALLBACK.length];
}
const TOOLTIP_CONTENT_STYLE: React.CSSProperties = {
backgroundColor: '#fffefb',
border: '1px solid #e5e7eb',
borderRadius: 8,
padding: '8px 12px',
color: '#333833',
boxShadow: 'none',
fontFamily: "'Azeret Mono', monospace",
fontSize: 12,
};
function TooltipBox({ active, payload, label }: any) {
if (!active || !payload?.length) return null;
return (
<div
style={{
background: 'white',
border: `2px solid ${INK}`,
borderRadius: 2,
padding: '6px 10px',
...MONO,
}}
>
{label !== undefined && <div style={{ fontWeight: 600 }}>{label}</div>}
<div style={TOOLTIP_CONTENT_STYLE}>
{label !== undefined && <div style={{ fontWeight: 500, color: VERDE_INSTITUCIONAL, marginBottom: 4 }}>{label}</div>}
{payload.map((p: any, i: number) => (
<div key={i}>
<div key={i} style={{ fontVariantNumeric: 'tabular-nums' }}>
{p.name}: <strong>{p.value}</strong>
</div>
))}
@@ -86,8 +103,11 @@ function usePrefersReducedMotion(): boolean {
function fmtFechaCorta(iso: string): string {
// 'YYYY-MM-DD' → 'DD/MM'
const [, m, d] = iso.split('-');
return `${d}/${m}`;
const parts = iso.split('-');
if (parts.length >= 3) {
return `${parts[2]}/${parts[1]}`;
}
return iso;
}
export default function Chart(props: Props) {
@@ -103,21 +123,40 @@ export default function Chart(props: Props) {
);
return (
<ResponsiveContainer width="100%" height={alto}>
<BarChart data={datos} layout="vertical" margin={{ top: 8, right: 24, left: 8, bottom: 8 }}>
<CartesianGrid stroke={PENCIL} strokeDasharray="2 3" horizontal={false} />
<XAxis type="number" allowDecimals={false} stroke={INK} tick={MONO as any} />
<BarChart data={datos} layout="vertical" margin={{ top: 8, right: 28, left: 8, bottom: 8 }}>
<CartesianGrid stroke="#e5e7eb" strokeDasharray="2 3" horizontal={false} />
<XAxis
type="number"
allowDecimals={false}
stroke="#e5e7eb"
tick={{ fill: '#333833', fontSize: 11, fontFamily: "'Azeret Mono', monospace", fontVariantNumeric: 'tabular-nums' } as any}
/>
<YAxis
type="category"
dataKey="label"
width={100}
stroke={INK}
tick={{ ...MONO, fontSize: 10 } as any}
stroke="#e5e7eb"
tick={{ fill: '#333833', fontSize: 11, fontFamily: "'Azeret Mono', monospace" } as any}
interval={0}
tickFormatter={(v: string) => (v.length > 14 ? v.slice(0, 13) + '…' : v)}
/>
<Tooltip content={<TooltipBox />} cursor={{ fill: 'rgba(56,56,56,0.06)' }} />
<Bar dataKey="valor" fill={PRIMARY} stroke={INK} strokeWidth={1.5} animationDuration={anim} name="Solicitado">
<LabelList dataKey="valor" position="right" style={MONO as any} />
<Tooltip
content={<TooltipBox />}
cursor={{ fill: 'rgba(0, 114, 63, 0.05)' }}
contentStyle={TOOLTIP_CONTENT_STYLE}
/>
<Bar
dataKey="valor"
fill={PRIMARY}
radius={[0, 8, 8, 0]}
animationDuration={anim}
name="Solicitado"
>
<LabelList
dataKey="valor"
position="right"
style={{ fill: '#333833', fontSize: 11, fontFamily: "'Azeret Mono', monospace", fontVariantNumeric: 'tabular-nums' } as any}
/>
</Bar>
</BarChart>
</ResponsiveContainer>
@@ -129,17 +168,30 @@ export default function Chart(props: Props) {
return (
<ResponsiveContainer width="100%" height={alto}>
<LineChart data={datos} margin={{ top: 8, right: 16, left: 0, bottom: 8 }}>
<CartesianGrid stroke={PENCIL} strokeDasharray="2 3" />
<XAxis dataKey="fechaCorta" stroke={INK} tick={MONO as any} interval="preserveStartEnd" />
<YAxis allowDecimals={false} stroke={INK} tick={MONO as any} />
<Tooltip content={<TooltipBox />} cursor={{ stroke: INK, strokeWidth: 1, strokeDasharray: '2 3' }} />
<CartesianGrid stroke="#e5e7eb" strokeDasharray="2 3" />
<XAxis
dataKey="fechaCorta"
stroke="#e5e7eb"
tick={{ fill: '#333833', fontSize: 11, fontFamily: "'Azeret Mono', monospace", fontVariantNumeric: 'tabular-nums' } as any}
interval="preserveStartEnd"
/>
<YAxis
allowDecimals={false}
stroke="#e5e7eb"
tick={{ fill: '#333833', fontSize: 11, fontFamily: "'Azeret Mono', monospace", fontVariantNumeric: 'tabular-nums' } as any}
/>
<Tooltip
content={<TooltipBox />}
cursor={{ stroke: '#e5e7eb', strokeWidth: 1, strokeDasharray: '2 3' }}
contentStyle={TOOLTIP_CONTENT_STYLE}
/>
<Line
type="monotone"
dataKey="valor"
stroke={PRIMARY}
strokeWidth={2}
dot={{ fill: PRIMARY, stroke: INK, strokeWidth: 1.5, r: 3 }}
activeDot={{ fill: SECONDARY, stroke: INK, strokeWidth: 1.5, r: 5 }}
dot={{ fill: PRIMARY, stroke: BONE_WHITE, strokeWidth: 1, r: 3 }}
activeDot={{ fill: DORADO_UABC, stroke: PRIMARY, strokeWidth: 1, r: 5 }}
animationDuration={anim}
name="Solicitudes"
/>
@@ -153,15 +205,15 @@ export default function Chart(props: Props) {
return (
<ResponsiveContainer width="100%" height={alto}>
<PieChart margin={{ top: 8, right: 8, left: 8, bottom: 8 }}>
<Tooltip content={<TooltipBox />} />
<Tooltip content={<TooltipBox />} contentStyle={TOOLTIP_CONTENT_STYLE} />
<Pie
data={datos}
dataKey="valor"
nameKey="label"
innerRadius="55%"
outerRadius="85%"
stroke={INK}
strokeWidth={1.5}
stroke={BONE_WHITE}
strokeWidth={1}
isAnimationActive={!reduced}
animationDuration={anim}
label={false}
@@ -175,11 +227,16 @@ export default function Chart(props: Props) {
layout="horizontal"
verticalAlign="bottom"
align="center"
iconType="square"
wrapperStyle={{ ...MONO, marginTop: 8 }}
iconType="circle"
wrapperStyle={{
fontFamily: "'Azeret Mono', monospace",
fontSize: 12,
color: '#333833',
marginTop: 12,
}}
formatter={(value: string, entry: any) => {
const v = entry?.payload?.valor;
return v != null ? `${value} (${v})` : value;
return <span style={{ color: '#333833', marginRight: 8, fontFamily: "'Azeret Mono', monospace" }}>{v != null ? `${value} (${v})` : value}</span>;
}}
/>
</PieChart>
@@ -81,20 +81,23 @@ export default function CategoriaForm({ mode, categoria }: Props) {
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(92vw,24rem)]"
className="card bg-bone-white rounded-[12px] p-0 border border-hairline-gray backdrop:bg-negro-institucional/40 w-[min(92vw,24rem)]"
style={{ boxShadow: 'none' }}
>
<form onSubmit={submit} className="p-5 sm:p-6 flex flex-col gap-4" autoComplete="off">
<form onSubmit={submit} className="p-6 flex flex-col gap-4" autoComplete="off">
<div className="flex items-start justify-between gap-3">
<h2 id={titleId} className="text-lg font-semibold">
<h2 id={titleId} className="text-xl text-graphite font-normal">
{mode === 'create' ? 'Nueva categoría' : `Editar: ${categoria?.nombre}`}
</h2>
<button
type="button"
onClick={close}
aria-label="Cerrar"
className="rounded-lg p-1 hover:bg-black/5 transition-colors leading-none text-xl"
className="rounded-[8px] p-1.5 hover:bg-linen text-graphite transition-colors leading-none"
>
<span aria-hidden="true">×</span>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
@@ -122,8 +125,7 @@ export default function CategoriaForm({ mode, categoria }: Props) {
id={`${titleId}-err`}
role="alert"
tabIndex={-1}
className="text-sm"
style={{ color: 'var(--color-danger-text)' }}
className="text-sm text-negro-institucional"
>
{error}
</p>
@@ -54,17 +54,9 @@ export default function EliminarCategoria({ id, nombre, materialesCount }: Props
<>
<button
type="button"
className="btn btn-ghost transition-colors duration-150"
className="btn btn-ghost"
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
onClick={open}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--color-danger)';
e.currentTarget.style.color = 'white';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = 'var(--color-ink)';
}}
>
Eliminar
</button>
@@ -72,37 +64,37 @@ export default function EliminarCategoria({ id, nombre, materialesCount }: Props
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(92vw,28rem)]"
className="card bg-bone-white rounded-[12px] p-0 border border-hairline-gray backdrop:bg-negro-institucional/40 w-[min(92vw,28rem)]"
style={{ boxShadow: 'none' }}
>
<div className="p-5 sm:p-6 flex flex-col gap-4">
<h2 id={titleId} className="text-lg font-semibold">
<div className="p-6 flex flex-col gap-4">
<h2 id={titleId} className="text-xl text-graphite font-normal">
Eliminar categoría
</h2>
{bloqueado ? (
<>
<p className="text-sm">
No se puede eliminar <strong>{nombre}</strong>: tiene{' '}
<p className="text-sm text-graphite">
No se puede eliminar <strong className="font-medium text-graphite">{nombre}</strong>: tiene{' '}
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{materialesCount}</span>{' '}
material{materialesCount === 1 ? '' : 'es'} asociado
{materialesCount === 1 ? '' : 's'}. Reasígnalos primero.
</p>
<a
href={`/admin/inventario?cat=${id}`}
className="text-sm underline"
style={{ color: 'var(--color-primary)' }}
className="text-sm text-verde-institucional hover:text-verde-claro underline"
>
Ver materiales de esta categoría →
</a>
</>
) : (
<p className="text-sm">
¿Seguro que quieres eliminar <strong>{nombre}</strong>?
<p className="text-sm text-graphite">
¿Seguro que quieres eliminar <strong className="font-medium text-graphite">{nombre}</strong>?
</p>
)}
{error && (
<p role="alert" className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
<p role="alert" className="text-sm text-negro-institucional">
{error}
</p>
)}
@@ -114,8 +106,7 @@ export default function EliminarCategoria({ id, nombre, materialesCount }: Props
{!bloqueado && (
<button
type="button"
className="btn"
style={{ background: 'var(--color-danger)', color: 'var(--color-ink)' }}
className="btn btn-danger"
onClick={submit}
disabled={loading}
>
@@ -55,8 +55,8 @@ export default function EliminarMaterial({ id, nombre, tienePrestamos, compact }
<button
type="button"
className={
'btn btn-ghost hover:!bg-[color:var(--color-danger)] hover:!text-[color:var(--color-ink)]' +
(compact ? ' w-full text-sm px-2' : '')
'btn btn-ghost' +
(compact ? ' w-full text-xs px-2' : '')
}
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
onClick={open}
@@ -67,22 +67,23 @@ export default function EliminarMaterial({ id, nombre, tienePrestamos, compact }
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(92vw,26rem)]"
className="card bg-bone-white rounded-[12px] p-0 border border-hairline-gray backdrop:bg-negro-institucional/40 w-[min(92vw,26rem)]"
style={{ boxShadow: 'none' }}
>
<div className="p-5 sm:p-6 flex flex-col gap-4">
<h2 id={titleId} className="text-lg font-semibold">
<div className="p-6 flex flex-col gap-4">
<h2 id={titleId} className="text-xl text-graphite font-normal">
Eliminar material
</h2>
<p className="text-sm">
¿Seguro que quieres eliminar <strong>{nombre}</strong>? Esta acción no se puede deshacer.
<p className="text-sm text-graphite">
¿Seguro que quieres eliminar <strong className="font-medium text-graphite">{nombre}</strong>? Esta acción no se puede deshacer.
</p>
{tienePrestamos && (
<p className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
<p className="text-sm text-negro-institucional">
Este material tiene préstamos asociados y no podrá eliminarse.
</p>
)}
{error && (
<p role="alert" className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
<p role="alert" className="text-sm text-negro-institucional">
{error}
</p>
)}
@@ -92,8 +93,7 @@ export default function EliminarMaterial({ id, nombre, tienePrestamos, compact }
</button>
<button
type="button"
className="btn"
style={{ background: 'var(--color-danger)', color: 'var(--color-ink)' }}
className="btn btn-danger"
onClick={submit}
disabled={loading}
>
@@ -186,7 +186,7 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr
const triggerLabel = mode === 'create' ? 'Nuevo material' : 'Editar';
const triggerClass =
(mode === 'create' ? 'btn btn-primary' : 'btn btn-ghost') +
(compact ? ' w-full text-sm px-2' : '');
(compact ? ' w-full text-xs px-2' : '');
const triggerStyle = compact
? { minHeight: '36px', paddingBlock: '0.25rem' as const }
: undefined;
@@ -202,18 +202,19 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(94vw,36rem)]"
className="card bg-bone-white rounded-[12px] p-0 border border-hairline-gray backdrop:bg-negro-institucional/40 w-[min(94vw,36rem)] max-h-[90vh] overflow-y-auto"
style={{ boxShadow: 'none' }}
>
<form onSubmit={submit} className="p-5 sm:p-6 flex flex-col gap-4" autoComplete="off">
<form onSubmit={submit} className="p-6 flex flex-col gap-4" autoComplete="off">
<div className="flex items-start justify-between gap-3">
<h2 id={titleId} className="text-lg font-semibold">
<h2 id={titleId} className="text-xl text-graphite font-normal">
{mode === 'create' ? 'Nuevo material' : `Editar: ${material?.nombre}`}
</h2>
<button
type="button"
onClick={close}
aria-label="Cerrar"
className="rounded-lg p-1.5 hover:bg-black/5 transition-colors"
className="rounded-[8px] p-1.5 hover:bg-linen text-graphite transition-colors"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
@@ -226,13 +227,12 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr
<label className="label" htmlFor={`${titleId}-file`}>Foto</label>
<div className="flex items-start gap-3">
<div
className="w-24 h-24 shrink-0 grid place-items-center bg-[color:var(--color-chalk)]"
style={{ border: '2px solid var(--color-ink)' }}
className="w-24 h-24 shrink-0 grid place-items-center bg-linen rounded-[12px] border border-hairline-gray overflow-hidden"
>
{currentImg ? (
<img src={currentImg} alt="" className="w-full h-full object-cover" />
) : (
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" className="stroke-graphite" strokeWidth="1.5" aria-hidden="true">
<rect x="3" y="4" width="18" height="16" rx="1" />
<path d="M3 16l5-5 4 4 3-3 6 6" />
<circle cx="8" cy="9" r="1.5" />
@@ -251,7 +251,7 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr
{(file || imagenPath) && (
<button
type="button"
className="btn btn-ghost self-start"
className="btn btn-ghost self-start text-xs"
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
onClick={() => {
setFile(null);
@@ -326,16 +326,16 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr
/>
</div>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<label className="flex items-start gap-2 text-sm cursor-pointer text-graphite">
<input
type="checkbox"
checked={trackeado}
onChange={(e) => setTrackeado(e.target.checked)}
className="mt-1"
className="mt-1 accent-verde-institucional"
/>
<span>
<span className="font-medium">Rastrear cada unidad por separado</span>
<span className="block opacity-70 text-xs">
<span className="block text-graphite/70 text-xs">
Ej. Laptop-01, Laptop-02… El stock se calcula del número de unidades registradas abajo.
</span>
</span>
@@ -359,10 +359,10 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr
style={{ fontVariantNumeric: 'tabular-nums' }}
/>
{trackeado && (
<p className="text-xs opacity-70 mt-1">Se calcula del número de unidades.</p>
<p className="text-xs text-graphite/70 mt-1">Se calcula del número de unidades.</p>
)}
{!trackeado && mode === 'edit' && material && (
<p className="text-xs opacity-60 mt-1">
<p className="text-xs text-graphite/60 mt-1">
Prestados: {material.cantidad_total - material.cantidad_disponible}
</p>
)}
@@ -389,8 +389,7 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr
id={`${titleId}-err`}
role="alert"
tabIndex={-1}
className="text-sm"
style={{ color: 'var(--color-danger-text)' }}
className="text-sm text-negro-institucional"
>
{error}
</p>
@@ -408,10 +407,7 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr
{/* Gestor de unidades — solo edit + trackeado */}
{mode === 'edit' && material && material.trackeado_por_unidad && (
<div
className="border-t-2 p-5 sm:p-6"
style={{ borderColor: 'var(--color-ink)', background: 'var(--color-chalk)' }}
>
<div className="border-t border-hairline-gray p-6 bg-linen rounded-b-[12px]">
<UnidadesManager materialId={material.id} />
</div>
)}
@@ -88,7 +88,7 @@ export default function UnidadesManager({ materialId }: { materialId: number })
return (
<div>
<h3 className="text-sm font-semibold uppercase tracking-wider mb-3">Unidades individuales</h3>
<h3 className="eyebrow mb-3">Unidades individuales</h3>
<form onSubmit={crear} className="flex gap-2 mb-4">
<input
@@ -110,9 +110,9 @@ export default function UnidadesManager({ materialId }: { materialId: number })
</form>
{loading ? (
<p className="text-sm opacity-70">Cargando…</p>
<p className="text-sm text-graphite/70">Cargando…</p>
) : unidades.length === 0 ? (
<p className="text-sm opacity-70">Sin unidades registradas.</p>
<p className="text-sm text-graphite/70">Sin unidades registradas.</p>
) : (
<div className="flex flex-col gap-2">
{unidades.map((u) => {
@@ -120,7 +120,7 @@ export default function UnidadesManager({ materialId }: { materialId: number })
return (
<div
key={u.id}
className="card p-3 grid grid-cols-1 sm:grid-cols-[1fr_auto_auto] gap-2 items-center"
className="card-flat p-3.5 grid grid-cols-1 sm:grid-cols-[1fr_auto_auto] gap-2 items-center bg-bone-white border border-hairline-gray rounded-[12px]"
>
<input
type="text"
@@ -151,7 +151,7 @@ export default function UnidadesManager({ materialId }: { materialId: number })
</select>
<button
type="button"
className="btn btn-ghost"
className="btn btn-danger text-xs px-3"
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
disabled={bloqueada}
onClick={() => eliminar(u)}
@@ -64,27 +64,28 @@ export default function EliminarMaestro({ id, nombre, alumnosCount }: Props) {
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(92vw,28rem)]"
className="card bg-bone-white rounded-[12px] p-0 border border-hairline-gray backdrop:bg-negro-institucional/40 w-[min(92vw,28rem)]"
style={{ boxShadow: 'none' }}
>
<div className="p-5 sm:p-6 flex flex-col gap-4">
<h2 id={titleId} className="text-lg font-semibold">
<div className="p-6 flex flex-col gap-4">
<h2 id={titleId} className="text-xl text-graphite font-normal">
Eliminar maestro
</h2>
{bloqueado ? (
<p className="text-sm">
No se puede eliminar <strong>{nombre}</strong>: tiene{' '}
<p className="text-sm text-graphite">
No se puede eliminar <strong className="font-medium text-graphite">{nombre}</strong>: tiene{' '}
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{alumnosCount}</span>{' '}
alumno{alumnosCount === 1 ? '' : 's'} como tutor. Reasígnalos primero desde su perfil.
</p>
) : (
<p className="text-sm">
¿Seguro que quieres eliminar <strong>{nombre}</strong>?
<p className="text-sm text-graphite">
¿Seguro que quieres eliminar <strong className="font-medium text-graphite">{nombre}</strong>?
</p>
)}
{error && (
<p role="alert" className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
<p role="alert" className="text-sm text-negro-institucional">
{error}
</p>
)}
@@ -96,8 +97,7 @@ export default function EliminarMaestro({ id, nombre, alumnosCount }: Props) {
{!bloqueado && (
<button
type="button"
className="btn"
style={{ background: 'var(--color-danger)', color: 'var(--color-ink)' }}
className="btn btn-danger"
onClick={submit}
disabled={loading}
>
+15 -12
View File
@@ -87,20 +87,23 @@ export default function MaestroForm({ mode, maestro }: Props) {
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(92vw,24rem)]"
className="card bg-bone-white rounded-[12px] p-0 border border-hairline-gray backdrop:bg-negro-institucional/40 w-[min(92vw,24rem)]"
style={{ boxShadow: 'none' }}
>
<form onSubmit={submit} className="p-5 sm:p-6 flex flex-col gap-4" autoComplete="off">
<form onSubmit={submit} className="p-6 flex flex-col gap-4" autoComplete="off">
<div className="flex items-start justify-between gap-3">
<h2 id={titleId} className="text-lg font-semibold">
<h2 id={titleId} className="text-xl text-graphite font-normal">
{mode === 'create' ? 'Nuevo maestro' : `Editar: ${maestro?.nombre}`}
</h2>
<button
type="button"
onClick={close}
aria-label="Cerrar"
className="rounded-lg p-1 hover:bg-black/5 transition-colors leading-none text-xl"
className="rounded-[8px] p-1.5 hover:bg-linen text-graphite transition-colors leading-none"
>
<span aria-hidden="true">×</span>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
@@ -122,23 +125,24 @@ export default function MaestroForm({ mode, maestro }: Props) {
/>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<label className="flex items-center gap-2 text-sm cursor-pointer text-graphite">
<input
type="checkbox"
checked={activo}
onChange={(e) => setActivo(e.target.checked)}
className="accent-verde-institucional"
/>
Activo (visible en el catálogo de maestros)
<span>Activo <span className="text-graphite/70 text-xs">(visible en el catálogo de maestros)</span></span>
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<label className="flex items-start gap-2 text-sm cursor-pointer text-graphite">
<input
type="checkbox"
className="mt-0.5"
className="mt-0.5 accent-verde-institucional"
checked={esTutor}
onChange={(e) => setEsTutor(e.target.checked)}
/>
<span>Es tutor <span className="opacity-70">(aparece en la lista de tutores del perfil de alumnos)</span></span>
<span>Es tutor <span className="text-graphite/70 text-xs">(aparece en la lista de tutores del perfil de alumnos)</span></span>
</label>
{error && (
@@ -147,8 +151,7 @@ export default function MaestroForm({ mode, maestro }: Props) {
id={`${titleId}-err`}
role="alert"
tabIndex={-1}
className="text-sm"
style={{ color: 'var(--color-danger-text)' }}
className="text-sm text-negro-institucional"
>
{error}
</p>
+34 -24
View File
@@ -107,7 +107,7 @@ export function Autocomplete({ name, endpoint, label, placeholder, initialValue,
ref={inputRef}
id={inputId}
type="text"
className="input"
className="input rounded-[8px]"
role="combobox"
aria-autocomplete="list"
aria-expanded={open}
@@ -119,13 +119,18 @@ export function Autocomplete({ name, endpoint, label, placeholder, initialValue,
setText(e.target.value);
if (value) setValue(''); // el usuario editó → invalidar la selección previa
}}
onFocus={() => items.length && setOpen(true)}
onFocus={() => items.length > 0 && setOpen(true)}
onKeyDown={onKey}
autoComplete="off"
spellCheck={false}
/>
{(text || value) && (
<button type="button" className="btn btn-ghost px-3" onClick={clear} aria-label="Limpiar">
<button
type="button"
className="btn btn-ghost px-3"
onClick={clear}
aria-label="Limpiar"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
@@ -136,32 +141,37 @@ export function Autocomplete({ name, endpoint, label, placeholder, initialValue,
<ul
id={listId}
role="listbox"
className="absolute z-10 mt-1 w-full bg-white rounded-lg max-h-64 overflow-auto"
style={{ border: '1px solid color-mix(in oklab, var(--color-ink) 15%, transparent)' }}
className="absolute z-50 mt-1 w-full bg-bone-white border border-hairline-gray rounded-[8px] max-h-64 overflow-auto shadow-none p-1"
>
{loading && items.length === 0 && (
<li className="px-3 py-2 text-sm opacity-70">Buscando…</li>
<li className="px-3 py-2 text-sm text-graphite opacity-70">Buscando…</li>
)}
{!loading && items.length === 0 && (
<li className="px-3 py-2 text-sm opacity-70">Sin resultados</li>
<li className="px-3 py-2 text-sm text-graphite opacity-70">Sin resultados</li>
)}
{items.map((it, i) => (
<li
key={it.id}
id={`${listId}-opt-${i}`}
role="option"
aria-selected={i === active}
className="px-3 py-2 text-sm cursor-pointer transition-colors"
style={i === active ? { background: 'color-mix(in oklab, var(--color-primary) 10%, transparent)' } : undefined}
onMouseEnter={() => setActive(i)}
onMouseDown={(e) => {
e.preventDefault();
pick(it);
}}
>
{it.label}
</li>
))}
{items.map((it, i) => {
const isSelected = String(it.id) === value;
const isFocused = i === active;
return (
<li
key={it.id}
id={`${listId}-opt-${i}`}
role="option"
aria-selected={isFocused || isSelected}
className={`px-3 py-2 text-sm cursor-pointer transition-colors rounded-[8px] ${
isFocused || isSelected
? 'bg-linen text-verde-institucional font-medium'
: 'text-graphite hover:bg-linen'
}`}
onMouseDown={(e) => {
e.preventDefault();
pick(it);
}}
>
{it.label}
</li>
);
})}
</ul>
)}
</div>
@@ -82,24 +82,24 @@ function AprobarDialog({ prestamo }: { prestamo: Prestamo }) {
return (
<>
<button type="button" className="btn btn-primary" onClick={dlg.open}>Aprobar</button>
<dialog ref={dlg.ref} className="rounded-lg p-0 max-w-md w-[calc(100%-2rem)] bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40">
<form onSubmit={submit} className="p-6">
<h2 className="text-lg font-semibold">Aprobar solicitud</h2>
<p className="text-sm opacity-70 mt-1">
<dialog ref={dlg.ref} className="card rounded-[12px] max-w-md w-[calc(100%-2rem)] backdrop:bg-black/40 shadow-none">
<form onSubmit={submit} className="p-0">
<h2 className="text-xl md:text-2xl font-light text-graphite">Aprobar solicitud</h2>
<p className="text-sm text-graphite/70 mt-1">
{prestamo.alumno?.nombre ?? prestamo.alumno?.email} solicita, para {prestamo.maestro_responsable}:
</p>
<ul className="text-sm mt-1 list-disc pl-5">
<ul className="text-sm mt-2 list-disc pl-5 space-y-1">
{prestamo.items.map((i, idx) => (
<li key={idx}>
<strong>{i.cantidad}</strong> de <strong>{i.material?.nombre ?? '—'}</strong>
{(i.material?.cantidad_disponible ?? 0) < i.cantidad && (
<span style={{ color: 'var(--color-danger-text)' }}> — stock insuficiente ({i.material?.cantidad_disponible ?? 0} disp.)</span>
<span className="text-dorado-uabc font-medium"> — stock insuficiente ({i.material?.cantidad_disponible ?? 0} disp.)</span>
)}
</li>
))}
</ul>
{stockInsuficiente && (
<div role="alert" className="mt-3 rounded-lg p-2 text-sm" style={{ background: 'color-mix(in oklab, var(--color-danger) 12%, transparent)', color: 'var(--color-danger-text)' }}>
<div role="alert" className="mt-3 rounded-[12px] p-3 text-sm bg-negro-institucional text-bone-white">
Uno o más materiales no tienen stock suficiente.
</div>
)}
@@ -128,7 +128,7 @@ function AprobarDialog({ prestamo }: { prestamo: Prestamo }) {
/>
</div>
{error && (
<div role="alert" className="mt-3 rounded-lg p-2 text-sm" style={{ background: 'color-mix(in oklab, var(--color-danger) 12%, transparent)', color: 'var(--color-danger-text)' }}>
<div role="alert" className="mt-3 rounded-[12px] p-3 text-sm bg-negro-institucional text-bone-white">
{error}
</div>
)}
@@ -184,18 +184,18 @@ function RechazarDialog({ prestamo }: { prestamo: Prestamo }) {
<>
<button
type="button"
className="btn btn-ghost hover:!text-[color:var(--color-danger)]"
className="btn btn-danger"
onClick={dlg.open}
>
Rechazar
</button>
<dialog ref={dlg.ref} className="rounded-lg p-0 max-w-md w-[calc(100%-2rem)] bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40">
<form onSubmit={submit} className="p-6">
<h2 className="text-lg font-semibold">Rechazar solicitud</h2>
<p className="text-sm opacity-70 mt-1">
<dialog ref={dlg.ref} className="card rounded-[12px] max-w-md w-[calc(100%-2rem)] backdrop:bg-black/40 shadow-none">
<form onSubmit={submit} className="p-0">
<h2 className="text-xl md:text-2xl font-light text-graphite">Rechazar solicitud</h2>
<p className="text-sm text-graphite/70 mt-1">
{resumenNombres(prestamo.items)} · {prestamo.alumno?.nombre ?? prestamo.alumno?.email}
</p>
<p className="text-sm opacity-70 mt-1">
<p className="text-sm text-graphite/70 mt-1">
Explica al alumno por qué se rechaza. Este texto queda registrado.
</p>
<div className="mt-4">
@@ -213,7 +213,7 @@ function RechazarDialog({ prestamo }: { prestamo: Prestamo }) {
/>
</div>
{error && (
<div role="alert" className="mt-3 rounded-lg p-2 text-sm" style={{ background: 'color-mix(in oklab, var(--color-danger) 12%, transparent)', color: 'var(--color-danger-text)' }}>
<div role="alert" className="mt-3 rounded-[12px] p-3 text-sm bg-negro-institucional text-bone-white">
{error}
</div>
)}
@@ -221,9 +221,8 @@ function RechazarDialog({ prestamo }: { prestamo: Prestamo }) {
<button type="button" className="btn btn-ghost" onClick={dlg.close} disabled={loading}>Cancelar</button>
<button
type="submit"
className="btn"
className="btn btn-danger"
disabled={loading}
style={{ background: 'var(--color-danger)', color: 'var(--color-ink)' }}
>
{loading ? 'Rechazando…' : 'Rechazar solicitud'}
</button>
@@ -40,23 +40,23 @@ export default function MarcarDevuelto({ prestamoId, resumenMateriales }: { pres
return (
<>
<button type="button" className="btn btn-secondary" onClick={() => ref.current?.showModal()}>
<button type="button" className="btn btn-primary" onClick={() => ref.current?.showModal()}>
Marcar devuelto
</button>
<dialog ref={ref} className="rounded-lg p-0 max-w-md w-[calc(100%-2rem)] bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40">
<form onSubmit={submit} className="p-6">
<h2 className="text-lg font-semibold">Confirmar devolución</h2>
<p className="text-sm opacity-80 mt-2">
¿Confirmas la devolución de <strong>{resumenMateriales}</strong>? El material regresará al inventario disponible.
<dialog ref={ref} className="card rounded-[12px] max-w-md w-[calc(100%-2rem)] backdrop:bg-black/40 shadow-none">
<form onSubmit={submit} className="p-0">
<h2 className="text-xl md:text-2xl font-light text-graphite">Confirmar devolución</h2>
<p className="text-sm text-graphite/80 mt-2">
¿Confirmas la devolución de <strong className="text-graphite">{resumenMateriales}</strong>? El material regresará al inventario disponible.
</p>
{error && (
<div role="alert" className="mt-3 rounded-lg p-2 text-sm" style={{ background: 'color-mix(in oklab, var(--color-danger) 12%, transparent)', color: 'var(--color-danger-text)' }}>
<div role="alert" className="mt-3 rounded-[12px] p-3 text-sm bg-negro-institucional text-bone-white">
{error}
</div>
)}
<div className="mt-6 flex justify-end gap-2">
<button type="button" className="btn btn-ghost" onClick={() => ref.current?.close()} disabled={loading}>Cancelar</button>
<button type="submit" className="btn btn-secondary" disabled={loading}>
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? 'Registrando…' : 'Confirmar devolución'}
</button>
</div>
+109 -79
View File
@@ -94,8 +94,7 @@ function ReasignarUnidad({
return (
<button
type="button"
className="btn btn-ghost"
style={{ minHeight: '30px', paddingBlock: '0.15rem', fontSize: '0.75rem' }}
className="btn btn-ghost text-xs py-1 px-2.5 min-h-[28px]"
onClick={abrir}
>
Cambiar unidad
@@ -103,10 +102,9 @@ function ReasignarUnidad({
);
}
return (
<div className="flex flex-wrap items-center gap-1 mt-1">
<div className="flex flex-wrap items-center gap-1.5 mt-1">
<select
className="input"
style={{ minHeight: '32px', paddingBlock: '0.2rem', fontSize: '0.85rem' }}
className="input text-xs py-1 px-2 min-h-[32px] w-auto"
value={selected}
onChange={(e) => setSelected(e.target.value)}
disabled={loading || saving}
@@ -118,8 +116,7 @@ function ReasignarUnidad({
</select>
<button
type="button"
className="btn btn-primary"
style={{ minHeight: '32px', paddingBlock: '0.2rem', fontSize: '0.85rem' }}
className="btn btn-primary text-xs py-1 px-2.5 min-h-[32px]"
onClick={confirmar}
disabled={!selected || saving}
>
@@ -127,8 +124,7 @@ function ReasignarUnidad({
</button>
<button
type="button"
className="btn btn-ghost"
style={{ minHeight: '32px', paddingBlock: '0.2rem', fontSize: '0.85rem' }}
className="btn btn-ghost text-xs py-1 px-2.5 min-h-[32px]"
onClick={() => setOpen(false)}
disabled={saving}
>
@@ -170,84 +166,118 @@ export default function VerDetalles({ prestamoId }: { prestamoId: number }) {
const canReasignar = data && ['aprobado', 'activo'].includes(data.prestamo.estado);
const getChipCls = (st: string) => {
const map: Record<string, string> = {
pendiente: 'chip-pendiente',
aprobado: 'chip-aprobado',
activo: 'chip-activo',
devuelto: 'chip-devuelto',
rechazado: 'chip-rechazado',
vencido: 'chip-vencido',
};
return map[st] ?? 'chip-pendiente';
};
return (
<>
<button type="button" className="btn btn-ghost" onClick={open}>Detalles</button>
<dialog ref={ref} className="rounded-lg p-0 max-w-lg w-[calc(100%-2rem)] bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40">
<div className="p-6">
<div className="flex items-start justify-between gap-4">
<h2 className="text-lg font-semibold">Detalle del préstamo</h2>
<button type="button" className="rounded-lg p-1.5 hover:bg-black/5 transition-colors" onClick={() => ref.current?.close()} aria-label="Cerrar">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<button type="button" className="btn btn-ghost" onClick={open}>Ver detalles</button>
<dialog ref={ref} className="card rounded-[12px] max-w-lg w-[calc(100%-2rem)] backdrop:bg-black/40 shadow-none">
<div className="flex items-start justify-between gap-4">
<h2 className="text-xl md:text-2xl font-light text-graphite">Detalle del préstamo</h2>
<button
type="button"
className="rounded-full p-2 text-graphite/70 hover:text-graphite hover:bg-linen transition-colors"
onClick={() => ref.current?.close()}
aria-label="Cerrar"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{loading && <p className="mt-4 text-sm text-graphite/70">Cargando…</p>}
{error && (
<div role="alert" className="mt-3 rounded-[12px] p-3 text-sm bg-negro-institucional text-bone-white">
{error}
</div>
)}
{loading && <p className="mt-4 text-sm opacity-70">Cargando…</p>}
{error && (
<div role="alert" className="mt-3 rounded-lg p-2 text-sm" style={{ background: 'color-mix(in oklab, var(--color-danger) 12%, transparent)', color: 'var(--color-danger-text)' }}>
{error}
</div>
)}
{data && (
<>
<dl className="mt-4 grid grid-cols-3 gap-y-2 text-sm">
<dt className="opacity-70">Alumno</dt>
<dd className="col-span-2">{data.prestamo.alumno?.nombre ?? data.prestamo.alumno?.email}</dd>
<dt className="opacity-70">Maestro</dt>
<dd className="col-span-2 break-words">{data.prestamo.maestro_responsable}</dd>
<dt className="opacity-70">Materiales</dt>
<dd className="col-span-2">
<ul className="list-disc pl-4 space-y-2">
{data.prestamo.items.map((i) => (
<li key={i.id}>
<strong style={{ fontVariantNumeric: 'tabular-nums' }}>{i.cantidad}</strong>× {i.material?.nombre ?? '—'}{' '}
<span className="opacity-70">· Inv. {i.material?.numero_inventario ?? '—'}</span>
{i.material_unidad?.etiqueta && (
<div className="text-xs">
<span className="opacity-70">Unidad: </span>
<span className="font-semibold">{i.material_unidad.etiqueta}</span>
</div>
)}
{i.descripcion && <div className="text-xs opacity-70">{i.descripcion}</div>}
{data && (
<>
<dl className="mt-4 grid grid-cols-3 gap-y-3 text-sm">
<dt className="text-graphite/70">Alumno</dt>
<dd className="col-span-2 text-graphite font-medium">{data.prestamo.alumno?.nombre ?? data.prestamo.alumno?.email}</dd>
<dt className="text-graphite/70">Maestro</dt>
<dd className="col-span-2 break-words text-graphite">{data.prestamo.maestro_responsable}</dd>
<dt className="text-graphite/70">Materiales</dt>
<dd className="col-span-2">
<ul className="border-y border-hairline-gray my-2 divide-y divide-hairline-gray">
{data.prestamo.items.map((i) => (
<li key={i.id} className="py-2.5 first:pt-2 last:pb-2 border-b border-hairline-gray last:border-b-0">
<div className="flex items-baseline justify-between gap-2">
<div>
<strong style={{ fontVariantNumeric: 'tabular-nums' }}>{i.cantidad}×</strong>{' '}
<span className="font-medium text-graphite">{i.material?.nombre ?? '—'}</span>
<span className="text-xs text-graphite/60 ml-2">Inv. {i.material?.numero_inventario ?? '—'}</span>
</div>
{canReasignar && i.material_unidad && (
<ReasignarUnidad solicitudId={data.prestamo.id} item={i} />
)}
</li>
))}
</ul>
</dd>
<dt className="opacity-70">Solicitado</dt>
<dd className="col-span-2">{fmt.format(new Date(data.prestamo.fecha_solicitud))}</dd>
{data.prestamo.fecha_aprobacion && (<><dt className="opacity-70">Aprobado</dt><dd className="col-span-2">{fmt.format(new Date(data.prestamo.fecha_aprobacion))}</dd></>)}
{data.prestamo.fecha_devolucion_estimada && (<><dt className="opacity-70">Devolver antes</dt><dd className="col-span-2">{fmt.format(new Date(data.prestamo.fecha_devolucion_estimada + 'T00:00:00'))}</dd></>)}
{data.prestamo.notas && (<><dt className="opacity-70">Notas</dt><dd className="col-span-2 whitespace-pre-wrap">{data.prestamo.notas}</dd></>)}
</dl>
<h3 className="mt-6 mb-2 font-semibold text-sm">Historial de cambios</h3>
{data.audit_log.length === 0 ? (
<p className="text-sm opacity-70">Sin cambios registrados.</p>
) : (
<ul className="text-sm divide-y" style={{ borderColor: 'color-mix(in oklab, var(--color-ink) 10%, transparent)' }}>
{data.audit_log.map((e, i) => {
const fecha = e.at ?? e.created_at ?? e.fecha ?? e.timestamp;
return (
<li key={i} className="py-2 flex items-baseline gap-2 flex-wrap">
<span className="font-medium">{e.accion ?? 'cambio'}</span>
<span className="opacity-80">
{(e.estado_anterior ?? '—')} → {(e.estado_nuevo ?? '—')}
</span>
{fecha && <span className="opacity-60 ml-auto text-xs">{fmt.format(new Date(fecha))}</span>}
</li>
);
})}
</div>
{i.material_unidad?.etiqueta && (
<div className="text-xs text-graphite/70 mt-1">
Unidad: <span className="font-medium text-graphite">{i.material_unidad.etiqueta}</span>
</div>
)}
{i.descripcion && <div className="text-xs text-graphite/60 mt-0.5">{i.descripcion}</div>}
</li>
))}
</ul>
)}
</>
)}
</div>
</dd>
<dt className="text-graphite/70">Solicitado</dt>
<dd className="col-span-2 text-graphite">{fmt.format(new Date(data.prestamo.fecha_solicitud))}</dd>
{data.prestamo.fecha_aprobacion && (<><dt className="text-graphite/70">Aprobado</dt><dd className="col-span-2 text-graphite">{fmt.format(new Date(data.prestamo.fecha_aprobacion))}</dd></>)}
{data.prestamo.fecha_devolucion_estimada && (<><dt className="text-graphite/70">Devolver antes</dt><dd className="col-span-2 text-graphite">{fmt.format(new Date(data.prestamo.fecha_devolucion_estimada + 'T00:00:00'))}</dd></>)}
{data.prestamo.notas && (<><dt className="text-graphite/70">Notas</dt><dd className="col-span-2 text-graphite whitespace-pre-wrap">{data.prestamo.notas}</dd></>)}
</dl>
<h3 className="mt-6 mb-3 text-base font-medium text-graphite">Historial de cambios</h3>
{data.audit_log.length === 0 ? (
<p className="text-sm text-graphite/70">Sin cambios registrados.</p>
) : (
<div className="relative pl-4 border-l border-hairline-gray space-y-3 my-2">
{data.audit_log.map((e, i) => {
const fecha = e.at ?? e.created_at ?? e.fecha ?? e.timestamp;
const estadoNuevo = e.estado_nuevo;
const estadoAnt = e.estado_anterior;
return (
<div key={i} className="relative">
<div className="absolute -left-[21px] top-1.5 w-2 h-2 rounded-full bg-verde-institucional" />
<div className="flex items-center justify-between gap-2 flex-wrap text-sm">
<span className="font-medium text-graphite capitalize">{e.accion ?? 'Cambio'}</span>
{fecha && <span className="text-xs text-graphite/60">{fmt.format(new Date(fecha))}</span>}
</div>
{(estadoAnt || estadoNuevo) && (
<div className="flex items-center gap-1.5 mt-1 text-xs">
{estadoAnt && (
<>
<span className={`chip ${getChipCls(estadoAnt)}`}>{estadoAnt}</span>
<span className="text-graphite/40">→</span>
</>
)}
{estadoNuevo && (
<span className={`chip ${getChipCls(estadoNuevo)}`}>{estadoNuevo}</span>
)}
</div>
)}
</div>
);
})}
</div>
)}
</>
)}
</dialog>
</>
);
+41 -14
View File
@@ -11,6 +11,7 @@ export default function BanearForm({ profile, adminSelf }: Props) {
const firstFieldRef = useRef<HTMLTextAreaElement | null>(null);
const errorRef = useRef<HTMLParagraphElement | null>(null);
const [razon, setRazon] = useState('');
const [expiresAt, setExpiresAt] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -21,6 +22,7 @@ export default function BanearForm({ profile, adminSelf }: Props) {
if (adminSelf) return;
setError(null);
setRazon('');
setExpiresAt('');
dialogRef.current?.showModal();
queueMicrotask(() => firstFieldRef.current?.focus());
};
@@ -54,10 +56,18 @@ export default function BanearForm({ profile, adminSelf }: Props) {
setLoading(true);
setError(null);
try {
const payload: { profile_id: string; razon: string; expires_at?: string } = {
profile_id: profile.id,
razon: trimmed,
};
if (expiresAt) {
payload.expires_at = new Date(expiresAt + 'T23:59:59').toISOString();
}
const res = await fetch('/api/admin/baneos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile_id: profile.id, razon: trimmed }),
body: JSON.stringify(payload),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json?.error ?? 'No se pudo banear');
@@ -79,11 +89,10 @@ export default function BanearForm({ profile, adminSelf }: Props) {
<>
<button
type="button"
className="btn btn-ghost"
className="btn btn-danger text-xs px-3"
style={{
minHeight: '36px',
paddingBlock: '0.25rem',
color: adminSelf ? undefined : 'var(--color-danger-text)',
opacity: adminSelf ? 0.5 : 1,
cursor: adminSelf ? 'not-allowed' : undefined,
}}
@@ -98,24 +107,27 @@ export default function BanearForm({ profile, adminSelf }: Props) {
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(92vw,28rem)]"
className="card bg-bone-white rounded-[12px] p-0 border border-hairline-gray backdrop:bg-negro-institucional/40 w-[min(92vw,28rem)]"
style={{ boxShadow: 'none' }}
>
<form onSubmit={submit} className="p-5 sm:p-6 flex flex-col gap-4" autoComplete="off">
<form onSubmit={submit} className="p-6 flex flex-col gap-4" autoComplete="off">
<div className="flex items-start justify-between gap-3">
<h2 id={titleId} className="text-lg font-semibold">
<h2 id={titleId} className="text-xl text-graphite font-normal">
Banear a {label}
</h2>
<button
type="button"
onClick={close}
aria-label="Cerrar"
className="rounded-lg p-1 hover:bg-black/5 transition-colors leading-none text-xl"
className="rounded-[8px] p-1.5 hover:bg-linen text-graphite transition-colors leading-none"
>
<span aria-hidden="true">×</span>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<p className="text-sm" style={{ color: 'var(--color-pencil)' }}>
<p className="text-sm text-graphite/70">
El usuario no podrá acceder al sistema hasta ser desbaneado.
</p>
@@ -137,19 +149,35 @@ export default function BanearForm({ profile, adminSelf }: Props) {
aria-describedby={error ? `${titleId}-err` : undefined}
placeholder="Explica el motivo — el usuario lo verá en su pantalla de bloqueo."
/>
<p className="text-xs mt-1" style={{ color: 'var(--color-pencil)' }}>
<p className="text-xs text-graphite/60 mt-1">
{razon.length}/500
</p>
</div>
<div>
<label className="label" htmlFor={`${titleId}-expires`}>
Fecha de expiración (opcional)
</label>
<input
id={`${titleId}-expires`}
type="date"
className="input"
value={expiresAt}
min={new Date().toISOString().split('T')[0]}
onChange={(e) => setExpiresAt(e.target.value)}
/>
<p className="text-xs text-graphite/60 mt-1">
Opcional. Si se omite, el baneo será indefinido hasta desbanear manualmente.
</p>
</div>
{error && (
<p
ref={errorRef}
id={`${titleId}-err`}
role="alert"
tabIndex={-1}
className="text-sm"
style={{ color: 'var(--color-danger-text)' }}
className="text-sm text-negro-institucional"
>
{error}
</p>
@@ -161,8 +189,7 @@ export default function BanearForm({ profile, adminSelf }: Props) {
</button>
<button
type="submit"
className="btn"
style={{ background: 'var(--color-danger)', color: 'var(--color-ink)' }}
className="btn btn-danger"
disabled={loading}
>
{loading ? 'Baneando…' : 'Banear'}
@@ -37,7 +37,7 @@ export default function DesbanearButton({ baneoId, nombre }: Props) {
<>
<button
type="button"
className="btn btn-primary"
className="btn btn-secondary text-xs px-3"
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
onClick={submit}
disabled={loading}
@@ -48,17 +48,18 @@ export default function DesbanearButton({ baneoId, nombre }: Props) {
<dialog
ref={errorDialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(92vw,26rem)]"
className="card bg-bone-white rounded-[12px] p-0 border border-hairline-gray backdrop:bg-negro-institucional/40 w-[min(92vw,26rem)]"
style={{ boxShadow: 'none' }}
>
<div className="p-5 sm:p-6 flex flex-col gap-4">
<h2 id={titleId} className="text-lg font-semibold">
<div className="p-6 flex flex-col gap-4">
<h2 id={titleId} className="text-xl text-graphite font-normal">
No se pudo desbanear
</h2>
<p className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
<p className="text-sm text-negro-institucional">
{error}
</p>
<div className="flex justify-end pt-2">
<button type="button" className="btn btn-primary" onClick={() => errorDialogRef.current?.close()}>
<button type="button" className="btn btn-ghost" onClick={() => errorDialogRef.current?.close()}>
Entendido
</button>
</div>
+42 -9
View File
@@ -6,32 +6,65 @@ type Props = {
};
export default function AgregarMaterial({ material }: Props) {
const { addItem, has } = useCart();
const { items, addItem, updateCantidad, removeItem, has } = useCart();
const sinStock = material.cantidad_disponible === 0;
const enCarrito = has(material.id);
if (enCarrito) {
const item = items.find((i) => i.material_id === material.id);
const cantidad = item?.cantidad ?? 1;
return (
<button type="button" className="btn btn-secondary w-full" disabled>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M4.5 12.75l6 6 9-13.5" />
</svg>
En tu solicitud
</button>
<div className="flex items-center justify-between gap-2 w-full">
<div className="flex items-center gap-1">
<button
type="button"
className="btn btn-ghost rounded-full w-8 h-8 p-0 flex items-center justify-center shrink-0"
aria-label={`Disminuir cantidad de ${material.nombre}`}
onClick={() => {
if (cantidad > 1) {
updateCantidad(material.id, cantidad - 1);
} else {
removeItem(material.id);
toast({ kind: 'info', title: 'Eliminado de tu solicitud', description: material.nombre });
}
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M5 12h14" />
</svg>
</button>
<span className="font-sans text-sm font-medium text-graphite min-w-[1.75rem] text-center" style={{ fontVariantNumeric: 'tabular-nums' }}>
{cantidad}
</span>
<button
type="button"
className="btn btn-ghost rounded-full w-8 h-8 p-0 flex items-center justify-center shrink-0"
disabled={cantidad >= material.cantidad_disponible}
aria-label={`Aumentar cantidad de ${material.nombre}`}
onClick={() => updateCantidad(material.id, cantidad + 1)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M12 5v14M5 12h14" />
</svg>
</button>
</div>
<span className="text-xs text-graphite/70 truncate">En tu solicitud</span>
</div>
);
}
return (
<button
type="button"
className="btn btn-primary w-full"
className="btn btn-secondary w-full"
disabled={sinStock}
onClick={() => {
addItem(material);
toast({ kind: 'success', title: 'Agregado a tu solicitud', description: material.nombre });
}}
>
{sinStock ? 'Sin stock' : 'Agregar'}
{sinStock ? 'Sin stock' : 'Agregar al carrito'}
</button>
);
}
+10 -5
View File
@@ -82,25 +82,30 @@ export default function BuscadorCatalogo({ q: qInitial = '' }: { q?: string }) {
<label className="label sr-only" htmlFor="buscador-catalogo">
Buscar material
</label>
<div className="relative">
<div className="relative flex items-center">
<span className="absolute left-3.5 pointer-events-none text-graphite/50 flex items-center" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="stroke-graphite">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
</span>
<input
id="buscador-catalogo"
className="input"
className="input pl-11 pr-10 rounded-[8px]"
type="search"
placeholder="Buscar por nombre o número de inventario…"
value={q}
onChange={(e) => onChange(e.target.value)}
autoComplete="off"
style={{ paddingRight: q ? '2.5rem' : undefined }}
/>
{q && (
<button
type="button"
onClick={clear}
aria-label="Limpiar búsqueda"
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded-[2px] hover:bg-black/5 transition-colors"
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 rounded-full hover:bg-linen text-graphite transition-colors"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
+2 -9
View File
@@ -50,13 +50,8 @@ export default function FiltroCategorias({ categorias }: Props) {
const chipClass = (v: string) =>
v === active
? 'btn'
: 'btn btn-ghost';
const chipStyle = (v: string): React.CSSProperties =>
v === active
? { background: 'var(--color-primary)', color: 'white', minHeight: '40px', paddingBlock: '0.375rem' }
: { minHeight: '40px', paddingBlock: '0.375rem' };
? 'bg-verde-institucional text-bone-white rounded-[8px] px-3 py-1 border border-verde-institucional inline-flex items-center whitespace-nowrap text-xs font-medium cursor-pointer transition-colors'
: 'chip cursor-pointer hover:bg-linen hover:border-dorado-uabc transition-colors';
return (
<div
@@ -70,7 +65,6 @@ export default function FiltroCategorias({ categorias }: Props) {
role="tab"
aria-selected={active === ALL}
className={chipClass(ALL)}
style={chipStyle(ALL)}
data-cat-value={ALL}
data-cat-active={active === ALL ? 'true' : undefined}
onClick={() => pick(ALL)}
@@ -86,7 +80,6 @@ export default function FiltroCategorias({ categorias }: Props) {
role="tab"
aria-selected={active === v}
className={chipClass(v)}
style={chipStyle(v)}
data-cat-value={v}
data-cat-active={active === v ? 'true' : undefined}
onClick={() => pick(v)}
+39 -51
View File
@@ -14,14 +14,6 @@ type Material = {
categoria: { id: number; nombre: string } | null;
};
// El catálogo sólo lista materiales con estado 'disponible' (ver catalogo.astro),
// así que el chip siempre coincide con el mismo estilo que usa el inventario del admin.
const DISPONIBLE_BADGE_STYLE = {
background: 'color-mix(in oklab, var(--color-positive) 18%, white)',
color: 'var(--color-ink)',
border: '1.5px solid var(--color-positive)',
} as const;
type CartItem = {
material_id: number;
nombre: string;
@@ -238,7 +230,7 @@ export default function CartProvider({
return (
<CartContext.Provider value={value}>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
{materiales.map((m) => {
const src = imgUrl(m.imagen_path);
return (
@@ -249,34 +241,32 @@ export default function CartProvider({
data-nombre={m.nombre}
data-numero-inventario={m.numero_inventario ?? ''}
>
<div
className="relative aspect-square w-full bg-[color:var(--color-chalk)] grid place-items-center"
style={{ borderBottom: '2px solid var(--color-ink)' }}
>
<div className="relative aspect-square w-full rounded-t-[12px] overflow-hidden bg-linen flex items-center justify-center border-b border-hairline-gray">
{src ? (
<img src={src} alt={m.nombre} className="w-full h-full object-cover" loading="lazy" />
<img src={src} alt={m.nombre} className="w-full h-full aspect-square object-cover rounded-t-[12px]" loading="lazy" />
) : (
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="var(--color-ink)" strokeWidth={1.5} aria-hidden="true">
<rect x="3" y="4" width="18" height="16" rx="1" />
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} className="text-graphite/40" aria-hidden="true">
<rect x="3" y="4" width="18" height="16" rx="2" />
<path d="M3 16l5-5 4 4 3-3 6 6" />
<circle cx="8" cy="9" r="1.5" />
</svg>
)}
<span
className="absolute bottom-1.5 right-1.5 text-xs px-2 py-0.5 rounded-[2px] whitespace-nowrap shadow-[var(--shadow-hard-sm)]"
style={DISPONIBLE_BADGE_STYLE}
>
<span className="absolute bottom-2 right-2 badge">
Disponible
</span>
</div>
<div className="p-4 flex flex-col gap-3 flex-1">
<h3 className="font-semibold leading-snug">{m.nombre}</h3>
{m.categoria && <p className="text-xs opacity-70 -mt-1">{m.categoria.nombre}</p>}
<div className="p-6 flex flex-col gap-2 flex-1">
<span className="eyebrow text-dorado-uabc">{m.categoria?.nombre ?? 'Sin categoría'}</span>
<h3 className="text-[16px] font-medium text-graphite leading-snug">
{m.nombre}
</h3>
{m.descripcion && <p className="text-sm opacity-75 line-clamp-2">{m.descripcion}</p>}
{m.descripcion && (
<p className="text-sm text-graphite/75 line-clamp-2">{m.descripcion}</p>
)}
<dl className="text-xs opacity-70 grid grid-cols-2 gap-1">
<dl className="text-xs text-graphite/70 grid grid-cols-2 gap-1 my-2">
<dt className="opacity-60">Inventario</dt>
<dd className="text-right font-mono">{m.numero_inventario ?? '—'}</dd>
<dt className="opacity-60">Disponibles</dt>
@@ -285,7 +275,7 @@ export default function CartProvider({
</dd>
</dl>
<div className="mt-auto">
<div className="mt-auto pt-2">
<AgregarMaterial material={{ id: m.id, nombre: m.nombre, cantidad_disponible: m.cantidad_disponible }} />
</div>
</div>
@@ -297,8 +287,7 @@ export default function CartProvider({
{items.length > 0 && (
<button
type="button"
className="btn btn-primary fixed bottom-24 right-4 md:bottom-6 md:right-6 z-40"
style={{ boxShadow: 'var(--shadow-hard)' }}
className="btn btn-primary fixed bottom-24 right-4 md:bottom-6 md:right-6 z-40 px-6 py-3"
onClick={openCheckout}
>
Ver solicitud ({items.length})
@@ -308,44 +297,43 @@ export default function CartProvider({
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(92vw,32rem)]"
className="card bg-bone-white rounded-[12px] p-6 text-graphite border border-hairline-gray backdrop:bg-negro-institucional/60 w-[min(92vw,32rem)] m-auto shadow-none"
>
<form onSubmit={submit} className="p-5 sm:p-6 flex flex-col gap-4">
<div className="flex items-start justify-between gap-3">
<h2 id={titleId} className="text-lg font-semibold">
<form onSubmit={submit} className="flex flex-col gap-5">
<div className="flex items-center justify-between gap-3 pb-3 border-b border-hairline-gray">
<h2 id={titleId} className="text-xl font-normal text-graphite">
Tu solicitud
</h2>
<button
type="button"
onClick={closeCheckout}
aria-label="Cerrar"
className="rounded-lg p-1.5 hover:bg-black/5 transition-colors"
className="btn btn-ghost rounded-full w-8 h-8 p-0 flex items-center justify-center shrink-0"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{items.length === 0 ? (
<p className="text-sm opacity-70">Tu solicitud está vacía. Cierra esta ventana y agrega material del catálogo.</p>
<p className="text-sm text-graphite/70">Tu solicitud está vacía. Cierra esta ventana y agrega material del catálogo.</p>
) : (
<ul className="flex flex-col gap-3 max-h-64 overflow-y-auto -mx-1 px-1">
{items.map((item) => (
<li
key={item.material_id}
className="flex items-start gap-2 pb-3"
style={{ borderBottom: '1.5px solid color-mix(in oklab, var(--color-ink) 15%, transparent)' }}
className="flex items-start gap-3 pb-3 border-b border-hairline-gray"
>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{item.nombre}</p>
<p className="font-sans font-medium text-sm text-graphite truncate">{item.nombre}</p>
<div className="flex items-center gap-2 mt-1.5">
<label className="sr-only" htmlFor={`cant-cart-${item.material_id}`}>
Cantidad de {item.nombre}
</label>
<input
id={`cant-cart-${item.material_id}`}
className="input"
className="input rounded-[8px]"
type="number"
min={1}
max={item.cantidad_disponible}
@@ -354,14 +342,14 @@ export default function CartProvider({
onChange={(e) => updateCantidad(item.material_id, Number(e.target.value) || 1)}
style={{ width: '4.5rem', minHeight: '36px', paddingBlock: '0.25rem', fontVariantNumeric: 'tabular-nums' }}
/>
<span className="text-xs opacity-60">de {item.cantidad_disponible}</span>
<span className="text-xs text-graphite/70">de {item.cantidad_disponible}</span>
</div>
<label className="sr-only" htmlFor={`desc-cart-${item.material_id}`}>
Nota para {item.nombre}
</label>
<input
id={`desc-cart-${item.material_id}`}
className="input mt-2"
className="input rounded-[8px] mt-2"
type="text"
maxLength={200}
value={item.descripcion}
@@ -374,9 +362,9 @@ export default function CartProvider({
type="button"
onClick={() => removeItem(item.material_id)}
aria-label={`Quitar ${item.nombre}`}
className="rounded-lg p-1.5 hover:bg-black/5 transition-colors shrink-0"
className="btn btn-ghost rounded-full w-8 h-8 p-0 flex items-center justify-center shrink-0"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
@@ -391,7 +379,7 @@ export default function CartProvider({
<select
ref={firstFieldRef}
id="maestro-select"
className="input"
className="input rounded-[8px]"
value={maestroId}
onChange={(e) => setMaestroId(e.target.value)}
aria-describedby="maestro-hint"
@@ -403,7 +391,7 @@ export default function CartProvider({
<option key={m.id} value={String(m.id)}>{m.nombre}</option>
))}
</select>
<p id="maestro-hint" className="text-xs opacity-70 mt-1">
<p id="maestro-hint" className="text-xs text-graphite/70 mt-1">
{tutorNombre
? <>Si no eliges nadie se usará tu tutor: <strong>{tutorNombre}</strong>.</>
: 'Debes elegir un maestro; no tienes tutor guardado.'}
@@ -415,7 +403,7 @@ export default function CartProvider({
<label className="label" htmlFor="notas-cart">Motivo del préstamo</label>
<textarea
id="notas-cart"
className="input"
className="input rounded-[8px]"
rows={3}
maxLength={100}
value={notas}
@@ -424,25 +412,25 @@ export default function CartProvider({
style={{ minHeight: '96px' }}
aria-describedby="notas-cart-hint"
/>
<p id="notas-cart-hint" className="text-xs opacity-60 mt-1">
<p id="notas-cart-hint" className="text-xs text-graphite/70 mt-1">
Ejemplo: Clase de Electrónica Analógica · Prof. Gómez. (Opcional, máx. 100 caracteres)
</p>
</div>
{error && (
<p role="alert" className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
<p role="alert" className="text-sm text-negro-institucional">
{error} · Ajusta la solicitud e intenta de nuevo.
</p>
)}
{ok && (
<p role="status" className="text-sm" style={{ color: 'var(--color-positive)' }}>
<p role="status" className="text-sm text-verde-institucional font-medium">
Solicitud enviada. Actualizando…
</p>
)}
{!perfilCompleto ? (
<div className="card p-3">
<p className="text-sm mb-3">Necesitas completar tu perfil (matrícula + tutor) para crear vales.</p>
<div className="card p-4 rounded-[12px] bg-linen border border-hairline-gray">
<p className="text-sm text-graphite mb-3">Necesitas completar tu perfil (matrícula + tutor) para crear vales.</p>
<a href="/perfil" className="btn btn-primary">Completar perfil</a>
</div>
) : (
+10 -15
View File
@@ -6,31 +6,26 @@ type Props = {
className?: string;
};
// Contraste rápido: fondo HSL con L=55% → texto blanco pasa AA con casi todos los hues.
// Mantenemos blanco fijo para consistencia visual.
export default function Avatar({ info, size = 40, className = '' }: Props) {
const style: React.CSSProperties = {
width: size,
height: size,
border: '2px solid var(--color-ink)',
borderRadius: 2,
display: 'grid',
placeItems: 'center',
overflow: 'hidden',
background: info.src ? 'white' : info.hueColor,
color: 'white',
fontWeight: 700,
fontSize: Math.max(11, Math.floor(size * 0.4)),
lineHeight: 1,
flexShrink: 0,
fontSize: Math.max(12, Math.floor(size * 0.38)),
};
return (
<div className={className} style={style} aria-hidden="true">
<div
className={`rounded-full overflow-hidden shrink-0 grid place-items-center border border-hairline-gray select-none leading-none ${
info.src ? 'bg-bone-white' : 'bg-linen text-verde-institucional font-medium'
} ${className}`}
style={style}
aria-hidden="true"
>
{info.src ? (
<img
src={info.src}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
className="w-full h-full object-cover rounded-full"
referrerPolicy="no-referrer"
/>
) : (
+30 -19
View File
@@ -123,35 +123,46 @@ export default function PerfilForm({
<form onSubmit={submit} className="flex flex-col gap-5" autoComplete="off">
<div>
<label className="label" htmlFor="p-foto">Foto</label>
<div className="flex items-center gap-3">
<Avatar info={previewInfo} size={72} />
<div className="flex-1 flex flex-col gap-2">
<div className="flex items-center gap-4">
<Avatar info={previewInfo} size={96} className="w-24 h-24 rounded-full" />
<div className="flex-1 flex flex-col items-start gap-2">
<input
ref={fileInputRef}
id="p-foto"
className="input"
className="hidden"
type="file"
accept="image/*"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
{(file || fotoPath) && (
<div className="flex gap-2 flex-wrap items-center">
<button
type="button"
className="btn btn-ghost self-start"
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
onClick={() => {
setFile(null);
if (fileInputRef.current) fileInputRef.current.value = '';
setFotoPath(null);
}}
className="btn btn-ghost"
onClick={() => fileInputRef.current?.click()}
>
Quitar foto
Subir foto
</button>
{(file || fotoPath) && (
<button
type="button"
className="btn btn-ghost text-negro-institucional"
onClick={() => {
setFile(null);
if (fileInputRef.current) fileInputRef.current.value = '';
setFotoPath(null);
}}
>
Quitar foto
</button>
)}
</div>
{file && (
<span className="text-xs text-graphite/70 truncate max-w-xs">{file.name}</span>
)}
</div>
</div>
{!file && !fotoPath && googleAvatarUrl && (
<p className="text-xs opacity-70 mt-2">Mostrando tu foto de Google. Sube una para reemplazarla.</p>
<p className="text-xs text-graphite/70 mt-2">Mostrando tu foto de Google. Sube una para reemplazarla.</p>
)}
</div>
@@ -159,7 +170,7 @@ export default function PerfilForm({
<label className="label" htmlFor="p-mat">{matriculaLabel}</label>
<input
id="p-mat"
className="input"
className="input rounded-[8px]"
type="text"
value={matricula}
onChange={(e) => setMatricula(e.target.value)}
@@ -174,7 +185,7 @@ export default function PerfilForm({
<label className="label" htmlFor="p-sem">Semestre</label>
<select
id="p-sem"
className="input"
className="input rounded-[8px]"
value={semestre}
onChange={(e) => setSemestre(e.target.value)}
>
@@ -191,7 +202,7 @@ export default function PerfilForm({
<label className="label" htmlFor="p-tutor">Tutor</label>
<select
id="p-tutor"
className="input"
className="input rounded-[8px]"
value={tutorId}
onChange={(e) => setTutorId(e.target.value)}
>
@@ -200,12 +211,12 @@ export default function PerfilForm({
<option key={m.id} value={String(m.id)}>{m.nombre}</option>
))}
</select>
<p className="text-xs opacity-70 mt-1">Se rellenará por defecto al crear vales.</p>
<p className="text-xs text-graphite/70 mt-1">Se rellenará por defecto al crear vales.</p>
</div>
)}
{error && (
<p role="alert" className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
<p role="alert" className="text-sm text-negro-institucional">
{error}
</p>
)}