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
+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>