Cambios verificados (working tree previamente probado)

- src/lib/text.ts: helper truncate() reutilizable.
- SolicitudCart, DesbanearButton, vistas admin y endpoint solicitudes:
  ajustes usando el helper.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 09:04:38 -07:00
parent 3cccf758a8
commit 800ec60a53
7 changed files with 109 additions and 59 deletions
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useRef, useState } from 'react';
import { toastAfterReload } from '@/lib/toast'; import { toastAfterReload } from '@/lib/toast';
type Props = { type Props = {
@@ -8,6 +8,9 @@ type Props = {
export default function DesbanearButton({ baneoId, nombre }: Props) { export default function DesbanearButton({ baneoId, nombre }: Props) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const errorDialogRef = useRef<HTMLDialogElement | null>(null);
const titleId = `desbanear-err-${baneoId}`;
const submit = async () => { const submit = async () => {
if (loading) return; if (loading) return;
@@ -24,20 +27,43 @@ export default function DesbanearButton({ baneoId, nombre }: Props) {
}); });
location.reload(); location.reload();
} catch (err) { } catch (err) {
alert(err instanceof Error ? err.message : 'Error inesperado'); setError(err instanceof Error ? err.message : 'Error inesperado');
errorDialogRef.current?.showModal();
setLoading(false); setLoading(false);
} }
}; };
return ( return (
<button <>
type="button" <button
className="btn btn-primary" type="button"
style={{ minHeight: '36px', paddingBlock: '0.25rem' }} className="btn btn-primary"
onClick={submit} style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
disabled={loading} onClick={submit}
> disabled={loading}
{loading ? 'Desbaneando…' : 'Desbanear'} >
</button> {loading ? 'Desbaneando…' : 'Desbanear'}
</button>
<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)]"
>
<div className="p-5 sm:p-6 flex flex-col gap-4">
<h2 id={titleId} className="text-lg font-semibold">
No se pudo desbanear
</h2>
<p className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
{error}
</p>
<div className="flex justify-end pt-2">
<button type="button" className="btn btn-primary" onClick={() => errorDialogRef.current?.close()}>
Entendido
</button>
</div>
</div>
</dialog>
</>
); );
} }
+59 -36
View File
@@ -1,5 +1,6 @@
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { toast, toastAfterReload } from '@/lib/toast'; import { toast, toastAfterReload } from '@/lib/toast';
import { imgUrl } from '@/lib/materialImg';
import AgregarMaterial from './AgregarMaterial'; import AgregarMaterial from './AgregarMaterial';
type Material = { type Material = {
@@ -9,9 +10,18 @@ type Material = {
cantidad_disponible: number; cantidad_disponible: number;
cantidad_total: number; cantidad_total: number;
numero_inventario: string | null; numero_inventario: string | null;
imagen_path: string | null;
categoria: { id: number; nombre: string } | null; 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 = { type CartItem = {
material_id: number; material_id: number;
nombre: string; nombre: string;
@@ -229,46 +239,59 @@ export default function CartProvider({
return ( return (
<CartContext.Provider value={value}> <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-4">
{materiales.map((m) => ( {materiales.map((m) => {
<article const src = imgUrl(m.imagen_path);
key={m.id} return (
className="card flex flex-col gap-3" <article
data-cat={m.categoria?.id ?? 'sin'} key={m.id}
data-nombre={m.nombre} className="card p-0 flex flex-col overflow-hidden"
data-numero-inventario={m.numero_inventario ?? ''} data-cat={m.categoria?.id ?? 'sin'}
> data-nombre={m.nombre}
<div className="flex items-start justify-between gap-2"> data-numero-inventario={m.numero_inventario ?? ''}
<h3 className="font-semibold leading-snug">{m.nombre}</h3> >
{m.categoria && ( <div
className="relative aspect-square w-full bg-[color:var(--color-chalk)] grid place-items-center"
style={{ borderBottom: '2px solid var(--color-ink)' }}
>
{src ? (
<img src={src} alt={m.nombre} className="w-full h-full object-cover" 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" />
<path d="M3 16l5-5 4 4 3-3 6 6" />
<circle cx="8" cy="9" r="1.5" />
</svg>
)}
<span <span
className="text-xs px-2 py-0.5 rounded-[2px] whitespace-nowrap uppercase tracking-wide" 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={{ style={DISPONIBLE_BADGE_STYLE}
background: 'color-mix(in oklab, var(--color-primary) 25%, white)',
color: 'var(--color-ink)',
border: '1.5px solid var(--color-primary)',
}}
> >
{m.categoria.nombre} Disponible
</span> </span>
)} </div>
</div>
{m.descripcion && <p className="text-sm opacity-75 line-clamp-2">{m.descripcion}</p>} <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>}
<dl className="text-xs opacity-70 grid grid-cols-2 gap-1"> {m.descripcion && <p className="text-sm opacity-75 line-clamp-2">{m.descripcion}</p>}
<dt className="opacity-60">Inventario</dt>
<dd className="text-right font-mono">{m.numero_inventario ?? '—'}</dd>
<dt className="opacity-60">Disponibles</dt>
<dd className="text-right" style={{ fontVariantNumeric: 'tabular-nums' }}>
{m.cantidad_disponible} / {m.cantidad_total}
</dd>
</dl>
<div className="mt-auto"> <dl className="text-xs opacity-70 grid grid-cols-2 gap-1">
<AgregarMaterial material={{ id: m.id, nombre: m.nombre, cantidad_disponible: m.cantidad_disponible }} /> <dt className="opacity-60">Inventario</dt>
</div> <dd className="text-right font-mono">{m.numero_inventario ?? '—'}</dd>
</article> <dt className="opacity-60">Disponibles</dt>
))} <dd className="text-right" style={{ fontVariantNumeric: 'tabular-nums' }}>
{m.cantidad_disponible} / {m.cantidad_total}
</dd>
</dl>
<div className="mt-auto">
<AgregarMaterial material={{ id: m.id, nombre: m.nombre, cantidad_disponible: m.cantidad_disponible }} />
</div>
</div>
</article>
);
})}
</div> </div>
{items.length > 0 && ( {items.length > 0 && (
@@ -394,7 +417,7 @@ export default function CartProvider({
id="notas-cart" id="notas-cart"
className="input" className="input"
rows={3} rows={3}
maxLength={250} maxLength={100}
value={notas} value={notas}
onChange={(e) => setNotas(e.target.value)} onChange={(e) => setNotas(e.target.value)}
placeholder="¿Para qué clase o proyecto lo necesitas?" placeholder="¿Para qué clase o proyecto lo necesitas?"
@@ -402,7 +425,7 @@ export default function CartProvider({
aria-describedby="notas-cart-hint" 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 opacity-60 mt-1">
Ejemplo: Clase de Electrónica Analógica · Prof. Gómez · práctica 3. (Opcional, máx. 250 caracteres) Ejemplo: Clase de Electrónica Analógica · Prof. Gómez. (Opcional, máx. 100 caracteres)
</p> </p>
</div> </div>
+1
View File
@@ -0,0 +1 @@
export const truncate = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '…' : s);
+5 -7
View File
@@ -136,7 +136,7 @@ const estadoBadge = (e: Material['estado']) => {
return ( return (
<article class="card p-0 flex flex-col overflow-hidden"> <article class="card p-0 flex flex-col overflow-hidden">
<div <div
class="aspect-square w-full bg-[color:var(--color-chalk)] grid place-items-center" class="relative aspect-square w-full bg-[color:var(--color-chalk)] grid place-items-center"
style="border-bottom: 2px solid var(--color-ink);" style="border-bottom: 2px solid var(--color-ink);"
> >
{src ? ( {src ? (
@@ -148,14 +148,12 @@ const estadoBadge = (e: Material['estado']) => {
<circle cx="8" cy="9" r="1.5" /> <circle cx="8" cy="9" r="1.5" />
</svg> </svg>
)} )}
<span class="absolute bottom-1.5 right-1.5 text-xs px-2 py-0.5 rounded-[2px] whitespace-nowrap shadow-[var(--shadow-hard-sm)]" style={badge.style}>
{badge.label}
</span>
</div> </div>
<div class="p-3 flex flex-col gap-2 flex-1"> <div class="p-3 flex flex-col gap-2 flex-1">
<div class="flex items-start justify-between gap-2"> <h3 class="uppercase font-semibold text-sm leading-snug line-clamp-2">{m.nombre}</h3>
<h3 class="uppercase font-semibold text-sm leading-snug line-clamp-2">{m.nombre}</h3>
<span class="text-xs px-2 py-0.5 rounded-[2px] whitespace-nowrap shrink-0" style={badge.style}>
{badge.label}
</span>
</div>
{m.categoria && ( {m.categoria && (
<p class="text-xs opacity-70 -mt-1">{m.categoria.nombre}</p> <p class="text-xs opacity-70 -mt-1">{m.categoria.nombre}</p>
)} )}
+3 -2
View File
@@ -2,6 +2,7 @@
import AppLayout from '@/layouts/AppLayout.astro'; import AppLayout from '@/layouts/AppLayout.astro';
import MarcarDevuelto from '@/components/admin/solicitudes/MarcarDevuelto.tsx'; import MarcarDevuelto from '@/components/admin/solicitudes/MarcarDevuelto.tsx';
import VerDetalles from '@/components/admin/solicitudes/VerDetalles.tsx'; import VerDetalles from '@/components/admin/solicitudes/VerDetalles.tsx';
import { truncate } from '@/lib/text';
const supabase = Astro.locals.supabase; const supabase = Astro.locals.supabase;
const { data, error } = await supabase const { data, error } = await supabase
@@ -107,7 +108,7 @@ const tabs = [
</div> </div>
{r.notas && ( {r.notas && (
<div class="text-xs mt-1.5 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);"> <div class="text-xs mt-1.5 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);">
<span class="opacity-60">Motivo:</span> <span class="break-words">{r.notas}</span> <span class="opacity-60">Motivo:</span> <span class="break-words">{truncate(r.notas, 50)}</span>
</div> </div>
)} )}
</td> </td>
@@ -162,7 +163,7 @@ const tabs = [
<div class="text-sm mt-1 opacity-80">Devolver antes de <span style="font-variant-numeric: tabular-nums;">{r.fecha_devolucion_estimada ? fmtFecha.format(new Date(r.fecha_devolucion_estimada + 'T00:00:00')) : '—'}</span></div> <div class="text-sm mt-1 opacity-80">Devolver antes de <span style="font-variant-numeric: tabular-nums;">{r.fecha_devolucion_estimada ? fmtFecha.format(new Date(r.fecha_devolucion_estimada + 'T00:00:00')) : '—'}</span></div>
{r.notas && ( {r.notas && (
<p class="text-sm mt-2 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);"> <p class="text-sm mt-2 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);">
<span class="opacity-60">Motivo:</span> <span class="break-words">{r.notas}</span> <span class="opacity-60">Motivo:</span> <span class="break-words">{truncate(r.notas, 50)}</span>
</p> </p>
)} )}
<div class="mt-3 flex gap-2 flex-wrap"> <div class="mt-3 flex gap-2 flex-wrap">
+3 -2
View File
@@ -1,6 +1,7 @@
--- ---
import AppLayout from '@/layouts/AppLayout.astro'; import AppLayout from '@/layouts/AppLayout.astro';
import AccionesSolicitud from '@/components/admin/solicitudes/AccionesSolicitud.tsx'; import AccionesSolicitud from '@/components/admin/solicitudes/AccionesSolicitud.tsx';
import { truncate } from '@/lib/text';
const supabase = Astro.locals.supabase; const supabase = Astro.locals.supabase;
const { data, error } = await supabase const { data, error } = await supabase
@@ -99,7 +100,7 @@ const tabs = [
</div> </div>
{r.notas && ( {r.notas && (
<div class="text-xs mt-1.5 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);"> <div class="text-xs mt-1.5 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);">
<span class="opacity-60">Motivo:</span> <span class="break-words">{r.notas}</span> <span class="opacity-60">Motivo:</span> <span class="break-words">{truncate(r.notas, 50)}</span>
</div> </div>
)} )}
</td> </td>
@@ -135,7 +136,7 @@ const tabs = [
</div> </div>
{r.notas && ( {r.notas && (
<p class="text-sm mt-2 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);"> <p class="text-sm mt-2 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);">
<span class="opacity-60">Motivo:</span> <span class="break-words">{r.notas}</span> <span class="opacity-60">Motivo:</span> <span class="break-words">{truncate(r.notas, 50)}</span>
</p> </p>
)} )}
<div class="mt-3"> <div class="mt-3">
+1 -1
View File
@@ -56,7 +56,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
? rawMaestro ? rawMaestro
: null; : null;
const notas = typeof body.notas === 'string' && body.notas.trim() ? body.notas.trim().slice(0, 250) : null; const notas = typeof body.notas === 'string' && body.notas.trim() ? body.notas.trim().slice(0, 100) : null;
if (!Array.isArray(body.items) || body.items.length === 0) { if (!Array.isArray(body.items) || body.items.length === 0) {
return Response.json({ error: 'Agrega al menos un material' }, { status: 400 }); return Response.json({ error: 'Agrega al menos un material' }, { status: 400 });