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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user