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