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:
@@ -38,6 +38,8 @@ export default function BadgeSolicitudes() {
|
||||
() => {
|
||||
setBadge(readBadge() + 1);
|
||||
toast({ title: 'Nueva solicitud pendiente', kind: 'info' });
|
||||
// Autoplay puede fallar (política del navegador antes de interacción); ignorar.
|
||||
new Audio('/audio/sonido_notificacion.mp3').play().catch(() => {});
|
||||
},
|
||||
)
|
||||
.on(
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
mode: 'create' | 'edit';
|
||||
maestro?: { id: number; nombre: string; activo: boolean };
|
||||
maestro?: { id: number; nombre: string; activo: boolean; es_tutor: boolean };
|
||||
};
|
||||
|
||||
export default function MaestroForm({ mode, maestro }: Props) {
|
||||
@@ -11,6 +11,7 @@ export default function MaestroForm({ mode, maestro }: Props) {
|
||||
const errorRef = useRef<HTMLParagraphElement | null>(null);
|
||||
const [nombre, setNombre] = useState(maestro?.nombre ?? '');
|
||||
const [activo, setActivo] = useState(maestro?.activo ?? true);
|
||||
const [esTutor, setEsTutor] = useState(maestro?.es_tutor ?? false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -21,6 +22,7 @@ export default function MaestroForm({ mode, maestro }: Props) {
|
||||
if (mode === 'create') {
|
||||
setNombre('');
|
||||
setActivo(true);
|
||||
setEsTutor(false);
|
||||
}
|
||||
dialogRef.current?.showModal();
|
||||
queueMicrotask(() => firstFieldRef.current?.focus());
|
||||
@@ -60,7 +62,7 @@ export default function MaestroForm({ mode, maestro }: Props) {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ nombre: trimmed, activo }),
|
||||
body: JSON.stringify({ nombre: trimmed, activo, es_tutor: esTutor }),
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(json?.error ?? 'No se pudo guardar');
|
||||
@@ -126,7 +128,17 @@ export default function MaestroForm({ mode, maestro }: Props) {
|
||||
checked={activo}
|
||||
onChange={(e) => setActivo(e.target.checked)}
|
||||
/>
|
||||
Activo (visible en la lista de tutores)
|
||||
Activo (visible en el catálogo de maestros)
|
||||
</label>
|
||||
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
checked={esTutor}
|
||||
onChange={(e) => setEsTutor(e.target.checked)}
|
||||
/>
|
||||
<span>Es tutor <span className="opacity-70">(aparece en la lista de tutores del perfil de alumnos)</span></span>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -41,7 +41,7 @@ function useDialog() {
|
||||
|
||||
function AprobarDialog({ prestamo }: { prestamo: Prestamo }) {
|
||||
const dlg = useDialog();
|
||||
const [fecha, setFecha] = useState(enDias(7));
|
||||
const [fecha, setFecha] = useState(enDias(1));
|
||||
const [notas, setNotas] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -197,7 +197,7 @@ export default function VerDetalles({ prestamoId }: { prestamoId: number }) {
|
||||
<dt className="opacity-70">Alumno</dt>
|
||||
<dd className="col-span-2">{data.prestamo.alumno?.nombre ?? data.prestamo.alumno?.email}</dd>
|
||||
<dt className="opacity-70">Maestro</dt>
|
||||
<dd className="col-span-2">{data.prestamo.maestro_responsable}</dd>
|
||||
<dd className="col-span-2 break-words">{data.prestamo.maestro_responsable}</dd>
|
||||
<dt className="opacity-70">Materiales</dt>
|
||||
<dd className="col-span-2">
|
||||
<ul className="list-disc pl-4 space-y-2">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ type Props = {
|
||||
userId: string;
|
||||
email: string;
|
||||
nombre: string | null;
|
||||
rol: 'alumno' | 'docente' | 'admin';
|
||||
initialProfile: {
|
||||
matricula: string | null;
|
||||
semestre: string | null;
|
||||
@@ -28,11 +29,14 @@ export default function PerfilForm({
|
||||
userId,
|
||||
email,
|
||||
nombre,
|
||||
rol,
|
||||
initialProfile,
|
||||
maestros,
|
||||
googleAvatarUrl,
|
||||
mode = 'edit',
|
||||
}: Props) {
|
||||
const soloAlumno = rol === 'alumno';
|
||||
const matriculaLabel = rol === 'docente' ? 'Número de empleado' : 'Matrícula';
|
||||
const [matricula, setMatricula] = useState(initialProfile.matricula ?? '');
|
||||
const [semestre, setSemestre] = useState(initialProfile.semestre ?? '');
|
||||
const [tutorId, setTutorId] = useState<string>(
|
||||
@@ -91,9 +95,11 @@ export default function PerfilForm({
|
||||
}
|
||||
const body: Record<string, string | number | null> = {
|
||||
matricula: matricula.trim() || null,
|
||||
semestre: semestre || null,
|
||||
tutor_id: tutorId ? Number(tutorId) : null,
|
||||
};
|
||||
if (soloAlumno) {
|
||||
body.semestre = semestre || null;
|
||||
body.tutor_id = tutorId ? Number(tutorId) : null;
|
||||
}
|
||||
if (nuevoPath !== undefined) body.foto_path = nuevoPath;
|
||||
|
||||
const res = await fetch('/api/profile', {
|
||||
@@ -154,7 +160,7 @@ export default function PerfilForm({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label" htmlFor="p-mat">Matrícula</label>
|
||||
<label className="label" htmlFor="p-mat">{matriculaLabel}</label>
|
||||
<input
|
||||
id="p-mat"
|
||||
className="input"
|
||||
@@ -167,36 +173,40 @@ export default function PerfilForm({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label" htmlFor="p-sem">Semestre</label>
|
||||
<select
|
||||
id="p-sem"
|
||||
className="input"
|
||||
value={semestre}
|
||||
onChange={(e) => setSemestre(e.target.value)}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{SEM_OPTS.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{soloAlumno && (
|
||||
<div>
|
||||
<label className="label" htmlFor="p-sem">Semestre</label>
|
||||
<select
|
||||
id="p-sem"
|
||||
className="input"
|
||||
value={semestre}
|
||||
onChange={(e) => setSemestre(e.target.value)}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{SEM_OPTS.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="label" htmlFor="p-tutor">Tutor</label>
|
||||
<select
|
||||
id="p-tutor"
|
||||
className="input"
|
||||
value={tutorId}
|
||||
onChange={(e) => setTutorId(e.target.value)}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{maestros.map((m) => (
|
||||
<option key={m.id} value={String(m.id)}>{m.nombre}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs opacity-70 mt-1">Se rellenará por defecto al crear vales.</p>
|
||||
</div>
|
||||
{soloAlumno && (
|
||||
<div>
|
||||
<label className="label" htmlFor="p-tutor">Tutor</label>
|
||||
<select
|
||||
id="p-tutor"
|
||||
className="input"
|
||||
value={tutorId}
|
||||
onChange={(e) => setTutorId(e.target.value)}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{maestros.map((m) => (
|
||||
<option key={m.id} value={String(m.id)}>{m.nombre}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs opacity-70 mt-1">Se rellenará por defecto al crear vales.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
|
||||
|
||||
Reference in New Issue
Block a user