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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user