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