Files
labre-web/src/components/admin/solicitudes/AccionesSolicitud.tsx
T
LakG 57b07b9b82 Rediseño impeccable: brand mark, páginas 403/404, DESIGN.md + PRODUCT.md
Rediseño hecho en sesión aparte con el skill impeccable. Se registran
las especificaciones y ajustes visuales al sistema ya en producción.

Añade:
- BrandMark.astro con el escudo/logo del laboratorio.
- Favicon actualizado (svg + ico).
- Páginas propias 403 y 404 consistentes con el sistema visual.
- DESIGN.md y PRODUCT.md documentando decisiones.
- .impeccable/design.json con el design system tokenizado.

Ajusta componentes existentes (modales, autocomplete, layout, login,
solicitudes, mis-préstamos, middleware) para alinearse a los tokens
del design system.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-16 15:19:57 -07:00

222 lines
8.2 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { toast, toastAfterReload } from '@/lib/toast';
type Prestamo = {
id: number;
cantidad: number;
material: { nombre: string; cantidad_disponible: number } | null;
alumno: { nombre: string | null; email: string } | null;
};
const hoy = () => new Date().toISOString().split('T')[0];
const enDias = (d: number) => new Date(Date.now() + d * 86400000).toISOString().split('T')[0];
export default function AccionesSolicitud({ prestamo }: { prestamo: Prestamo }) {
return (
<div className="flex gap-2 flex-wrap">
<AprobarDialog prestamo={prestamo} />
<RechazarDialog prestamo={prestamo} />
</div>
);
}
function useDialog() {
const ref = useRef<HTMLDialogElement>(null);
const open = () => ref.current?.showModal();
const close = () => ref.current?.close();
// Cerrar al hacer click en backdrop
useEffect(() => {
const d = ref.current;
if (!d) return;
const onClick = (e: MouseEvent) => {
if (e.target === d) d.close();
};
d.addEventListener('click', onClick);
return () => d.removeEventListener('click', onClick);
}, []);
return { ref, open, close };
}
function AprobarDialog({ prestamo }: { prestamo: Prestamo }) {
const dlg = useDialog();
const [fecha, setFecha] = useState(enDias(7));
const [notas, setNotas] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const stockInsuficiente = (prestamo.material?.cantidad_disponible ?? 0) < prestamo.cantidad;
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!fecha || fecha < hoy()) {
setError('La fecha de devolución debe ser hoy o posterior.');
return;
}
setLoading(true);
try {
const res = await fetch(`/api/admin/prestamos/${prestamo.id}/aprobar`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ fecha_devolucion_estimada: fecha, notas: notas.trim() || undefined }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.error ?? `Error ${res.status}`);
}
toastAfterReload({
kind: 'success',
title: 'Solicitud aprobada',
description: `${prestamo.material?.nombre ?? 'Material'} · ${prestamo.alumno?.nombre ?? prestamo.alumno?.email ?? ''}`,
});
location.reload();
} catch (err: any) {
const msg = err.message ?? 'Error al aprobar.';
setError(msg);
toast({ kind: 'error', title: 'No se pudo aprobar', description: msg });
setLoading(false);
}
};
return (
<>
<button type="button" className="btn btn-primary" onClick={dlg.open}>Aprobar</button>
<dialog ref={dlg.ref} className="rounded-lg p-0 max-w-md w-[calc(100%-2rem)] backdrop:bg-black/40">
<form onSubmit={submit} className="p-6">
<h2 className="text-lg font-semibold">Aprobar solicitud</h2>
<p className="text-sm opacity-70 mt-1">
{prestamo.alumno?.nombre ?? prestamo.alumno?.email} solicita{' '}
<strong>{prestamo.cantidad}</strong> de <strong>{prestamo.material?.nombre ?? '—'}</strong>.
</p>
{stockInsuficiente && (
<div role="alert" className="mt-3 rounded-lg p-2 text-sm" style={{ background: 'color-mix(in oklab, var(--color-danger) 12%, transparent)', color: 'var(--color-danger)' }}>
Stock disponible ({prestamo.material?.cantidad_disponible ?? 0}) menor a lo solicitado.
</div>
)}
<div className="mt-4">
<label className="label" htmlFor={`fecha-${prestamo.id}`}>Devolver antes de</label>
<input
id={`fecha-${prestamo.id}`}
type="date"
className="input"
value={fecha}
min={hoy()}
onChange={(e) => setFecha(e.target.value)}
required
autoFocus
/>
</div>
<div className="mt-3">
<label className="label" htmlFor={`notas-${prestamo.id}`}>Notas (opcional)</label>
<textarea
id={`notas-${prestamo.id}`}
className="input"
rows={3}
value={notas}
onChange={(e) => setNotas(e.target.value)}
placeholder="Observaciones para el alumno o registro interno…"
/>
</div>
{error && (
<div role="alert" className="mt-3 rounded-lg p-2 text-sm" style={{ background: 'color-mix(in oklab, var(--color-danger) 12%, transparent)', color: 'var(--color-danger)' }}>
{error}
</div>
)}
<div className="mt-6 flex justify-end gap-2">
<button type="button" className="btn btn-ghost" onClick={dlg.close} disabled={loading}>Cancelar</button>
<button type="submit" className="btn btn-primary" disabled={loading}>{loading ? 'Aprobando…' : 'Aprobar solicitud'}</button>
</div>
</form>
</dialog>
</>
);
}
function RechazarDialog({ prestamo }: { prestamo: Prestamo }) {
const dlg = useDialog();
const [motivo, setMotivo] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (motivo.trim().length < 5) {
setError('El motivo debe tener al menos 5 caracteres.');
return;
}
setLoading(true);
try {
const res = await fetch(`/api/admin/prestamos/${prestamo.id}/rechazar`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ motivo: motivo.trim() }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.error ?? `Error ${res.status}`);
}
toastAfterReload({
kind: 'info',
title: 'Solicitud rechazada',
description: `${prestamo.material?.nombre ?? 'Material'} · ${prestamo.alumno?.nombre ?? prestamo.alumno?.email ?? ''}`,
});
location.reload();
} catch (err: any) {
const msg = err.message ?? 'Error al rechazar.';
setError(msg);
toast({ kind: 'error', title: 'No se pudo rechazar', description: msg });
setLoading(false);
}
};
return (
<>
<button
type="button"
className="btn btn-ghost hover:!text-[color:var(--color-danger)]"
onClick={dlg.open}
>
Rechazar
</button>
<dialog ref={dlg.ref} className="rounded-lg p-0 max-w-md w-[calc(100%-2rem)] backdrop:bg-black/40">
<form onSubmit={submit} className="p-6">
<h2 className="text-lg font-semibold">Rechazar solicitud</h2>
<p className="text-sm opacity-70 mt-1">
Explica al alumno por qué se rechaza. Este texto queda registrado.
</p>
<div className="mt-4">
<label className="label" htmlFor={`motivo-${prestamo.id}`}>Motivo</label>
<textarea
id={`motivo-${prestamo.id}`}
className="input"
rows={4}
value={motivo}
onChange={(e) => setMotivo(e.target.value)}
required
minLength={5}
autoFocus
placeholder="Ej. Material no disponible por mantenimiento."
/>
</div>
{error && (
<div role="alert" className="mt-3 rounded-lg p-2 text-sm" style={{ background: 'color-mix(in oklab, var(--color-danger) 12%, transparent)', color: 'var(--color-danger)' }}>
{error}
</div>
)}
<div className="mt-6 flex justify-end gap-2">
<button type="button" className="btn btn-ghost" onClick={dlg.close} disabled={loading}>Cancelar</button>
<button
type="submit"
className="btn"
disabled={loading}
style={{ background: 'var(--color-danger)', color: 'white' }}
>
{loading ? 'Rechazando…' : 'Rechazar solicitud'}
</button>
</div>
</form>
</dialog>
</>
);
}