5 features: buscador catálogo, grid+fotos+unidades, estadísticas separada, badge realtime
Buscador catálogo alumno: - BuscadorCatalogo.tsx con debounce 120ms, normaliza tildes, ?q= en URL - FiltroCategorias usa data-* attributes, coordinación via window.__labreFiltrar - Cards con data-nombre y data-numero-inventario para filtro combinable AND Grid + imágenes + unidades individuales (admin): - Inventario ahora es grid 2/3/4/5 col con fotos aspect-square - MaterialForm: input file con preview, checkbox trackeado_por_unidad - UnidadesManager: agregar/editar/eliminar unidades por material - Reasignar unidad desde detalles de solicitud (aprobada/activa) - Alumno ve etiqueta de unidad asignada en mis-préstamos - Helper materialImg.imgUrl(path) para URL pública del bucket - Auto-submit debounced del filtro de inventario (input + selects) Estadísticas separada: - Nueva ruta /admin/estadisticas con 6 KPIs (movidos de /admin/inventario) - 3 gráficas recharts: top 10 materiales, solicitudes/día, distribución estados - Chart.tsx wrapper con paleta UABC + respeta prefers-reduced-motion - Nav admin: Panel · Solicitudes · Inventario (box) · Estadísticas (stats) · Reportes - admin/index.astro usa todayMX() (fix inconsistencia) Notificaciones pendientes (admin): - Badge SSR con conteo de pendientes sobre ícono Solicitudes (sidebar + dock) - BadgeSolicitudes.tsx: canal Realtime en prestamos.solicitudes, +1 en INSERT con toast, refetch en UPDATE (replica identity default no trae old.estado) - Recharts añadido a package.json
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
import { useEffect } from 'react';
|
||||
import { browserClient } from '@/lib/supabase';
|
||||
import { toast } from '@/lib/toast';
|
||||
|
||||
function setBadge(n: number) {
|
||||
const v = Math.max(0, n | 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`);
|
||||
});
|
||||
}
|
||||
|
||||
function readBadge(): number {
|
||||
const el = document.querySelector<HTMLElement>('[data-badge="pendientes"]');
|
||||
const n = parseInt(el?.textContent ?? '0', 10);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
export default function BadgeSolicitudes() {
|
||||
useEffect(() => {
|
||||
const supabase = browserClient();
|
||||
|
||||
const refetch = async () => {
|
||||
const { count } = await supabase
|
||||
.from('solicitudes')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('estado', 'pendiente');
|
||||
setBadge(count ?? 0);
|
||||
};
|
||||
|
||||
const channel = supabase
|
||||
.channel('solicitudes-pendientes')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: 'INSERT', schema: 'prestamos', table: 'solicitudes', filter: 'estado=eq.pendiente' },
|
||||
() => {
|
||||
setBadge(readBadge() + 1);
|
||||
toast({ title: 'Nueva solicitud pendiente', kind: 'info' });
|
||||
},
|
||||
)
|
||||
.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.
|
||||
'postgres_changes',
|
||||
{ event: 'UPDATE', schema: 'prestamos', table: 'solicitudes' },
|
||||
() => { refetch(); },
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
LineChart,
|
||||
Line,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
LabelList,
|
||||
} from 'recharts';
|
||||
|
||||
type Barras = { tipo: 'barras'; datos: { label: string; valor: number }[]; alto?: number };
|
||||
type Linea = { tipo: 'linea'; datos: { fecha: string; valor: number }[]; alto?: number };
|
||||
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';
|
||||
|
||||
const MONO: React.CSSProperties = {
|
||||
fontFamily: "'JetBrains Mono Variable', ui-monospace, monospace",
|
||||
fontSize: 11,
|
||||
color: INK,
|
||||
};
|
||||
|
||||
// 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,
|
||||
};
|
||||
const DONUT_FALLBACK = [PRIMARY, SECONDARY, POSITIVE, ATTENTION, PENCIL, DANGER];
|
||||
|
||||
function donutColor(label: string, i: number): string {
|
||||
return DONUT_BY_LABEL[label.toLowerCase()] ?? DONUT_FALLBACK[i % DONUT_FALLBACK.length];
|
||||
}
|
||||
|
||||
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>}
|
||||
{payload.map((p: any, i: number) => (
|
||||
<div key={i}>
|
||||
{p.name}: <strong>{p.value}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function usePrefersReducedMotion(): boolean {
|
||||
const [reduced, setReduced] = useState(false);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
setReduced(mq.matches);
|
||||
const on = () => setReduced(mq.matches);
|
||||
mq.addEventListener?.('change', on);
|
||||
return () => mq.removeEventListener?.('change', on);
|
||||
}, []);
|
||||
return reduced;
|
||||
}
|
||||
|
||||
function fmtFechaCorta(iso: string): string {
|
||||
// 'YYYY-MM-DD' → 'DD/MM'
|
||||
const [, m, d] = iso.split('-');
|
||||
return `${d}/${m}`;
|
||||
}
|
||||
|
||||
export default function Chart(props: Props) {
|
||||
const reduced = usePrefersReducedMotion();
|
||||
const anim = reduced ? 0 : 400;
|
||||
const alto = props.alto ?? 260;
|
||||
|
||||
if (props.tipo === 'barras') {
|
||||
// Horizontal para que quepan nombres largos de material.
|
||||
const datos = useMemo(
|
||||
() => props.datos.slice().sort((a, b) => a.valor - b.valor),
|
||||
[props.datos],
|
||||
);
|
||||
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} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="label"
|
||||
width={140}
|
||||
stroke={INK}
|
||||
tick={{ ...MONO, fontSize: 10 } as any}
|
||||
interval={0}
|
||||
/>
|
||||
<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} />
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.tipo === 'linea') {
|
||||
const datos = props.datos.map((d) => ({ ...d, fechaCorta: fmtFechaCorta(d.fecha) }));
|
||||
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' }} />
|
||||
<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 }}
|
||||
animationDuration={anim}
|
||||
name="Solicitudes"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// donut
|
||||
const datos = props.datos;
|
||||
const total = datos.reduce((s, d) => s + d.valor, 0);
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={alto}>
|
||||
<PieChart margin={{ top: 8, right: 8, left: 8, bottom: 8 }}>
|
||||
<Tooltip content={<TooltipBox />} />
|
||||
<Pie
|
||||
data={datos}
|
||||
dataKey="valor"
|
||||
nameKey="label"
|
||||
innerRadius="55%"
|
||||
outerRadius="85%"
|
||||
stroke={INK}
|
||||
strokeWidth={1.5}
|
||||
isAnimationActive={!reduced}
|
||||
animationDuration={anim}
|
||||
label={({ label, valor }: any) => (total ? `${label} (${valor})` : label)}
|
||||
labelLine={{ stroke: PENCIL }}
|
||||
>
|
||||
{datos.map((d, i) => (
|
||||
<Cell key={d.label} fill={donutColor(d.label, i)} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { browserClient } from '@/lib/supabase';
|
||||
import { imgUrl } from '@/lib/materialImg';
|
||||
import { toast } from '@/lib/toast';
|
||||
import UnidadesManager from './UnidadesManager';
|
||||
|
||||
type Categoria = { id: number; nombre: string };
|
||||
type Material = {
|
||||
@@ -10,6 +14,8 @@ type Material = {
|
||||
cantidad_disponible: number;
|
||||
numero_inventario: string | null;
|
||||
estado: 'disponible' | 'mantenimiento' | 'baja';
|
||||
imagen_path?: string | null;
|
||||
trackeado_por_unidad?: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -18,10 +24,13 @@ type Props = {
|
||||
categorias: Categoria[];
|
||||
};
|
||||
|
||||
const BUCKET = 'materiales-fotos';
|
||||
|
||||
export default function MaterialForm({ mode, material, categorias }: Props) {
|
||||
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
||||
const firstFieldRef = useRef<HTMLInputElement | null>(null);
|
||||
const errorRef = useRef<HTMLParagraphElement | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const [nombre, setNombre] = useState(material?.nombre ?? '');
|
||||
const [categoriaId, setCategoriaId] = useState<string>(
|
||||
@@ -33,11 +42,26 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
||||
);
|
||||
const [numeroInventario, setNumeroInventario] = useState(material?.numero_inventario ?? '');
|
||||
const [estado, setEstado] = useState<Material['estado']>(material?.estado ?? 'disponible');
|
||||
const [trackeado, setTrackeado] = useState<boolean>(material?.trackeado_por_unidad ?? false);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [imagenPath, setImagenPath] = useState<string | null>(material?.imagen_path ?? null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const titleId = `mat-form-${mode}-${material?.id ?? 'new'}`;
|
||||
|
||||
// preview URL con revoke en cleanup
|
||||
useEffect(() => {
|
||||
if (!file) {
|
||||
setPreviewUrl(null);
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(file);
|
||||
setPreviewUrl(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [file]);
|
||||
|
||||
const open = () => {
|
||||
setError(null);
|
||||
if (mode === 'create') {
|
||||
@@ -47,6 +71,9 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
||||
setCantidadTotal('0');
|
||||
setNumeroInventario('');
|
||||
setEstado('disponible');
|
||||
setTrackeado(false);
|
||||
setFile(null);
|
||||
setImagenPath(null);
|
||||
}
|
||||
dialogRef.current?.showModal();
|
||||
queueMicrotask(() => firstFieldRef.current?.focus());
|
||||
@@ -68,49 +95,101 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
errorRef.current?.focus();
|
||||
}
|
||||
if (error) errorRef.current?.focus();
|
||||
}, [error]);
|
||||
|
||||
async function uploadFotoFor(id: number): Promise<string | null> {
|
||||
if (!file) return null;
|
||||
const ext = (file.name.split('.').pop() ?? 'jpg').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
const path = `${id}/${Date.now()}.${ext || 'jpg'}`;
|
||||
const { error: upErr } = await browserClient()
|
||||
.storage.from(BUCKET)
|
||||
.upload(path, file, { upsert: true, contentType: file.type || undefined });
|
||||
if (upErr) {
|
||||
toast({ kind: 'error', title: 'La foto no se subió', description: 'El material se guardó sin foto.' });
|
||||
return null;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const payload = {
|
||||
nombre: nombre.trim(),
|
||||
categoria_id: categoriaId === '' ? null : Number(categoriaId),
|
||||
descripcion: descripcion.trim() || null,
|
||||
cantidad_total: Number(cantidadTotal),
|
||||
numero_inventario: numeroInventario.trim() || null,
|
||||
estado,
|
||||
};
|
||||
|
||||
if (!payload.nombre) {
|
||||
const nombreT = nombre.trim();
|
||||
if (!nombreT) {
|
||||
setError('El nombre es obligatorio');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(payload.cantidad_total) || payload.cantidad_total < 0) {
|
||||
const cantidad = Number(cantidadTotal);
|
||||
if (!trackeado && (!Number.isInteger(cantidad) || cantidad < 0)) {
|
||||
setError('La cantidad total debe ser un entero mayor o igual a 0');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const url =
|
||||
mode === 'create' ? '/api/admin/materiales' : `/api/admin/materiales/${material!.id}`;
|
||||
const method = mode === 'create' ? 'POST' : 'PATCH';
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(json?.error ?? 'No se pudo guardar');
|
||||
if (mode === 'create') {
|
||||
// 1) crear material sin imagen
|
||||
const res = await fetch('/api/admin/materiales', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
nombre: nombreT,
|
||||
categoria_id: categoriaId === '' ? null : Number(categoriaId),
|
||||
descripcion: descripcion.trim() || null,
|
||||
cantidad_total: trackeado ? 0 : cantidad,
|
||||
numero_inventario: numeroInventario.trim() || null,
|
||||
estado,
|
||||
trackeado_por_unidad: trackeado,
|
||||
}),
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(json?.error ?? 'No se pudo crear');
|
||||
|
||||
// 2) subir foto (si hay) y PATCH imagen_path
|
||||
if (file && json.id) {
|
||||
const path = await uploadFotoFor(json.id);
|
||||
if (path) {
|
||||
const p = await fetch(`/api/admin/materiales/${json.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ imagen_path: path }),
|
||||
});
|
||||
if (!p.ok) {
|
||||
toast({ kind: 'error', title: 'Foto subida pero no vinculada', description: 'Recarga y vuelve a intentar.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// edit — sube nueva foto primero (si hay) y luego PATCH con el path incluido
|
||||
let newImagenPath: string | null | undefined = undefined;
|
||||
if (file && material) {
|
||||
const path = await uploadFotoFor(material.id);
|
||||
if (path) newImagenPath = path;
|
||||
}
|
||||
const patch: Record<string, unknown> = {
|
||||
nombre: nombreT,
|
||||
categoria_id: categoriaId === '' ? null : Number(categoriaId),
|
||||
descripcion: descripcion.trim() || null,
|
||||
numero_inventario: numeroInventario.trim() || null,
|
||||
estado,
|
||||
trackeado_por_unidad: trackeado,
|
||||
};
|
||||
if (!trackeado) patch.cantidad_total = cantidad;
|
||||
if (newImagenPath !== undefined) patch.imagen_path = newImagenPath;
|
||||
const res = await fetch(`/api/admin/materiales/${material!.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(json?.error ?? 'No se pudo guardar');
|
||||
}
|
||||
|
||||
dialogRef.current?.close();
|
||||
location.reload();
|
||||
} catch (err) {
|
||||
@@ -123,6 +202,8 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
||||
const triggerLabel = mode === 'create' ? 'Nuevo material' : 'Editar';
|
||||
const triggerClass = mode === 'create' ? 'btn btn-primary' : 'btn btn-ghost';
|
||||
|
||||
const currentImg = previewUrl ?? imgUrl(imagenPath);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" className={triggerClass} onClick={open}>
|
||||
@@ -132,7 +213,7 @@ export default function MaterialForm({ mode, material, categorias }: 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,32rem)]"
|
||||
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)]"
|
||||
>
|
||||
<form onSubmit={submit} className="p-5 sm:p-6 flex flex-col gap-4" autoComplete="off">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
@@ -151,10 +232,53 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Foto */}
|
||||
<div>
|
||||
<label className="label" htmlFor={`${titleId}-nombre`}>
|
||||
Nombre
|
||||
</label>
|
||||
<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)' }}
|
||||
>
|
||||
{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">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
id={`${titleId}-file`}
|
||||
className="input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
{(file || imagenPath) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost self-start"
|
||||
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||
onClick={() => {
|
||||
setFile(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
if (imagenPath && mode === 'edit') setImagenPath(null);
|
||||
}}
|
||||
>
|
||||
Quitar foto
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label" htmlFor={`${titleId}-nombre`}>Nombre</label>
|
||||
<input
|
||||
ref={firstFieldRef}
|
||||
id={`${titleId}-nombre`}
|
||||
@@ -171,9 +295,7 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label" htmlFor={`${titleId}-cat`}>
|
||||
Categoría
|
||||
</label>
|
||||
<label className="label" htmlFor={`${titleId}-cat`}>Categoría</label>
|
||||
<select
|
||||
id={`${titleId}-cat`}
|
||||
className="input"
|
||||
@@ -182,17 +304,13 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
||||
>
|
||||
<option value="">Sin categoría</option>
|
||||
{categorias.map((c) => (
|
||||
<option key={c.id} value={String(c.id)}>
|
||||
{c.nombre}
|
||||
</option>
|
||||
<option key={c.id} value={String(c.id)}>{c.nombre}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label" htmlFor={`${titleId}-inv`}>
|
||||
Nº de inventario
|
||||
</label>
|
||||
<label className="label" htmlFor={`${titleId}-inv`}>Nº de inventario</label>
|
||||
<input
|
||||
id={`${titleId}-inv`}
|
||||
className="input"
|
||||
@@ -206,9 +324,7 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label" htmlFor={`${titleId}-desc`}>
|
||||
Descripción
|
||||
</label>
|
||||
<label className="label" htmlFor={`${titleId}-desc`}>Descripción</label>
|
||||
<textarea
|
||||
id={`${titleId}-desc`}
|
||||
className="input"
|
||||
@@ -221,11 +337,24 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={trackeado}
|
||||
onChange={(e) => setTrackeado(e.target.checked)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">Rastrear cada unidad por separado</span>
|
||||
<span className="block opacity-70 text-xs">
|
||||
Ej. Laptop-01, Laptop-02… El stock se calcula del número de unidades registradas abajo.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label" htmlFor={`${titleId}-total`}>
|
||||
Cantidad total
|
||||
</label>
|
||||
<label className="label" htmlFor={`${titleId}-total`}>Cantidad total</label>
|
||||
<input
|
||||
id={`${titleId}-total`}
|
||||
className="input"
|
||||
@@ -233,12 +362,17 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
||||
min={0}
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
value={cantidadTotal}
|
||||
value={trackeado ? (material?.cantidad_total ?? 0) : cantidadTotal}
|
||||
onChange={(e) => setCantidadTotal(e.target.value)}
|
||||
required
|
||||
required={!trackeado}
|
||||
disabled={trackeado}
|
||||
readOnly={trackeado}
|
||||
style={{ fontVariantNumeric: 'tabular-nums' }}
|
||||
/>
|
||||
{mode === 'edit' && material && (
|
||||
{trackeado && (
|
||||
<p className="text-xs opacity-70 mt-1">Se calcula del número de unidades.</p>
|
||||
)}
|
||||
{!trackeado && mode === 'edit' && material && (
|
||||
<p className="text-xs opacity-60 mt-1">
|
||||
Prestados: {material.cantidad_total - material.cantidad_disponible}
|
||||
</p>
|
||||
@@ -246,9 +380,7 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label" htmlFor={`${titleId}-estado`}>
|
||||
Estado
|
||||
</label>
|
||||
<label className="label" htmlFor={`${titleId}-estado`}>Estado</label>
|
||||
<select
|
||||
id={`${titleId}-estado`}
|
||||
className="input"
|
||||
@@ -284,6 +416,16 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* 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)' }}
|
||||
>
|
||||
<UnidadesManager materialId={material.id} />
|
||||
</div>
|
||||
)}
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
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="text-sm font-semibold uppercase tracking-wider 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 opacity-70">Cargando…</p>
|
||||
) : unidades.length === 0 ? (
|
||||
<p className="text-sm opacity-70">Sin unidades registradas.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead
|
||||
className="text-left"
|
||||
style={{ background: 'color-mix(in oklab, var(--color-ink) 4%, transparent)' }}
|
||||
>
|
||||
<tr>
|
||||
<th className="px-2 py-2 font-medium">Etiqueta</th>
|
||||
<th className="px-2 py-2 font-medium">Estado</th>
|
||||
<th className="px-2 py-2 font-medium">Notas</th>
|
||||
<th className="px-2 py-2 font-medium text-right">Acción</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{unidades.map((u) => {
|
||||
const bloqueada = u.estado === 'prestado';
|
||||
return (
|
||||
<tr
|
||||
key={u.id}
|
||||
className="border-t"
|
||||
style={{ borderColor: 'color-mix(in oklab, var(--color-ink) 12%, transparent)' }}
|
||||
>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
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;
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<select
|
||||
className="input"
|
||||
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||
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>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||
defaultValue={u.notas ?? ''}
|
||||
placeholder="Opcional…"
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value;
|
||||
if ((u.notas ?? '') !== v) patch(u, { notas: v || null });
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||
disabled={bloqueada}
|
||||
onClick={() => eliminar(u)}
|
||||
>
|
||||
Eliminar
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { toast, toastAfterReload } from '@/lib/toast';
|
||||
|
||||
type Unidad = { id: number; etiqueta: string };
|
||||
type Item = {
|
||||
id: number;
|
||||
cantidad: number;
|
||||
descripcion: string | null;
|
||||
material: { nombre: string; numero_inventario: string | null } | null;
|
||||
material: { id: number; nombre: string; numero_inventario: string | null; trackeado_por_unidad?: boolean } | null;
|
||||
material_unidad: Unidad | null;
|
||||
};
|
||||
type Detalles = {
|
||||
prestamo: {
|
||||
@@ -23,6 +27,117 @@ type Detalles = {
|
||||
|
||||
const fmt = new Intl.DateTimeFormat('es-MX', { dateStyle: 'medium', timeStyle: 'short' });
|
||||
|
||||
const ERROR_TXT: Record<string, string> = {
|
||||
no_autorizado: 'No autorizado',
|
||||
item_no_existe: 'El renglón ya no existe',
|
||||
solicitud_no_activa: 'La solicitud no está activa',
|
||||
item_sin_unidad: 'Este renglón no tiene unidad asignada',
|
||||
unidad_no_existe: 'La unidad seleccionada no existe',
|
||||
unidad_de_otro_material: 'La unidad no corresponde a este material',
|
||||
unidad_no_disponible: 'La unidad ya no está disponible',
|
||||
};
|
||||
|
||||
function ReasignarUnidad({
|
||||
solicitudId,
|
||||
item,
|
||||
}: {
|
||||
solicitudId: number;
|
||||
item: Item;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [unidades, setUnidades] = useState<Unidad[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selected, setSelected] = useState<string>('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const abrir = async () => {
|
||||
setOpen(true);
|
||||
if (unidades.length || !item.material) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await fetch(`/api/admin/materiales/${item.material.id}/unidades?estado=disponible`);
|
||||
const j = await r.json();
|
||||
if (!r.ok) throw new Error(j?.error ?? 'Error');
|
||||
setUnidades(j.unidades ?? []);
|
||||
} catch (e) {
|
||||
toast({ kind: 'error', title: 'No se pudieron cargar unidades', description: e instanceof Error ? e.message : '' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmar = async () => {
|
||||
if (!selected || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const r = await fetch(`/api/admin/solicitudes/${solicitudId}/reasignar-unidad`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ item_id: item.id, nueva_unidad_id: Number(selected) }),
|
||||
});
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) {
|
||||
const key = String(j?.error ?? '');
|
||||
toast({ kind: 'error', title: ERROR_TXT[key] ?? 'No se pudo reasignar' });
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
toastAfterReload({ kind: 'success', title: 'Unidad reasignada' });
|
||||
location.reload();
|
||||
} catch {
|
||||
toast({ kind: 'error', title: 'Error inesperado' });
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ minHeight: '30px', paddingBlock: '0.15rem', fontSize: '0.75rem' }}
|
||||
onClick={abrir}
|
||||
>
|
||||
Cambiar unidad
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1 mt-1">
|
||||
<select
|
||||
className="input"
|
||||
style={{ minHeight: '32px', paddingBlock: '0.2rem', fontSize: '0.85rem' }}
|
||||
value={selected}
|
||||
onChange={(e) => setSelected(e.target.value)}
|
||||
disabled={loading || saving}
|
||||
>
|
||||
<option value="">{loading ? 'Cargando…' : 'Selecciona unidad…'}</option>
|
||||
{unidades.map((u) => (
|
||||
<option key={u.id} value={String(u.id)}>{u.etiqueta}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ minHeight: '32px', paddingBlock: '0.2rem', fontSize: '0.85rem' }}
|
||||
onClick={confirmar}
|
||||
disabled={!selected || saving}
|
||||
>
|
||||
{saving ? '…' : 'Confirmar'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ minHeight: '32px', paddingBlock: '0.2rem', fontSize: '0.85rem' }}
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VerDetalles({ prestamoId }: { prestamoId: number }) {
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
const [data, setData] = useState<Detalles | null>(null);
|
||||
@@ -53,6 +168,8 @@ export default function VerDetalles({ prestamoId }: { prestamoId: number }) {
|
||||
}
|
||||
};
|
||||
|
||||
const canReasignar = data && ['aprobado', 'activo'].includes(data.prestamo.estado);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" className="btn btn-ghost" onClick={open}>Detalles</button>
|
||||
@@ -83,12 +200,21 @@ export default function VerDetalles({ prestamoId }: { prestamoId: number }) {
|
||||
<dd className="col-span-2">{data.prestamo.maestro_responsable}</dd>
|
||||
<dt className="opacity-70">Materiales</dt>
|
||||
<dd className="col-span-2">
|
||||
<ul className="list-disc pl-4">
|
||||
{data.prestamo.items.map((i, idx) => (
|
||||
<li key={idx}>
|
||||
<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>}
|
||||
{canReasignar && i.material_unidad && (
|
||||
<ReasignarUnidad solicitudId={data.prestamo.id} item={i} />
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
// ponytail: coordinar via window para evitar Context entre 2 islands hermanas
|
||||
declare global {
|
||||
interface Window {
|
||||
__labreFiltrar?: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
const norm = (s: string) =>
|
||||
s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
||||
|
||||
function unify() {
|
||||
const cards = document.querySelectorAll<HTMLElement>('[data-cat]');
|
||||
let anyVisible = false;
|
||||
cards.forEach((el) => {
|
||||
const hidden = el.dataset.hiddenByCat === 'true' || el.dataset.hiddenBySearch === 'true';
|
||||
el.hidden = hidden;
|
||||
if (!hidden) anyVisible = true;
|
||||
});
|
||||
const empty = document.getElementById('catalogo-empty-filter');
|
||||
if (empty) empty.hidden = anyVisible || cards.length === 0;
|
||||
}
|
||||
|
||||
function applyText(value: string) {
|
||||
const needle = norm(value.trim());
|
||||
const cards = document.querySelectorAll<HTMLElement>('[data-cat]');
|
||||
cards.forEach((el) => {
|
||||
if (!needle) {
|
||||
delete el.dataset.hiddenBySearch;
|
||||
return;
|
||||
}
|
||||
const nombre = norm(el.dataset.nombre ?? '');
|
||||
const inv = norm(el.dataset.numeroInventario ?? '');
|
||||
const match = nombre.includes(needle) || (!!inv && inv.includes(needle));
|
||||
if (match) delete el.dataset.hiddenBySearch;
|
||||
else el.dataset.hiddenBySearch = 'true';
|
||||
});
|
||||
unify();
|
||||
}
|
||||
|
||||
export default function BuscadorCatalogo({ q: qInitial = '' }: { q?: string }) {
|
||||
const [q, setQ] = useState(qInitial);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
window.__labreFiltrar = unify;
|
||||
applyText(qInitial);
|
||||
return () => {
|
||||
window.__labreFiltrar = undefined;
|
||||
if (timerRef.current) window.clearTimeout(timerRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const syncUrl = (value: string) => {
|
||||
const url = new URL(location.href);
|
||||
const clean = value.trim();
|
||||
if (clean) url.searchParams.set('q', clean);
|
||||
else url.searchParams.delete('q');
|
||||
history.replaceState(null, '', url.toString());
|
||||
};
|
||||
|
||||
const onChange = (value: string) => {
|
||||
setQ(value);
|
||||
if (timerRef.current) window.clearTimeout(timerRef.current);
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
applyText(value);
|
||||
syncUrl(value);
|
||||
}, 120);
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
setQ('');
|
||||
if (timerRef.current) window.clearTimeout(timerRef.current);
|
||||
applyText('');
|
||||
syncUrl('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="label sr-only" htmlFor="buscador-catalogo">
|
||||
Buscar material
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="buscador-catalogo"
|
||||
className="input"
|
||||
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"
|
||||
>
|
||||
<svg width="14" height="14" 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,12 +10,21 @@ function applyFilter(value: string) {
|
||||
cards.forEach((el) => {
|
||||
const cat = el.dataset.cat;
|
||||
const show = value === ALL || cat === value;
|
||||
el.hidden = !show;
|
||||
if (show) delete el.dataset.hiddenByCat;
|
||||
else el.dataset.hiddenByCat = 'true';
|
||||
});
|
||||
const empty = document.getElementById('catalogo-empty-filter');
|
||||
if (empty) {
|
||||
const anyVisible = Array.from(cards).some((el) => !el.hidden);
|
||||
empty.hidden = anyVisible || cards.length === 0;
|
||||
// El buscador (BuscadorCatalogo, client:load) define este helper y unifica
|
||||
// ambos flags (cat + search). Fallback si aún no montó: aplicar directo.
|
||||
if (typeof window !== 'undefined' && window.__labreFiltrar) {
|
||||
window.__labreFiltrar();
|
||||
} else {
|
||||
let anyVisible = false;
|
||||
cards.forEach((el) => {
|
||||
el.hidden = el.dataset.hiddenByCat === 'true';
|
||||
if (!el.hidden) anyVisible = true;
|
||||
});
|
||||
const empty = document.getElementById('catalogo-empty-filter');
|
||||
if (empty) empty.hidden = anyVisible || cards.length === 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +71,8 @@ export default function FiltroCategorias({ categorias }: Props) {
|
||||
aria-selected={active === ALL}
|
||||
className={chipClass(ALL)}
|
||||
style={chipStyle(ALL)}
|
||||
data-cat-value={ALL}
|
||||
data-cat-active={active === ALL ? 'true' : undefined}
|
||||
onClick={() => pick(ALL)}
|
||||
>
|
||||
Todas
|
||||
@@ -76,6 +87,8 @@ export default function FiltroCategorias({ categorias }: Props) {
|
||||
aria-selected={active === v}
|
||||
className={chipClass(v)}
|
||||
style={chipStyle(v)}
|
||||
data-cat-value={v}
|
||||
data-cat-active={active === v ? 'true' : undefined}
|
||||
onClick={() => pick(v)}
|
||||
>
|
||||
{c.nombre}
|
||||
|
||||
@@ -174,7 +174,13 @@ export default function CartProvider({ materiales }: { materiales: Material[] })
|
||||
<CartContext.Provider value={value}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{materiales.map((m) => (
|
||||
<article key={m.id} className="card flex flex-col gap-3" data-cat={m.categoria?.id ?? 'sin'}>
|
||||
<article
|
||||
key={m.id}
|
||||
className="card flex flex-col gap-3"
|
||||
data-cat={m.categoria?.id ?? 'sin'}
|
||||
data-nombre={m.nombre}
|
||||
data-numero-inventario={m.numero_inventario ?? ''}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="font-semibold leading-snug">{m.nombre}</h3>
|
||||
{m.categoria && (
|
||||
|
||||
Reference in New Issue
Block a user