0f2ea59754
Rol docente:
- profiles.rol acepta 'alumno'|'docente'|'admin' (migración 0006)
- Docente en checkout: no elige maestro; RPC usa su propio nombre como maestro_responsable
- Perfil/onboarding condicionales por rol: docente sin semestre ni tutor, label matrícula → "Número de empleado"
- catalogo.astro: perfilCompleto para docente solo requiere matrícula
Combobox maestros + separar tutores:
- maestros.es_tutor bool: subset marcado que aparece en el select de tutor del perfil
- MaestroForm: nuevo checkbox "Es tutor"; endpoints POST/PATCH aceptan es_tutor
- admin/maestros.astro: columna Tutor con SVG check
- perfil/onboarding queries filtran es_tutor=true, activo=true
- Checkout SolicitudCart: <select> combobox de maestros activos (reemplaza textarea);
option vacía usa el tutor por defecto; endpoint recibe maestro_id numeric
Fecha default 1 día:
- AccionesSolicitud: enDias(7) → enDias(1) por default al aprobar
Sonido notificación:
- Movido src/audio/sonido_notificacion.mp3 → public/audio/
- BadgeSolicitudes: new Audio('/audio/sonido_notificacion.mp3').play() en INSERT
Issues:
- Límite motivo 250 chars (era 500) en checkout + backend
- break-words en Maestro/Motivo de las 3 vistas admin y VerDetalles
236 lines
9.1 KiB
TypeScript
236 lines
9.1 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { toast, toastAfterReload } from '@/lib/toast';
|
|
|
|
type Item = { cantidad: number; material: { nombre: string; cantidad_disponible: number } | null };
|
|
type Prestamo = {
|
|
id: number;
|
|
items: Item[];
|
|
maestro_responsable: string;
|
|
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];
|
|
const resumenNombres = (items: Item[]) => items.map((i) => i.material?.nombre ?? 'Material').join(', ');
|
|
|
|
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(1));
|
|
const [notas, setNotas] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const stockInsuficiente = prestamo.items.some((i) => (i.material?.cantidad_disponible ?? 0) < i.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/solicitudes/${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: `${resumenNombres(prestamo.items)} · ${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)] bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] 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, para {prestamo.maestro_responsable}:
|
|
</p>
|
|
<ul className="text-sm mt-1 list-disc pl-5">
|
|
{prestamo.items.map((i, idx) => (
|
|
<li key={idx}>
|
|
<strong>{i.cantidad}</strong> de <strong>{i.material?.nombre ?? '—'}</strong>
|
|
{(i.material?.cantidad_disponible ?? 0) < i.cantidad && (
|
|
<span style={{ color: 'var(--color-danger-text)' }}> — stock insuficiente ({i.material?.cantidad_disponible ?? 0} disp.)</span>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
{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-text)' }}>
|
|
Uno o más materiales no tienen stock suficiente.
|
|
</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-text)' }}>
|
|
{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/solicitudes/${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: `${resumenNombres(prestamo.items)} · ${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)] bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] 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">
|
|
{resumenNombres(prestamo.items)} · {prestamo.alumno?.nombre ?? prestamo.alumno?.email}
|
|
</p>
|
|
<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-text)' }}>
|
|
{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: 'var(--color-ink)' }}
|
|
>
|
|
{loading ? 'Rechazando…' : 'Rechazar solicitud'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</dialog>
|
|
</>
|
|
);
|
|
}
|