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:
2026-08-24 08:46:39 -07:00
parent ef40241ade
commit e159e8d297
28 changed files with 1533 additions and 218 deletions
+6
View File
@@ -6,6 +6,12 @@ RUN npm ci
FROM node:22-alpine AS build FROM node:22-alpine AS build
WORKDIR /app WORKDIR /app
ARG PUBLIC_SUPABASE_URL
ARG PUBLIC_SUPABASE_ANON_KEY
ARG PUBLIC_APP_URL
ENV PUBLIC_SUPABASE_URL=$PUBLIC_SUPABASE_URL \
PUBLIC_SUPABASE_ANON_KEY=$PUBLIC_SUPABASE_ANON_KEY \
PUBLIC_APP_URL=$PUBLIC_APP_URL
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
RUN npm run build RUN npm run build
+6 -1
View File
@@ -1,6 +1,11 @@
services: services:
labre-web: labre-web:
build: . build:
context: .
args:
PUBLIC_SUPABASE_URL: ${PUBLIC_SUPABASE_URL}
PUBLIC_SUPABASE_ANON_KEY: ${PUBLIC_SUPABASE_ANON_KEY}
PUBLIC_APP_URL: ${PUBLIC_APP_URL}
container_name: labre-web container_name: labre-web
restart: unless-stopped restart: unless-stopped
env_file: .env.production env_file: .env.production
+16 -4
View File
@@ -13,6 +13,7 @@ import {
Tooltip, Tooltip,
ResponsiveContainer, ResponsiveContainer,
LabelList, LabelList,
Legend,
} from 'recharts'; } from 'recharts';
type Barras = { tipo: 'barras'; datos: { label: string; valor: number }[]; alto?: number }; type Barras = { tipo: 'barras'; datos: { label: string; valor: number }[]; alto?: number };
@@ -108,10 +109,11 @@ export default function Chart(props: Props) {
<YAxis <YAxis
type="category" type="category"
dataKey="label" dataKey="label"
width={140} width={100}
stroke={INK} stroke={INK}
tick={{ ...MONO, fontSize: 10 } as any} tick={{ ...MONO, fontSize: 10 } as any}
interval={0} interval={0}
tickFormatter={(v: string) => (v.length > 14 ? v.slice(0, 13) + '…' : v)}
/> />
<Tooltip content={<TooltipBox />} cursor={{ fill: 'rgba(56,56,56,0.06)' }} /> <Tooltip content={<TooltipBox />} cursor={{ fill: 'rgba(56,56,56,0.06)' }} />
<Bar dataKey="valor" fill={PRIMARY} stroke={INK} strokeWidth={1.5} animationDuration={anim} name="Solicitado"> <Bar dataKey="valor" fill={PRIMARY} stroke={INK} strokeWidth={1.5} animationDuration={anim} name="Solicitado">
@@ -148,7 +150,6 @@ export default function Chart(props: Props) {
// donut // donut
const datos = props.datos; const datos = props.datos;
const total = datos.reduce((s, d) => s + d.valor, 0);
return ( return (
<ResponsiveContainer width="100%" height={alto}> <ResponsiveContainer width="100%" height={alto}>
<PieChart margin={{ top: 8, right: 8, left: 8, bottom: 8 }}> <PieChart margin={{ top: 8, right: 8, left: 8, bottom: 8 }}>
@@ -163,13 +164,24 @@ export default function Chart(props: Props) {
strokeWidth={1.5} strokeWidth={1.5}
isAnimationActive={!reduced} isAnimationActive={!reduced}
animationDuration={anim} animationDuration={anim}
label={({ label, valor }: any) => (total ? `${label} (${valor})` : label)} label={false}
labelLine={{ stroke: PENCIL }} labelLine={false}
> >
{datos.map((d, i) => ( {datos.map((d, i) => (
<Cell key={d.label} fill={donutColor(d.label, i)} /> <Cell key={d.label} fill={donutColor(d.label, i)} />
))} ))}
</Pie> </Pie>
<Legend
layout="horizontal"
verticalAlign="bottom"
align="center"
iconType="square"
wrapperStyle={{ ...MONO, marginTop: 8 }}
formatter={(value: string, entry: any) => {
const v = entry?.payload?.valor;
return v != null ? `${value} (${v})` : value;
}}
/>
</PieChart> </PieChart>
</ResponsiveContainer> </ResponsiveContainer>
); );
@@ -4,9 +4,10 @@ type Props = {
id: number; id: number;
nombre: string; nombre: string;
tienePrestamos?: boolean; tienePrestamos?: boolean;
compact?: boolean;
}; };
export default function EliminarMaterial({ id, nombre, tienePrestamos }: Props) { export default function EliminarMaterial({ id, nombre, tienePrestamos, compact }: Props) {
const dialogRef = useRef<HTMLDialogElement | null>(null); const dialogRef = useRef<HTMLDialogElement | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -53,7 +54,10 @@ export default function EliminarMaterial({ id, nombre, tienePrestamos }: Props)
<> <>
<button <button
type="button" type="button"
className="btn btn-ghost hover:!bg-[color:var(--color-danger)] hover:!text-[color:var(--color-ink)]" className={
'btn btn-ghost hover:!bg-[color:var(--color-danger)] hover:!text-[color:var(--color-ink)]' +
(compact ? ' w-full text-sm px-2' : '')
}
style={{ minHeight: '36px', paddingBlock: '0.25rem' }} style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
onClick={open} onClick={open}
> >
@@ -22,11 +22,12 @@ type Props = {
mode: 'create' | 'edit'; mode: 'create' | 'edit';
material?: Material; material?: Material;
categorias: Categoria[]; categorias: Categoria[];
compact?: boolean;
}; };
const BUCKET = 'materiales-fotos'; const BUCKET = 'materiales-fotos';
export default function MaterialForm({ mode, material, categorias }: Props) { export default function MaterialForm({ mode, material, categorias, compact }: Props) {
const dialogRef = useRef<HTMLDialogElement | null>(null); const dialogRef = useRef<HTMLDialogElement | null>(null);
const firstFieldRef = useRef<HTMLInputElement | null>(null); const firstFieldRef = useRef<HTMLInputElement | null>(null);
const errorRef = useRef<HTMLParagraphElement | null>(null); const errorRef = useRef<HTMLParagraphElement | null>(null);
@@ -200,13 +201,18 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
}; };
const triggerLabel = mode === 'create' ? 'Nuevo material' : 'Editar'; const triggerLabel = mode === 'create' ? 'Nuevo material' : 'Editar';
const triggerClass = mode === 'create' ? 'btn btn-primary' : 'btn btn-ghost'; const triggerClass =
(mode === 'create' ? 'btn btn-primary' : 'btn btn-ghost') +
(compact ? ' w-full text-sm px-2' : '');
const triggerStyle = compact
? { minHeight: '36px', paddingBlock: '0.25rem' as const }
: undefined;
const currentImg = previewUrl ?? imgUrl(imagenPath); const currentImg = previewUrl ?? imgUrl(imagenPath);
return ( return (
<> <>
<button type="button" className={triggerClass} onClick={open}> <button type="button" className={triggerClass} style={triggerStyle} onClick={open}>
{triggerLabel} {triggerLabel}
</button> </button>
@@ -114,32 +114,18 @@ export default function UnidadesManager({ materialId }: { materialId: number })
) : unidades.length === 0 ? ( ) : unidades.length === 0 ? (
<p className="text-sm opacity-70">Sin unidades registradas.</p> <p className="text-sm opacity-70">Sin unidades registradas.</p>
) : ( ) : (
<div className="overflow-x-auto"> <div className="flex flex-col gap-2">
<table className="w-full text-sm">
<thead
className="text-left"
style={{ background: 'color-mix(in oklab, var(--color-ink) 4%, transparent)' }}
>
<tr>
<th className="px-2 py-2 font-medium">Etiqueta</th>
<th className="px-2 py-2 font-medium">Estado</th>
<th className="px-2 py-2 font-medium">Notas</th>
<th className="px-2 py-2 font-medium text-right">Acción</th>
</tr>
</thead>
<tbody>
{unidades.map((u) => { {unidades.map((u) => {
const bloqueada = u.estado === 'prestado'; const bloqueada = u.estado === 'prestado';
return ( return (
<tr <div
key={u.id} key={u.id}
className="border-t" className="card p-3 grid grid-cols-1 sm:grid-cols-[1fr_auto_auto] gap-2 items-center"
style={{ borderColor: 'color-mix(in oklab, var(--color-ink) 12%, transparent)' }}
> >
<td className="px-2 py-2">
<input <input
type="text" type="text"
className="input" className="input"
aria-label="Etiqueta"
style={{ minHeight: '36px', paddingBlock: '0.25rem' }} style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
defaultValue={u.etiqueta} defaultValue={u.etiqueta}
onBlur={(e) => { onBlur={(e) => {
@@ -148,11 +134,10 @@ export default function UnidadesManager({ materialId }: { materialId: number })
else e.target.value = u.etiqueta; else e.target.value = u.etiqueta;
}} }}
/> />
</td>
<td className="px-2 py-2">
<select <select
className="input" className="input"
style={{ minHeight: '36px', paddingBlock: '0.25rem' }} aria-label="Estado"
style={{ minHeight: '36px', paddingBlock: '0.25rem', minWidth: '130px' }}
value={bloqueada ? 'prestado' : u.estado} value={bloqueada ? 'prestado' : u.estado}
disabled={bloqueada} disabled={bloqueada}
onChange={(e) => patch(u, { estado: e.target.value as Unidad['estado'] })} onChange={(e) => patch(u, { estado: e.target.value as Unidad['estado'] })}
@@ -164,21 +149,6 @@ export default function UnidadesManager({ materialId }: { materialId: number })
</option> </option>
))} ))}
</select> </select>
</td>
<td className="px-2 py-2">
<input
type="text"
className="input"
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
defaultValue={u.notas ?? ''}
placeholder="Opcional…"
onBlur={(e) => {
const v = e.target.value;
if ((u.notas ?? '') !== v) patch(u, { notas: v || null });
}}
/>
</td>
<td className="px-2 py-2 text-right">
<button <button
type="button" type="button"
className="btn btn-ghost" className="btn btn-ghost"
@@ -188,12 +158,21 @@ export default function UnidadesManager({ materialId }: { materialId: number })
> >
Eliminar Eliminar
</button> </button>
</td> <input
</tr> type="text"
className="input sm:col-span-3"
aria-label="Notas"
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
defaultValue={u.notas ?? ''}
placeholder="Notas (opcional)…"
onBlur={(e) => {
const v = e.target.value;
if ((u.notas ?? '') !== v) patch(u, { notas: v || null });
}}
/>
</div>
); );
})} })}
</tbody>
</table>
</div> </div>
)} )}
</div> </div>
@@ -0,0 +1,112 @@
import { useEffect, useRef, useState } from 'react';
type Props = {
id: number;
nombre: string;
alumnosCount: number;
};
export default function EliminarMaestro({ id, nombre, alumnosCount }: Props) {
const dialogRef = useRef<HTMLDialogElement | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const titleId = `del-mae-${id}`;
const bloqueado = alumnosCount > 0;
const open = () => {
setError(null);
dialogRef.current?.showModal();
};
const close = () => {
if (loading) return;
dialogRef.current?.close();
};
useEffect(() => {
const dlg = dialogRef.current;
if (!dlg) return;
const onClick = (e: MouseEvent) => {
if (e.target === dlg) close();
};
dlg.addEventListener('click', onClick);
return () => dlg.removeEventListener('click', onClick);
});
const submit = async () => {
if (loading) return;
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/admin/maestros/${id}`, { method: 'DELETE' });
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json?.error ?? 'No se pudo eliminar');
dialogRef.current?.close();
location.reload();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error inesperado');
} finally {
setLoading(false);
}
};
return (
<>
<button
type="button"
className="btn btn-ghost"
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
onClick={open}
>
Eliminar
</button>
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(92vw,28rem)]"
>
<div className="p-5 sm:p-6 flex flex-col gap-4">
<h2 id={titleId} className="text-lg font-semibold">
Eliminar maestro
</h2>
{bloqueado ? (
<p className="text-sm">
No se puede eliminar <strong>{nombre}</strong>: tiene{' '}
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{alumnosCount}</span>{' '}
alumno{alumnosCount === 1 ? '' : 's'} como tutor. Reasígnalos primero desde su perfil.
</p>
) : (
<p className="text-sm">
¿Seguro que quieres eliminar <strong>{nombre}</strong>?
</p>
)}
{error && (
<p role="alert" className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
{error}
</p>
)}
<div className="flex gap-2 justify-end pt-2">
<button type="button" className="btn btn-ghost" onClick={close} disabled={loading}>
{bloqueado ? 'Cerrar' : 'Cancelar'}
</button>
{!bloqueado && (
<button
type="button"
className="btn"
style={{ background: 'var(--color-danger)', color: 'var(--color-ink)' }}
onClick={submit}
disabled={loading}
>
{loading ? 'Eliminando…' : 'Eliminar'}
</button>
)}
</div>
</div>
</dialog>
</>
);
}
@@ -0,0 +1,157 @@
import { useEffect, useRef, useState } from 'react';
type Props = {
mode: 'create' | 'edit';
maestro?: { id: number; nombre: string; activo: boolean };
};
export default function MaestroForm({ mode, maestro }: Props) {
const dialogRef = useRef<HTMLDialogElement | null>(null);
const firstFieldRef = useRef<HTMLInputElement | null>(null);
const errorRef = useRef<HTMLParagraphElement | null>(null);
const [nombre, setNombre] = useState(maestro?.nombre ?? '');
const [activo, setActivo] = useState(maestro?.activo ?? true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const titleId = `mae-form-${mode}-${maestro?.id ?? 'new'}`;
const open = () => {
setError(null);
if (mode === 'create') {
setNombre('');
setActivo(true);
}
dialogRef.current?.showModal();
queueMicrotask(() => firstFieldRef.current?.focus());
};
const close = () => {
if (loading) return;
dialogRef.current?.close();
};
useEffect(() => {
const dlg = dialogRef.current;
if (!dlg) return;
const onClick = (e: MouseEvent) => {
if (e.target === dlg) close();
};
dlg.addEventListener('click', onClick);
return () => dlg.removeEventListener('click', onClick);
});
useEffect(() => {
if (error) errorRef.current?.focus();
}, [error]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (loading) return;
const trimmed = nombre.trim();
if (!trimmed) {
setError('El nombre es obligatorio');
return;
}
setLoading(true);
setError(null);
const url = mode === 'create' ? '/api/admin/maestros' : `/api/admin/maestros/${maestro!.id}`;
const method = mode === 'create' ? 'POST' : 'PATCH';
try {
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nombre: trimmed, activo }),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json?.error ?? 'No se pudo guardar');
dialogRef.current?.close();
location.reload();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error inesperado');
} finally {
setLoading(false);
}
};
const triggerLabel = mode === 'create' ? 'Nuevo maestro' : 'Editar';
const triggerClass = mode === 'create' ? 'btn btn-primary' : 'btn btn-ghost';
return (
<>
<button type="button" className={triggerClass} onClick={open}>
{triggerLabel}
</button>
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(92vw,24rem)]"
>
<form onSubmit={submit} className="p-5 sm:p-6 flex flex-col gap-4" autoComplete="off">
<div className="flex items-start justify-between gap-3">
<h2 id={titleId} className="text-lg font-semibold">
{mode === 'create' ? 'Nuevo maestro' : `Editar: ${maestro?.nombre}`}
</h2>
<button
type="button"
onClick={close}
aria-label="Cerrar"
className="rounded-lg p-1 hover:bg-black/5 transition-colors leading-none text-xl"
>
<span aria-hidden="true">×</span>
</button>
</div>
<div>
<label className="label" htmlFor={`${titleId}-nombre`}>
Nombre
</label>
<input
ref={firstFieldRef}
id={`${titleId}-nombre`}
className="input"
type="text"
value={nombre}
onChange={(e) => setNombre(e.target.value)}
required
autoComplete="off"
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${titleId}-err` : undefined}
/>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={activo}
onChange={(e) => setActivo(e.target.checked)}
/>
Activo (visible en la lista de tutores)
</label>
{error && (
<p
ref={errorRef}
id={`${titleId}-err`}
role="alert"
tabIndex={-1}
className="text-sm"
style={{ color: 'var(--color-danger-text)' }}
>
{error}
</p>
)}
<div className="flex gap-2 justify-end pt-2">
<button type="button" className="btn btn-ghost" onClick={close} disabled={loading}>
Cancelar
</button>
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? 'Guardando…' : 'Guardar'}
</button>
</div>
</form>
</dialog>
</>
);
}
+36 -7
View File
@@ -39,10 +39,18 @@ 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));
export default function CartProvider({ materiales }: { materiales: Material[] }) { export default function CartProvider({
materiales,
tutorNombre = null,
perfilCompleto = true,
}: {
materiales: Material[];
tutorNombre?: string | null;
perfilCompleto?: boolean;
}) {
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<HTMLInputElement | null>(null); const firstFieldRef = useRef<HTMLTextAreaElement | null>(null);
const [maestroResponsable, setMaestroResponsable] = useState(''); const [maestroResponsable, setMaestroResponsable] = useState('');
const [notas, setNotas] = useState(''); const [notas, setNotas] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -129,7 +137,7 @@ export default function CartProvider({ materiales }: { materiales: Material[] })
const submit = async (e: React.FormEvent) => { const submit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (loading || items.length === 0) return; if (loading || items.length === 0 || !perfilCompleto) return;
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
@@ -151,10 +159,13 @@ export default function CartProvider({ materiales }: { materiales: Material[] })
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;
toastAfterReload({ toastAfterReload({
kind: 'success', kind: 'success',
title: 'Solicitud enviada', title: 'Solicitud enviada',
description: `${items.length} material${items.length === 1 ? '' : 'es'} · pendiente de aprobación`, description: usedFallback
? `${items.length} material${items.length === 1 ? '' : 'es'} · maestro: ${tutorNombre} (tu tutor)`
: `${items.length} material${items.length === 1 ? '' : 'es'} · pendiente de aprobación`,
}); });
setTimeout(() => { setTimeout(() => {
dialogRef.current?.close(); dialogRef.current?.close();
@@ -308,16 +319,27 @@ export default function CartProvider({ materiales }: { materiales: Material[] })
<div> <div>
<label className="label" htmlFor="maestro-responsable">Maestro responsable</label> <label className="label" htmlFor="maestro-responsable">Maestro responsable</label>
<input <textarea
ref={firstFieldRef} ref={firstFieldRef}
id="maestro-responsable" id="maestro-responsable"
className="input" className="input"
type="text" rows={2}
required
maxLength={200} maxLength={200}
value={maestroResponsable} value={maestroResponsable}
onChange={(e) => setMaestroResponsable(e.target.value)} onChange={(e) => setMaestroResponsable(e.target.value)}
placeholder={tutorNombre ?? 'Escribe el nombre del maestro responsable'}
aria-describedby="maestro-hint"
style={{ minHeight: '64px' }}
/> />
{tutorNombre ? (
<p id="maestro-hint" className="text-xs opacity-70 mt-1">
Si dejas esto vacío, se usará tu tutor: <strong>{tutorNombre}</strong>.
</p>
) : (
<p id="maestro-hint" className="text-xs opacity-60 mt-1">
Debes escribir un maestro; no tienes tutor guardado.
</p>
)}
</div> </div>
<div> <div>
@@ -349,6 +371,12 @@ export default function CartProvider({ materiales }: { materiales: Material[] })
</p> </p>
)} )}
{!perfilCompleto ? (
<div className="card p-3">
<p className="text-sm mb-3">Necesitas completar tu perfil (matrícula + tutor) para crear vales.</p>
<a href="/perfil" className="btn btn-primary">Completar perfil</a>
</div>
) : (
<div className="flex gap-2 justify-end pt-2"> <div className="flex gap-2 justify-end pt-2">
<button type="button" className="btn btn-ghost" onClick={closeCheckout} disabled={loading}> <button type="button" className="btn btn-ghost" onClick={closeCheckout} disabled={loading}>
Cancelar Cancelar
@@ -357,6 +385,7 @@ export default function CartProvider({ materiales }: { materiales: Material[] })
{loading ? 'Enviando…' : 'Enviar solicitud'} {loading ? 'Enviando…' : 'Enviar solicitud'}
</button> </button>
</div> </div>
)}
</form> </form>
</dialog> </dialog>
</CartContext.Provider> </CartContext.Provider>
+41
View File
@@ -0,0 +1,41 @@
import type { AvatarInfo } from '@/lib/avatar';
type Props = {
info: AvatarInfo;
size?: number;
className?: string;
};
// Contraste rápido: fondo HSL con L=55% → texto blanco pasa AA con casi todos los hues.
// Mantenemos blanco fijo para consistencia visual.
export default function Avatar({ info, size = 40, className = '' }: Props) {
const style: React.CSSProperties = {
width: size,
height: size,
border: '2px solid var(--color-ink)',
borderRadius: 2,
display: 'grid',
placeItems: 'center',
overflow: 'hidden',
background: info.src ? 'white' : info.hueColor,
color: 'white',
fontWeight: 700,
fontSize: Math.max(11, Math.floor(size * 0.4)),
lineHeight: 1,
flexShrink: 0,
};
return (
<div className={className} style={style} aria-hidden="true">
{info.src ? (
<img
src={info.src}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
referrerPolicy="no-referrer"
/>
) : (
<span>{info.initials}</span>
)}
</div>
);
}
+217
View File
@@ -0,0 +1,217 @@
import { useEffect, useRef, useState } from 'react';
import { browserClient } from '@/lib/supabase';
import { avatarInfo } from '@/lib/avatar';
import { toast, toastAfterReload } from '@/lib/toast';
import Avatar from './Avatar';
type Maestro = { id: number; nombre: string };
type Props = {
userId: string;
email: string;
nombre: string | null;
initialProfile: {
matricula: string | null;
semestre: string | null;
tutor_id: number | null;
foto_path: string | null;
};
maestros: Maestro[];
googleAvatarUrl?: string | null;
mode?: 'edit' | 'onboarding';
};
const BUCKET = 'avatares';
const SEM_OPTS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'];
export default function PerfilForm({
userId,
email,
nombre,
initialProfile,
maestros,
googleAvatarUrl,
mode = 'edit',
}: Props) {
const [matricula, setMatricula] = useState(initialProfile.matricula ?? '');
const [semestre, setSemestre] = useState(initialProfile.semestre ?? '');
const [tutorId, setTutorId] = useState<string>(
initialProfile.tutor_id != null ? String(initialProfile.tutor_id) : '',
);
const [fotoPath, setFotoPath] = useState<string | null>(initialProfile.foto_path);
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (!file) {
setPreviewUrl(null);
return;
}
const url = URL.createObjectURL(file);
setPreviewUrl(url);
return () => URL.revokeObjectURL(url);
}, [file]);
const info = avatarInfo({
email,
nombre,
fotoPath,
googleAvatarUrl,
});
const previewInfo = previewUrl
? { ...info, src: previewUrl }
: info;
async function uploadAvatar(): Promise<string | null> {
if (!file) return null;
const ext = (file.name.split('.').pop() ?? 'jpg').toLowerCase().replace(/[^a-z0-9]/g, '') || 'jpg';
const path = `${userId}/${Date.now()}.${ext}`;
const { error: upErr } = await browserClient()
.storage.from(BUCKET)
.upload(path, file, { upsert: true, contentType: file.type || undefined });
if (upErr) {
toast({ kind: 'error', title: 'La foto no se subió', description: upErr.message });
return null;
}
return path;
}
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (loading) return;
setLoading(true);
setError(null);
try {
let nuevoPath: string | null | undefined = undefined;
if (file) {
nuevoPath = await uploadAvatar();
}
const body: Record<string, string | number | null> = {
matricula: matricula.trim() || null,
semestre: semestre || null,
tutor_id: tutorId ? Number(tutorId) : null,
};
if (nuevoPath !== undefined) body.foto_path = nuevoPath;
const res = await fetch('/api/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json?.error ?? 'no se pudo guardar');
if (mode === 'onboarding') {
toastAfterReload({ kind: 'success', title: 'Perfil guardado', description: 'Ya puedes crear vales.' });
window.location.href = '/';
} else {
toastAfterReload({ kind: 'success', title: 'Perfil actualizado' });
window.location.reload();
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Error inesperado');
setLoading(false);
}
};
return (
<form onSubmit={submit} className="flex flex-col gap-5" autoComplete="off">
<div>
<label className="label" htmlFor="p-foto">Foto</label>
<div className="flex items-center gap-3">
<Avatar info={previewInfo} size={72} />
<div className="flex-1 flex flex-col gap-2">
<input
ref={fileInputRef}
id="p-foto"
className="input"
type="file"
accept="image/*"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
{(file || fotoPath) && (
<button
type="button"
className="btn btn-ghost self-start"
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
onClick={() => {
setFile(null);
if (fileInputRef.current) fileInputRef.current.value = '';
setFotoPath(null);
}}
>
Quitar foto
</button>
)}
</div>
</div>
{!file && !fotoPath && googleAvatarUrl && (
<p className="text-xs opacity-70 mt-2">Mostrando tu foto de Google. Sube una para reemplazarla.</p>
)}
</div>
<div>
<label className="label" htmlFor="p-mat">Matrícula</label>
<input
id="p-mat"
className="input"
type="text"
value={matricula}
onChange={(e) => setMatricula(e.target.value)}
placeholder="123456"
autoComplete="off"
maxLength={15}
/>
</div>
<div>
<label className="label" htmlFor="p-sem">Semestre</label>
<select
id="p-sem"
className="input"
value={semestre}
onChange={(e) => setSemestre(e.target.value)}
>
<option value=""></option>
{SEM_OPTS.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
<div>
<label className="label" htmlFor="p-tutor">Tutor</label>
<select
id="p-tutor"
className="input"
value={tutorId}
onChange={(e) => setTutorId(e.target.value)}
>
<option value=""></option>
{maestros.map((m) => (
<option key={m.id} value={String(m.id)}>{m.nombre}</option>
))}
</select>
<p className="text-xs opacity-70 mt-1">Se rellenará por defecto al crear vales.</p>
</div>
{error && (
<p role="alert" className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
{error}
</p>
)}
<div className="flex gap-2 justify-end pt-2 flex-wrap">
{mode === 'onboarding' && (
<a href="/" className="btn btn-ghost">Omitir por ahora</a>
)}
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? 'Guardando…' : mode === 'onboarding' ? 'Guardar y continuar' : 'Guardar cambios'}
</button>
</div>
</form>
);
}
+3
View File
@@ -8,6 +8,9 @@ export type Profile = {
nombre: string | null; nombre: string | null;
matricula: string | null; matricula: string | null;
rol: 'alumno' | 'admin'; rol: 'alumno' | 'admin';
semestre: string | null;
tutor_id: number | null;
foto_path: string | null;
}; };
declare global { declare global {
+47 -8
View File
@@ -3,15 +3,35 @@ import Layout from './Layout.astro';
import Toaster from '@/components/Toaster.tsx'; import Toaster from '@/components/Toaster.tsx';
import BadgeSolicitudes from '@/components/admin/BadgeSolicitudes.tsx'; import BadgeSolicitudes from '@/components/admin/BadgeSolicitudes.tsx';
import BrandMark from '@/components/BrandMark.astro'; import BrandMark from '@/components/BrandMark.astro';
import Avatar from '@/components/profile/Avatar.tsx';
import { avatarInfo } from '@/lib/avatar';
interface Props { interface Props {
title?: string; title?: string;
} }
const { title } = Astro.props; const { title } = Astro.props;
const user = Astro.locals.user;
const profile = Astro.locals.profile; const profile = Astro.locals.profile;
const isAdmin = profile?.rol === 'admin'; const isAdmin = profile?.rol === 'admin';
const googleAvatarUrl = user
? ((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
: null;
const avatar = profile
? avatarInfo({
email: profile.email,
nombre: profile.nombre,
fotoPath: profile.foto_path,
googleAvatarUrl,
})
: null;
const perfilIncompleto = profile?.rol === 'alumno' && (!profile.matricula || !profile.tutor_id);
let pendientesCount = 0; let pendientesCount = 0;
if (isAdmin) { if (isAdmin) {
const { count } = await Astro.locals.supabase const { count } = await Astro.locals.supabase
@@ -112,11 +132,24 @@ const activeCheck = (item: NavItem) => {
))} ))}
</nav> </nav>
<div class="p-3" style="border-top: 2px solid var(--color-ink);"> <div class="p-3" style="border-top: 2px solid var(--color-ink);">
<div class="px-3 py-2 text-sm"> <a href="/perfil" class="flex items-center gap-3 px-2 py-2 rounded-[2px] hover:bg-[color:var(--color-chalk)] transition-colors">
<div class="font-medium truncate">{profile?.nombre ?? profile?.email}</div> {avatar && <Avatar client:load info={avatar} size={36} />}
<div class="text-xs uppercase tracking-wide" style="color: var(--color-pencil);">{profile?.rol}</div> <div class="min-w-0 flex-1">
<div class="text-sm font-medium truncate">{profile?.nombre ?? profile?.email}</div>
<div class="text-xs uppercase tracking-wide flex items-center gap-1.5" style="color: var(--color-pencil);">
<span>Ver perfil</span>
{perfilIncompleto && (
<span
aria-label="Perfil incompleto"
title="Perfil incompleto"
class="inline-block w-2 h-2 rounded-full motion-safe:animate-pulse"
style="background: var(--color-danger);"
></span>
)}
</div> </div>
<form method="POST" action="/api/auth/signout"> </div>
</a>
<form method="POST" action="/api/auth/signout" class="mt-1">
<button type="submit" class="w-full text-left px-3 py-2 rounded-[2px] text-sm uppercase tracking-wide hover:bg-[color:var(--color-chalk)] transition-colors flex items-center gap-2"> <button type="submit" class="w-full text-left px-3 py-2 rounded-[2px] text-sm uppercase tracking-wide hover:bg-[color:var(--color-chalk)] transition-colors flex items-center gap-2">
<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-hidden="true"> <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-hidden="true">
<path d={ICONS.logout} /> <path d={ICONS.logout} />
@@ -135,10 +168,16 @@ const activeCheck = (item: NavItem) => {
</div> </div>
<span class="font-semibold text-sm uppercase tracking-wide">LabPréstamos</span> <span class="font-semibold text-sm uppercase tracking-wide">LabPréstamos</span>
</a> </a>
<div class="text-xs truncate max-w-[45%] text-right"> <a href="/perfil" class="relative flex items-center gap-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--color-ink)] rounded-[2px]" aria-label="Mi perfil">
<div class="font-medium truncate">{profile?.nombre ?? profile?.email}</div> {avatar && <Avatar client:load info={avatar} size={36} />}
<div class="uppercase tracking-wide" style="color: var(--color-pencil);">{profile?.rol}</div> {perfilIncompleto && (
</div> <span
aria-hidden="true"
class="absolute -top-0.5 -right-0.5 w-2.5 h-2.5 rounded-full motion-safe:animate-pulse"
style="background: var(--color-danger); border: 1.5px solid var(--color-ink);"
></span>
)}
</a>
</header> </header>
<main id="main" class="flex-1 p-4 md:p-8 pb-24 md:pb-8 md:ml-64 sidebar-aware transition-[margin] duration-200 ease-out"> <main id="main" class="flex-1 p-4 md:p-8 pb-24 md:pb-8 md:ml-64 sidebar-aware transition-[margin] duration-200 ease-out">
+54
View File
@@ -0,0 +1,54 @@
// Helper puro para resolver avatar del usuario: URL, iniciales y color de fondo.
export type AvatarInfo = {
src?: string | null;
initials: string;
hueColor: string;
};
function supabaseBase(): string | undefined {
return (
(import.meta.env.PUBLIC_SUPABASE_URL as string | undefined) ??
(process.env.PUBLIC_SUPABASE_URL as string | undefined)
);
}
function fotoUrl(path: string): string | null {
const base = supabaseBase();
if (!base) return null;
return `${base}/storage/v1/object/public/avatares/${path}`;
}
function initialsFrom(nombre: string | null | undefined, email: string): string {
const src = (nombre ?? '').trim();
if (src) {
const parts = src.split(/\s+/).filter(Boolean);
const letters = parts.slice(0, 2).map((p) => p[0]!.toUpperCase());
if (letters.length) return letters.join('');
}
const local = (email.split('@')[0] ?? '').trim();
return (local[0] ?? '?').toUpperCase();
}
function hashHue(seed: string): number {
let h = 0;
for (let i = 0; i < seed.length; i++) {
h = (h * 31 + seed.charCodeAt(i)) >>> 0;
}
return h % 360;
}
export function avatarInfo(args: {
email: string;
nombre?: string | null;
fotoPath?: string | null;
googleAvatarUrl?: string | null;
}): AvatarInfo {
const src = args.fotoPath
? fotoUrl(args.fotoPath)
: (args.googleAvatarUrl ?? undefined);
return {
src: src ?? undefined,
initials: initialsFrom(args.nombre, args.email),
hueColor: `hsl(${hashHue(args.email.toLowerCase())}, 60%, 55%)`,
};
}
+37
View File
@@ -13,6 +13,43 @@ function tzOffset(date: Date, tz: string): string {
return m?.[1] ?? '+00:00'; return m?.[1] ?? '+00:00';
} }
function hourMX(now: Date = new Date()): number {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: MX_TZ,
hour: '2-digit',
hour12: false,
}).formatToParts(now);
const h = parseInt(parts.find((p) => p.type === 'hour')?.value ?? '0', 10);
return h % 24; // "24" en algunos runtimes al filo de medianoche.
}
export function saludoHora(
now: Date = new Date()
): 'Buenos días' | 'Buenas tardes' | 'Buenas noches' {
const h = hourMX(now);
if (h >= 5 && h < 12) return 'Buenos días';
if (h >= 12 && h < 19) return 'Buenas tardes';
return 'Buenas noches';
}
// "Hoy a las 15:32" / "Ayer a las 10:00" / "Hace 3 días" / fecha absoluta.
export function fmtFechaRelativa(input: string | Date, now: Date = new Date()): string {
const d = typeof input === 'string' ? new Date(input) : input;
const hoy = todayMX(now);
const suDia = todayMX(d);
const diffDays = Math.round((hoy.startUTC.getTime() - suDia.startUTC.getTime()) / 86400000);
const hora = new Intl.DateTimeFormat('es-MX', {
timeZone: MX_TZ,
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(d);
if (diffDays === 0) return `Hoy a las ${hora}`;
if (diffDays === 1) return `Ayer a las ${hora}`;
if (diffDays > 1 && diffDays < 7) return `Hace ${diffDays} días`;
return new Intl.DateTimeFormat('es-MX', { dateStyle: 'medium', timeZone: MX_TZ }).format(d);
}
export function todayMX(now: Date = new Date()): { export function todayMX(now: Date = new Date()): {
label: string; label: string;
startUTC: Date; startUTC: Date;
+2 -2
View File
@@ -43,8 +43,8 @@ export function serverClient(cookies: AstroCookies) {
export function browserClient() { export function browserClient() {
return createBrowserClient( return createBrowserClient(
import.meta.env.PUBLIC_SUPABASE_URL, SUPABASE_URL,
import.meta.env.PUBLIC_SUPABASE_ANON_KEY, SUPABASE_ANON_KEY,
{ db: { schema: SCHEMA }, cookieOptions }, { db: { schema: SCHEMA }, cookieOptions },
); );
} }
+1 -1
View File
@@ -21,7 +21,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
if (user) { if (user) {
const { data: profile } = await supabase const { data: profile } = await supabase
.from('profiles') .from('profiles')
.select('id, email, nombre, matricula, rol') .select('id, email, nombre, matricula, rol, semestre, tutor_id, foto_path')
.eq('id', user.id) .eq('id', user.id)
.maybeSingle(); .maybeSingle();
context.locals.profile = profile ?? null; context.locals.profile = profile ?? null;
+131 -39
View File
@@ -1,32 +1,52 @@
--- ---
import AppLayout from '@/layouts/AppLayout.astro'; import AppLayout from '@/layouts/AppLayout.astro';
import { todayMX } from '@/lib/date'; import { todayMX, saludoHora, fmtFechaRelativa } from '@/lib/date';
const supabase = Astro.locals.supabase; const supabase = Astro.locals.supabase;
const profile = Astro.locals.profile;
const dia = todayMX(); 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 }).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 supabase
.from('solicitudes') .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 }) .order('fecha_solicitud', { ascending: false })
.limit(5), .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 = [ const estadoLabel: Record<Estado, string> = {
{ 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> = {
pendiente: 'Pendiente', pendiente: 'Pendiente',
aprobado: 'En préstamo', aprobado: 'En préstamo',
activo: 'En préstamo', activo: 'En préstamo',
@@ -34,35 +54,87 @@ const estadoLabel: Record<string, string> = {
rechazado: 'Rechazado', rechazado: 'Rechazado',
vencido: 'Vencido', 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"> <AppLayout title="Panel — LabPréstamos">
<div class="max-w-6xl"> <div class="max-w-5xl mx-auto">
<header class="mb-6"> <header class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
<h1 class="text-2xl md:text-3xl font-semibold">Panel</h1> <h1 class="font-mono uppercase text-3xl md:text-5xl font-semibold leading-tight" style="letter-spacing: -0.01em;">
<p class="text-sm mt-1" style="color: var(--color-pencil);">Resumen del laboratorio.</p> {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> </header>
<section aria-label="Indicadores" class="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-4 mb-8"> <section aria-labelledby="atencion-h" class="mb-10">
{kpis.map((k) => ( <div class="flex items-baseline justify-between mb-3">
<a href={k.href} class="card-link"> <h2 id="atencion-h" class="text-lg font-semibold uppercase tracking-wide">Requieren tu atención</h2>
<div {pendientesData.length > 0 && (
class="text-4xl font-semibold leading-none" <a href="/admin/solicitudes" class="text-sm underline decoration-[color:var(--color-ink)] underline-offset-4">Ver todas</a>
style={`font-variant-numeric: tabular-nums; color: var(--color-${k.tone === 'ink' ? 'ink' : k.tone});`} )}
> </div>
{k.value} {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> </div>
<div class="mt-2 text-sm uppercase tracking-wide" style="color: var(--color-pencil);">{k.label}</div>
</a> </a>
))} ))}
</div>
)}
</section> </section>
<section aria-labelledby="ultimas-h"> <section aria-labelledby="ultimas-h">
<div class="flex items-baseline justify-between mb-3"> <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> <h2 id="ultimas-h" class="text-lg font-semibold uppercase tracking-wide">Actividad reciente</h2>
<a href="/admin/solicitudes" class="text-sm underline decoration-[color:var(--color-ink)] underline-offset-4">Ver todas</a> <a href="/admin/solicitudes/historial" class="text-sm underline decoration-[color:var(--color-ink)] underline-offset-4">Historial</a>
</div> </div>
<div class="card-flat p-0 overflow-hidden">
{ultimas.data && ultimas.data.length > 0 ? ( {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"> <table class="w-full text-sm">
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);"> <thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
<tr> <tr>
@@ -73,26 +145,46 @@ const estadoLabel: Record<string, string> = {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{ultimas.data.map((r: any) => ( {ultimasData.map((r) => (
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);"> <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">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</td>
<td class="p-3"> <td class="p-3">
<div class="flex flex-col gap-0.5"> <div class="flex flex-col gap-0.5">
{(r.items ?? []).map((i: any) => ( {(r.items ?? []).map((i) => (
<div>{i.cantidad}× {i.material?.nombre ?? '—'}</div> <div>{i.cantidad}× {i.material?.nombre ?? '—'}</div>
))} ))}
</div> </div>
</td> </td>
<td class="p-3">{estadoLabel[r.estado] ?? r.estado}</td> <td class="p-3">
<td class="p-3 opacity-80">{fmtFecha.format(new Date(r.fecha_solicitud))}</td> <span class="badge" style={badgeStyle(r.estado)}>{estadoLabel[r.estado]}</span>
</td>
<td class="p-3 opacity-80">{fmtFechaRelativa(r.fecha_solicitud)}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
) : (
<p class="p-6 opacity-70 text-sm">Aún no hay solicitudes registradas.</p>
)}
</div> </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> </section>
</div> </div>
</AppLayout> </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);"> <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 Categorías
</a> </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> </nav>
{error && ( {error && (
+3 -2
View File
@@ -181,9 +181,10 @@ const estadoBadge = (e: Material['estado']) => {
{m.numero_inventario && ( {m.numero_inventario && (
<p class="text-xs opacity-60 font-mono truncate">{m.numero_inventario}</p> <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 <MaterialForm
mode="edit" mode="edit"
compact
material={{ material={{
id: m.id, id: m.id,
nombre: m.nombre, nombre: m.nombre,
@@ -199,7 +200,7 @@ const estadoBadge = (e: Material['estado']) => {
categorias={categorias} categorias={categorias}
client:load client:load
/> />
<EliminarMaterial id={m.id} nombre={m.nombre} client:load /> <EliminarMaterial id={m.id} nombre={m.nombre} compact client:load />
</div> </div>
</div> </div>
</article> </article>
+119
View File
@@ -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>
+24 -1
View File
@@ -6,6 +6,29 @@ import BuscadorCatalogo from '@/components/alumno/BuscadorCatalogo.tsx';
const qInitial = Astro.url.searchParams.get('q') ?? ''; 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 = { type Material = {
id: number; id: number;
nombre: string; nombre: string;
@@ -72,7 +95,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} client:load /> <CartProvider materiales={materiales} tutorNombre={tutorNombre} perfilCompleto={perfilCompleto} client:load />
</> </>
)} )}
</div> </div>
+75
View File
@@ -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 });
};
+37
View File
@@ -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 });
};
+84
View File
@@ -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: 515 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 });
};
+92 -31
View File
@@ -1,45 +1,106 @@
--- ---
import AppLayout from '@/layouts/AppLayout.astro'; import AppLayout from '@/layouts/AppLayout.astro';
import { saludoHora, fmtFechaRelativa } from '@/lib/date';
const profile = Astro.locals.profile; 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"> <AppLayout title="Inicio — LabPréstamos">
<div class="max-w-3xl"> <div class="max-w-2xl mx-auto">
<header class="mb-8"> <header class="mb-8">
<p class="text-sm" style="color: var(--color-pencil);">Bienvenido{nombre ? ',' : ''}</p> <h1 class="font-mono uppercase text-3xl md:text-5xl font-semibold leading-tight" style="letter-spacing: -0.01em;">
<h1 class="text-2xl md:text-3xl font-semibold">{nombre || 'Laboratorio de Sistemas'}</h1> {saludo}, {nombreCorto}
</h1>
</header> </header>
<div class="grid gap-5 md:grid-cols-2"> <section class="card-raised mb-8">
{isAdmin ? ( <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="/admin/solicitudes" class="card-link"> <a href="/alumno/catalogo" class="btn btn-primary">Ir al catálogo</a>
<h2 class="font-semibold text-lg mb-1 uppercase tracking-wide">Solicitudes</h2> </section>
<p class="text-sm" style="color: var(--color-pencil);">Aprobar o rechazar peticiones de material.</p>
</a> {actual ? (
<a href="/admin/inventario" class="card-link"> <section aria-labelledby="actual-h">
<h2 class="font-semibold text-lg mb-1 uppercase tracking-wide">Inventario</h2> <div class="flex items-center justify-between gap-3 mb-3">
<p class="text-sm" style="color: var(--color-pencil);">Materiales y categorías del laboratorio.</p> <h2 id="actual-h" class="text-lg font-semibold uppercase tracking-wide">Tu préstamo actual</h2>
</a> <span class="badge" style={badgeStyle(actual.estado)}>{estadoLabel[actual.estado]}</span>
<a href="/admin/reportes" class="card-link md:col-span-2"> </div>
<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"> <a href="/alumno/mis-prestamos" class="card-link">
<h2 class="font-semibold text-lg mb-1 uppercase tracking-wide">Mis préstamos</h2> <ul class="space-y-1 mb-4">
<p class="text-sm" style="color: var(--color-pencil);">Estado de tus solicitudes actuales e historial.</p> {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> </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> </div>
</div>
</AppLayout> </AppLayout>
+57
View File
@@ -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>
+62
View File
@@ -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>