Files
labre-web/src/components/admin/inventario/MaterialForm.tsx
T
LakG 544bbf38a9 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
2026-08-22 18:03:38 -07:00

433 lines
16 KiB
TypeScript

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 = {
id: number;
nombre: string;
categoria_id: number | null;
descripcion: string | null;
cantidad_total: number;
cantidad_disponible: number;
numero_inventario: string | null;
estado: 'disponible' | 'mantenimiento' | 'baja';
imagen_path?: string | null;
trackeado_por_unidad?: boolean;
};
type Props = {
mode: 'create' | 'edit';
material?: Material;
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>(
material?.categoria_id != null ? String(material.categoria_id) : '',
);
const [descripcion, setDescripcion] = useState(material?.descripcion ?? '');
const [cantidadTotal, setCantidadTotal] = useState<string>(
material ? String(material.cantidad_total) : '0',
);
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') {
setNombre('');
setCategoriaId('');
setDescripcion('');
setCantidadTotal('0');
setNumeroInventario('');
setEstado('disponible');
setTrackeado(false);
setFile(null);
setImagenPath(null);
}
dialogRef.current?.showModal();
queueMicrotask(() => firstFieldRef.current?.focus());
};
const close = () => {
if (loading) return;
dialogRef.current?.close();
};
useEffect(() => {
const dlg = dialogRef.current;
if (!dlg) return;
const onClick = (e: MouseEvent) => {
if (e.target === dlg) close();
};
dlg.addEventListener('click', onClick);
return () => dlg.removeEventListener('click', onClick);
});
useEffect(() => {
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 nombreT = nombre.trim();
if (!nombreT) {
setError('El nombre es obligatorio');
setLoading(false);
return;
}
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;
}
try {
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) {
setError(err instanceof Error ? err.message : 'Error inesperado');
} finally {
setLoading(false);
}
};
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}>
{triggerLabel}
</button>
<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)]"
>
<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">
<h2 id={titleId} className="text-lg font-semibold">
{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"
>
<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>
{/* Foto */}
<div>
<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`}
className="input"
type="text"
value={nombre}
onChange={(e) => setNombre(e.target.value)}
required
autoComplete="off"
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${titleId}-err` : undefined}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="label" htmlFor={`${titleId}-cat`}>Categoría</label>
<select
id={`${titleId}-cat`}
className="input"
value={categoriaId}
onChange={(e) => setCategoriaId(e.target.value)}
>
<option value="">Sin categoría</option>
{categorias.map((c) => (
<option key={c.id} value={String(c.id)}>{c.nombre}</option>
))}
</select>
</div>
<div>
<label className="label" htmlFor={`${titleId}-inv`}> de inventario</label>
<input
id={`${titleId}-inv`}
className="input"
type="text"
value={numeroInventario}
onChange={(e) => setNumeroInventario(e.target.value)}
autoComplete="off"
placeholder="Opcional…"
/>
</div>
</div>
<div>
<label className="label" htmlFor={`${titleId}-desc`}>Descripción</label>
<textarea
id={`${titleId}-desc`}
className="input"
rows={3}
maxLength={500}
value={descripcion}
onChange={(e) => setDescripcion(e.target.value)}
placeholder="Detalles, especificaciones, notas…"
style={{ minHeight: '80px' }}
/>
</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>
<input
id={`${titleId}-total`}
className="input"
type="number"
min={0}
step={1}
inputMode="numeric"
value={trackeado ? (material?.cantidad_total ?? 0) : cantidadTotal}
onChange={(e) => setCantidadTotal(e.target.value)}
required={!trackeado}
disabled={trackeado}
readOnly={trackeado}
style={{ fontVariantNumeric: 'tabular-nums' }}
/>
{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>
)}
</div>
<div>
<label className="label" htmlFor={`${titleId}-estado`}>Estado</label>
<select
id={`${titleId}-estado`}
className="input"
value={estado}
onChange={(e) => setEstado(e.target.value as Material['estado'])}
>
<option value="disponible">Disponible</option>
<option value="mantenimiento">Mantenimiento</option>
<option value="baja">Baja</option>
</select>
</div>
</div>
{error && (
<p
ref={errorRef}
id={`${titleId}-err`}
role="alert"
tabIndex={-1}
className="text-sm"
style={{ color: 'var(--color-danger-text)' }}
>
{error}
</p>
)}
<div className="flex gap-2 justify-end pt-2">
<button type="button" className="btn btn-ghost" onClick={close} disabled={loading}>
Cancelar
</button>
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? 'Guardando…' : 'Guardar'}
</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>
</>
);
}