Perfil + onboarding + home reformulado + fixes
Perfil de usuario: - /perfil con avatar, matrícula, semestre, tutor, foto de perfil - /onboarding opcional (skippeable) para primer login - Avatar con prioridad: foto propia > Google OAuth > iniciales sobre color HSL - Endpoint PATCH /api/profile con validación y guard self-only - Middleware carga semestre, tutor_id, foto_path - Sidebar footer con Avatar + link a perfil CRUD admin de maestros: - /admin/maestros con tabla desktop / cards mobile - MaestroForm + EliminarMaestro (mismo patrón que categorias) - Endpoints POST/PATCH/DELETE con guard admin + 23503 traducido - Subnav de inventario ahora incluye tab Maestros Home reformulado: - Alumno: saludo por hora + CTA "¿Qué vas a pedir hoy?" + card de último préstamo activo - Admin: saludo + 2 KPIs de alerta (Pendientes, Vencidos) + últimas 2 pendientes + actividad reciente (tabla desktop, cards mobile — fix del bug 6) - Helper saludoHora() y fmtFechaRelativa() en date.ts con offset MX Checkout con tutor auto: - SolicitudCart: input maestro → textarea con placeholder = nombre del tutor - Guard perfil incompleto: CTA "Completa tu perfil" si !matricula || !tutor_id - RPC crear_solicitud: fallback automático al nombre del tutor si textarea vacío Fixes: - Bug upload imagen: Dockerfile ARG + docker-compose build.args pasan PUBLIC_* al bundle client de Vite - Grid inventario mobile: acciones en grid 2-col con prop compact en botones - UnidadesManager mobile: tabla reemplazada por cards, select estado con min-width 130px - Chart donut: Legend horizontal debajo (sin labels internos cortadas) - Chart barras: YAxis width fijo 100 + tickFormatter que trunca >14 chars - Panel admin mobile: tabla ahora tiene versión cards mobile (parte del rediseño home)
This commit is contained in:
+156
-64
@@ -1,32 +1,52 @@
|
||||
---
|
||||
import AppLayout from '@/layouts/AppLayout.astro';
|
||||
import { todayMX } from '@/lib/date';
|
||||
import { todayMX, saludoHora, fmtFechaRelativa } from '@/lib/date';
|
||||
|
||||
const supabase = Astro.locals.supabase;
|
||||
const profile = Astro.locals.profile;
|
||||
const dia = todayMX();
|
||||
const saludo = saludoHora();
|
||||
|
||||
const [pend, activos, vencidos, agotados, ultimas] = await Promise.all([
|
||||
const nombreCorto =
|
||||
profile?.nombre?.trim().split(/\s+/)[0] ??
|
||||
profile?.email?.split('@')[0] ??
|
||||
'';
|
||||
|
||||
type Estado = 'pendiente' | 'aprobado' | 'activo' | 'rechazado' | 'devuelto' | 'vencido';
|
||||
type Item = { cantidad: number; material: { nombre: string } | null };
|
||||
type Alumno = { nombre: string | null; email: string } | null;
|
||||
type Sol = {
|
||||
id: number;
|
||||
estado: Estado;
|
||||
fecha_solicitud: string;
|
||||
alumno: Alumno;
|
||||
items: Item[];
|
||||
};
|
||||
|
||||
const [pend, vencidos, pendientes2, ultimas] = await Promise.all([
|
||||
supabase.from('solicitudes').select('*', { count: 'exact', head: true }).eq('estado', 'pendiente'),
|
||||
supabase.from('solicitudes').select('*', { count: 'exact', head: true }).in('estado', ['aprobado', 'activo']),
|
||||
supabase.from('solicitudes').select('*', { count: 'exact', head: true }).in('estado', ['aprobado', 'activo']).lt('fecha_devolucion_estimada', dia.isoDate),
|
||||
supabase.from('materiales').select('*', { count: 'exact', head: true }).eq('cantidad_disponible', 0),
|
||||
supabase
|
||||
.from('solicitudes')
|
||||
.select('id, fecha_solicitud, estado, alumno:profiles!alumno_id(nombre, email), items:solicitud_items(cantidad, material:materiales(nombre))')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.in('estado', ['aprobado', 'activo'])
|
||||
.lt('fecha_devolucion_estimada', dia.isoDate),
|
||||
supabase
|
||||
.from('solicitudes')
|
||||
.select('id, estado, fecha_solicitud, alumno:profiles!alumno_id(nombre, email), items:solicitud_items(cantidad, material:materiales(nombre))')
|
||||
.eq('estado', 'pendiente')
|
||||
.order('fecha_solicitud', { ascending: false })
|
||||
.limit(2),
|
||||
supabase
|
||||
.from('solicitudes')
|
||||
.select('id, estado, fecha_solicitud, alumno:profiles!alumno_id(nombre, email), items:solicitud_items(cantidad, material:materiales(nombre))')
|
||||
.order('fecha_solicitud', { ascending: false })
|
||||
.limit(5),
|
||||
]);
|
||||
|
||||
const fmtFecha = new Intl.DateTimeFormat('es-MX', { dateStyle: 'medium', timeStyle: 'short' });
|
||||
const pendientesData = (pendientes2.data ?? []) as unknown as Sol[];
|
||||
const ultimasData = (ultimas.data ?? []) as unknown as Sol[];
|
||||
|
||||
const kpis = [
|
||||
{ label: 'Pendientes', value: pend.count ?? 0, href: '/admin/solicitudes', tone: 'primary' },
|
||||
{ label: 'En préstamo', value: activos.count ?? 0, href: '/admin/solicitudes/activos', tone: 'secondary' },
|
||||
{ label: 'Vencidos', value: vencidos.count ?? 0, href: '/admin/solicitudes/activos', tone: 'danger' },
|
||||
{ label: 'Agotados', value: agotados.count ?? 0, href: '/admin/inventario', tone: 'ink' },
|
||||
];
|
||||
|
||||
const estadoLabel: Record<string, string> = {
|
||||
const estadoLabel: Record<Estado, string> = {
|
||||
pendiente: 'Pendiente',
|
||||
aprobado: 'En préstamo',
|
||||
activo: 'En préstamo',
|
||||
@@ -34,65 +54,137 @@ const estadoLabel: Record<string, string> = {
|
||||
rechazado: 'Rechazado',
|
||||
vencido: 'Vencido',
|
||||
};
|
||||
const estadoColor: Record<Estado, string> = {
|
||||
pendiente: 'var(--color-ink)',
|
||||
aprobado: 'var(--color-positive)',
|
||||
activo: 'var(--color-positive)',
|
||||
devuelto: 'var(--color-ink)',
|
||||
rechazado: 'var(--color-danger)',
|
||||
vencido: 'var(--color-danger)',
|
||||
};
|
||||
const badgeStyle = (e: Estado) =>
|
||||
`background: color-mix(in oklab, ${estadoColor[e]} 18%, white); color: var(--color-ink); border: 1.5px solid ${estadoColor[e]};`;
|
||||
|
||||
const resumenItems = (items: Item[]) => {
|
||||
const preview = items.slice(0, 3).map((i) => `${i.cantidad}× ${i.material?.nombre ?? '—'}`).join(', ');
|
||||
const extra = items.length - 3;
|
||||
return extra > 0 ? `${preview} (+${extra})` : preview;
|
||||
};
|
||||
|
||||
const kpiPend = pend.count ?? 0;
|
||||
const kpiVenc = vencidos.count ?? 0;
|
||||
---
|
||||
<AppLayout title="Panel — LabPréstamos">
|
||||
<div class="max-w-6xl">
|
||||
<header class="mb-6">
|
||||
<h1 class="text-2xl md:text-3xl font-semibold">Panel</h1>
|
||||
<p class="text-sm mt-1" style="color: var(--color-pencil);">Resumen del laboratorio.</p>
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<header class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
|
||||
<h1 class="font-mono uppercase text-3xl md:text-5xl font-semibold leading-tight" style="letter-spacing: -0.01em;">
|
||||
{saludo}, {nombreCorto}
|
||||
</h1>
|
||||
<div class="flex gap-3 shrink-0">
|
||||
<a href="/admin/solicitudes" class="card-flat px-4 py-3 text-center">
|
||||
<div class="text-2xl font-semibold leading-none" style={`font-variant-numeric: tabular-nums; color: ${kpiPend > 0 ? 'var(--color-primary)' : 'var(--color-ink)'};`}>{kpiPend}</div>
|
||||
<div class="mt-1 text-xs uppercase tracking-wide" style="color: var(--color-pencil);">Pendientes</div>
|
||||
</a>
|
||||
<a href="/admin/solicitudes/activos" class="card-flat px-4 py-3 text-center">
|
||||
<div class="text-2xl font-semibold leading-none" style={`font-variant-numeric: tabular-nums; color: ${kpiVenc > 0 ? 'var(--color-danger)' : 'var(--color-ink)'};`}>{kpiVenc}</div>
|
||||
<div class="mt-1 text-xs uppercase tracking-wide" style="color: var(--color-pencil);">Vencidos</div>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section aria-label="Indicadores" class="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-4 mb-8">
|
||||
{kpis.map((k) => (
|
||||
<a href={k.href} class="card-link">
|
||||
<div
|
||||
class="text-4xl font-semibold leading-none"
|
||||
style={`font-variant-numeric: tabular-nums; color: var(--color-${k.tone === 'ink' ? 'ink' : k.tone});`}
|
||||
>
|
||||
{k.value}
|
||||
</div>
|
||||
<div class="mt-2 text-sm uppercase tracking-wide" style="color: var(--color-pencil);">{k.label}</div>
|
||||
</a>
|
||||
))}
|
||||
<section aria-labelledby="atencion-h" class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 id="atencion-h" class="text-lg font-semibold uppercase tracking-wide">Requieren tu atención</h2>
|
||||
{pendientesData.length > 0 && (
|
||||
<a href="/admin/solicitudes" class="text-sm underline decoration-[color:var(--color-ink)] underline-offset-4">Ver todas</a>
|
||||
)}
|
||||
</div>
|
||||
{pendientesData.length === 0 ? (
|
||||
<div class="card text-center">
|
||||
<p class="text-sm opacity-70">Todo al día. No hay solicitudes pendientes.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
{pendientesData.map((s) => (
|
||||
<a href="/admin/solicitudes" class="card-link">
|
||||
<div class="flex items-start justify-between gap-3 mb-2">
|
||||
<h3 class="font-semibold leading-tight">{s.alumno?.nombre ?? s.alumno?.email ?? '—'}</h3>
|
||||
<span class="badge shrink-0" style={badgeStyle(s.estado)}>{estadoLabel[s.estado]}</span>
|
||||
</div>
|
||||
<p class="text-sm leading-snug mb-3" style="color: var(--color-pencil);">{resumenItems(s.items ?? [])}</p>
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span style="color: var(--color-pencil);">{fmtFechaRelativa(s.fecha_solicitud)}</span>
|
||||
<span class="underline decoration-[color:var(--color-ink)] underline-offset-4">Revisar</span>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="ultimas-h">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 id="ultimas-h" class="text-lg font-semibold uppercase tracking-wide">Últimas 5 solicitudes</h2>
|
||||
<a href="/admin/solicitudes" class="text-sm underline decoration-[color:var(--color-ink)] underline-offset-4">Ver todas</a>
|
||||
<h2 id="ultimas-h" class="text-lg font-semibold uppercase tracking-wide">Actividad reciente</h2>
|
||||
<a href="/admin/solicitudes/historial" class="text-sm underline decoration-[color:var(--color-ink)] underline-offset-4">Historial</a>
|
||||
</div>
|
||||
<div class="card-flat p-0 overflow-hidden">
|
||||
{ultimas.data && ultimas.data.length > 0 ? (
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
|
||||
<tr>
|
||||
<th class="p-3 font-medium">Alumno</th>
|
||||
<th class="p-3 font-medium">Material</th>
|
||||
<th class="p-3 font-medium">Estado</th>
|
||||
<th class="p-3 font-medium">Solicitado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ultimas.data.map((r: any) => (
|
||||
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);">
|
||||
<td class="p-3">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</td>
|
||||
<td class="p-3">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{(r.items ?? []).map((i: any) => (
|
||||
<div>{i.cantidad}× {i.material?.nombre ?? '—'}</div>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-3">{estadoLabel[r.estado] ?? r.estado}</td>
|
||||
<td class="p-3 opacity-80">{fmtFecha.format(new Date(r.fecha_solicitud))}</td>
|
||||
|
||||
{ultimasData.length === 0 ? (
|
||||
<div class="card text-center">
|
||||
<p class="text-sm opacity-70">Aún no hay solicitudes registradas.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div class="hidden md:block card-flat p-0 overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
|
||||
<tr>
|
||||
<th class="p-3 font-medium">Alumno</th>
|
||||
<th class="p-3 font-medium">Material</th>
|
||||
<th class="p-3 font-medium">Estado</th>
|
||||
<th class="p-3 font-medium">Solicitado</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p class="p-6 opacity-70 text-sm">Aún no hay solicitudes registradas.</p>
|
||||
)}
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ultimasData.map((r) => (
|
||||
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);">
|
||||
<td class="p-3">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</td>
|
||||
<td class="p-3">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{(r.items ?? []).map((i) => (
|
||||
<div>{i.cantidad}× {i.material?.nombre ?? '—'}</div>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-3">
|
||||
<span class="badge" style={badgeStyle(r.estado)}>{estadoLabel[r.estado]}</span>
|
||||
</td>
|
||||
<td class="p-3 opacity-80">{fmtFechaRelativa(r.fecha_solicitud)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="md:hidden flex flex-col gap-3">
|
||||
{ultimasData.map((r) => (
|
||||
<article class="card">
|
||||
<div class="flex items-start justify-between gap-3 mb-2">
|
||||
<div class="min-w-0">
|
||||
<h3 class="font-semibold leading-tight truncate">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</h3>
|
||||
<p class="text-xs mt-1" style="color: var(--color-pencil);">{fmtFechaRelativa(r.fecha_solicitud)}</p>
|
||||
</div>
|
||||
<span class="badge shrink-0" style={badgeStyle(r.estado)}>{estadoLabel[r.estado]}</span>
|
||||
</div>
|
||||
<div class="text-sm leading-snug">
|
||||
{(r.items ?? []).map((i) => (
|
||||
<div>{i.cantidad}× {i.material?.nombre ?? '—'}</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
@@ -37,6 +37,9 @@ const categorias = ((data ?? []) as unknown as CategoriaRow[]).map((c) => ({
|
||||
<a href="/admin/inventario/categorias" class="px-4 py-2 text-sm font-medium border-b-2" style="border-color: var(--color-primary); color: var(--color-primary);">
|
||||
Categorías
|
||||
</a>
|
||||
<a href="/admin/maestros" class="px-4 py-2 text-sm font-medium border-b-2 border-transparent opacity-70 transition-colors duration-150 hover:opacity-100">
|
||||
Maestros
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -181,9 +181,10 @@ const estadoBadge = (e: Material['estado']) => {
|
||||
{m.numero_inventario && (
|
||||
<p class="text-xs opacity-60 font-mono truncate">{m.numero_inventario}</p>
|
||||
)}
|
||||
<div class="flex gap-2 mt-auto pt-2">
|
||||
<div class="grid grid-cols-2 gap-2 mt-auto pt-2">
|
||||
<MaterialForm
|
||||
mode="edit"
|
||||
compact
|
||||
material={{
|
||||
id: m.id,
|
||||
nombre: m.nombre,
|
||||
@@ -199,7 +200,7 @@ const estadoBadge = (e: Material['estado']) => {
|
||||
categorias={categorias}
|
||||
client:load
|
||||
/>
|
||||
<EliminarMaterial id={m.id} nombre={m.nombre} client:load />
|
||||
<EliminarMaterial id={m.id} nombre={m.nombre} compact client:load />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
import AppLayout from '@/layouts/AppLayout.astro';
|
||||
import MaestroForm from '@/components/admin/maestros/MaestroForm.tsx';
|
||||
import EliminarMaestro from '@/components/admin/maestros/EliminarMaestro.tsx';
|
||||
|
||||
type MaestroRow = {
|
||||
id: number;
|
||||
nombre: string;
|
||||
activo: boolean;
|
||||
alumnos: { count: number }[];
|
||||
};
|
||||
|
||||
const { data, error } = await Astro.locals.supabase
|
||||
.from('maestros')
|
||||
.select('id, nombre, activo, 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,
|
||||
count: m.alumnos?.[0]?.count ?? 0,
|
||||
}));
|
||||
---
|
||||
<AppLayout title="Maestros — Admin">
|
||||
<div class="max-w-4xl">
|
||||
<header class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<h1 class="text-2xl md:text-3xl font-semibold">Maestros</h1>
|
||||
<p class="text-sm opacity-70 mt-1">Catálogo de tutores que los alumnos eligen en su perfil.</p>
|
||||
</div>
|
||||
<MaestroForm mode="create" client:load />
|
||||
</header>
|
||||
|
||||
<nav class="flex gap-2 mb-6 border-b" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);" aria-label="Sub-navegación de inventario">
|
||||
<a href="/admin/inventario" class="px-4 py-2 text-sm font-medium border-b-2 border-transparent opacity-70 transition-colors duration-150 hover:opacity-100">
|
||||
Materiales
|
||||
</a>
|
||||
<a href="/admin/inventario/categorias" class="px-4 py-2 text-sm font-medium border-b-2 border-transparent opacity-70 transition-colors duration-150 hover:opacity-100">
|
||||
Categorías
|
||||
</a>
|
||||
<a href="/admin/maestros" class="px-4 py-2 text-sm font-medium border-b-2" style="border-color: var(--color-primary); color: var(--color-primary);">
|
||||
Maestros
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
{error && (
|
||||
<div class="card mb-6" role="alert" style="border-color: color-mix(in oklab, var(--color-danger) 30%, transparent);">
|
||||
<p class="text-sm" style="color: var(--color-danger-text);">
|
||||
No se pudieron cargar los maestros. Recarga la página.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && maestros.length === 0 && (
|
||||
<div class="card text-center">
|
||||
<h2 class="text-lg font-semibold mb-1">Sin maestros aún</h2>
|
||||
<p class="text-sm opacity-70">Agrega el primero para que los alumnos puedan elegirlo como tutor.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && maestros.length > 0 && (
|
||||
<>
|
||||
<div class="hidden md:block card p-0 overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
|
||||
<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-right"># Alumnos</th>
|
||||
<th class="px-4 py-3 font-medium text-right">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{maestros.map((m) => (
|
||||
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 8%, transparent);">
|
||||
<td class="px-4 py-3 font-medium">{m.nombre}</td>
|
||||
<td class="px-4 py-3">
|
||||
{m.activo ? (
|
||||
<span class="text-xs uppercase tracking-wide" style="color: var(--color-primary);">Activo</span>
|
||||
) : (
|
||||
<span class="text-xs uppercase tracking-wide opacity-60">Inactivo</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 />
|
||||
<EliminarMaestro id={m.id} nombre={m.nombre} alumnosCount={m.count} client:load />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="md:hidden flex flex-col gap-3">
|
||||
{maestros.map((m) => (
|
||||
<article class="card flex items-start justify-between gap-3">
|
||||
<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'}
|
||||
</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 />
|
||||
<EliminarMaestro id={m.id} nombre={m.nombre} alumnosCount={m.count} client:load />
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AppLayout>
|
||||
@@ -6,6 +6,29 @@ import BuscadorCatalogo from '@/components/alumno/BuscadorCatalogo.tsx';
|
||||
|
||||
const qInitial = Astro.url.searchParams.get('q') ?? '';
|
||||
|
||||
const supabase = Astro.locals.supabase;
|
||||
const userId = Astro.locals.user?.id;
|
||||
|
||||
// Trae matricula + tutor_id directo (el middleware sólo hidrata columnas antiguas).
|
||||
let matricula: string | null = null;
|
||||
let tutorId: number | null = null;
|
||||
if (userId) {
|
||||
const { data: p } = await supabase
|
||||
.from('profiles')
|
||||
.select('matricula, tutor_id')
|
||||
.eq('id', userId)
|
||||
.maybeSingle();
|
||||
matricula = p?.matricula ?? null;
|
||||
tutorId = (p as { tutor_id?: number | null } | null)?.tutor_id ?? null;
|
||||
}
|
||||
|
||||
let tutorNombre: string | null = null;
|
||||
if (tutorId) {
|
||||
const { data } = await supabase.from('maestros').select('nombre').eq('id', tutorId).maybeSingle();
|
||||
tutorNombre = data?.nombre ?? null;
|
||||
}
|
||||
const perfilCompleto = !!(matricula && tutorId);
|
||||
|
||||
type Material = {
|
||||
id: number;
|
||||
nombre: string;
|
||||
@@ -72,7 +95,7 @@ const categoriasFiltro = grupoList
|
||||
Ningún material en esta categoría. Prueba con otra.
|
||||
</p>
|
||||
|
||||
<CartProvider materiales={materiales} client:load />
|
||||
<CartProvider materiales={materiales} tutorNombre={tutorNombre} perfilCompleto={perfilCompleto} client:load />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const parseId = (raw: string | undefined) => {
|
||||
const id = Number(raw);
|
||||
return Number.isInteger(id) && id > 0 ? id : null;
|
||||
};
|
||||
|
||||
export const PATCH: APIRoute = async ({ request, locals, params }) => {
|
||||
if (locals.profile?.rol !== 'admin') {
|
||||
return Response.json({ error: 'No autorizado' }, { status: 403 });
|
||||
}
|
||||
|
||||
const id = parseId(params.id);
|
||||
if (!id) return Response.json({ error: 'ID inválido' }, { status: 400 });
|
||||
|
||||
let body: { nombre?: unknown; activo?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: 'JSON inválido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (typeof body.nombre === 'string') {
|
||||
const nombre = body.nombre.trim();
|
||||
if (!nombre) return Response.json({ error: 'El nombre no puede estar vacío' }, { status: 400 });
|
||||
patch.nombre = nombre;
|
||||
}
|
||||
if (typeof body.activo === 'boolean') patch.activo = body.activo;
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
return Response.json({ error: 'Nada para actualizar' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { error } = await locals.supabase.from('maestros').update(patch).eq('id', id);
|
||||
|
||||
if (error) {
|
||||
if ((error as { code?: string }).code === '23505') {
|
||||
return Response.json({ error: 'Ya existe un maestro con ese nombre' }, { status: 409 });
|
||||
}
|
||||
return Response.json({ error: 'No se pudo actualizar' }, { status: 500 });
|
||||
}
|
||||
|
||||
return Response.json({ ok: true });
|
||||
};
|
||||
|
||||
export const DELETE: APIRoute = async ({ locals, params }) => {
|
||||
if (locals.profile?.rol !== 'admin') {
|
||||
return Response.json({ error: 'No autorizado' }, { status: 403 });
|
||||
}
|
||||
|
||||
const id = parseId(params.id);
|
||||
if (!id) return Response.json({ error: 'ID inválido' }, { status: 400 });
|
||||
|
||||
const { count } = await locals.supabase
|
||||
.from('profiles')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('tutor_id', id);
|
||||
|
||||
const { error } = await locals.supabase.from('maestros').delete().eq('id', id);
|
||||
|
||||
if (error) {
|
||||
if ((error as { code?: string }).code === '23503') {
|
||||
return Response.json(
|
||||
{ error: `Tiene ${count ?? 'algún'} alumno${(count ?? 0) === 1 ? '' : 's'} como tutor — reasígnalos primero` },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
return Response.json({ error: 'No se pudo eliminar' }, { status: 500 });
|
||||
}
|
||||
|
||||
return Response.json({ ok: true });
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
if (locals.profile?.rol !== 'admin') {
|
||||
return Response.json({ error: 'No autorizado' }, { status: 403 });
|
||||
}
|
||||
|
||||
let body: { nombre?: unknown; activo?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: 'JSON inválido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const nombre = typeof body.nombre === 'string' ? body.nombre.trim() : '';
|
||||
if (!nombre) {
|
||||
return Response.json({ error: 'El nombre es obligatorio' }, { status: 400 });
|
||||
}
|
||||
const activo = typeof body.activo === 'boolean' ? body.activo : true;
|
||||
|
||||
const { data, error } = await locals.supabase
|
||||
.from('maestros')
|
||||
.insert({ nombre, activo })
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
if ((error as { code?: string }).code === '23505') {
|
||||
return Response.json({ error: 'Ya existe un maestro con ese nombre' }, { status: 409 });
|
||||
}
|
||||
return Response.json({ error: 'No se pudo crear el maestro' }, { status: 500 });
|
||||
}
|
||||
|
||||
return Response.json({ id: data.id }, { status: 201 });
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const MAT_RE = /^[A-Za-z0-9]{5,15}$/;
|
||||
const SEM_VALID = new Set(['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12']);
|
||||
|
||||
type PatchBody = {
|
||||
matricula?: unknown;
|
||||
semestre?: unknown;
|
||||
tutor_id?: unknown;
|
||||
foto_path?: unknown;
|
||||
};
|
||||
|
||||
function cleanStr(v: unknown): string | null | undefined {
|
||||
if (v === undefined) return undefined;
|
||||
if (v === null) return null;
|
||||
if (typeof v !== 'string') return undefined;
|
||||
const t = v.trim();
|
||||
return t === '' ? null : t;
|
||||
}
|
||||
|
||||
export const PATCH: APIRoute = async ({ request, locals }) => {
|
||||
const user = locals.user;
|
||||
if (!user) return Response.json({ error: 'no autorizado' }, { status: 401 });
|
||||
|
||||
let body: PatchBody;
|
||||
try {
|
||||
body = (await request.json()) as PatchBody;
|
||||
} catch {
|
||||
return Response.json({ error: 'JSON inválido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const payload: Record<string, string | number | null> = {};
|
||||
|
||||
const matricula = cleanStr(body.matricula);
|
||||
if (matricula !== undefined) {
|
||||
if (matricula !== null && !MAT_RE.test(matricula)) {
|
||||
return Response.json({ error: 'matrícula: 5–15 caracteres alfanuméricos' }, { status: 400 });
|
||||
}
|
||||
payload.matricula = matricula;
|
||||
}
|
||||
|
||||
const semestre = cleanStr(body.semestre);
|
||||
if (semestre !== undefined) {
|
||||
if (semestre !== null && !SEM_VALID.has(semestre)) {
|
||||
return Response.json({ error: 'semestre: debe ser entre 1 y 12' }, { status: 400 });
|
||||
}
|
||||
payload.semestre = semestre;
|
||||
}
|
||||
|
||||
if (body.tutor_id !== undefined) {
|
||||
if (body.tutor_id === null) {
|
||||
payload.tutor_id = null;
|
||||
} else if (typeof body.tutor_id === 'number' && Number.isInteger(body.tutor_id)) {
|
||||
payload.tutor_id = body.tutor_id;
|
||||
} else {
|
||||
return Response.json({ error: 'tutor_id inválido' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const fotoPath = cleanStr(body.foto_path);
|
||||
if (fotoPath !== undefined) payload.foto_path = fotoPath;
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
return Response.json({ error: 'sin cambios' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data, error } = await locals.supabase
|
||||
.from('profiles')
|
||||
.update(payload)
|
||||
.eq('id', user.id)
|
||||
.select('id, email, nombre, matricula, rol, semestre, tutor_id, foto_path')
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
if ((error as { code?: string }).code === '23503') {
|
||||
return Response.json({ error: 'tutor no válido' }, { status: 400 });
|
||||
}
|
||||
return Response.json({ error: 'no se pudo actualizar' }, { status: 500 });
|
||||
}
|
||||
|
||||
return Response.json({ ok: true, profile: data });
|
||||
};
|
||||
+95
-34
@@ -1,45 +1,106 @@
|
||||
---
|
||||
import AppLayout from '@/layouts/AppLayout.astro';
|
||||
import { saludoHora, fmtFechaRelativa } from '@/lib/date';
|
||||
|
||||
const profile = Astro.locals.profile;
|
||||
const isAdmin = profile?.rol === 'admin';
|
||||
const nombre = profile?.nombre ?? profile?.email?.split('@')[0] ?? '';
|
||||
|
||||
if (profile?.rol === 'admin') {
|
||||
return Astro.redirect('/admin', 302);
|
||||
}
|
||||
|
||||
const user = Astro.locals.user;
|
||||
const supabase = Astro.locals.supabase;
|
||||
|
||||
const nombreCorto =
|
||||
profile?.nombre?.trim().split(/\s+/)[0] ??
|
||||
profile?.email?.split('@')[0] ??
|
||||
'';
|
||||
const saludo = saludoHora();
|
||||
|
||||
type Estado = 'pendiente' | 'aprobado' | 'activo' | 'rechazado' | 'devuelto' | 'vencido';
|
||||
type Item = { cantidad: number; material: { nombre: string } | null };
|
||||
type Actual = { id: number; estado: Estado; fecha_solicitud: string; items: Item[] } | null;
|
||||
|
||||
let actual: Actual = null;
|
||||
if (user) {
|
||||
const { data } = await supabase
|
||||
.from('solicitudes')
|
||||
.select('id, estado, fecha_solicitud, items:solicitud_items(cantidad, material:materiales(nombre))')
|
||||
.eq('alumno_id', user.id)
|
||||
.in('estado', ['pendiente', 'aprobado', 'activo'])
|
||||
.order('fecha_solicitud', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
actual = (data as unknown as Actual) ?? null;
|
||||
}
|
||||
|
||||
const estadoLabel: Record<Estado, string> = {
|
||||
pendiente: 'Pendiente',
|
||||
aprobado: 'Aprobado',
|
||||
activo: 'Activo',
|
||||
rechazado: 'Rechazado',
|
||||
devuelto: 'Devuelto',
|
||||
vencido: 'Vencido',
|
||||
};
|
||||
const estadoColor: Record<Estado, string> = {
|
||||
pendiente: 'var(--color-ink)',
|
||||
aprobado: 'var(--color-positive)',
|
||||
activo: 'var(--color-positive)',
|
||||
devuelto: 'var(--color-ink)',
|
||||
rechazado: 'var(--color-danger)',
|
||||
vencido: 'var(--color-danger)',
|
||||
};
|
||||
const badgeStyle = (e: Estado) =>
|
||||
`background: color-mix(in oklab, ${estadoColor[e]} 18%, white); color: var(--color-ink); border: 1.5px solid ${estadoColor[e]};`;
|
||||
|
||||
const items = actual?.items ?? [];
|
||||
const preview = items.slice(0, 3);
|
||||
const extra = items.length - preview.length;
|
||||
---
|
||||
<AppLayout title="Inicio — LabPréstamos">
|
||||
<div class="max-w-3xl">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<header class="mb-8">
|
||||
<p class="text-sm" style="color: var(--color-pencil);">Bienvenido{nombre ? ',' : ''}</p>
|
||||
<h1 class="text-2xl md:text-3xl font-semibold">{nombre || 'Laboratorio de Sistemas'}</h1>
|
||||
<h1 class="font-mono uppercase text-3xl md:text-5xl font-semibold leading-tight" style="letter-spacing: -0.01em;">
|
||||
{saludo}, {nombreCorto}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-5 md:grid-cols-2">
|
||||
{isAdmin ? (
|
||||
<>
|
||||
<a href="/admin/solicitudes" class="card-link">
|
||||
<h2 class="font-semibold text-lg mb-1 uppercase tracking-wide">Solicitudes</h2>
|
||||
<p class="text-sm" style="color: var(--color-pencil);">Aprobar o rechazar peticiones de material.</p>
|
||||
</a>
|
||||
<a href="/admin/inventario" class="card-link">
|
||||
<h2 class="font-semibold text-lg mb-1 uppercase tracking-wide">Inventario</h2>
|
||||
<p class="text-sm" style="color: var(--color-pencil);">Materiales y categorías del laboratorio.</p>
|
||||
</a>
|
||||
<a href="/admin/reportes" class="card-link md:col-span-2">
|
||||
<h2 class="font-semibold text-lg mb-1 uppercase tracking-wide">Reportes</h2>
|
||||
<p class="text-sm" style="color: var(--color-pencil);">Historial y exportación de préstamos.</p>
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<a href="/alumno/catalogo" class="card-link">
|
||||
<h2 class="font-semibold text-lg mb-1 uppercase tracking-wide">Catálogo</h2>
|
||||
<p class="text-sm" style="color: var(--color-pencil);">Ver material disponible y solicitar un préstamo.</p>
|
||||
</a>
|
||||
<a href="/alumno/mis-prestamos" class="card-link">
|
||||
<h2 class="font-semibold text-lg mb-1 uppercase tracking-wide">Mis préstamos</h2>
|
||||
<p class="text-sm" style="color: var(--color-pencil);">Estado de tus solicitudes actuales e historial.</p>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<section class="card-raised mb-8">
|
||||
<p class="text-xs uppercase tracking-wide mb-1" style="color: var(--color-pencil);">Nuevo préstamo</p>
|
||||
<h2 class="text-xl md:text-2xl font-semibold mb-5">¿Qué vas a pedir hoy?</h2>
|
||||
<a href="/alumno/catalogo" class="btn btn-primary">Ir al catálogo</a>
|
||||
</section>
|
||||
|
||||
{actual ? (
|
||||
<section aria-labelledby="actual-h">
|
||||
<div class="flex items-center justify-between gap-3 mb-3">
|
||||
<h2 id="actual-h" class="text-lg font-semibold uppercase tracking-wide">Tu préstamo actual</h2>
|
||||
<span class="badge" style={badgeStyle(actual.estado)}>{estadoLabel[actual.estado]}</span>
|
||||
</div>
|
||||
<a href="/alumno/mis-prestamos" class="card-link">
|
||||
<ul class="space-y-1 mb-4">
|
||||
{preview.map((it) => (
|
||||
<li class="leading-snug">
|
||||
<span class="font-semibold" style="font-variant-numeric: tabular-nums;">{it.cantidad}×</span>{' '}
|
||||
<span class="font-semibold">{it.material?.nombre ?? 'Material eliminado'}</span>
|
||||
</li>
|
||||
))}
|
||||
{extra > 0 && (
|
||||
<li class="text-sm opacity-70">(y {extra} más)</li>
|
||||
)}
|
||||
</ul>
|
||||
<div class="flex items-center justify-between gap-3 text-sm">
|
||||
<span style="color: var(--color-pencil);">Solicitado {fmtFechaRelativa(actual.fecha_solicitud)}</span>
|
||||
<span class="underline decoration-[color:var(--color-ink)] underline-offset-4">Ver detalles</span>
|
||||
</div>
|
||||
</a>
|
||||
</section>
|
||||
) : (
|
||||
<section class="card text-center">
|
||||
<h2 class="text-lg font-semibold mb-1">Aún no tienes préstamos activos</h2>
|
||||
<p class="text-sm opacity-70 mb-5">¿Vamos por algo del laboratorio?</p>
|
||||
<a href="/alumno/catalogo" class="btn btn-primary">Ir al catálogo</a>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
import Layout from '@/layouts/Layout.astro';
|
||||
import BrandMark from '@/components/BrandMark.astro';
|
||||
import PerfilForm from '@/components/profile/PerfilForm.tsx';
|
||||
|
||||
const user = Astro.locals.user!;
|
||||
const profile = Astro.locals.profile!;
|
||||
|
||||
const googleAvatarUrl =
|
||||
(user.user_metadata as Record<string, unknown> | undefined)?.avatar_url as string | undefined ||
|
||||
(user.user_metadata as Record<string, unknown> | undefined)?.picture as string | undefined ||
|
||||
null;
|
||||
|
||||
const { data: maestrosData } = await Astro.locals.supabase
|
||||
.from('maestros')
|
||||
.select('id, nombre')
|
||||
.eq('activo', true)
|
||||
.order('nombre');
|
||||
const maestros = (maestrosData ?? []) as { id: number; nombre: string }[];
|
||||
|
||||
const primerNombre = (profile.nombre ?? profile.email.split('@')[0]).split(/\s+/)[0];
|
||||
---
|
||||
<Layout title="Bienvenido — LabPréstamos">
|
||||
<main id="main" class="min-h-screen grid place-items-center p-4">
|
||||
<div class="w-full max-w-lg">
|
||||
<div class="text-center mb-6">
|
||||
<div class="inline-grid place-items-center w-16 h-16 rounded-[2px] bg-[color:var(--color-primary)] p-4 mb-4" style="border: 2px solid var(--color-ink); box-shadow: var(--shadow-hard);">
|
||||
<BrandMark class="w-full h-full text-white" />
|
||||
</div>
|
||||
<h1 class="text-2xl md:text-3xl font-semibold uppercase tracking-wide">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card-raised">
|
||||
<PerfilForm
|
||||
client:load
|
||||
mode="onboarding"
|
||||
userId={user.id}
|
||||
email={profile.email}
|
||||
nombre={profile.nombre}
|
||||
initialProfile={{
|
||||
matricula: profile.matricula,
|
||||
semestre: profile.semestre,
|
||||
tutor_id: profile.tutor_id,
|
||||
foto_path: profile.foto_path,
|
||||
}}
|
||||
maestros={maestros}
|
||||
googleAvatarUrl={googleAvatarUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</Layout>
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
import AppLayout from '@/layouts/AppLayout.astro';
|
||||
import PerfilForm from '@/components/profile/PerfilForm.tsx';
|
||||
import Avatar from '@/components/profile/Avatar.tsx';
|
||||
import { avatarInfo } from '@/lib/avatar';
|
||||
|
||||
const user = Astro.locals.user!;
|
||||
const profile = Astro.locals.profile!;
|
||||
|
||||
const googleAvatarUrl =
|
||||
(user.user_metadata as Record<string, unknown> | undefined)?.avatar_url as string | undefined ||
|
||||
(user.user_metadata as Record<string, unknown> | undefined)?.picture as string | undefined ||
|
||||
null;
|
||||
|
||||
const { data: maestrosData } = await Astro.locals.supabase
|
||||
.from('maestros')
|
||||
.select('id, nombre')
|
||||
.eq('activo', true)
|
||||
.order('nombre');
|
||||
const maestros = (maestrosData ?? []) as { id: number; nombre: string }[];
|
||||
|
||||
const info = avatarInfo({
|
||||
email: profile.email,
|
||||
nombre: profile.nombre,
|
||||
fotoPath: profile.foto_path,
|
||||
googleAvatarUrl,
|
||||
});
|
||||
---
|
||||
<AppLayout title="Mi perfil — LabPréstamos">
|
||||
<div class="max-w-md mx-auto">
|
||||
<header class="flex items-center gap-4 mb-6">
|
||||
<Avatar client:load info={info} size={80} />
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-xl md:text-2xl font-semibold uppercase tracking-wide leading-tight truncate">
|
||||
{profile.nombre ?? profile.email.split('@')[0]}
|
||||
</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}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="card-raised">
|
||||
<h2 class="text-sm uppercase tracking-wide font-semibold mb-4">Datos del perfil</h2>
|
||||
<PerfilForm
|
||||
client:load
|
||||
userId={user.id}
|
||||
email={profile.email}
|
||||
nombre={profile.nombre}
|
||||
initialProfile={{
|
||||
matricula: profile.matricula,
|
||||
semestre: profile.semestre,
|
||||
tutor_id: profile.tutor_id,
|
||||
foto_path: profile.foto_path,
|
||||
}}
|
||||
maestros={maestros}
|
||||
googleAvatarUrl={googleAvatarUrl}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</AppLayout>
|
||||
Reference in New Issue
Block a user