ac3e7061ac
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
181 lines
6.3 KiB
TypeScript
181 lines
6.3 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { toast } from '@/lib/toast';
|
|
|
|
type Unidad = {
|
|
id: number;
|
|
etiqueta: string;
|
|
estado: 'disponible' | 'prestado' | 'mantenimiento' | 'baja';
|
|
notas: string | null;
|
|
created_at: string;
|
|
};
|
|
|
|
const ESTADOS = ['disponible', 'mantenimiento', 'baja'] as const;
|
|
|
|
export default function UnidadesManager({ materialId }: { materialId: number }) {
|
|
const [unidades, setUnidades] = useState<Unidad[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [nuevaEtiqueta, setNuevaEtiqueta] = useState('');
|
|
const [creando, setCreando] = useState(false);
|
|
|
|
const load = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetch(`/api/admin/materiales/${materialId}/unidades`);
|
|
const j = await res.json();
|
|
if (!res.ok) throw new Error(j?.error ?? 'Error al cargar');
|
|
setUnidades(j.unidades ?? []);
|
|
} catch (e) {
|
|
toast({ kind: 'error', title: 'No se pudieron cargar las unidades', description: e instanceof Error ? e.message : '' });
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
load();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [materialId]);
|
|
|
|
const crear = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const et = nuevaEtiqueta.trim();
|
|
if (!et || creando) return;
|
|
setCreando(true);
|
|
try {
|
|
const res = await fetch(`/api/admin/materiales/${materialId}/unidades`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ etiqueta: et }),
|
|
});
|
|
const j = await res.json();
|
|
if (!res.ok) throw new Error(j?.error ?? 'No se pudo crear');
|
|
setNuevaEtiqueta('');
|
|
await load();
|
|
toast({ kind: 'success', title: 'Unidad agregada', description: et });
|
|
} catch (e) {
|
|
toast({ kind: 'error', title: 'No se pudo agregar', description: e instanceof Error ? e.message : '' });
|
|
} finally {
|
|
setCreando(false);
|
|
}
|
|
};
|
|
|
|
const patch = async (u: Unidad, body: Partial<Pick<Unidad, 'etiqueta' | 'estado' | 'notas'>>) => {
|
|
const res = await fetch(`/api/admin/materiales/unidades/${u.id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const j = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
toast({ kind: 'error', title: 'No se pudo actualizar', description: j?.error });
|
|
await load();
|
|
return;
|
|
}
|
|
setUnidades((prev) => prev.map((x) => (x.id === u.id ? { ...x, ...body } as Unidad : x)));
|
|
};
|
|
|
|
const eliminar = async (u: Unidad) => {
|
|
if (!confirm(`Eliminar unidad "${u.etiqueta}"?`)) return;
|
|
const res = await fetch(`/api/admin/materiales/unidades/${u.id}`, { method: 'DELETE' });
|
|
const j = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
toast({ kind: 'error', title: 'No se pudo eliminar', description: j?.error });
|
|
return;
|
|
}
|
|
setUnidades((prev) => prev.filter((x) => x.id !== u.id));
|
|
toast({ kind: 'success', title: 'Unidad eliminada' });
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<h3 className="eyebrow mb-3">Unidades individuales</h3>
|
|
|
|
<form onSubmit={crear} className="flex gap-2 mb-4">
|
|
<input
|
|
className="input flex-1"
|
|
type="text"
|
|
placeholder="Etiqueta (ej. Laptop-01)"
|
|
value={nuevaEtiqueta}
|
|
onChange={(e) => setNuevaEtiqueta(e.target.value)}
|
|
maxLength={80}
|
|
/>
|
|
<button
|
|
type="submit"
|
|
className="btn btn-primary"
|
|
disabled={creando || !nuevaEtiqueta.trim()}
|
|
style={{ minHeight: '44px' }}
|
|
>
|
|
+ Agregar
|
|
</button>
|
|
</form>
|
|
|
|
{loading ? (
|
|
<p className="text-sm text-graphite/70">Cargando…</p>
|
|
) : unidades.length === 0 ? (
|
|
<p className="text-sm text-graphite/70">Sin unidades registradas.</p>
|
|
) : (
|
|
<div className="flex flex-col gap-2">
|
|
{unidades.map((u) => {
|
|
const bloqueada = u.estado === 'prestado';
|
|
return (
|
|
<div
|
|
key={u.id}
|
|
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"
|
|
className="input"
|
|
aria-label="Etiqueta"
|
|
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
|
defaultValue={u.etiqueta}
|
|
onBlur={(e) => {
|
|
const v = e.target.value.trim();
|
|
if (v && v !== u.etiqueta) patch(u, { etiqueta: v });
|
|
else e.target.value = u.etiqueta;
|
|
}}
|
|
/>
|
|
<select
|
|
className="input"
|
|
aria-label="Estado"
|
|
style={{ minHeight: '36px', paddingBlock: '0.25rem', minWidth: '130px' }}
|
|
value={bloqueada ? 'prestado' : u.estado}
|
|
disabled={bloqueada}
|
|
onChange={(e) => patch(u, { estado: e.target.value as Unidad['estado'] })}
|
|
>
|
|
{bloqueada && <option value="prestado">Prestado</option>}
|
|
{ESTADOS.map((s) => (
|
|
<option key={s} value={s}>
|
|
{s[0].toUpperCase() + s.slice(1)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
type="button"
|
|
className="btn btn-danger text-xs px-3"
|
|
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
|
disabled={bloqueada}
|
|
onClick={() => eliminar(u)}
|
|
>
|
|
Eliminar
|
|
</button>
|
|
<input
|
|
type="text"
|
|
className="input sm:col-span-3"
|
|
aria-label="Notas"
|
|
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
|
defaultValue={u.notas ?? ''}
|
|
placeholder="Notas (opcional)…"
|
|
onBlur={(e) => {
|
|
const v = e.target.value;
|
|
if ((u.notas ?? '') !== v) patch(u, { notas: v || null });
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|