Files
labre-web/src/components/profile/PerfilForm.tsx
T
LakG e159e8d297 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)
2026-08-24 08:46:39 -07:00

218 lines
6.7 KiB
TypeScript

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>
);
}