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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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