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:
@@ -7,18 +7,20 @@ type MaestroRow = {
|
||||
id: number;
|
||||
nombre: string;
|
||||
activo: boolean;
|
||||
es_tutor: boolean;
|
||||
alumnos: { count: number }[];
|
||||
};
|
||||
|
||||
const { data, error } = await Astro.locals.supabase
|
||||
.from('maestros')
|
||||
.select('id, nombre, activo, alumnos:profiles!tutor_id(count)')
|
||||
.select('id, nombre, activo, es_tutor, alumnos:profiles!tutor_id(count)')
|
||||
.order('nombre');
|
||||
|
||||
const maestros = ((data ?? []) as unknown as MaestroRow[]).map((m) => ({
|
||||
id: m.id,
|
||||
nombre: m.nombre,
|
||||
activo: m.activo,
|
||||
es_tutor: m.es_tutor,
|
||||
count: m.alumnos?.[0]?.count ?? 0,
|
||||
}));
|
||||
---
|
||||
@@ -67,6 +69,7 @@ const maestros = ((data ?? []) as unknown as MaestroRow[]).map((m) => ({
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">Nombre</th>
|
||||
<th class="px-4 py-3 font-medium">Estado</th>
|
||||
<th class="px-4 py-3 font-medium text-center">Tutor</th>
|
||||
<th class="px-4 py-3 font-medium text-right"># Alumnos</th>
|
||||
<th class="px-4 py-3 font-medium text-right">Acciones</th>
|
||||
</tr>
|
||||
@@ -82,12 +85,21 @@ const maestros = ((data ?? []) as unknown as MaestroRow[]).map((m) => ({
|
||||
<span class="text-xs uppercase tracking-wide opacity-60">Inactivo</span>
|
||||
)}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
{m.es_tutor ? (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-label="Sí" role="img" style="display:inline-block; color: var(--color-primary);">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
) : (
|
||||
<span class="opacity-40" aria-label="No">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right" style="font-variant-numeric: tabular-nums;">
|
||||
{m.count > 0 ? m.count : <span class="opacity-60">0</span>}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex gap-2 justify-end">
|
||||
<MaestroForm mode="edit" maestro={{ id: m.id, nombre: m.nombre, activo: m.activo }} client:load />
|
||||
<MaestroForm mode="edit" maestro={{ id: m.id, nombre: m.nombre, activo: m.activo, es_tutor: m.es_tutor }} client:load />
|
||||
<EliminarMaestro id={m.id} nombre={m.nombre} alumnosCount={m.count} client:load />
|
||||
</div>
|
||||
</td>
|
||||
@@ -103,11 +115,11 @@ const maestros = ((data ?? []) as unknown as MaestroRow[]).map((m) => ({
|
||||
<div>
|
||||
<h3 class="font-semibold">{m.nombre}</h3>
|
||||
<p class="text-xs opacity-70 mt-1">
|
||||
{m.activo ? 'Activo' : 'Inactivo'} · {m.count} alumno{m.count === 1 ? '' : 's'}
|
||||
{m.activo ? 'Activo' : 'Inactivo'} · {m.es_tutor ? 'Tutor' : 'No tutor'} · {m.count} alumno{m.count === 1 ? '' : 's'}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 shrink-0">
|
||||
<MaestroForm mode="edit" maestro={{ id: m.id, nombre: m.nombre, activo: m.activo }} client:load />
|
||||
<MaestroForm mode="edit" maestro={{ id: m.id, nombre: m.nombre, activo: m.activo, es_tutor: m.es_tutor }} client:load />
|
||||
<EliminarMaestro id={m.id} nombre={m.nombre} alumnosCount={m.count} client:load />
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -94,7 +94,7 @@ const tabs = [
|
||||
<td class="p-3">
|
||||
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
|
||||
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
|
||||
<div class="text-xs opacity-70 mt-1">Maestro: {r.maestro_responsable}</div>
|
||||
<div class="text-xs opacity-70 mt-1 break-words">Maestro: {r.maestro_responsable}</div>
|
||||
</td>
|
||||
<td class="p-3">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
@@ -107,7 +107,7 @@ const tabs = [
|
||||
</div>
|
||||
{r.notas && (
|
||||
<div class="text-xs mt-1.5 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);">
|
||||
<span class="opacity-60">Motivo:</span> {r.notas}
|
||||
<span class="opacity-60">Motivo:</span> <span class="break-words">{r.notas}</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
@@ -150,7 +150,7 @@ const tabs = [
|
||||
)}
|
||||
</div>
|
||||
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
|
||||
<div class="text-xs opacity-70 mb-2">Maestro: {r.maestro_responsable}</div>
|
||||
<div class="text-xs opacity-70 mb-2 break-words">Maestro: {r.maestro_responsable}</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{(r.items ?? []).map((i: any) => (
|
||||
<div class="text-sm">
|
||||
@@ -162,7 +162,7 @@ const tabs = [
|
||||
<div class="text-sm mt-1 opacity-80">Devolver antes de <span style="font-variant-numeric: tabular-nums;">{r.fecha_devolucion_estimada ? fmtFecha.format(new Date(r.fecha_devolucion_estimada + 'T00:00:00')) : '—'}</span></div>
|
||||
{r.notas && (
|
||||
<p class="text-sm mt-2 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);">
|
||||
<span class="opacity-60">Motivo:</span> {r.notas}
|
||||
<span class="opacity-60">Motivo:</span> <span class="break-words">{r.notas}</span>
|
||||
</p>
|
||||
)}
|
||||
<div class="mt-3 flex gap-2 flex-wrap">
|
||||
|
||||
@@ -115,7 +115,7 @@ const estadoLabel: Record<string, string> = {
|
||||
<td class="p-3">
|
||||
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
|
||||
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
|
||||
<div class="text-xs opacity-70 mt-1">Maestro: {r.maestro_responsable}</div>
|
||||
<div class="text-xs opacity-70 mt-1 break-words">Maestro: {r.maestro_responsable}</div>
|
||||
</td>
|
||||
<td class="p-3">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
@@ -144,7 +144,7 @@ const estadoLabel: Record<string, string> = {
|
||||
<span class="text-xs rounded-[2px] px-2 py-0.5" style="background: color-mix(in oklab, var(--color-ink) 10%, transparent);">{estadoLabel[r.estado] ?? r.estado}</span>
|
||||
</div>
|
||||
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
|
||||
<div class="text-xs opacity-70">Maestro: {r.maestro_responsable}</div>
|
||||
<div class="text-xs opacity-70 break-words">Maestro: {r.maestro_responsable}</div>
|
||||
<div class="flex flex-col gap-1 mt-2">
|
||||
{(r.items ?? []).map((i: any) => (
|
||||
<div class="text-sm">{i.cantidad}× {i.material?.nombre ?? '—'}</div>
|
||||
|
||||
@@ -84,7 +84,7 @@ const tabs = [
|
||||
<td class="p-3">
|
||||
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
|
||||
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
|
||||
<div class="text-xs opacity-70 mt-1">Maestro: {r.maestro_responsable}</div>
|
||||
<div class="text-xs opacity-70 mt-1 break-words">Maestro: {r.maestro_responsable}</div>
|
||||
</td>
|
||||
<td class="p-3">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
@@ -99,7 +99,7 @@ const tabs = [
|
||||
</div>
|
||||
{r.notas && (
|
||||
<div class="text-xs mt-1.5 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);">
|
||||
<span class="opacity-60">Motivo:</span> {r.notas}
|
||||
<span class="opacity-60">Motivo:</span> <span class="break-words">{r.notas}</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
@@ -124,7 +124,7 @@ const tabs = [
|
||||
<div class="text-xs opacity-70">{fmtFecha.format(new Date(r.fecha_solicitud))}</div>
|
||||
</div>
|
||||
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
|
||||
<div class="text-xs opacity-70 mb-2">Maestro: {r.maestro_responsable}</div>
|
||||
<div class="text-xs opacity-70 mb-2 break-words">Maestro: {r.maestro_responsable}</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{(r.items ?? []).map((i: any) => (
|
||||
<div class="text-sm">
|
||||
@@ -135,7 +135,7 @@ const tabs = [
|
||||
</div>
|
||||
{r.notas && (
|
||||
<p class="text-sm mt-2 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);">
|
||||
<span class="opacity-60">Motivo:</span> {r.notas}
|
||||
<span class="opacity-60">Motivo:</span> <span class="break-words">{r.notas}</span>
|
||||
</p>
|
||||
)}
|
||||
<div class="mt-3">
|
||||
|
||||
@@ -8,6 +8,8 @@ const qInitial = Astro.url.searchParams.get('q') ?? '';
|
||||
|
||||
const supabase = Astro.locals.supabase;
|
||||
const userId = Astro.locals.user?.id;
|
||||
const rol = Astro.locals.profile?.rol ?? 'alumno';
|
||||
const esDocente = rol === 'docente';
|
||||
|
||||
// Trae matricula + tutor_id directo (el middleware sólo hidrata columnas antiguas).
|
||||
let matricula: string | null = null;
|
||||
@@ -27,7 +29,21 @@ if (tutorId) {
|
||||
const { data } = await supabase.from('maestros').select('nombre').eq('id', tutorId).maybeSingle();
|
||||
tutorNombre = data?.nombre ?? null;
|
||||
}
|
||||
const perfilCompleto = !!(matricula && tutorId);
|
||||
|
||||
// Docente: solo requiere matrícula (número de empleado). Alumno: matrícula + tutor.
|
||||
const perfilCompleto = esDocente ? !!matricula : !!(matricula && tutorId);
|
||||
|
||||
// Lista completa de maestros activos para el combobox del checkout (solo alumno lo usa).
|
||||
type MaestroOpt = { id: number; nombre: string };
|
||||
let maestros: MaestroOpt[] = [];
|
||||
if (!esDocente) {
|
||||
const { data: ms } = await supabase
|
||||
.from('maestros')
|
||||
.select('id, nombre')
|
||||
.eq('activo', true)
|
||||
.order('nombre');
|
||||
maestros = (ms ?? []) as MaestroOpt[];
|
||||
}
|
||||
|
||||
type Material = {
|
||||
id: number;
|
||||
@@ -95,7 +111,7 @@ const categoriasFiltro = grupoList
|
||||
Ningún material en esta categoría. Prueba con otra.
|
||||
</p>
|
||||
|
||||
<CartProvider materiales={materiales} tutorNombre={tutorNombre} perfilCompleto={perfilCompleto} client:load />
|
||||
<CartProvider materiales={materiales} tutorNombre={tutorNombre} perfilCompleto={perfilCompleto} esDocente={esDocente} maestros={maestros} client:load />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@ export const PATCH: APIRoute = async ({ request, locals, params }) => {
|
||||
const id = parseId(params.id);
|
||||
if (!id) return Response.json({ error: 'ID inválido' }, { status: 400 });
|
||||
|
||||
let body: { nombre?: unknown; activo?: unknown };
|
||||
let body: { nombre?: unknown; activo?: unknown; es_tutor?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
@@ -29,6 +29,7 @@ export const PATCH: APIRoute = async ({ request, locals, params }) => {
|
||||
patch.nombre = nombre;
|
||||
}
|
||||
if (typeof body.activo === 'boolean') patch.activo = body.activo;
|
||||
if (typeof body.es_tutor === 'boolean') patch.es_tutor = body.es_tutor;
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
return Response.json({ error: 'Nada para actualizar' }, { status: 400 });
|
||||
|
||||
@@ -7,7 +7,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
return Response.json({ error: 'No autorizado' }, { status: 403 });
|
||||
}
|
||||
|
||||
let body: { nombre?: unknown; activo?: unknown };
|
||||
let body: { nombre?: unknown; activo?: unknown; es_tutor?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
@@ -19,10 +19,11 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
return Response.json({ error: 'El nombre es obligatorio' }, { status: 400 });
|
||||
}
|
||||
const activo = typeof body.activo === 'boolean' ? body.activo : true;
|
||||
const es_tutor = typeof body.es_tutor === 'boolean' ? body.es_tutor : false;
|
||||
|
||||
const { data, error } = await locals.supabase
|
||||
.from('maestros')
|
||||
.insert({ nombre, activo })
|
||||
.insert({ nombre, activo, es_tutor })
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
|
||||
@@ -8,6 +8,12 @@ function statusForError(message: string): { status: number; error: string } {
|
||||
if (message.startsWith('no_autenticado')) {
|
||||
return { status: 401, error: 'No autenticado' };
|
||||
}
|
||||
if (message.startsWith('perfil_no_existe')) {
|
||||
return { status: 400, error: 'Tu perfil no existe. Recarga la página.' };
|
||||
}
|
||||
if (message.startsWith('maestro_no_valido') || message.startsWith('tutor_no_valido')) {
|
||||
return { status: 400, error: 'El maestro seleccionado no es válido' };
|
||||
}
|
||||
if (
|
||||
message.startsWith('maestro_responsable_requerido') ||
|
||||
message.startsWith('items_requeridos') ||
|
||||
@@ -24,6 +30,9 @@ function statusForError(message: string): { status: number; error: string } {
|
||||
if (message.startsWith('stock_insuficiente')) {
|
||||
return { status: 409, error: 'Sin stock suficiente para uno de los materiales' };
|
||||
}
|
||||
if (message.startsWith('sin_unidad_disponible')) {
|
||||
return { status: 409, error: 'Sin unidades disponibles para uno de los materiales' };
|
||||
}
|
||||
return { status: 500, error: 'No se pudo registrar la solicitud' };
|
||||
}
|
||||
|
||||
@@ -32,19 +41,23 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
return Response.json({ error: 'No autenticado' }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: { maestro_responsable?: unknown; notas?: unknown; items?: unknown };
|
||||
let body: { maestro_id?: unknown; notas?: unknown; items?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: 'JSON inválido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const maestro_responsable = typeof body.maestro_responsable === 'string' ? body.maestro_responsable.trim() : '';
|
||||
const notas = typeof body.notas === 'string' && body.notas.trim() ? body.notas.trim() : null;
|
||||
// maestro_id: number opcional. Si docente o si alumno sin elección, va null
|
||||
// y la RPC decide (docente usa su propio nombre; alumno cae al tutor).
|
||||
const rawMaestro = body.maestro_id;
|
||||
const maestro_id =
|
||||
typeof rawMaestro === 'number' && Number.isInteger(rawMaestro) && rawMaestro > 0
|
||||
? rawMaestro
|
||||
: null;
|
||||
|
||||
const notas = typeof body.notas === 'string' && body.notas.trim() ? body.notas.trim().slice(0, 250) : null;
|
||||
|
||||
if (!maestro_responsable) {
|
||||
return Response.json({ error: 'Maestro responsable requerido' }, { status: 400 });
|
||||
}
|
||||
if (!Array.isArray(body.items) || body.items.length === 0) {
|
||||
return Response.json({ error: 'Agrega al menos un material' }, { status: 400 });
|
||||
}
|
||||
@@ -64,7 +77,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
}
|
||||
|
||||
const { data, error } = await locals.supabase.rpc('crear_solicitud', {
|
||||
p_maestro_responsable: maestro_responsable,
|
||||
p_maestro_id: maestro_id,
|
||||
p_notas: notas,
|
||||
p_items: items,
|
||||
});
|
||||
|
||||
@@ -15,10 +15,15 @@ const { data: maestrosData } = await Astro.locals.supabase
|
||||
.from('maestros')
|
||||
.select('id, nombre')
|
||||
.eq('activo', true)
|
||||
.eq('es_tutor', true)
|
||||
.order('nombre');
|
||||
const maestros = (maestrosData ?? []) as { id: number; nombre: string }[];
|
||||
|
||||
const primerNombre = (profile.nombre ?? profile.email.split('@')[0]).split(/\s+/)[0];
|
||||
const copyIntro =
|
||||
profile.rol === 'docente'
|
||||
? 'Completa tu perfil como docente. Solo tarda un momento.'
|
||||
: 'Completa tu perfil para crear vales de préstamo. Puedes omitirlo, pero será requerido antes de solicitar material.';
|
||||
---
|
||||
<Layout title="Bienvenido — LabPréstamos">
|
||||
<main id="main" class="min-h-screen grid place-items-center p-4">
|
||||
@@ -31,7 +36,7 @@ const primerNombre = (profile.nombre ?? profile.email.split('@')[0]).split(/\s+/
|
||||
Bienvenido, {primerNombre}
|
||||
</h1>
|
||||
<p class="text-sm mt-2" style="color: var(--color-pencil);">
|
||||
Completa tu perfil para crear vales de préstamo. Puedes omitirlo, pero será requerido antes de solicitar material.
|
||||
{copyIntro}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -42,6 +47,7 @@ const primerNombre = (profile.nombre ?? profile.email.split('@')[0]).split(/\s+/
|
||||
userId={user.id}
|
||||
email={profile.email}
|
||||
nombre={profile.nombre}
|
||||
rol={profile.rol}
|
||||
initialProfile={{
|
||||
matricula: profile.matricula,
|
||||
semestre: profile.semestre,
|
||||
|
||||
@@ -16,9 +16,13 @@ const { data: maestrosData } = await Astro.locals.supabase
|
||||
.from('maestros')
|
||||
.select('id, nombre')
|
||||
.eq('activo', true)
|
||||
.eq('es_tutor', true)
|
||||
.order('nombre');
|
||||
const maestros = (maestrosData ?? []) as { id: number; nombre: string }[];
|
||||
|
||||
const rolLabel =
|
||||
profile.rol === 'admin' ? 'Administrador' : profile.rol === 'docente' ? 'Docente' : 'Alumno';
|
||||
|
||||
const info = avatarInfo({
|
||||
email: profile.email,
|
||||
nombre: profile.nombre,
|
||||
@@ -36,7 +40,7 @@ const info = avatarInfo({
|
||||
</h1>
|
||||
<p class="text-sm truncate" style="color: var(--color-pencil);">{profile.email}</p>
|
||||
<span class="badge mt-2" style={`background: ${profile.rol === 'admin' ? 'var(--color-secondary)' : 'var(--color-chalk)'};`}>
|
||||
{profile.rol}
|
||||
{rolLabel}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
@@ -48,6 +52,7 @@ const info = avatarInfo({
|
||||
userId={user.id}
|
||||
email={profile.email}
|
||||
nombre={profile.nombre}
|
||||
rol={profile.rol}
|
||||
initialProfile={{
|
||||
matricula: profile.matricula,
|
||||
semestre: profile.semestre,
|
||||
|
||||
Reference in New Issue
Block a user