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
+16 -4
View File
@@ -13,6 +13,7 @@ import {
Tooltip,
ResponsiveContainer,
LabelList,
Legend,
} from 'recharts';
type Barras = { tipo: 'barras'; datos: { label: string; valor: number }[]; alto?: number };
@@ -108,10 +109,11 @@ export default function Chart(props: Props) {
<YAxis
type="category"
dataKey="label"
width={140}
width={100}
stroke={INK}
tick={{ ...MONO, fontSize: 10 } as any}
interval={0}
tickFormatter={(v: string) => (v.length > 14 ? v.slice(0, 13) + '…' : v)}
/>
<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">
@@ -148,7 +150,6 @@ export default function Chart(props: Props) {
// donut
const datos = props.datos;
const total = datos.reduce((s, d) => s + d.valor, 0);
return (
<ResponsiveContainer width="100%" height={alto}>
<PieChart margin={{ top: 8, right: 8, left: 8, bottom: 8 }}>
@@ -163,13 +164,24 @@ export default function Chart(props: Props) {
strokeWidth={1.5}
isAnimationActive={!reduced}
animationDuration={anim}
label={({ label, valor }: any) => (total ? `${label} (${valor})` : label)}
labelLine={{ stroke: PENCIL }}
label={false}
labelLine={false}
>
{datos.map((d, i) => (
<Cell key={d.label} fill={donutColor(d.label, i)} />
))}
</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>
</ResponsiveContainer>
);
@@ -4,9 +4,10 @@ type Props = {
id: number;
nombre: string;
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 [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -53,7 +54,10 @@ export default function EliminarMaterial({ id, nombre, tienePrestamos }: Props)
<>
<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' }}
onClick={open}
>
@@ -22,11 +22,12 @@ type Props = {
mode: 'create' | 'edit';
material?: Material;
categorias: Categoria[];
compact?: boolean;
};
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 firstFieldRef = useRef<HTMLInputElement | 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 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);
return (
<>
<button type="button" className={triggerClass} onClick={open}>
<button type="button" className={triggerClass} style={triggerStyle} onClick={open}>
{triggerLabel}
</button>
@@ -114,86 +114,65 @@ export default function UnidadesManager({ materialId }: { materialId: number })
) : unidades.length === 0 ? (
<p className="text-sm opacity-70">Sin unidades registradas.</p>
) : (
<div className="overflow-x-auto">
<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) => {
const bloqueada = u.estado === 'prestado';
return (
<tr
key={u.id}
className="border-t"
style={{ borderColor: 'color-mix(in oklab, var(--color-ink) 12%, transparent)' }}
>
<td className="px-2 py-2">
<input
type="text"
className="input"
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
defaultValue={u.etiqueta}
onBlur={(e) => {
const v = e.target.value.trim();
if (v && v !== u.etiqueta) patch(u, { etiqueta: v });
else e.target.value = u.etiqueta;
}}
/>
</td>
<td className="px-2 py-2">
<select
className="input"
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
value={bloqueada ? 'prestado' : u.estado}
disabled={bloqueada}
onChange={(e) => patch(u, { estado: e.target.value as Unidad['estado'] })}
>
{bloqueada && <option value="prestado">Prestado</option>}
{ESTADOS.map((s) => (
<option key={s} value={s}>
{s[0].toUpperCase() + s.slice(1)}
</option>
))}
</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
type="button"
className="btn btn-ghost"
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
disabled={bloqueada}
onClick={() => eliminar(u)}
>
Eliminar
</button>
</td>
</tr>
);
})}
</tbody>
</table>
<div className="flex flex-col gap-2">
{unidades.map((u) => {
const bloqueada = u.estado === 'prestado';
return (
<div
key={u.id}
className="card p-3 grid grid-cols-1 sm:grid-cols-[1fr_auto_auto] gap-2 items-center"
>
<input
type="text"
className="input"
aria-label="Etiqueta"
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
defaultValue={u.etiqueta}
onBlur={(e) => {
const v = e.target.value.trim();
if (v && v !== u.etiqueta) patch(u, { etiqueta: v });
else e.target.value = u.etiqueta;
}}
/>
<select
className="input"
aria-label="Estado"
style={{ minHeight: '36px', paddingBlock: '0.25rem', minWidth: '130px' }}
value={bloqueada ? 'prestado' : u.estado}
disabled={bloqueada}
onChange={(e) => patch(u, { estado: e.target.value as Unidad['estado'] })}
>
{bloqueada && <option value="prestado">Prestado</option>}
{ESTADOS.map((s) => (
<option key={s} value={s}>
{s[0].toUpperCase() + s.slice(1)}
</option>
))}
</select>
<button
type="button"
className="btn btn-ghost"
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
disabled={bloqueada}
onClick={() => eliminar(u)}
>
Eliminar
</button>
<input
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>
);
})}
</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>
</>
);
}
+44 -15
View File
@@ -39,10 +39,18 @@ export function useCart() {
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 dialogRef = useRef<HTMLDialogElement | null>(null);
const firstFieldRef = useRef<HTMLInputElement | null>(null);
const firstFieldRef = useRef<HTMLTextAreaElement | null>(null);
const [maestroResponsable, setMaestroResponsable] = useState('');
const [notas, setNotas] = useState('');
const [loading, setLoading] = useState(false);
@@ -129,7 +137,7 @@ export default function CartProvider({ materiales }: { materiales: Material[] })
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (loading || items.length === 0) return;
if (loading || items.length === 0 || !perfilCompleto) return;
setLoading(true);
setError(null);
try {
@@ -151,10 +159,13 @@ export default function CartProvider({ materiales }: { materiales: Material[] })
throw new Error(json?.error ?? 'No se pudo enviar la solicitud');
}
setOk(true);
const usedFallback = maestroResponsable.trim() === '' && !!tutorNombre;
toastAfterReload({
kind: 'success',
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(() => {
dialogRef.current?.close();
@@ -308,16 +319,27 @@ export default function CartProvider({ materiales }: { materiales: Material[] })
<div>
<label className="label" htmlFor="maestro-responsable">Maestro responsable</label>
<input
<textarea
ref={firstFieldRef}
id="maestro-responsable"
className="input"
type="text"
required
rows={2}
maxLength={200}
value={maestroResponsable}
onChange={(e) => setMaestroResponsable(e.target.value)}
placeholder={tutorNombre ?? 'Escribe el nombre del maestro responsable'}
aria-describedby="maestro-hint"
style={{ minHeight: '64px' }}
/>
{tutorNombre ? (
<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>
@@ -349,14 +371,21 @@ export default function CartProvider({ materiales }: { materiales: Material[] })
</p>
)}
<div className="flex gap-2 justify-end pt-2">
<button type="button" className="btn btn-ghost" onClick={closeCheckout} disabled={loading}>
Cancelar
</button>
<button type="submit" className="btn btn-primary" disabled={loading || ok || items.length === 0}>
{loading ? 'Enviando…' : 'Enviar solicitud'}
</button>
</div>
{!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">
<button type="button" className="btn btn-ghost" onClick={closeCheckout} disabled={loading}>
Cancelar
</button>
<button type="submit" className="btn btn-primary" disabled={loading || ok || items.length === 0}>
{loading ? 'Enviando…' : 'Enviar solicitud'}
</button>
</div>
)}
</form>
</dialog>
</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>
);
}