Compare commits
2 Commits
bcecdbb184
...
0f2ea59754
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f2ea59754 | |||
| 44fe7527be |
Binary file not shown.
@@ -38,6 +38,8 @@ export default function BadgeSolicitudes() {
|
|||||||
() => {
|
() => {
|
||||||
setBadge(readBadge() + 1);
|
setBadge(readBadge() + 1);
|
||||||
toast({ title: 'Nueva solicitud pendiente', kind: 'info' });
|
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(
|
.on(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
mode: 'create' | 'edit';
|
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) {
|
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 errorRef = useRef<HTMLParagraphElement | null>(null);
|
||||||
const [nombre, setNombre] = useState(maestro?.nombre ?? '');
|
const [nombre, setNombre] = useState(maestro?.nombre ?? '');
|
||||||
const [activo, setActivo] = useState(maestro?.activo ?? true);
|
const [activo, setActivo] = useState(maestro?.activo ?? true);
|
||||||
|
const [esTutor, setEsTutor] = useState(maestro?.es_tutor ?? false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ export default function MaestroForm({ mode, maestro }: Props) {
|
|||||||
if (mode === 'create') {
|
if (mode === 'create') {
|
||||||
setNombre('');
|
setNombre('');
|
||||||
setActivo(true);
|
setActivo(true);
|
||||||
|
setEsTutor(false);
|
||||||
}
|
}
|
||||||
dialogRef.current?.showModal();
|
dialogRef.current?.showModal();
|
||||||
queueMicrotask(() => firstFieldRef.current?.focus());
|
queueMicrotask(() => firstFieldRef.current?.focus());
|
||||||
@@ -60,7 +62,7 @@ export default function MaestroForm({ mode, maestro }: Props) {
|
|||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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(() => ({}));
|
const json = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) throw new Error(json?.error ?? 'No se pudo guardar');
|
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}
|
checked={activo}
|
||||||
onChange={(e) => setActivo(e.target.checked)}
|
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>
|
</label>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function useDialog() {
|
|||||||
|
|
||||||
function AprobarDialog({ prestamo }: { prestamo: Prestamo }) {
|
function AprobarDialog({ prestamo }: { prestamo: Prestamo }) {
|
||||||
const dlg = useDialog();
|
const dlg = useDialog();
|
||||||
const [fecha, setFecha] = useState(enDias(7));
|
const [fecha, setFecha] = useState(enDias(1));
|
||||||
const [notas, setNotas] = useState('');
|
const [notas, setNotas] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ export default function VerDetalles({ prestamoId }: { prestamoId: number }) {
|
|||||||
<dt className="opacity-70">Alumno</dt>
|
<dt className="opacity-70">Alumno</dt>
|
||||||
<dd className="col-span-2">{data.prestamo.alumno?.nombre ?? data.prestamo.alumno?.email}</dd>
|
<dd className="col-span-2">{data.prestamo.alumno?.nombre ?? data.prestamo.alumno?.email}</dd>
|
||||||
<dt className="opacity-70">Maestro</dt>
|
<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>
|
<dt className="opacity-70">Materiales</dt>
|
||||||
<dd className="col-span-2">
|
<dd className="col-span-2">
|
||||||
<ul className="list-disc pl-4 space-y-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));
|
const clamp = (n: number, max: number) => Math.max(1, Math.min(max, n));
|
||||||
|
|
||||||
|
type MaestroOpt = { id: number; nombre: string };
|
||||||
|
|
||||||
export default function CartProvider({
|
export default function CartProvider({
|
||||||
materiales,
|
materiales,
|
||||||
tutorNombre = null,
|
tutorNombre = null,
|
||||||
perfilCompleto = true,
|
perfilCompleto = true,
|
||||||
|
esDocente = false,
|
||||||
|
maestros = [],
|
||||||
}: {
|
}: {
|
||||||
materiales: Material[];
|
materiales: Material[];
|
||||||
tutorNombre?: string | null;
|
tutorNombre?: string | null;
|
||||||
perfilCompleto?: boolean;
|
perfilCompleto?: boolean;
|
||||||
|
esDocente?: boolean;
|
||||||
|
maestros?: MaestroOpt[];
|
||||||
}) {
|
}) {
|
||||||
const [cart, setCart] = useState<Map<number, CartItem>>(new Map());
|
const [cart, setCart] = useState<Map<number, CartItem>>(new Map());
|
||||||
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
||||||
const firstFieldRef = useRef<HTMLTextAreaElement | null>(null);
|
const firstFieldRef = useRef<HTMLSelectElement | null>(null);
|
||||||
const [maestroResponsable, setMaestroResponsable] = useState('');
|
const [maestroId, setMaestroId] = useState<string>('');
|
||||||
const [notas, setNotas] = useState('');
|
const [notas, setNotas] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -141,11 +147,12 @@ export default function CartProvider({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
const maestroIdNum = maestroId ? Number(maestroId) : null;
|
||||||
const res = await fetch('/api/solicitudes', {
|
const res = await fetch('/api/solicitudes', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
maestro_responsable: maestroResponsable,
|
maestro_id: esDocente ? null : maestroIdNum,
|
||||||
notas: notas || undefined,
|
notas: notas || undefined,
|
||||||
items: items.map((i) => ({
|
items: items.map((i) => ({
|
||||||
material_id: i.material_id,
|
material_id: i.material_id,
|
||||||
@@ -159,7 +166,7 @@ export default function CartProvider({
|
|||||||
throw new Error(json?.error ?? 'No se pudo enviar la solicitud');
|
throw new Error(json?.error ?? 'No se pudo enviar la solicitud');
|
||||||
}
|
}
|
||||||
setOk(true);
|
setOk(true);
|
||||||
const usedFallback = maestroResponsable.trim() === '' && !!tutorNombre;
|
const usedFallback = !esDocente && !maestroIdNum && !!tutorNombre;
|
||||||
toastAfterReload({
|
toastAfterReload({
|
||||||
kind: 'success',
|
kind: 'success',
|
||||||
title: 'Solicitud enviada',
|
title: 'Solicitud enviada',
|
||||||
@@ -317,30 +324,31 @@ export default function CartProvider({
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
{!esDocente && (
|
||||||
<label className="label" htmlFor="maestro-responsable">Maestro responsable</label>
|
<div>
|
||||||
<textarea
|
<label className="label" htmlFor="maestro-select">Maestro responsable</label>
|
||||||
ref={firstFieldRef}
|
<select
|
||||||
id="maestro-responsable"
|
ref={firstFieldRef}
|
||||||
className="input"
|
id="maestro-select"
|
||||||
rows={2}
|
className="input"
|
||||||
maxLength={200}
|
value={maestroId}
|
||||||
value={maestroResponsable}
|
onChange={(e) => setMaestroId(e.target.value)}
|
||||||
onChange={(e) => setMaestroResponsable(e.target.value)}
|
aria-describedby="maestro-hint"
|
||||||
placeholder={tutorNombre ?? 'Escribe el nombre del maestro responsable'}
|
>
|
||||||
aria-describedby="maestro-hint"
|
<option value="">
|
||||||
style={{ minHeight: '64px' }}
|
{tutorNombre ? `— Usar mi tutor (${tutorNombre}) —` : '— Elige un maestro —'}
|
||||||
/>
|
</option>
|
||||||
{tutorNombre ? (
|
{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">
|
<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>
|
||||||
) : (
|
</div>
|
||||||
<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>
|
<label className="label" htmlFor="notas-cart">Motivo del préstamo</label>
|
||||||
@@ -348,7 +356,7 @@ export default function CartProvider({
|
|||||||
id="notas-cart"
|
id="notas-cart"
|
||||||
className="input"
|
className="input"
|
||||||
rows={3}
|
rows={3}
|
||||||
maxLength={500}
|
maxLength={250}
|
||||||
value={notas}
|
value={notas}
|
||||||
onChange={(e) => setNotas(e.target.value)}
|
onChange={(e) => setNotas(e.target.value)}
|
||||||
placeholder="¿Para qué clase o proyecto lo necesitas?"
|
placeholder="¿Para qué clase o proyecto lo necesitas?"
|
||||||
@@ -356,7 +364,7 @@ export default function CartProvider({
|
|||||||
aria-describedby="notas-cart-hint"
|
aria-describedby="notas-cart-hint"
|
||||||
/>
|
/>
|
||||||
<p id="notas-cart-hint" className="text-xs opacity-60 mt-1">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type Props = {
|
|||||||
userId: string;
|
userId: string;
|
||||||
email: string;
|
email: string;
|
||||||
nombre: string | null;
|
nombre: string | null;
|
||||||
|
rol: 'alumno' | 'docente' | 'admin';
|
||||||
initialProfile: {
|
initialProfile: {
|
||||||
matricula: string | null;
|
matricula: string | null;
|
||||||
semestre: string | null;
|
semestre: string | null;
|
||||||
@@ -28,11 +29,14 @@ export default function PerfilForm({
|
|||||||
userId,
|
userId,
|
||||||
email,
|
email,
|
||||||
nombre,
|
nombre,
|
||||||
|
rol,
|
||||||
initialProfile,
|
initialProfile,
|
||||||
maestros,
|
maestros,
|
||||||
googleAvatarUrl,
|
googleAvatarUrl,
|
||||||
mode = 'edit',
|
mode = 'edit',
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const soloAlumno = rol === 'alumno';
|
||||||
|
const matriculaLabel = rol === 'docente' ? 'Número de empleado' : 'Matrícula';
|
||||||
const [matricula, setMatricula] = useState(initialProfile.matricula ?? '');
|
const [matricula, setMatricula] = useState(initialProfile.matricula ?? '');
|
||||||
const [semestre, setSemestre] = useState(initialProfile.semestre ?? '');
|
const [semestre, setSemestre] = useState(initialProfile.semestre ?? '');
|
||||||
const [tutorId, setTutorId] = useState<string>(
|
const [tutorId, setTutorId] = useState<string>(
|
||||||
@@ -91,9 +95,11 @@ export default function PerfilForm({
|
|||||||
}
|
}
|
||||||
const body: Record<string, string | number | null> = {
|
const body: Record<string, string | number | null> = {
|
||||||
matricula: matricula.trim() || 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;
|
if (nuevoPath !== undefined) body.foto_path = nuevoPath;
|
||||||
|
|
||||||
const res = await fetch('/api/profile', {
|
const res = await fetch('/api/profile', {
|
||||||
@@ -154,7 +160,7 @@ export default function PerfilForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label" htmlFor="p-mat">Matrícula</label>
|
<label className="label" htmlFor="p-mat">{matriculaLabel}</label>
|
||||||
<input
|
<input
|
||||||
id="p-mat"
|
id="p-mat"
|
||||||
className="input"
|
className="input"
|
||||||
@@ -167,36 +173,40 @@ export default function PerfilForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{soloAlumno && (
|
||||||
<label className="label" htmlFor="p-sem">Semestre</label>
|
<div>
|
||||||
<select
|
<label className="label" htmlFor="p-sem">Semestre</label>
|
||||||
id="p-sem"
|
<select
|
||||||
className="input"
|
id="p-sem"
|
||||||
value={semestre}
|
className="input"
|
||||||
onChange={(e) => setSemestre(e.target.value)}
|
value={semestre}
|
||||||
>
|
onChange={(e) => setSemestre(e.target.value)}
|
||||||
<option value="">—</option>
|
>
|
||||||
{SEM_OPTS.map((s) => (
|
<option value="">—</option>
|
||||||
<option key={s} value={s}>{s}</option>
|
{SEM_OPTS.map((s) => (
|
||||||
))}
|
<option key={s} value={s}>{s}</option>
|
||||||
</select>
|
))}
|
||||||
</div>
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
{soloAlumno && (
|
||||||
<label className="label" htmlFor="p-tutor">Tutor</label>
|
<div>
|
||||||
<select
|
<label className="label" htmlFor="p-tutor">Tutor</label>
|
||||||
id="p-tutor"
|
<select
|
||||||
className="input"
|
id="p-tutor"
|
||||||
value={tutorId}
|
className="input"
|
||||||
onChange={(e) => setTutorId(e.target.value)}
|
value={tutorId}
|
||||||
>
|
onChange={(e) => setTutorId(e.target.value)}
|
||||||
<option value="">—</option>
|
>
|
||||||
{maestros.map((m) => (
|
<option value="">—</option>
|
||||||
<option key={m.id} value={String(m.id)}>{m.nombre}</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>
|
</select>
|
||||||
</div>
|
<p className="text-xs opacity-70 mt-1">Se rellenará por defecto al crear vales.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
|
<p role="alert" className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
|
||||||
|
|||||||
Vendored
+1
-1
@@ -7,7 +7,7 @@ export type Profile = {
|
|||||||
email: string;
|
email: string;
|
||||||
nombre: string | null;
|
nombre: string | null;
|
||||||
matricula: string | null;
|
matricula: string | null;
|
||||||
rol: 'alumno' | 'admin';
|
rol: 'alumno' | 'docente' | 'admin';
|
||||||
semestre: string | null;
|
semestre: string | null;
|
||||||
tutor_id: number | null;
|
tutor_id: number | null;
|
||||||
foto_path: string | null;
|
foto_path: string | null;
|
||||||
|
|||||||
@@ -7,18 +7,20 @@ type MaestroRow = {
|
|||||||
id: number;
|
id: number;
|
||||||
nombre: string;
|
nombre: string;
|
||||||
activo: boolean;
|
activo: boolean;
|
||||||
|
es_tutor: boolean;
|
||||||
alumnos: { count: number }[];
|
alumnos: { count: number }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data, error } = await Astro.locals.supabase
|
const { data, error } = await Astro.locals.supabase
|
||||||
.from('maestros')
|
.from('maestros')
|
||||||
.select('id, nombre, activo, alumnos:profiles!tutor_id(count)')
|
.select('id, nombre, activo, es_tutor, alumnos:profiles!tutor_id(count)')
|
||||||
.order('nombre');
|
.order('nombre');
|
||||||
|
|
||||||
const maestros = ((data ?? []) as unknown as MaestroRow[]).map((m) => ({
|
const maestros = ((data ?? []) as unknown as MaestroRow[]).map((m) => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
nombre: m.nombre,
|
nombre: m.nombre,
|
||||||
activo: m.activo,
|
activo: m.activo,
|
||||||
|
es_tutor: m.es_tutor,
|
||||||
count: m.alumnos?.[0]?.count ?? 0,
|
count: m.alumnos?.[0]?.count ?? 0,
|
||||||
}));
|
}));
|
||||||
---
|
---
|
||||||
@@ -67,6 +69,7 @@ const maestros = ((data ?? []) as unknown as MaestroRow[]).map((m) => ({
|
|||||||
<tr>
|
<tr>
|
||||||
<th class="px-4 py-3 font-medium">Nombre</th>
|
<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">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"># Alumnos</th>
|
||||||
<th class="px-4 py-3 font-medium text-right">Acciones</th>
|
<th class="px-4 py-3 font-medium text-right">Acciones</th>
|
||||||
</tr>
|
</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>
|
<span class="text-xs uppercase tracking-wide opacity-60">Inactivo</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</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;">
|
<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>}
|
{m.count > 0 ? m.count : <span class="opacity-60">0</span>}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<div class="flex gap-2 justify-end">
|
<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 />
|
<EliminarMaestro id={m.id} nombre={m.nombre} alumnosCount={m.count} client:load />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -103,11 +115,11 @@ const maestros = ((data ?? []) as unknown as MaestroRow[]).map((m) => ({
|
|||||||
<div>
|
<div>
|
||||||
<h3 class="font-semibold">{m.nombre}</h3>
|
<h3 class="font-semibold">{m.nombre}</h3>
|
||||||
<p class="text-xs opacity-70 mt-1">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2 shrink-0">
|
<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 />
|
<EliminarMaestro id={m.id} nombre={m.nombre} alumnosCount={m.count} client:load />
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ const tabs = [
|
|||||||
<td class="p-3">
|
<td class="p-3">
|
||||||
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
|
<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">{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>
|
||||||
<td class="p-3">
|
<td class="p-3">
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1.5">
|
||||||
@@ -107,7 +107,7 @@ const tabs = [
|
|||||||
</div>
|
</div>
|
||||||
{r.notas && (
|
{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);">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
@@ -150,7 +150,7 @@ const tabs = [
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</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">
|
<div class="flex flex-col gap-1.5">
|
||||||
{(r.items ?? []).map((i: any) => (
|
{(r.items ?? []).map((i: any) => (
|
||||||
<div class="text-sm">
|
<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>
|
<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 && (
|
{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);">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div class="mt-3 flex gap-2 flex-wrap">
|
<div class="mt-3 flex gap-2 flex-wrap">
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ const estadoLabel: Record<string, string> = {
|
|||||||
<td class="p-3">
|
<td class="p-3">
|
||||||
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
|
<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">{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>
|
||||||
<td class="p-3">
|
<td class="p-3">
|
||||||
<div class="flex flex-col gap-1.5">
|
<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>
|
<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>
|
||||||
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</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">
|
<div class="flex flex-col gap-1 mt-2">
|
||||||
{(r.items ?? []).map((i: any) => (
|
{(r.items ?? []).map((i: any) => (
|
||||||
<div class="text-sm">{i.cantidad}× {i.material?.nombre ?? '—'}</div>
|
<div class="text-sm">{i.cantidad}× {i.material?.nombre ?? '—'}</div>
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ const tabs = [
|
|||||||
<td class="p-3">
|
<td class="p-3">
|
||||||
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
|
<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">{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>
|
||||||
<td class="p-3">
|
<td class="p-3">
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1.5">
|
||||||
@@ -99,7 +99,7 @@ const tabs = [
|
|||||||
</div>
|
</div>
|
||||||
{r.notas && (
|
{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);">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
@@ -124,7 +124,7 @@ const tabs = [
|
|||||||
<div class="text-xs opacity-70">{fmtFecha.format(new Date(r.fecha_solicitud))}</div>
|
<div class="text-xs opacity-70">{fmtFecha.format(new Date(r.fecha_solicitud))}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</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">
|
<div class="flex flex-col gap-1.5">
|
||||||
{(r.items ?? []).map((i: any) => (
|
{(r.items ?? []).map((i: any) => (
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
@@ -135,7 +135,7 @@ const tabs = [
|
|||||||
</div>
|
</div>
|
||||||
{r.notas && (
|
{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);">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ const qInitial = Astro.url.searchParams.get('q') ?? '';
|
|||||||
|
|
||||||
const supabase = Astro.locals.supabase;
|
const supabase = Astro.locals.supabase;
|
||||||
const userId = Astro.locals.user?.id;
|
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).
|
// Trae matricula + tutor_id directo (el middleware sólo hidrata columnas antiguas).
|
||||||
let matricula: string | null = null;
|
let matricula: string | null = null;
|
||||||
@@ -27,7 +29,21 @@ if (tutorId) {
|
|||||||
const { data } = await supabase.from('maestros').select('nombre').eq('id', tutorId).maybeSingle();
|
const { data } = await supabase.from('maestros').select('nombre').eq('id', tutorId).maybeSingle();
|
||||||
tutorNombre = data?.nombre ?? null;
|
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 = {
|
type Material = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -95,7 +111,7 @@ const categoriasFiltro = grupoList
|
|||||||
Ningún material en esta categoría. Prueba con otra.
|
Ningún material en esta categoría. Prueba con otra.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<CartProvider materiales={materiales} tutorNombre={tutorNombre} perfilCompleto={perfilCompleto} client:load />
|
<CartProvider materiales={materiales} tutorNombre={tutorNombre} perfilCompleto={perfilCompleto} esDocente={esDocente} maestros={maestros} client:load />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const PATCH: APIRoute = async ({ request, locals, params }) => {
|
|||||||
const id = parseId(params.id);
|
const id = parseId(params.id);
|
||||||
if (!id) return Response.json({ error: 'ID inválido' }, { status: 400 });
|
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 {
|
try {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -29,6 +29,7 @@ export const PATCH: APIRoute = async ({ request, locals, params }) => {
|
|||||||
patch.nombre = nombre;
|
patch.nombre = nombre;
|
||||||
}
|
}
|
||||||
if (typeof body.activo === 'boolean') patch.activo = body.activo;
|
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) {
|
if (Object.keys(patch).length === 0) {
|
||||||
return Response.json({ error: 'Nada para actualizar' }, { status: 400 });
|
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 });
|
return Response.json({ error: 'No autorizado' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: { nombre?: unknown; activo?: unknown };
|
let body: { nombre?: unknown; activo?: unknown; es_tutor?: unknown };
|
||||||
try {
|
try {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -19,10 +19,11 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
return Response.json({ error: 'El nombre es obligatorio' }, { status: 400 });
|
return Response.json({ error: 'El nombre es obligatorio' }, { status: 400 });
|
||||||
}
|
}
|
||||||
const activo = typeof body.activo === 'boolean' ? body.activo : true;
|
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
|
const { data, error } = await locals.supabase
|
||||||
.from('maestros')
|
.from('maestros')
|
||||||
.insert({ nombre, activo })
|
.insert({ nombre, activo, es_tutor })
|
||||||
.select('id')
|
.select('id')
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ function statusForError(message: string): { status: number; error: string } {
|
|||||||
if (message.startsWith('no_autenticado')) {
|
if (message.startsWith('no_autenticado')) {
|
||||||
return { status: 401, error: '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 (
|
if (
|
||||||
message.startsWith('maestro_responsable_requerido') ||
|
message.startsWith('maestro_responsable_requerido') ||
|
||||||
message.startsWith('items_requeridos') ||
|
message.startsWith('items_requeridos') ||
|
||||||
@@ -24,6 +30,9 @@ function statusForError(message: string): { status: number; error: string } {
|
|||||||
if (message.startsWith('stock_insuficiente')) {
|
if (message.startsWith('stock_insuficiente')) {
|
||||||
return { status: 409, error: 'Sin stock suficiente para uno de los materiales' };
|
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' };
|
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 });
|
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 {
|
try {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
} catch {
|
} catch {
|
||||||
return Response.json({ error: 'JSON inválido' }, { status: 400 });
|
return Response.json({ error: 'JSON inválido' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const maestro_responsable = typeof body.maestro_responsable === 'string' ? body.maestro_responsable.trim() : '';
|
// maestro_id: number opcional. Si docente o si alumno sin elección, va null
|
||||||
const notas = typeof body.notas === 'string' && body.notas.trim() ? body.notas.trim() : 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) {
|
if (!Array.isArray(body.items) || body.items.length === 0) {
|
||||||
return Response.json({ error: 'Agrega al menos un material' }, { status: 400 });
|
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', {
|
const { data, error } = await locals.supabase.rpc('crear_solicitud', {
|
||||||
p_maestro_responsable: maestro_responsable,
|
p_maestro_id: maestro_id,
|
||||||
p_notas: notas,
|
p_notas: notas,
|
||||||
p_items: items,
|
p_items: items,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,10 +15,15 @@ const { data: maestrosData } = await Astro.locals.supabase
|
|||||||
.from('maestros')
|
.from('maestros')
|
||||||
.select('id, nombre')
|
.select('id, nombre')
|
||||||
.eq('activo', true)
|
.eq('activo', true)
|
||||||
|
.eq('es_tutor', true)
|
||||||
.order('nombre');
|
.order('nombre');
|
||||||
const maestros = (maestrosData ?? []) as { id: number; nombre: string }[];
|
const maestros = (maestrosData ?? []) as { id: number; nombre: string }[];
|
||||||
|
|
||||||
const primerNombre = (profile.nombre ?? profile.email.split('@')[0]).split(/\s+/)[0];
|
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">
|
<Layout title="Bienvenido — LabPréstamos">
|
||||||
<main id="main" class="min-h-screen grid place-items-center p-4">
|
<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}
|
Bienvenido, {primerNombre}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-sm mt-2" style="color: var(--color-pencil);">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -42,6 +47,7 @@ const primerNombre = (profile.nombre ?? profile.email.split('@')[0]).split(/\s+/
|
|||||||
userId={user.id}
|
userId={user.id}
|
||||||
email={profile.email}
|
email={profile.email}
|
||||||
nombre={profile.nombre}
|
nombre={profile.nombre}
|
||||||
|
rol={profile.rol}
|
||||||
initialProfile={{
|
initialProfile={{
|
||||||
matricula: profile.matricula,
|
matricula: profile.matricula,
|
||||||
semestre: profile.semestre,
|
semestre: profile.semestre,
|
||||||
|
|||||||
@@ -16,9 +16,13 @@ const { data: maestrosData } = await Astro.locals.supabase
|
|||||||
.from('maestros')
|
.from('maestros')
|
||||||
.select('id, nombre')
|
.select('id, nombre')
|
||||||
.eq('activo', true)
|
.eq('activo', true)
|
||||||
|
.eq('es_tutor', true)
|
||||||
.order('nombre');
|
.order('nombre');
|
||||||
const maestros = (maestrosData ?? []) as { id: number; nombre: string }[];
|
const maestros = (maestrosData ?? []) as { id: number; nombre: string }[];
|
||||||
|
|
||||||
|
const rolLabel =
|
||||||
|
profile.rol === 'admin' ? 'Administrador' : profile.rol === 'docente' ? 'Docente' : 'Alumno';
|
||||||
|
|
||||||
const info = avatarInfo({
|
const info = avatarInfo({
|
||||||
email: profile.email,
|
email: profile.email,
|
||||||
nombre: profile.nombre,
|
nombre: profile.nombre,
|
||||||
@@ -36,7 +40,7 @@ const info = avatarInfo({
|
|||||||
</h1>
|
</h1>
|
||||||
<p class="text-sm truncate" style="color: var(--color-pencil);">{profile.email}</p>
|
<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)'};`}>
|
<span class="badge mt-2" style={`background: ${profile.rol === 'admin' ? 'var(--color-secondary)' : 'var(--color-chalk)'};`}>
|
||||||
{profile.rol}
|
{rolLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -48,6 +52,7 @@ const info = avatarInfo({
|
|||||||
userId={user.id}
|
userId={user.id}
|
||||||
email={profile.email}
|
email={profile.email}
|
||||||
nombre={profile.nombre}
|
nombre={profile.nombre}
|
||||||
|
rol={profile.rol}
|
||||||
initialProfile={{
|
initialProfile={{
|
||||||
matricula: profile.matricula,
|
matricula: profile.matricula,
|
||||||
semestre: profile.semestre,
|
semestre: profile.semestre,
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
-- 0006_docente_maestros_v16.sql
|
||||||
|
-- v1.6:
|
||||||
|
-- A) profiles.rol acepta 'docente' (alumno|docente|admin)
|
||||||
|
-- B) maestros.es_tutor bool (subset de maestros que son elegibles como tutor)
|
||||||
|
-- C) RPC crear_solicitud reescrita: recibe p_maestro_id int; resuelve nombre
|
||||||
|
-- y lo guarda en maestro_responsable (snapshot). Docente no requiere
|
||||||
|
-- maestro (se guarda su propio nombre como responsable). Fallback al
|
||||||
|
-- tutor guardado si p_maestro_id es null y el user es alumno.
|
||||||
|
|
||||||
|
begin;
|
||||||
|
|
||||||
|
-- A) Rol docente ----------------------------------------------------------
|
||||||
|
alter table prestamos.profiles drop constraint if exists profiles_rol_check;
|
||||||
|
alter table prestamos.profiles
|
||||||
|
add constraint profiles_rol_check check (rol in ('alumno','docente','admin'));
|
||||||
|
|
||||||
|
-- B) Flag es_tutor --------------------------------------------------------
|
||||||
|
alter table prestamos.maestros
|
||||||
|
add column if not exists es_tutor boolean not null default false;
|
||||||
|
|
||||||
|
-- 'Sin especificar' queda como fallback de tutor para no romper flows
|
||||||
|
-- previos (no es un maestro real; se puede desmarcar despues desde /admin/maestros).
|
||||||
|
update prestamos.maestros set es_tutor = true where nombre = 'Sin especificar';
|
||||||
|
|
||||||
|
-- Self-heal policy insert amplia el check a los 3 roles no-admin.
|
||||||
|
-- Sigue prohibiendo auto-promocion a admin.
|
||||||
|
drop policy if exists profiles_insert_self on prestamos.profiles;
|
||||||
|
create policy profiles_insert_self on prestamos.profiles
|
||||||
|
for insert to authenticated
|
||||||
|
with check (id = auth.uid() and rol in ('alumno','docente'));
|
||||||
|
|
||||||
|
-- C) RPC crear_solicitud v2: recibe maestro_id ----------------------------
|
||||||
|
-- Cambio de firma: antes (text, text, jsonb) -> ahora (int, text, jsonb).
|
||||||
|
-- Drop de la anterior para no dejar overload muerto.
|
||||||
|
drop function if exists prestamos.crear_solicitud(text, text, jsonb);
|
||||||
|
|
||||||
|
create or replace function prestamos.crear_solicitud(
|
||||||
|
p_maestro_id int,
|
||||||
|
p_notas text,
|
||||||
|
p_items jsonb
|
||||||
|
) returns bigint
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = prestamos, pg_temp
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_alumno uuid := auth.uid();
|
||||||
|
v_solicitud_id bigint;
|
||||||
|
v_item jsonb;
|
||||||
|
v_material_id int;
|
||||||
|
v_cantidad int;
|
||||||
|
v_descripcion text;
|
||||||
|
v_disponible int;
|
||||||
|
v_estado_mat text;
|
||||||
|
v_trackeado boolean;
|
||||||
|
v_unidad_id bigint;
|
||||||
|
v_asignadas int;
|
||||||
|
v_maestro text;
|
||||||
|
v_rol text;
|
||||||
|
v_nombre_propio text;
|
||||||
|
v_tutor_id int;
|
||||||
|
begin
|
||||||
|
if v_alumno is null then
|
||||||
|
raise exception 'no_autenticado';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select rol, nombre, tutor_id
|
||||||
|
into v_rol, v_nombre_propio, v_tutor_id
|
||||||
|
from prestamos.profiles where id = v_alumno;
|
||||||
|
|
||||||
|
if v_rol is null then
|
||||||
|
raise exception 'perfil_no_existe';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_rol = 'docente' then
|
||||||
|
-- Docente: no elige maestro, se registra su propio nombre como responsable.
|
||||||
|
v_maestro := coalesce(nullif(trim(v_nombre_propio), ''), '(Docente)');
|
||||||
|
elsif p_maestro_id is not null then
|
||||||
|
select nombre into v_maestro
|
||||||
|
from prestamos.maestros
|
||||||
|
where id = p_maestro_id and activo;
|
||||||
|
if v_maestro is null then
|
||||||
|
raise exception 'maestro_no_valido';
|
||||||
|
end if;
|
||||||
|
else
|
||||||
|
-- Alumno sin selección: cae al tutor guardado.
|
||||||
|
if v_tutor_id is null then
|
||||||
|
raise exception 'maestro_responsable_requerido';
|
||||||
|
end if;
|
||||||
|
select nombre into v_maestro
|
||||||
|
from prestamos.maestros where id = v_tutor_id;
|
||||||
|
if v_maestro is null then
|
||||||
|
raise exception 'tutor_no_valido';
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if p_items is null or jsonb_typeof(p_items) <> 'array' or jsonb_array_length(p_items) = 0 then
|
||||||
|
raise exception 'items_requeridos';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Lock por orden ascendente de material_id, evita deadlocks entre alumnos
|
||||||
|
for v_material_id in
|
||||||
|
select distinct (elem->>'material_id')::int
|
||||||
|
from jsonb_array_elements(p_items) elem
|
||||||
|
order by 1
|
||||||
|
loop
|
||||||
|
perform 1 from prestamos.materiales where id = v_material_id for update;
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
insert into prestamos.solicitudes (alumno_id, estado, maestro_responsable, notas)
|
||||||
|
values (v_alumno, 'pendiente', v_maestro, nullif(trim(coalesce(p_notas, '')), ''))
|
||||||
|
returning id into v_solicitud_id;
|
||||||
|
|
||||||
|
for v_item in select * from jsonb_array_elements(p_items)
|
||||||
|
loop
|
||||||
|
v_material_id := (v_item->>'material_id')::int;
|
||||||
|
v_cantidad := (v_item->>'cantidad')::int;
|
||||||
|
v_descripcion := nullif(trim(coalesce(v_item->>'descripcion', '')), '');
|
||||||
|
|
||||||
|
if v_material_id is null or v_cantidad is null or v_cantidad <= 0 then
|
||||||
|
raise exception 'item_invalido';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select cantidad_disponible, estado, trackeado_por_unidad
|
||||||
|
into v_disponible, v_estado_mat, v_trackeado
|
||||||
|
from prestamos.materiales where id = v_material_id;
|
||||||
|
|
||||||
|
if v_estado_mat is null then
|
||||||
|
raise exception 'material_no_existe: %', v_material_id;
|
||||||
|
end if;
|
||||||
|
if v_estado_mat <> 'disponible' then
|
||||||
|
raise exception 'material_no_disponible: %', v_material_id;
|
||||||
|
end if;
|
||||||
|
if v_disponible < v_cantidad then
|
||||||
|
raise exception 'stock_insuficiente: %', v_material_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_trackeado then
|
||||||
|
v_asignadas := 0;
|
||||||
|
while v_asignadas < v_cantidad loop
|
||||||
|
select id into v_unidad_id
|
||||||
|
from prestamos.material_unidades
|
||||||
|
where material_id = v_material_id and estado = 'disponible'
|
||||||
|
order by id
|
||||||
|
for update skip locked
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if v_unidad_id is null then
|
||||||
|
raise exception 'sin_unidad_disponible: %', v_material_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
insert into prestamos.solicitud_items
|
||||||
|
(solicitud_id, material_id, cantidad, descripcion, material_unidad_id)
|
||||||
|
values (v_solicitud_id, v_material_id, 1, v_descripcion, v_unidad_id);
|
||||||
|
|
||||||
|
update prestamos.material_unidades
|
||||||
|
set estado = 'prestado'
|
||||||
|
where id = v_unidad_id;
|
||||||
|
|
||||||
|
v_asignadas := v_asignadas + 1;
|
||||||
|
end loop;
|
||||||
|
else
|
||||||
|
insert into prestamos.solicitud_items (solicitud_id, material_id, cantidad, descripcion)
|
||||||
|
values (v_solicitud_id, v_material_id, v_cantidad, v_descripcion);
|
||||||
|
end if;
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
return v_solicitud_id;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
grant execute on function prestamos.crear_solicitud(int, text, jsonb) to authenticated;
|
||||||
|
|
||||||
|
commit;
|
||||||
Reference in New Issue
Block a user