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:
2026-08-22 18:03:38 -07:00
parent 43959056f8
commit 544bbf38a9
23 changed files with 1876 additions and 247 deletions
+58
View File
@@ -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;
}
+176
View File
@@ -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>
);
}
+192 -50
View File
@@ -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`}>
de inventario
</label>
<label className="label" htmlFor={`${titleId}-inv`}> 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>
+111
View File
@@ -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>
);
}
+18 -5
View File
@@ -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}
+7 -1
View File
@@ -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 && (
+39 -7
View File
@@ -1,6 +1,7 @@
---
import Layout from './Layout.astro';
import Toaster from '@/components/Toaster.tsx';
import BadgeSolicitudes from '@/components/admin/BadgeSolicitudes.tsx';
import BrandMark from '@/components/BrandMark.astro';
interface Props {
@@ -10,6 +11,15 @@ const { title } = Astro.props;
const profile = Astro.locals.profile;
const isAdmin = profile?.rol === 'admin';
let pendientesCount = 0;
if (isAdmin) {
const { count } = await Astro.locals.supabase
.from('solicitudes')
.select('*', { count: 'exact', head: true })
.eq('estado', 'pendiente');
pendientesCount = count ?? 0;
}
const path = Astro.url.pathname;
const isActive = (href: string) => path === href || path.startsWith(href + '/');
@@ -37,7 +47,8 @@ const alumnoNav: NavItem[] = [
const adminNav: NavItem[] = [
{ href: '/admin', label: 'Panel', icon: 'dashboard' },
{ href: '/admin/solicitudes', label: 'Solicitudes', icon: 'inbox' },
{ href: '/admin/inventario', label: 'Estadísticas', icon: 'stats' },
{ href: '/admin/inventario', label: 'Inventario', icon: 'box' },
{ href: '/admin/estadisticas', label: 'Estadísticas', icon: 'stats' },
{ href: '/admin/reportes', label: 'Reportes', icon: 'chart' },
];
const nav = isAdmin ? adminNav : alumnoNav;
@@ -76,15 +87,26 @@ const activeCheck = (item: NavItem) => {
<a
href={item.href}
class:list={[
'flex items-center gap-3 px-3 py-2.5 rounded-[2px] text-sm font-medium uppercase tracking-wide whitespace-nowrap transition-colors',
'relative flex items-center gap-3 px-3 py-2.5 rounded-[2px] text-sm font-medium uppercase tracking-wide whitespace-nowrap transition-colors',
activeCheck(item) ? 'bg-[color:var(--color-primary)] text-white' : 'hover:bg-[color:var(--color-chalk)]'
]}
style={activeCheck(item) ? 'border: 2px solid var(--color-ink);' : 'border: 2px solid transparent;'}
aria-current={activeCheck(item) ? 'page' : undefined}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d={ICONS[item.icon as keyof typeof ICONS]} />
</svg>
<span class="relative inline-grid place-items-center">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d={ICONS[item.icon as keyof typeof ICONS]} />
</svg>
{item.href === '/admin/solicitudes' && (
<span
data-badge="pendientes"
hidden={pendientesCount === 0}
aria-label={`${pendientesCount} solicitudes pendientes`}
class="absolute -top-2 -right-3 min-w-[18px] h-[18px] px-1 grid place-items-center rounded-[2px] font-mono font-bold text-[11px] leading-none"
style="background: var(--color-danger); color: var(--color-ink); border: 2px solid var(--color-ink);"
>{pendientesCount}</span>
)}
</span>
{item.label}
</a>
))}
@@ -149,11 +171,11 @@ const activeCheck = (item: NavItem) => {
href={item.href}
aria-label={item.label}
aria-current={active ? 'page' : undefined}
class="flex-1 grid place-items-center py-2.5"
class="relative flex-1 grid place-items-center py-2.5"
>
<span
class:list={[
'grid place-items-center rounded-[2px] w-11 h-11 transition-[background-color,transform] duration-100 motion-safe:active:scale-95',
'relative grid place-items-center rounded-[2px] w-11 h-11 transition-[background-color,transform] duration-100 motion-safe:active:scale-95',
active ? 'bg-[color:var(--color-primary)] text-white' : ''
]}
style={active ? 'border: 2px solid var(--color-ink);' : ''}
@@ -161,6 +183,15 @@ const activeCheck = (item: NavItem) => {
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d={ICONS[item.icon as keyof typeof ICONS]} />
</svg>
{item.href === '/admin/solicitudes' && (
<span
data-badge="pendientes"
hidden={pendientesCount === 0}
aria-label={`${pendientesCount} solicitudes pendientes`}
class="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 grid place-items-center rounded-[2px] font-mono font-bold text-[11px] leading-none"
style="background: var(--color-danger); color: var(--color-ink); border: 2px solid var(--color-ink);"
>{pendientesCount}</span>
)}
</span>
</a>
);
@@ -181,6 +212,7 @@ const activeCheck = (item: NavItem) => {
</nav>
<Toaster client:idle />
{isAdmin && <BadgeSolicitudes client:idle />}
</div>
</Layout>
+9
View File
@@ -0,0 +1,9 @@
// URL publica del bucket 'materiales-fotos' a partir del path guardado.
export function imgUrl(path?: string | null): string | null {
if (!path) return null;
const base =
(import.meta.env.PUBLIC_SUPABASE_URL as string | undefined) ??
(process.env.PUBLIC_SUPABASE_URL as string | undefined);
if (!base) return null;
return `${base}/storage/v1/object/public/materiales-fotos/${path}`;
}
+164
View File
@@ -0,0 +1,164 @@
---
import AppLayout from '@/layouts/AppLayout.astro';
import Chart from '@/components/admin/estadisticas/Chart.tsx';
import { todayMX } from '@/lib/date';
// El middleware ya garantiza rol admin en /admin/*.
const supabase = Astro.locals.supabase;
const dia = todayMX();
const countHead = { count: 'exact' as const, head: true };
const startISO = dia.startUTC.toISOString();
const endISO = dia.endUTC.toISOString();
// KPIs del día — misma lógica que /admin/inventario legacy.
const [
{ count: nSolicitadas },
{ count: nAprobadas },
{ count: nRechazadas },
{ count: nDevueltas },
{ count: nEnPrestamo },
{ count: nVencidos },
] = await Promise.all([
supabase.from('solicitudes').select('id', countHead).gte('fecha_solicitud', startISO).lt('fecha_solicitud', endISO),
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'aprobado').gte('at', startISO).lt('at', endISO),
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'rechazado').gte('at', startISO).lt('at', endISO),
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'devuelto').gte('at', startISO).lt('at', endISO),
supabase.from('solicitudes').select('id', countHead).in('estado', ['aprobado', 'activo']),
supabase.from('solicitudes').select('id', countHead).in('estado', ['aprobado', 'activo']).lt('fecha_devolucion_estimada', dia.isoDate),
]);
const kpis = [
{ label: 'Solicitadas', value: nSolicitadas ?? 0, tone: 'primary' },
{ label: 'Aprobadas', value: nAprobadas ?? 0, tone: 'primary' },
{ label: 'Rechazadas', value: nRechazadas ?? 0, tone: 'neutral' },
{ label: 'Devoluciones', value: nDevueltas ?? 0, tone: 'primary' },
{ label: 'En préstamo', value: nEnPrestamo ?? 0, tone: 'secondary', total: true },
{ label: 'Vencidos', value: nVencidos ?? 0, tone: (nVencidos ?? 0) > 0 ? 'danger' : 'neutral', total: true },
];
const toneStyle = (tone: string) => {
if (tone === 'primary') return 'color: var(--color-primary);';
if (tone === 'secondary') return 'color: var(--color-secondary-hover);';
if (tone === 'danger') return 'color: var(--color-danger-text);';
return 'color: color-mix(in oklab, var(--color-ink) 65%, transparent);';
};
// Ventana 30 días (rango inclusivo del día actual).
const DIAS = 30;
const desde = new Date(dia.startUTC.getTime() - (DIAS - 1) * 86400000).toISOString();
// (1) Top 10 materiales solicitados últimos 30 días.
const { data: topMaterialesRaw } = await supabase
.from('solicitud_items')
.select('cantidad, material:materiales(nombre), solicitud:solicitudes!inner(fecha_solicitud)')
.gte('solicitud.fecha_solicitud', desde);
const topMapa = new Map<string, number>();
for (const r of (topMaterialesRaw ?? []) as any[]) {
const nom = r.material?.nombre ?? '—';
topMapa.set(nom, (topMapa.get(nom) ?? 0) + (r.cantidad ?? 0));
}
const topMateriales = [...topMapa.entries()]
.map(([label, valor]) => ({ label, valor }))
.sort((a, b) => b.valor - a.valor)
.slice(0, 10);
// (2) Solicitudes por día últimos 30 días. Rellena huecos con 0.
const { data: solicitudesUlt30 } = await supabase
.from('solicitudes')
.select('fecha_solicitud')
.gte('fecha_solicitud', desde)
.order('fecha_solicitud', { ascending: true });
// Genera todas las fechas de la ventana en formato YYYY-MM-DD (zona MX).
function toIsoDateMX(d: Date): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/Tijuana',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(d);
}
const conteoPorDia = new Map<string, number>();
for (const r of (solicitudesUlt30 ?? []) as { fecha_solicitud: string }[]) {
const k = toIsoDateMX(new Date(r.fecha_solicitud));
conteoPorDia.set(k, (conteoPorDia.get(k) ?? 0) + 1);
}
const solicitudesPorDia: { fecha: string; valor: number }[] = [];
for (let i = 0; i < DIAS; i++) {
const d = new Date(dia.startUTC.getTime() - (DIAS - 1 - i) * 86400000);
const iso = toIsoDateMX(d);
solicitudesPorDia.push({ fecha: iso, valor: conteoPorDia.get(iso) ?? 0 });
}
// (3) Distribución de estados de todas las solicitudes.
const { data: estadosRaw } = await supabase.from('solicitudes').select('estado');
const estadoLabelMap: Record<string, string> = {
pendiente: 'Pendiente',
aprobado: 'Aprobado',
activo: 'Activo',
devuelto: 'Devuelto',
rechazado: 'Rechazado',
vencido: 'Vencido',
};
const estadoMapa = new Map<string, number>();
for (const r of (estadosRaw ?? []) as { estado: string | null }[]) {
if (!r.estado) continue;
estadoMapa.set(r.estado, (estadoMapa.get(r.estado) ?? 0) + 1);
}
const distribEstados = [...estadoMapa.entries()].map(([k, v]) => ({
label: estadoLabelMap[k] ?? k,
valor: v,
}));
---
<AppLayout title="Estadísticas — LabPréstamos">
<div class="max-w-6xl">
<header class="mb-6">
<h1 class="text-2xl md:text-3xl font-semibold">Estadísticas</h1>
<p class="text-sm mt-1 first-letter:uppercase" style="color: var(--color-pencil);">
Actividad del día · <time datetime={dia.isoDate}>{dia.label}</time>
</p>
</header>
<section aria-label="Métricas del día" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 mb-6">
{kpis.map((k) => (
<div class="card p-4">
<div class="text-xs" style="color: var(--color-pencil);">{k.label}{k.total ? ' (total)' : ' hoy'}</div>
<div class="text-3xl font-semibold mt-1 leading-none" style={`font-variant-numeric: tabular-nums; ${toneStyle(k.tone)}`}>
{k.value}
</div>
</div>
))}
</section>
<section aria-label="Análisis" class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="card">
<h3 class="text-sm font-semibold uppercase tracking-wide mb-3">Solicitudes por día · últimos {DIAS}</h3>
{solicitudesPorDia.some((d) => d.valor > 0) ? (
<Chart tipo="linea" datos={solicitudesPorDia} client:idle />
) : (
<p class="text-sm py-8 text-center" style="color: var(--color-pencil);">Sin actividad en los últimos {DIAS} días.</p>
)}
</div>
<div class="card">
<h3 class="text-sm font-semibold uppercase tracking-wide mb-3">Distribución de estados</h3>
{distribEstados.length > 0 ? (
<Chart tipo="donut" datos={distribEstados} client:idle />
) : (
<p class="text-sm py-8 text-center" style="color: var(--color-pencil);">Aún no hay solicitudes registradas.</p>
)}
</div>
<div class="card lg:col-span-2">
<h3 class="text-sm font-semibold uppercase tracking-wide mb-3">Top 10 materiales · últimos {DIAS} días</h3>
{topMateriales.length > 0 ? (
<Chart tipo="barras" datos={topMateriales} alto={320} client:idle />
) : (
<p class="text-sm py-8 text-center" style="color: var(--color-pencil);">Ningún material solicitado en los últimos {DIAS} días.</p>
)}
</div>
</section>
</div>
</AppLayout>
+3 -2
View File
@@ -1,13 +1,14 @@
---
import AppLayout from '@/layouts/AppLayout.astro';
import { todayMX } from '@/lib/date';
const supabase = Astro.locals.supabase;
const today = new Date().toISOString().split('T')[0];
const dia = todayMX();
const [pend, activos, vencidos, agotados, ultimas] = await Promise.all([
supabase.from('solicitudes').select('*', { count: 'exact', head: true }).eq('estado', 'pendiente'),
supabase.from('solicitudes').select('*', { count: 'exact', head: true }).in('estado', ['aprobado', 'activo']),
supabase.from('solicitudes').select('*', { count: 'exact', head: true }).in('estado', ['aprobado', 'activo']).lt('fecha_devolucion_estimada', today),
supabase.from('solicitudes').select('*', { count: 'exact', head: true }).in('estado', ['aprobado', 'activo']).lt('fecha_devolucion_estimada', dia.isoDate),
supabase.from('materiales').select('*', { count: 'exact', head: true }).eq('cantidad_disponible', 0),
supabase
.from('solicitudes')
+66 -153
View File
@@ -2,7 +2,7 @@
import AppLayout from '@/layouts/AppLayout.astro';
import MaterialForm from '@/components/admin/inventario/MaterialForm.tsx';
import EliminarMaterial from '@/components/admin/inventario/EliminarMaterial.tsx';
import { todayMX } from '@/lib/date';
import { imgUrl } from '@/lib/materialImg';
type Material = {
id: number;
@@ -12,48 +12,11 @@ type Material = {
cantidad_disponible: number;
numero_inventario: string | null;
estado: 'disponible' | 'mantenimiento' | 'baja';
imagen_path: string | null;
trackeado_por_unidad: boolean;
categoria: { id: number; nombre: string } | null;
};
// KPIs del día (fecha automática, timezone MX con DST)
const dia = todayMX();
const supabase = Astro.locals.supabase;
const countHead = { count: 'exact' as const, head: true };
const startISO = dia.startUTC.toISOString();
const endISO = dia.endUTC.toISOString();
const [
{ count: nSolicitadas },
{ count: nAprobadas },
{ count: nRechazadas },
{ count: nDevueltas },
{ count: nEnPrestamo },
{ count: nVencidos },
] = await Promise.all([
supabase.from('solicitudes').select('id', countHead).gte('fecha_solicitud', startISO).lt('fecha_solicitud', endISO),
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'aprobado').gte('at', startISO).lt('at', endISO),
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'rechazado').gte('at', startISO).lt('at', endISO),
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'devuelto').gte('at', startISO).lt('at', endISO),
supabase.from('solicitudes').select('id', countHead).in('estado', ['aprobado', 'activo']),
supabase.from('solicitudes').select('id', countHead).in('estado', ['aprobado', 'activo']).lt('fecha_devolucion_estimada', dia.isoDate),
]);
const kpis = [
{ label: 'Solicitadas', value: nSolicitadas ?? 0, tone: 'primary' },
{ label: 'Aprobadas', value: nAprobadas ?? 0, tone: 'primary' },
{ label: 'Rechazadas', value: nRechazadas ?? 0, tone: 'neutral' },
{ label: 'Devoluciones', value: nDevueltas ?? 0, tone: 'primary' },
{ label: 'En préstamo', value: nEnPrestamo ?? 0, tone: 'secondary', total: true },
{ label: 'Vencidos', value: nVencidos ?? 0, tone: (nVencidos ?? 0) > 0 ? 'danger' : 'neutral', total: true },
];
const toneStyle = (tone: string) => {
if (tone === 'primary') return 'color: var(--color-primary);';
if (tone === 'secondary') return 'color: var(--color-secondary-hover);';
if (tone === 'danger') return 'color: var(--color-danger);';
return 'color: color-mix(in oklab, var(--color-ink) 65%, transparent);';
};
const q = (Astro.url.searchParams.get('q') ?? '').trim();
const catRaw = Astro.url.searchParams.get('cat') ?? '';
const catId = catRaw && /^\d+$/.test(catRaw) ? Number(catRaw) : null;
@@ -63,7 +26,7 @@ const estado = ['disponible', 'mantenimiento', 'baja'].includes(estadoRaw) ? est
let query = Astro.locals.supabase
.from('materiales')
.select(
'id, nombre, descripcion, cantidad_total, cantidad_disponible, numero_inventario, estado, categoria:categorias(id, nombre)',
'id, nombre, descripcion, cantidad_total, cantidad_disponible, numero_inventario, estado, imagen_path, trackeado_por_unidad, categoria:categorias(id, nombre)',
);
if (q) query = query.or(`nombre.ilike.%${q}%,numero_inventario.ilike.%${q}%`);
@@ -96,34 +59,16 @@ const estadoBadge = (e: Material['estado']) => {
};
};
---
<AppLayout title="Estadísticas — LabPréstamos">
<div class="max-w-6xl">
<AppLayout title="Inventario — LabPréstamos">
<div class="max-w-7xl">
<header class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 mb-6">
<div>
<h1 class="text-2xl md:text-3xl font-semibold">Estadísticas</h1>
<p class="text-sm opacity-70 mt-1 first-letter:uppercase">
Actividad del día · <time datetime={dia.isoDate}>{dia.label}</time>
</p>
<h1 class="text-2xl md:text-3xl font-semibold">Inventario</h1>
<p class="text-sm opacity-70 mt-1">Materiales del laboratorio.</p>
</div>
<MaterialForm mode="create" categorias={categorias} client:load />
</header>
<section aria-label="Métricas del día" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 mb-6">
{kpis.map((k) => (
<div class="card p-4">
<div class="text-xs opacity-70">{k.label}{k.total ? ' (total)' : ' hoy'}</div>
<div class="text-3xl font-semibold mt-1 leading-none" style={`font-variant-numeric: tabular-nums; ${toneStyle(k.tone)}`}>
{k.value}
</div>
</div>
))}
</section>
<div class="mb-4">
<h2 class="text-lg font-semibold">Inventario</h2>
<p class="text-sm opacity-70">Materiales del laboratorio.</p>
</div>
<nav class="flex gap-2 mb-6 border-b" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);" aria-label="Sub-navegación de inventario">
<a href="/admin/inventario" class="px-4 py-2 text-sm font-medium border-b-2" style="border-color: var(--color-primary); color: var(--color-primary);">
Materiales
@@ -133,10 +78,10 @@ const estadoBadge = (e: Material['estado']) => {
</a>
</nav>
<form method="GET" class="card mb-6 grid grid-cols-1 sm:grid-cols-4 gap-3" role="search">
<form id="inv-filter-form" method="GET" class="card mb-6 grid grid-cols-1 sm:grid-cols-4 gap-3" role="search">
<div class="sm:col-span-2">
<label class="label" for="filtro-q">Buscar</label>
<input id="filtro-q" name="q" type="search" class="input" value={q} placeholder="Nombre o Nº inventario" autocomplete="off" />
<input id="filtro-q" name="q" type="search" class="input" value={q} placeholder="Buscar por nombre o Nº de inventario" autocomplete="off" />
</div>
<div>
<label class="label" for="filtro-cat">Categoría</label>
@@ -164,6 +109,21 @@ const estadoBadge = (e: Material['estado']) => {
</div>
</form>
<script is:inline>
(() => {
const form = document.getElementById('inv-filter-form');
if (!form) return;
const q = form.querySelector('input[name="q"]');
const selects = form.querySelectorAll('select');
let t;
q && q.addEventListener('input', () => {
clearTimeout(t);
t = setTimeout(() => form.submit(), 250);
});
selects.forEach((s) => s.addEventListener('change', () => form.submit()));
})();
</script>
{matError && (
<div class="card mb-6" role="alert" style="border-color: color-mix(in oklab, var(--color-danger) 30%, transparent);">
<p class="text-sm" style="color: var(--color-danger-text);">
@@ -184,93 +144,44 @@ const estadoBadge = (e: Material['estado']) => {
)}
{!matError && materiales.length > 0 && (
<>
<div class="hidden md:block card p-0 overflow-hidden">
<table class="w-full text-sm">
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
<tr>
<th class="px-4 py-3 font-medium">Nombre</th>
<th class="px-4 py-3 font-medium">Categoría</th>
<th class="px-4 py-3 font-medium">Nº inventario</th>
<th class="px-4 py-3 font-medium text-right">Stock</th>
<th class="px-4 py-3 font-medium">Estado</th>
<th class="px-4 py-3 font-medium text-right">Acciones</th>
</tr>
</thead>
<tbody>
{materiales.map((m) => {
const badge = estadoBadge(m.estado);
return (
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 8%, transparent);">
<td class="px-4 py-3">
<div class="font-medium">{m.nombre}</div>
{m.descripcion && (
<div class="text-xs opacity-60 line-clamp-1">{m.descripcion}</div>
)}
</td>
<td class="px-4 py-3 opacity-80">{m.categoria?.nombre ?? '—'}</td>
<td class="px-4 py-3 font-mono text-xs opacity-80">{m.numero_inventario ?? '—'}</td>
<td class="px-4 py-3 text-right" style="font-variant-numeric: tabular-nums;">
{m.cantidad_disponible} / {m.cantidad_total}
</td>
<td class="px-4 py-3">
<span class="text-xs px-2 py-0.5 rounded-[2px] whitespace-nowrap" style={badge.style}>
{badge.label}
</span>
</td>
<td class="px-4 py-3">
<div class="flex gap-2 justify-end">
<MaterialForm
mode="edit"
material={{
id: m.id,
nombre: m.nombre,
categoria_id: m.categoria?.id ?? null,
descripcion: m.descripcion,
cantidad_total: m.cantidad_total,
cantidad_disponible: m.cantidad_disponible,
numero_inventario: m.numero_inventario,
estado: m.estado,
}}
categorias={categorias}
client:load
/>
<EliminarMaterial id={m.id} nombre={m.nombre} client:load />
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div class="md:hidden flex flex-col gap-3">
{materiales.map((m) => {
const badge = estadoBadge(m.estado);
return (
<article class="card flex flex-col gap-3">
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{materiales.map((m) => {
const badge = estadoBadge(m.estado);
const src = imgUrl(m.imagen_path);
return (
<article class="card p-0 flex flex-col overflow-hidden">
<div
class="aspect-square w-full bg-[color:var(--color-chalk)] grid place-items-center"
style="border-bottom: 2px solid var(--color-ink);"
>
{src ? (
<img src={src} alt={m.nombre} class="w-full h-full object-cover" loading="lazy" />
) : (
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="var(--color-ink)" stroke-width="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 class="p-3 flex flex-col gap-2 flex-1">
<div class="flex items-start justify-between gap-2">
<div>
<h3 class="font-semibold leading-snug">{m.nombre}</h3>
{m.categoria && (
<p class="text-xs opacity-70 mt-0.5">{m.categoria.nombre}</p>
)}
</div>
<span class="text-xs px-2 py-0.5 rounded-[2px] whitespace-nowrap" style={badge.style}>
<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.descripcion && <p class="text-sm opacity-75 line-clamp-2">{m.descripcion}</p>}
<dl class="text-xs opacity-70 grid grid-cols-2 gap-1">
<dt class="opacity-60">Inventario</dt>
<dd class="text-right font-mono">{m.numero_inventario ?? '—'}</dd>
<dt class="opacity-60">Stock</dt>
<dd class="text-right" style="font-variant-numeric: tabular-nums;">
{m.cantidad_disponible} / {m.cantidad_total}
</dd>
</dl>
<div class="flex gap-2 justify-end mt-auto">
{m.categoria && (
<p class="text-xs opacity-70 -mt-1">{m.categoria.nombre}</p>
)}
<p class="text-xs" style="font-variant-numeric: tabular-nums;">
<span class="font-semibold">{m.cantidad_disponible}</span> / {m.cantidad_total} disponibles
{m.trackeado_por_unidad && <span class="opacity-70"> · por unidad</span>}
</p>
{m.numero_inventario && (
<p class="text-xs opacity-60 font-mono truncate">{m.numero_inventario}</p>
)}
<div class="flex gap-2 mt-auto pt-2">
<MaterialForm
mode="edit"
material={{
@@ -282,17 +193,19 @@ const estadoBadge = (e: Material['estado']) => {
cantidad_disponible: m.cantidad_disponible,
numero_inventario: m.numero_inventario,
estado: m.estado,
imagen_path: m.imagen_path,
trackeado_por_unidad: m.trackeado_por_unidad,
}}
categorias={categorias}
client:load
/>
<EliminarMaterial id={m.id} nombre={m.nombre} client:load />
</div>
</article>
);
})}
</div>
</>
</div>
</article>
);
})}
</div>
)}
</div>
</AppLayout>
+10 -5
View File
@@ -2,6 +2,9 @@
import AppLayout from '@/layouts/AppLayout.astro';
import CartProvider from '@/components/alumno/SolicitudCart.tsx';
import FiltroCategorias from '@/components/alumno/FiltroCategorias.tsx';
import BuscadorCatalogo from '@/components/alumno/BuscadorCatalogo.tsx';
const qInitial = Astro.url.searchParams.get('q') ?? '';
type Material = {
id: number;
@@ -10,12 +13,13 @@ type Material = {
cantidad_disponible: number;
cantidad_total: number;
numero_inventario: string | null;
imagen_path: string | null;
categoria: { id: number; nombre: string } | null;
};
const { data, error } = await Astro.locals.supabase
.from('materiales')
.select('id, nombre, descripcion, cantidad_disponible, cantidad_total, numero_inventario, categoria:categorias(id, nombre)')
.select('id, nombre, descripcion, cantidad_disponible, cantidad_total, numero_inventario, imagen_path, categoria:categorias(id, nombre)')
.eq('estado', 'disponible')
.order('nombre');
@@ -57,11 +61,12 @@ const categoriasFiltro = grupoList
{materiales.length > 0 && (
<>
{categoriasFiltro.length > 0 && (
<div class="mb-6">
<div class="mb-6 flex flex-col gap-3">
<BuscadorCatalogo q={qInitial} client:load />
{categoriasFiltro.length > 0 && (
<FiltroCategorias categorias={categoriasFiltro} client:idle />
</div>
)}
)}
</div>
<p id="catalogo-empty-filter" hidden class="text-sm opacity-70 mb-4">
Ningún material en esta categoría. Prueba con otra.
+5 -1
View File
@@ -7,6 +7,7 @@ type Item = {
cantidad: number;
descripcion: string | null;
material: { id: number; nombre: string; numero_inventario: string | null } | null;
material_unidad: { etiqueta: string } | null;
};
type Solicitud = {
@@ -25,7 +26,7 @@ const user = Astro.locals.user!;
const { data, error } = await Astro.locals.supabase
.from('solicitudes')
.select('id, estado, fecha_solicitud, fecha_aprobacion, fecha_devolucion_estimada, fecha_devolucion_real, notas, maestro_responsable, items:solicitud_items(cantidad, descripcion, material:materiales(id, nombre, numero_inventario))')
.select('id, estado, fecha_solicitud, fecha_aprobacion, fecha_devolucion_estimada, fecha_devolucion_real, notas, maestro_responsable, items:solicitud_items(cantidad, descripcion, material:materiales(id, nombre, numero_inventario), material_unidad:material_unidades(etiqueta))')
.eq('alumno_id', user.id)
.order('fecha_solicitud', { ascending: false });
@@ -101,6 +102,9 @@ const badgeStyle = (e: Estado) =>
{it.material?.numero_inventario && (
<span class="text-xs opacity-60 font-mono ml-1.5">{it.material.numero_inventario}</span>
)}
{it.material_unidad?.etiqueta && (
<div class="text-xs opacity-70">Unidad: {it.material_unidad.etiqueta}</div>
)}
{it.descripcion && (
<p class="text-xs opacity-60 ml-0.5">{it.descripcion}</p>
)}
+72 -16
View File
@@ -10,6 +10,8 @@ type Patch = {
estado?: string;
cantidad_total?: number;
cantidad_disponible?: number;
imagen_path?: string | null;
trackeado_por_unidad?: boolean;
};
const parseId = (raw: string | undefined) => {
@@ -72,29 +74,83 @@ export const PATCH: APIRoute = async ({ request, locals, params }) => {
patch.estado = estado;
}
if ('cantidad_total' in body) {
const nuevoTotal = Number(body.cantidad_total);
if (!Number.isInteger(nuevoTotal) || nuevoTotal < 0)
return Response.json({ error: 'Cantidad total inválida' }, { status: 400 });
if ('imagen_path' in body) {
if (body.imagen_path === null) {
patch.imagen_path = null;
} else if (typeof body.imagen_path === 'string' && body.imagen_path.trim()) {
patch.imagen_path = body.imagen_path.trim();
} else if (typeof body.imagen_path === 'string') {
patch.imagen_path = null;
} else {
return Response.json({ error: 'imagen_path inválido' }, { status: 400 });
}
}
const { data: actual, error: selErr } = await locals.supabase
// Toggle trackeado_por_unidad — cargamos estado actual del material una vez si se pide algo que dependa de él
let actual:
| { cantidad_total: number; cantidad_disponible: number; trackeado_por_unidad: boolean }
| null = null;
const necesitaActual = 'cantidad_total' in body || 'trackeado_por_unidad' in body;
if (necesitaActual) {
const { data, error: selErr } = await locals.supabase
.from('materiales')
.select('cantidad_total, cantidad_disponible')
.select('cantidad_total, cantidad_disponible, trackeado_por_unidad')
.eq('id', id)
.maybeSingle();
if (selErr || !actual)
if (selErr || !data)
return Response.json({ error: 'Material no encontrado' }, { status: 404 });
actual = data as typeof actual;
}
const prestados = actual.cantidad_total - actual.cantidad_disponible;
if (nuevoTotal < prestados) {
return Response.json(
{ error: `No puede quedar debajo de lo prestado (${prestados})` },
{ status: 409 },
);
if ('trackeado_por_unidad' in body) {
const nuevo = body.trackeado_por_unidad === true;
if (nuevo !== actual!.trackeado_por_unidad) {
if (nuevo) {
// Solo permitir activar si aún no hay stock — evita perder datos
if (actual!.cantidad_total !== 0) {
return Response.json(
{ error: 'Para activar rastreo por unidad, la cantidad total debe ser 0. Registra las unidades después.' },
{ status: 409 },
);
}
} else {
// Solo desactivar si no hay unidades registradas
const { count } = await locals.supabase
.from('material_unidades')
.select('id', { count: 'exact', head: true })
.eq('material_id', id);
if ((count ?? 0) > 0) {
return Response.json(
{ error: 'Para desactivar rastreo por unidad, elimina primero las unidades registradas.' },
{ status: 409 },
);
}
}
patch.trackeado_por_unidad = nuevo;
}
}
if ('cantidad_total' in body) {
// Si el material ya es (o va a quedar) trackeado, cantidad_total lo maneja el trigger; no aceptamos edición manual.
const efectivoTrackeado =
patch.trackeado_por_unidad ?? actual!.trackeado_por_unidad;
if (efectivoTrackeado) {
// ignorar silenciosamente cantidad_total en modo trackeado
} else {
const nuevoTotal = Number(body.cantidad_total);
if (!Number.isInteger(nuevoTotal) || nuevoTotal < 0)
return Response.json({ error: 'Cantidad total inválida' }, { status: 400 });
const prestados = actual!.cantidad_total - actual!.cantidad_disponible;
if (nuevoTotal < prestados) {
return Response.json(
{ error: `No puede quedar debajo de lo prestado (${prestados})` },
{ status: 409 },
);
}
patch.cantidad_total = nuevoTotal;
patch.cantidad_disponible = actual!.cantidad_disponible + (nuevoTotal - actual!.cantidad_total);
}
patch.cantidad_total = nuevoTotal;
patch.cantidad_disponible = actual.cantidad_disponible + (nuevoTotal - actual.cantidad_total);
}
if (Object.keys(patch).length === 0) {
@@ -0,0 +1,68 @@
import type { APIRoute } from 'astro';
export const prerender = false;
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
const parseId = (raw: string | undefined) => {
const id = Number(raw);
return Number.isInteger(id) && id > 0 ? id : null;
};
export const GET: APIRoute = async ({ locals, params, url }) => {
if (locals.profile?.rol !== 'admin') return json({ error: 'No autorizado' }, 403);
const materialId = parseId(params.id);
if (!materialId) return json({ error: 'ID inválido' }, 400);
let q = locals.supabase
.from('material_unidades')
.select('id, etiqueta, estado, notas, created_at')
.eq('material_id', materialId)
.order('id');
const estado = url.searchParams.get('estado');
if (estado && ['disponible', 'prestado', 'mantenimiento', 'baja'].includes(estado)) {
q = q.eq('estado', estado);
}
const { data, error } = await q;
if (error) return json({ error: 'No se pudo cargar' }, 500);
return json({ unidades: data ?? [] });
};
export const POST: APIRoute = async ({ locals, params, request }) => {
if (locals.profile?.rol !== 'admin') return json({ error: 'No autorizado' }, 403);
const materialId = parseId(params.id);
if (!materialId) return json({ error: 'ID inválido' }, 400);
let body: { etiqueta?: unknown; notas?: unknown };
try {
body = await request.json();
} catch {
return json({ error: 'JSON inválido' }, 400);
}
const etiqueta = typeof body.etiqueta === 'string' ? body.etiqueta.trim() : '';
if (!etiqueta) return json({ error: 'La etiqueta es obligatoria' }, 400);
const notas =
typeof body.notas === 'string' && body.notas.trim() ? body.notas.trim() : null;
const { data, error } = await locals.supabase
.from('material_unidades')
.insert({ material_id: materialId, etiqueta, notas })
.select('id, etiqueta, estado, notas, created_at')
.single();
if (error) {
if ((error as { code?: string }).code === '23505') {
return json({ error: 'Etiqueta duplicada para este material' }, 409);
}
if ((error as { code?: string }).code === '23503') {
return json({ error: 'Material inexistente' }, 409);
}
return json({ error: 'No se pudo crear la unidad' }, 500);
}
return json({ unidad: data }, 201);
};
+14 -2
View File
@@ -14,6 +14,8 @@ export const POST: APIRoute = async ({ request, locals }) => {
cantidad_total?: unknown;
numero_inventario?: unknown;
estado?: unknown;
imagen_path?: unknown;
trackeado_por_unidad?: unknown;
};
try {
body = await request.json();
@@ -26,8 +28,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
return Response.json({ error: 'El nombre es obligatorio' }, { status: 400 });
}
const cantidad_total = Number(body.cantidad_total);
if (!Number.isInteger(cantidad_total) || cantidad_total < 0) {
const trackeado_por_unidad = body.trackeado_por_unidad === true;
let cantidad_total = Number(body.cantidad_total);
if (trackeado_por_unidad) {
// trigger material_unidades sube total/disponible conforme se agreguen unidades
cantidad_total = 0;
} else if (!Number.isInteger(cantidad_total) || cantidad_total < 0) {
return Response.json({ error: 'La cantidad total debe ser un entero mayor o igual a 0' }, { status: 400 });
}
@@ -51,6 +58,9 @@ export const POST: APIRoute = async ({ request, locals }) => {
return Response.json({ error: 'Estado inválido' }, { status: 400 });
}
const imagen_path =
typeof body.imagen_path === 'string' && body.imagen_path.trim() ? body.imagen_path.trim() : null;
const { data, error } = await locals.supabase
.from('materiales')
.insert({
@@ -61,6 +71,8 @@ export const POST: APIRoute = async ({ request, locals }) => {
cantidad_disponible: cantidad_total,
numero_inventario,
estado: estadoIn,
imagen_path,
trackeado_por_unidad,
})
.select('id')
.single();
@@ -0,0 +1,98 @@
import type { APIRoute } from 'astro';
export const prerender = false;
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
const parseId = (raw: string | undefined) => {
const id = Number(raw);
return Number.isInteger(id) && id > 0 ? id : null;
};
export const PATCH: APIRoute = async ({ locals, params, request }) => {
if (locals.profile?.rol !== 'admin') return json({ error: 'No autorizado' }, 403);
const unidadId = parseId(params.unidadId);
if (!unidadId) return json({ error: 'ID inválido' }, 400);
let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
return json({ error: 'JSON inválido' }, 400);
}
const patch: Record<string, unknown> = {};
if ('etiqueta' in body) {
const etiqueta = typeof body.etiqueta === 'string' ? body.etiqueta.trim() : '';
if (!etiqueta) return json({ error: 'La etiqueta no puede quedar vacía' }, 400);
patch.etiqueta = etiqueta;
}
if ('estado' in body) {
const estado = typeof body.estado === 'string' ? body.estado : '';
if (!['disponible', 'mantenimiento', 'baja'].includes(estado)) {
return json({ error: "Estado inválido — 'prestado' lo asigna el sistema" }, 400);
}
patch.estado = estado;
}
if ('notas' in body) {
patch.notas =
typeof body.notas === 'string' && body.notas.trim() ? body.notas.trim() : null;
}
if (Object.keys(patch).length === 0) return json({ error: 'Nada que actualizar' }, 400);
// Si se cambia estado, no permitir tocarlo si la unidad está actualmente 'prestado'
if ('estado' in patch) {
const { data: cur, error: selErr } = await locals.supabase
.from('material_unidades')
.select('estado')
.eq('id', unidadId)
.maybeSingle();
if (selErr || !cur) return json({ error: 'Unidad no encontrada' }, 404);
if (cur.estado === 'prestado') {
return json({ error: 'Unidad prestada — el sistema controla su estado' }, 409);
}
}
const { error } = await locals.supabase
.from('material_unidades')
.update(patch)
.eq('id', unidadId);
if (error) {
if ((error as { code?: string }).code === '23505') {
return json({ error: 'Etiqueta duplicada para este material' }, 409);
}
return json({ error: 'No se pudo actualizar' }, 500);
}
return json({ ok: true });
};
export const DELETE: APIRoute = async ({ locals, params }) => {
if (locals.profile?.rol !== 'admin') return json({ error: 'No autorizado' }, 403);
const unidadId = parseId(params.unidadId);
if (!unidadId) return json({ error: 'ID inválido' }, 400);
const { data: cur, error: selErr } = await locals.supabase
.from('material_unidades')
.select('estado')
.eq('id', unidadId)
.maybeSingle();
if (selErr || !cur) return json({ error: 'Unidad no encontrada' }, 404);
if (cur.estado === 'prestado') {
return json({ error: 'Unidad prestada — no puedes eliminarla' }, 409);
}
const { error } = await locals.supabase.from('material_unidades').delete().eq('id', unidadId);
if (error) {
if ((error as { code?: string }).code === '23503') {
return json({ error: 'Tiene renglones asociados — no se puede eliminar' }, 409);
}
return json({ error: 'No se pudo eliminar' }, 500);
}
return json({ ok: true });
};
@@ -13,7 +13,7 @@ export const GET: APIRoute = async ({ params, locals }) => {
const prestamoQ = locals.supabase
.from('solicitudes')
.select(
'id, estado, fecha_solicitud, fecha_aprobacion, fecha_devolucion_estimada, fecha_devolucion_real, notas, maestro_responsable, alumno:profiles!alumno_id(id, nombre, email, matricula), items:solicitud_items(cantidad, descripcion, material:materiales(id, nombre, numero_inventario))'
'id, estado, fecha_solicitud, fecha_aprobacion, fecha_devolucion_estimada, fecha_devolucion_real, notas, maestro_responsable, alumno:profiles!alumno_id(id, nombre, email, matricula), items:solicitud_items(id, cantidad, descripcion, material:materiales(id, nombre, numero_inventario, trackeado_por_unidad), material_unidad:material_unidades(id, etiqueta))'
)
.eq('id', id)
.maybeSingle();
@@ -0,0 +1,49 @@
import type { APIRoute } from 'astro';
export const prerender = false;
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
// Traduce excepciones nombradas de prestamos.reasignar_unidad a códigos HTTP.
const CODE_MAP: Record<string, number> = {
no_autorizado: 403,
item_no_existe: 404,
solicitud_no_activa: 409,
item_sin_unidad: 409,
unidad_no_existe: 404,
unidad_de_otro_material: 409,
unidad_no_disponible: 409,
};
export const POST: APIRoute = async ({ locals, request }) => {
if (locals.profile?.rol !== 'admin') return json({ error: 'No autorizado' }, 403);
let body: { item_id?: unknown; nueva_unidad_id?: unknown };
try {
body = await request.json();
} catch {
return json({ error: 'JSON inválido' }, 400);
}
const itemId = Number(body.item_id);
const nuevaId = Number(body.nueva_unidad_id);
if (!Number.isInteger(itemId) || itemId <= 0) return json({ error: 'item_id inválido' }, 400);
if (!Number.isInteger(nuevaId) || nuevaId <= 0)
return json({ error: 'nueva_unidad_id inválido' }, 400);
const { error } = await locals.supabase.rpc('reasignar_unidad', {
p_item_id: itemId,
p_nueva_unidad_id: nuevaId,
});
if (error) {
const raw = String((error as { message?: string }).message ?? '');
for (const key of Object.keys(CODE_MAP)) {
if (raw.includes(key)) return json({ error: key }, CODE_MAP[key]);
}
return json({ error: 'No se pudo reasignar' }, 500);
}
return json({ ok: true });
};