v1.6: rol docente, combobox maestros, sonido notif, fecha 1 día, wrap admin

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
This commit is contained in:
2026-08-25 11:37:26 -07:00
parent 44fe7527be
commit 0f2ea59754
18 changed files with 180 additions and 94 deletions
+36 -28
View File
@@ -39,19 +39,25 @@ export function useCart() {
const clamp = (n: number, max: number) => Math.max(1, Math.min(max, n));
type MaestroOpt = { id: number; nombre: string };
export default function CartProvider({
materiales,
tutorNombre = null,
perfilCompleto = true,
esDocente = false,
maestros = [],
}: {
materiales: Material[];
tutorNombre?: string | null;
perfilCompleto?: boolean;
esDocente?: boolean;
maestros?: MaestroOpt[];
}) {
const [cart, setCart] = useState<Map<number, CartItem>>(new Map());
const dialogRef = useRef<HTMLDialogElement | null>(null);
const firstFieldRef = useRef<HTMLTextAreaElement | null>(null);
const [maestroResponsable, setMaestroResponsable] = useState('');
const firstFieldRef = useRef<HTMLSelectElement | null>(null);
const [maestroId, setMaestroId] = useState<string>('');
const [notas, setNotas] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -141,11 +147,12 @@ export default function CartProvider({
setLoading(true);
setError(null);
try {
const maestroIdNum = maestroId ? Number(maestroId) : null;
const res = await fetch('/api/solicitudes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
maestro_responsable: maestroResponsable,
maestro_id: esDocente ? null : maestroIdNum,
notas: notas || undefined,
items: items.map((i) => ({
material_id: i.material_id,
@@ -159,7 +166,7 @@ export default function CartProvider({
throw new Error(json?.error ?? 'No se pudo enviar la solicitud');
}
setOk(true);
const usedFallback = maestroResponsable.trim() === '' && !!tutorNombre;
const usedFallback = !esDocente && !maestroIdNum && !!tutorNombre;
toastAfterReload({
kind: 'success',
title: 'Solicitud enviada',
@@ -317,30 +324,31 @@ export default function CartProvider({
</ul>
)}
<div>
<label className="label" htmlFor="maestro-responsable">Maestro responsable</label>
<textarea
ref={firstFieldRef}
id="maestro-responsable"
className="input"
rows={2}
maxLength={200}
value={maestroResponsable}
onChange={(e) => setMaestroResponsable(e.target.value)}
placeholder={tutorNombre ?? 'Escribe el nombre del maestro responsable'}
aria-describedby="maestro-hint"
style={{ minHeight: '64px' }}
/>
{tutorNombre ? (
{!esDocente && (
<div>
<label className="label" htmlFor="maestro-select">Maestro responsable</label>
<select
ref={firstFieldRef}
id="maestro-select"
className="input"
value={maestroId}
onChange={(e) => setMaestroId(e.target.value)}
aria-describedby="maestro-hint"
>
<option value="">
{tutorNombre ? `— Usar mi tutor (${tutorNombre}) —` : '— Elige un maestro —'}
</option>
{maestros.map((m) => (
<option key={m.id} value={String(m.id)}>{m.nombre}</option>
))}
</select>
<p id="maestro-hint" className="text-xs opacity-70 mt-1">
Si dejas esto vacío, se usará tu tutor: <strong>{tutorNombre}</strong>.
{tutorNombre
? <>Si no eliges nadie se usará tu tutor: <strong>{tutorNombre}</strong>.</>
: 'Debes elegir un maestro; no tienes tutor guardado.'}
</p>
) : (
<p id="maestro-hint" className="text-xs opacity-60 mt-1">
Debes escribir un maestro; no tienes tutor guardado.
</p>
)}
</div>
</div>
)}
<div>
<label className="label" htmlFor="notas-cart">Motivo del préstamo</label>
@@ -348,7 +356,7 @@ export default function CartProvider({
id="notas-cart"
className="input"
rows={3}
maxLength={500}
maxLength={250}
value={notas}
onChange={(e) => setNotas(e.target.value)}
placeholder="¿Para qué clase o proyecto lo necesitas?"
@@ -356,7 +364,7 @@ export default function CartProvider({
aria-describedby="notas-cart-hint"
/>
<p id="notas-cart-hint" className="text-xs opacity-60 mt-1">
Ejemplo: Clase de Electrónica Analógica · Prof. Gómez · práctica 3. (Opcional)
Ejemplo: Clase de Electrónica Analógica · Prof. Gómez · práctica 3. (Opcional, máx. 250 caracteres)
</p>
</div>