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
+54
View File
@@ -0,0 +1,54 @@
// Helper puro para resolver avatar del usuario: URL, iniciales y color de fondo.
export type AvatarInfo = {
src?: string | null;
initials: string;
hueColor: string;
};
function supabaseBase(): string | undefined {
return (
(import.meta.env.PUBLIC_SUPABASE_URL as string | undefined) ??
(process.env.PUBLIC_SUPABASE_URL as string | undefined)
);
}
function fotoUrl(path: string): string | null {
const base = supabaseBase();
if (!base) return null;
return `${base}/storage/v1/object/public/avatares/${path}`;
}
function initialsFrom(nombre: string | null | undefined, email: string): string {
const src = (nombre ?? '').trim();
if (src) {
const parts = src.split(/\s+/).filter(Boolean);
const letters = parts.slice(0, 2).map((p) => p[0]!.toUpperCase());
if (letters.length) return letters.join('');
}
const local = (email.split('@')[0] ?? '').trim();
return (local[0] ?? '?').toUpperCase();
}
function hashHue(seed: string): number {
let h = 0;
for (let i = 0; i < seed.length; i++) {
h = (h * 31 + seed.charCodeAt(i)) >>> 0;
}
return h % 360;
}
export function avatarInfo(args: {
email: string;
nombre?: string | null;
fotoPath?: string | null;
googleAvatarUrl?: string | null;
}): AvatarInfo {
const src = args.fotoPath
? fotoUrl(args.fotoPath)
: (args.googleAvatarUrl ?? undefined);
return {
src: src ?? undefined,
initials: initialsFrom(args.nombre, args.email),
hueColor: `hsl(${hashHue(args.email.toLowerCase())}, 60%, 55%)`,
};
}
+37
View File
@@ -13,6 +13,43 @@ function tzOffset(date: Date, tz: string): string {
return m?.[1] ?? '+00:00';
}
function hourMX(now: Date = new Date()): number {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: MX_TZ,
hour: '2-digit',
hour12: false,
}).formatToParts(now);
const h = parseInt(parts.find((p) => p.type === 'hour')?.value ?? '0', 10);
return h % 24; // "24" en algunos runtimes al filo de medianoche.
}
export function saludoHora(
now: Date = new Date()
): 'Buenos días' | 'Buenas tardes' | 'Buenas noches' {
const h = hourMX(now);
if (h >= 5 && h < 12) return 'Buenos días';
if (h >= 12 && h < 19) return 'Buenas tardes';
return 'Buenas noches';
}
// "Hoy a las 15:32" / "Ayer a las 10:00" / "Hace 3 días" / fecha absoluta.
export function fmtFechaRelativa(input: string | Date, now: Date = new Date()): string {
const d = typeof input === 'string' ? new Date(input) : input;
const hoy = todayMX(now);
const suDia = todayMX(d);
const diffDays = Math.round((hoy.startUTC.getTime() - suDia.startUTC.getTime()) / 86400000);
const hora = new Intl.DateTimeFormat('es-MX', {
timeZone: MX_TZ,
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(d);
if (diffDays === 0) return `Hoy a las ${hora}`;
if (diffDays === 1) return `Ayer a las ${hora}`;
if (diffDays > 1 && diffDays < 7) return `Hace ${diffDays} días`;
return new Intl.DateTimeFormat('es-MX', { dateStyle: 'medium', timeZone: MX_TZ }).format(d);
}
export function todayMX(now: Date = new Date()): {
label: string;
startUTC: Date;
+2 -2
View File
@@ -43,8 +43,8 @@ export function serverClient(cookies: AstroCookies) {
export function browserClient() {
return createBrowserClient(
import.meta.env.PUBLIC_SUPABASE_URL,
import.meta.env.PUBLIC_SUPABASE_ANON_KEY,
SUPABASE_URL,
SUPABASE_ANON_KEY,
{ db: { schema: SCHEMA }, cookieOptions },
);
}