diff --git a/Dockerfile b/Dockerfile index c1629dd..40436a1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,12 @@ RUN npm ci FROM node:22-alpine AS build WORKDIR /app +ARG PUBLIC_SUPABASE_URL +ARG PUBLIC_SUPABASE_ANON_KEY +ARG PUBLIC_APP_URL +ENV PUBLIC_SUPABASE_URL=$PUBLIC_SUPABASE_URL \ + PUBLIC_SUPABASE_ANON_KEY=$PUBLIC_SUPABASE_ANON_KEY \ + PUBLIC_APP_URL=$PUBLIC_APP_URL COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build diff --git a/docker-compose.yml b/docker-compose.yml index a0d5b56..74b78d6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,11 @@ services: labre-web: - build: . + build: + context: . + args: + PUBLIC_SUPABASE_URL: ${PUBLIC_SUPABASE_URL} + PUBLIC_SUPABASE_ANON_KEY: ${PUBLIC_SUPABASE_ANON_KEY} + PUBLIC_APP_URL: ${PUBLIC_APP_URL} container_name: labre-web restart: unless-stopped env_file: .env.production diff --git a/src/components/admin/estadisticas/Chart.tsx b/src/components/admin/estadisticas/Chart.tsx index 9c25dde..f9299a2 100644 --- a/src/components/admin/estadisticas/Chart.tsx +++ b/src/components/admin/estadisticas/Chart.tsx @@ -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) { (v.length > 14 ? v.slice(0, 13) + '…' : v)} /> } cursor={{ fill: 'rgba(56,56,56,0.06)' }} /> @@ -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 ( @@ -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) => ( ))} + { + const v = entry?.payload?.valor; + return v != null ? `${value} (${v})` : value; + }} + /> ); diff --git a/src/components/admin/inventario/EliminarMaterial.tsx b/src/components/admin/inventario/EliminarMaterial.tsx index 6b6934a..3e5335c 100644 --- a/src/components/admin/inventario/EliminarMaterial.tsx +++ b/src/components/admin/inventario/EliminarMaterial.tsx @@ -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(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -53,7 +54,10 @@ export default function EliminarMaterial({ id, nombre, tienePrestamos }: Props) <> diff --git a/src/components/admin/inventario/UnidadesManager.tsx b/src/components/admin/inventario/UnidadesManager.tsx index fe35534..1fae982 100644 --- a/src/components/admin/inventario/UnidadesManager.tsx +++ b/src/components/admin/inventario/UnidadesManager.tsx @@ -114,86 +114,65 @@ export default function UnidadesManager({ materialId }: { materialId: number }) ) : unidades.length === 0 ? (

Sin unidades registradas.

) : ( -
- - - - - - - - - - - {unidades.map((u) => { - const bloqueada = u.estado === 'prestado'; - return ( - - - - - - - ); - })} - -
EtiquetaEstadoNotasAcción
- { - const v = e.target.value.trim(); - if (v && v !== u.etiqueta) patch(u, { etiqueta: v }); - else e.target.value = u.etiqueta; - }} - /> - - - - { - const v = e.target.value; - if ((u.notas ?? '') !== v) patch(u, { notas: v || null }); - }} - /> - - -
+
+ {unidades.map((u) => { + const bloqueada = u.estado === 'prestado'; + return ( +
+ { + const v = e.target.value.trim(); + if (v && v !== u.etiqueta) patch(u, { etiqueta: v }); + else e.target.value = u.etiqueta; + }} + /> + + + { + const v = e.target.value; + if ((u.notas ?? '') !== v) patch(u, { notas: v || null }); + }} + /> +
+ ); + })}
)}
diff --git a/src/components/admin/maestros/EliminarMaestro.tsx b/src/components/admin/maestros/EliminarMaestro.tsx new file mode 100644 index 0000000..6673584 --- /dev/null +++ b/src/components/admin/maestros/EliminarMaestro.tsx @@ -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(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( + <> + + + +
+

+ Eliminar maestro +

+ + {bloqueado ? ( +

+ No se puede eliminar {nombre}: tiene{' '} + {alumnosCount}{' '} + alumno{alumnosCount === 1 ? '' : 's'} como tutor. Reasígnalos primero desde su perfil. +

+ ) : ( +

+ ¿Seguro que quieres eliminar {nombre}? +

+ )} + + {error && ( +

+ {error} +

+ )} + +
+ + {!bloqueado && ( + + )} +
+
+
+ + ); +} diff --git a/src/components/admin/maestros/MaestroForm.tsx b/src/components/admin/maestros/MaestroForm.tsx new file mode 100644 index 0000000..00b4c4a --- /dev/null +++ b/src/components/admin/maestros/MaestroForm.tsx @@ -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(null); + const firstFieldRef = useRef(null); + const errorRef = useRef(null); + const [nombre, setNombre] = useState(maestro?.nombre ?? ''); + const [activo, setActivo] = useState(maestro?.activo ?? true); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( + <> + + + +
+
+

+ {mode === 'create' ? 'Nuevo maestro' : `Editar: ${maestro?.nombre}`} +

+ +
+ +
+ + setNombre(e.target.value)} + required + autoComplete="off" + aria-invalid={error ? true : undefined} + aria-describedby={error ? `${titleId}-err` : undefined} + /> +
+ + + + {error && ( + + )} + +
+ + +
+
+
+ + ); +} diff --git a/src/components/alumno/SolicitudCart.tsx b/src/components/alumno/SolicitudCart.tsx index 2aa200f..df21b37 100644 --- a/src/components/alumno/SolicitudCart.tsx +++ b/src/components/alumno/SolicitudCart.tsx @@ -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>(new Map()); const dialogRef = useRef(null); - const firstFieldRef = useRef(null); + const firstFieldRef = useRef(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[] })
- setMaestroResponsable(e.target.value)} + placeholder={tutorNombre ?? 'Escribe el nombre del maestro responsable'} + aria-describedby="maestro-hint" + style={{ minHeight: '64px' }} /> + {tutorNombre ? ( +

+ Si dejas esto vacío, se usará tu tutor: {tutorNombre}. +

+ ) : ( +

+ Debes escribir un maestro; no tienes tutor guardado. +

+ )}
@@ -349,14 +371,21 @@ export default function CartProvider({ materiales }: { materiales: Material[] })

)} -
- - -
+ {!perfilCompleto ? ( +
+

Necesitas completar tu perfil (matrícula + tutor) para crear vales.

+ Completar perfil +
+ ) : ( +
+ + +
+ )} diff --git a/src/components/profile/Avatar.tsx b/src/components/profile/Avatar.tsx new file mode 100644 index 0000000..bb0aaea --- /dev/null +++ b/src/components/profile/Avatar.tsx @@ -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 ( + + ); +} diff --git a/src/components/profile/PerfilForm.tsx b/src/components/profile/PerfilForm.tsx new file mode 100644 index 0000000..f7eda09 --- /dev/null +++ b/src/components/profile/PerfilForm.tsx @@ -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( + initialProfile.tutor_id != null ? String(initialProfile.tutor_id) : '', + ); + const [fotoPath, setFotoPath] = useState(initialProfile.foto_path); + const [file, setFile] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const fileInputRef = useRef(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 { + 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 = { + 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 ( +
+
+ +
+ +
+ setFile(e.target.files?.[0] ?? null)} + /> + {(file || fotoPath) && ( + + )} +
+
+ {!file && !fotoPath && googleAvatarUrl && ( +

Mostrando tu foto de Google. Sube una para reemplazarla.

+ )} +
+ +
+ + setMatricula(e.target.value)} + placeholder="123456" + autoComplete="off" + maxLength={15} + /> +
+ +
+ + +
+ +
+ + +

Se rellenará por defecto al crear vales.

+
+ + {error && ( +

+ {error} +

+ )} + +
+ {mode === 'onboarding' && ( + Omitir por ahora + )} + +
+
+ ); +} diff --git a/src/env.d.ts b/src/env.d.ts index 63e3eaf..07cd1d7 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -8,6 +8,9 @@ export type Profile = { nombre: string | null; matricula: string | null; rol: 'alumno' | 'admin'; + semestre: string | null; + tutor_id: number | null; + foto_path: string | null; }; declare global { diff --git a/src/layouts/AppLayout.astro b/src/layouts/AppLayout.astro index 9f7eeaf..1ee0b54 100644 --- a/src/layouts/AppLayout.astro +++ b/src/layouts/AppLayout.astro @@ -3,15 +3,35 @@ import Layout from './Layout.astro'; import Toaster from '@/components/Toaster.tsx'; import BadgeSolicitudes from '@/components/admin/BadgeSolicitudes.tsx'; import BrandMark from '@/components/BrandMark.astro'; +import Avatar from '@/components/profile/Avatar.tsx'; +import { avatarInfo } from '@/lib/avatar'; interface Props { title?: string; } const { title } = Astro.props; +const user = Astro.locals.user; const profile = Astro.locals.profile; const isAdmin = profile?.rol === 'admin'; +const googleAvatarUrl = user + ? ((user.user_metadata as Record | undefined)?.avatar_url as string | undefined) || + ((user.user_metadata as Record | undefined)?.picture as string | undefined) || + null + : null; + +const avatar = profile + ? avatarInfo({ + email: profile.email, + nombre: profile.nombre, + fotoPath: profile.foto_path, + googleAvatarUrl, + }) + : null; + +const perfilIncompleto = profile?.rol === 'alumno' && (!profile.matricula || !profile.tutor_id); + let pendientesCount = 0; if (isAdmin) { const { count } = await Astro.locals.supabase @@ -112,11 +132,24 @@ const activeCheck = (item: NavItem) => { ))}
-
-
{profile?.nombre ?? profile?.email}
-
{profile?.rol}
-
-
+ + {avatar && } +
+
{profile?.nombre ?? profile?.email}
+
+ Ver perfil + {perfilIncompleto && ( + + )} +
+
+
+
LabPréstamos -
-
{profile?.nombre ?? profile?.email}
-
{profile?.rol}
-
+ + {avatar && } + {perfilIncompleto && ( + + )} +
diff --git a/src/lib/avatar.ts b/src/lib/avatar.ts new file mode 100644 index 0000000..659b62b --- /dev/null +++ b/src/lib/avatar.ts @@ -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%)`, + }; +} diff --git a/src/lib/date.ts b/src/lib/date.ts index e3a17b2..b59ad57 100644 --- a/src/lib/date.ts +++ b/src/lib/date.ts @@ -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; diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index 2fba5e8..4e4cbe3 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -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 }, ); } diff --git a/src/middleware.ts b/src/middleware.ts index 83a5bf7..d4eff8a 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -21,7 +21,7 @@ export const onRequest = defineMiddleware(async (context, next) => { if (user) { const { data: profile } = await supabase .from('profiles') - .select('id, email, nombre, matricula, rol') + .select('id, email, nombre, matricula, rol, semestre, tutor_id, foto_path') .eq('id', user.id) .maybeSingle(); context.locals.profile = profile ?? null; diff --git a/src/pages/admin/index.astro b/src/pages/admin/index.astro index 6333936..7e20fe3 100644 --- a/src/pages/admin/index.astro +++ b/src/pages/admin/index.astro @@ -1,32 +1,52 @@ --- import AppLayout from '@/layouts/AppLayout.astro'; -import { todayMX } from '@/lib/date'; +import { todayMX, saludoHora, fmtFechaRelativa } from '@/lib/date'; const supabase = Astro.locals.supabase; +const profile = Astro.locals.profile; const dia = todayMX(); +const saludo = saludoHora(); -const [pend, activos, vencidos, agotados, ultimas] = await Promise.all([ +const nombreCorto = + profile?.nombre?.trim().split(/\s+/)[0] ?? + profile?.email?.split('@')[0] ?? + ''; + +type Estado = 'pendiente' | 'aprobado' | 'activo' | 'rechazado' | 'devuelto' | 'vencido'; +type Item = { cantidad: number; material: { nombre: string } | null }; +type Alumno = { nombre: string | null; email: string } | null; +type Sol = { + id: number; + estado: Estado; + fecha_solicitud: string; + alumno: Alumno; + items: Item[]; +}; + +const [pend, vencidos, pendientes2, ultimas] = await Promise.all([ supabase.from('solicitudes').select('*', { count: 'exact', head: true }).eq('estado', 'pendiente'), - supabase.from('solicitudes').select('*', { count: 'exact', head: true }).in('estado', ['aprobado', 'activo']), - supabase.from('solicitudes').select('*', { count: 'exact', head: true }).in('estado', ['aprobado', 'activo']).lt('fecha_devolucion_estimada', dia.isoDate), - supabase.from('materiales').select('*', { count: 'exact', head: true }).eq('cantidad_disponible', 0), supabase .from('solicitudes') - .select('id, fecha_solicitud, estado, alumno:profiles!alumno_id(nombre, email), items:solicitud_items(cantidad, material:materiales(nombre))') + .select('*', { count: 'exact', head: true }) + .in('estado', ['aprobado', 'activo']) + .lt('fecha_devolucion_estimada', dia.isoDate), + supabase + .from('solicitudes') + .select('id, estado, fecha_solicitud, alumno:profiles!alumno_id(nombre, email), items:solicitud_items(cantidad, material:materiales(nombre))') + .eq('estado', 'pendiente') + .order('fecha_solicitud', { ascending: false }) + .limit(2), + supabase + .from('solicitudes') + .select('id, estado, fecha_solicitud, alumno:profiles!alumno_id(nombre, email), items:solicitud_items(cantidad, material:materiales(nombre))') .order('fecha_solicitud', { ascending: false }) .limit(5), ]); -const fmtFecha = new Intl.DateTimeFormat('es-MX', { dateStyle: 'medium', timeStyle: 'short' }); +const pendientesData = (pendientes2.data ?? []) as unknown as Sol[]; +const ultimasData = (ultimas.data ?? []) as unknown as Sol[]; -const kpis = [ - { label: 'Pendientes', value: pend.count ?? 0, href: '/admin/solicitudes', tone: 'primary' }, - { label: 'En préstamo', value: activos.count ?? 0, href: '/admin/solicitudes/activos', tone: 'secondary' }, - { label: 'Vencidos', value: vencidos.count ?? 0, href: '/admin/solicitudes/activos', tone: 'danger' }, - { label: 'Agotados', value: agotados.count ?? 0, href: '/admin/inventario', tone: 'ink' }, -]; - -const estadoLabel: Record = { +const estadoLabel: Record = { pendiente: 'Pendiente', aprobado: 'En préstamo', activo: 'En préstamo', @@ -34,65 +54,137 @@ const estadoLabel: Record = { rechazado: 'Rechazado', vencido: 'Vencido', }; +const estadoColor: Record = { + pendiente: 'var(--color-ink)', + aprobado: 'var(--color-positive)', + activo: 'var(--color-positive)', + devuelto: 'var(--color-ink)', + rechazado: 'var(--color-danger)', + vencido: 'var(--color-danger)', +}; +const badgeStyle = (e: Estado) => + `background: color-mix(in oklab, ${estadoColor[e]} 18%, white); color: var(--color-ink); border: 1.5px solid ${estadoColor[e]};`; + +const resumenItems = (items: Item[]) => { + const preview = items.slice(0, 3).map((i) => `${i.cantidad}× ${i.material?.nombre ?? '—'}`).join(', '); + const extra = items.length - 3; + return extra > 0 ? `${preview} (+${extra})` : preview; +}; + +const kpiPend = pend.count ?? 0; +const kpiVenc = vencidos.count ?? 0; --- -
-
-

Panel

-

Resumen del laboratorio.

+
+
+

+ {saludo}, {nombreCorto} +

+
-
- {kpis.map((k) => ( - -
- {k.value} -
-
{k.label}
-
- ))} +
+
+

Requieren tu atención

+ {pendientesData.length > 0 && ( + Ver todas + )} +
+ {pendientesData.length === 0 ? ( +
+

Todo al día. No hay solicitudes pendientes.

+
+ ) : ( + + )}
-

Últimas 5 solicitudes

- Ver todas +

Actividad reciente

+ Historial
-
- {ultimas.data && ultimas.data.length > 0 ? ( - - - - - - - - - - - {ultimas.data.map((r: any) => ( - - - - - + + {ultimasData.length === 0 ? ( +
+

Aún no hay solicitudes registradas.

+
+ ) : ( + <> +
AlumnoMaterialEstadoSolicitado
{r.alumno?.nombre ?? r.alumno?.email ?? '—'} -
- {(r.items ?? []).map((i: any) => ( -
{i.cantidad}× {i.material?.nombre ?? '—'}
- ))} -
-
{estadoLabel[r.estado] ?? r.estado}{fmtFecha.format(new Date(r.fecha_solicitud))}
+ + + + + + - ))} - -
AlumnoMaterialEstadoSolicitado
- ) : ( -

Aún no hay solicitudes registradas.

- )} -
+ + + {ultimasData.map((r) => ( + + {r.alumno?.nombre ?? r.alumno?.email ?? '—'} + +
+ {(r.items ?? []).map((i) => ( +
{i.cantidad}× {i.material?.nombre ?? '—'}
+ ))} +
+ + + {estadoLabel[r.estado]} + + {fmtFechaRelativa(r.fecha_solicitud)} + + ))} + + +
+ +
+ {ultimasData.map((r) => ( +
+
+
+

{r.alumno?.nombre ?? r.alumno?.email ?? '—'}

+

{fmtFechaRelativa(r.fecha_solicitud)}

+
+ {estadoLabel[r.estado]} +
+
+ {(r.items ?? []).map((i) => ( +
{i.cantidad}× {i.material?.nombre ?? '—'}
+ ))} +
+
+ ))} +
+ + )}
diff --git a/src/pages/admin/inventario/categorias.astro b/src/pages/admin/inventario/categorias.astro index 613fb00..5ee9488 100644 --- a/src/pages/admin/inventario/categorias.astro +++ b/src/pages/admin/inventario/categorias.astro @@ -37,6 +37,9 @@ const categorias = ((data ?? []) as unknown as CategoriaRow[]).map((c) => ({ Categorías + + Maestros + {error && ( diff --git a/src/pages/admin/inventario/index.astro b/src/pages/admin/inventario/index.astro index 6752215..d5fe8d4 100644 --- a/src/pages/admin/inventario/index.astro +++ b/src/pages/admin/inventario/index.astro @@ -181,9 +181,10 @@ const estadoBadge = (e: Material['estado']) => { {m.numero_inventario && (

{m.numero_inventario}

)} -
+
{ categorias={categorias} client:load /> - +
diff --git a/src/pages/admin/maestros.astro b/src/pages/admin/maestros.astro new file mode 100644 index 0000000..66c4f6f --- /dev/null +++ b/src/pages/admin/maestros.astro @@ -0,0 +1,119 @@ +--- +import AppLayout from '@/layouts/AppLayout.astro'; +import MaestroForm from '@/components/admin/maestros/MaestroForm.tsx'; +import EliminarMaestro from '@/components/admin/maestros/EliminarMaestro.tsx'; + +type MaestroRow = { + id: number; + nombre: string; + activo: boolean; + alumnos: { count: number }[]; +}; + +const { data, error } = await Astro.locals.supabase + .from('maestros') + .select('id, nombre, activo, alumnos:profiles!tutor_id(count)') + .order('nombre'); + +const maestros = ((data ?? []) as unknown as MaestroRow[]).map((m) => ({ + id: m.id, + nombre: m.nombre, + activo: m.activo, + count: m.alumnos?.[0]?.count ?? 0, +})); +--- + +
+
+
+

Maestros

+

Catálogo de tutores que los alumnos eligen en su perfil.

+
+ +
+ + + + {error && ( + + )} + + {!error && maestros.length === 0 && ( +
+

Sin maestros aún

+

Agrega el primero para que los alumnos puedan elegirlo como tutor.

+
+ )} + + {!error && maestros.length > 0 && ( + <> + + +
+ {maestros.map((m) => ( +
+
+

{m.nombre}

+

+ {m.activo ? 'Activo' : 'Inactivo'} · {m.count} alumno{m.count === 1 ? '' : 's'} +

+
+
+ + +
+
+ ))} +
+ + )} +
+
diff --git a/src/pages/alumno/catalogo.astro b/src/pages/alumno/catalogo.astro index 09b66bb..5b7f99d 100644 --- a/src/pages/alumno/catalogo.astro +++ b/src/pages/alumno/catalogo.astro @@ -6,6 +6,29 @@ import BuscadorCatalogo from '@/components/alumno/BuscadorCatalogo.tsx'; const qInitial = Astro.url.searchParams.get('q') ?? ''; +const supabase = Astro.locals.supabase; +const userId = Astro.locals.user?.id; + +// Trae matricula + tutor_id directo (el middleware sólo hidrata columnas antiguas). +let matricula: string | null = null; +let tutorId: number | null = null; +if (userId) { + const { data: p } = await supabase + .from('profiles') + .select('matricula, tutor_id') + .eq('id', userId) + .maybeSingle(); + matricula = p?.matricula ?? null; + tutorId = (p as { tutor_id?: number | null } | null)?.tutor_id ?? null; +} + +let tutorNombre: string | null = null; +if (tutorId) { + const { data } = await supabase.from('maestros').select('nombre').eq('id', tutorId).maybeSingle(); + tutorNombre = data?.nombre ?? null; +} +const perfilCompleto = !!(matricula && tutorId); + type Material = { id: number; nombre: string; @@ -72,7 +95,7 @@ const categoriasFiltro = grupoList Ningún material en esta categoría. Prueba con otra.

- + )}
diff --git a/src/pages/api/admin/maestros/[id].ts b/src/pages/api/admin/maestros/[id].ts new file mode 100644 index 0000000..5eda2ac --- /dev/null +++ b/src/pages/api/admin/maestros/[id].ts @@ -0,0 +1,75 @@ +import type { APIRoute } from 'astro'; + +export const prerender = false; + +const parseId = (raw: string | undefined) => { + const id = Number(raw); + return Number.isInteger(id) && id > 0 ? id : null; +}; + +export const PATCH: APIRoute = async ({ request, locals, params }) => { + if (locals.profile?.rol !== 'admin') { + return Response.json({ error: 'No autorizado' }, { status: 403 }); + } + + const id = parseId(params.id); + if (!id) return Response.json({ error: 'ID inválido' }, { status: 400 }); + + let body: { nombre?: unknown; activo?: unknown }; + try { + body = await request.json(); + } catch { + return Response.json({ error: 'JSON inválido' }, { status: 400 }); + } + + const patch: Record = {}; + if (typeof body.nombre === 'string') { + const nombre = body.nombre.trim(); + if (!nombre) return Response.json({ error: 'El nombre no puede estar vacío' }, { status: 400 }); + patch.nombre = nombre; + } + if (typeof body.activo === 'boolean') patch.activo = body.activo; + + if (Object.keys(patch).length === 0) { + return Response.json({ error: 'Nada para actualizar' }, { status: 400 }); + } + + const { error } = await locals.supabase.from('maestros').update(patch).eq('id', id); + + if (error) { + if ((error as { code?: string }).code === '23505') { + return Response.json({ error: 'Ya existe un maestro con ese nombre' }, { status: 409 }); + } + return Response.json({ error: 'No se pudo actualizar' }, { status: 500 }); + } + + return Response.json({ ok: true }); +}; + +export const DELETE: APIRoute = async ({ locals, params }) => { + if (locals.profile?.rol !== 'admin') { + return Response.json({ error: 'No autorizado' }, { status: 403 }); + } + + const id = parseId(params.id); + if (!id) return Response.json({ error: 'ID inválido' }, { status: 400 }); + + const { count } = await locals.supabase + .from('profiles') + .select('id', { count: 'exact', head: true }) + .eq('tutor_id', id); + + const { error } = await locals.supabase.from('maestros').delete().eq('id', id); + + if (error) { + if ((error as { code?: string }).code === '23503') { + return Response.json( + { error: `Tiene ${count ?? 'algún'} alumno${(count ?? 0) === 1 ? '' : 's'} como tutor — reasígnalos primero` }, + { status: 409 }, + ); + } + return Response.json({ error: 'No se pudo eliminar' }, { status: 500 }); + } + + return Response.json({ ok: true }); +}; diff --git a/src/pages/api/admin/maestros/index.ts b/src/pages/api/admin/maestros/index.ts new file mode 100644 index 0000000..ad6b726 --- /dev/null +++ b/src/pages/api/admin/maestros/index.ts @@ -0,0 +1,37 @@ +import type { APIRoute } from 'astro'; + +export const prerender = false; + +export const POST: APIRoute = async ({ request, locals }) => { + if (locals.profile?.rol !== 'admin') { + return Response.json({ error: 'No autorizado' }, { status: 403 }); + } + + let body: { nombre?: unknown; activo?: unknown }; + try { + body = await request.json(); + } catch { + return Response.json({ error: 'JSON inválido' }, { status: 400 }); + } + + const nombre = typeof body.nombre === 'string' ? body.nombre.trim() : ''; + if (!nombre) { + return Response.json({ error: 'El nombre es obligatorio' }, { status: 400 }); + } + const activo = typeof body.activo === 'boolean' ? body.activo : true; + + const { data, error } = await locals.supabase + .from('maestros') + .insert({ nombre, activo }) + .select('id') + .single(); + + if (error) { + if ((error as { code?: string }).code === '23505') { + return Response.json({ error: 'Ya existe un maestro con ese nombre' }, { status: 409 }); + } + return Response.json({ error: 'No se pudo crear el maestro' }, { status: 500 }); + } + + return Response.json({ id: data.id }, { status: 201 }); +}; diff --git a/src/pages/api/profile.ts b/src/pages/api/profile.ts new file mode 100644 index 0000000..2470950 --- /dev/null +++ b/src/pages/api/profile.ts @@ -0,0 +1,84 @@ +import type { APIRoute } from 'astro'; + +export const prerender = false; + +const MAT_RE = /^[A-Za-z0-9]{5,15}$/; +const SEM_VALID = new Set(['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12']); + +type PatchBody = { + matricula?: unknown; + semestre?: unknown; + tutor_id?: unknown; + foto_path?: unknown; +}; + +function cleanStr(v: unknown): string | null | undefined { + if (v === undefined) return undefined; + if (v === null) return null; + if (typeof v !== 'string') return undefined; + const t = v.trim(); + return t === '' ? null : t; +} + +export const PATCH: APIRoute = async ({ request, locals }) => { + const user = locals.user; + if (!user) return Response.json({ error: 'no autorizado' }, { status: 401 }); + + let body: PatchBody; + try { + body = (await request.json()) as PatchBody; + } catch { + return Response.json({ error: 'JSON inválido' }, { status: 400 }); + } + + const payload: Record = {}; + + const matricula = cleanStr(body.matricula); + if (matricula !== undefined) { + if (matricula !== null && !MAT_RE.test(matricula)) { + return Response.json({ error: 'matrícula: 5–15 caracteres alfanuméricos' }, { status: 400 }); + } + payload.matricula = matricula; + } + + const semestre = cleanStr(body.semestre); + if (semestre !== undefined) { + if (semestre !== null && !SEM_VALID.has(semestre)) { + return Response.json({ error: 'semestre: debe ser entre 1 y 12' }, { status: 400 }); + } + payload.semestre = semestre; + } + + if (body.tutor_id !== undefined) { + if (body.tutor_id === null) { + payload.tutor_id = null; + } else if (typeof body.tutor_id === 'number' && Number.isInteger(body.tutor_id)) { + payload.tutor_id = body.tutor_id; + } else { + return Response.json({ error: 'tutor_id inválido' }, { status: 400 }); + } + } + + const fotoPath = cleanStr(body.foto_path); + if (fotoPath !== undefined) payload.foto_path = fotoPath; + + if (Object.keys(payload).length === 0) { + return Response.json({ error: 'sin cambios' }, { status: 400 }); + } + + const { data, error } = await locals.supabase + .from('profiles') + .update(payload) + .eq('id', user.id) + .select('id, email, nombre, matricula, rol, semestre, tutor_id, foto_path') + .single(); + + if (error) { + if ((error as { code?: string }).code === '23503') { + return Response.json({ error: 'tutor no válido' }, { status: 400 }); + } + return Response.json({ error: 'no se pudo actualizar' }, { status: 500 }); + } + + return Response.json({ ok: true, profile: data }); +}; diff --git a/src/pages/index.astro b/src/pages/index.astro index 7403f41..3b343d9 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,45 +1,106 @@ --- import AppLayout from '@/layouts/AppLayout.astro'; +import { saludoHora, fmtFechaRelativa } from '@/lib/date'; const profile = Astro.locals.profile; -const isAdmin = profile?.rol === 'admin'; -const nombre = profile?.nombre ?? profile?.email?.split('@')[0] ?? ''; + +if (profile?.rol === 'admin') { + return Astro.redirect('/admin', 302); +} + +const user = Astro.locals.user; +const supabase = Astro.locals.supabase; + +const nombreCorto = + profile?.nombre?.trim().split(/\s+/)[0] ?? + profile?.email?.split('@')[0] ?? + ''; +const saludo = saludoHora(); + +type Estado = 'pendiente' | 'aprobado' | 'activo' | 'rechazado' | 'devuelto' | 'vencido'; +type Item = { cantidad: number; material: { nombre: string } | null }; +type Actual = { id: number; estado: Estado; fecha_solicitud: string; items: Item[] } | null; + +let actual: Actual = null; +if (user) { + const { data } = await supabase + .from('solicitudes') + .select('id, estado, fecha_solicitud, items:solicitud_items(cantidad, material:materiales(nombre))') + .eq('alumno_id', user.id) + .in('estado', ['pendiente', 'aprobado', 'activo']) + .order('fecha_solicitud', { ascending: false }) + .limit(1) + .maybeSingle(); + actual = (data as unknown as Actual) ?? null; +} + +const estadoLabel: Record = { + pendiente: 'Pendiente', + aprobado: 'Aprobado', + activo: 'Activo', + rechazado: 'Rechazado', + devuelto: 'Devuelto', + vencido: 'Vencido', +}; +const estadoColor: Record = { + pendiente: 'var(--color-ink)', + aprobado: 'var(--color-positive)', + activo: 'var(--color-positive)', + devuelto: 'var(--color-ink)', + rechazado: 'var(--color-danger)', + vencido: 'var(--color-danger)', +}; +const badgeStyle = (e: Estado) => + `background: color-mix(in oklab, ${estadoColor[e]} 18%, white); color: var(--color-ink); border: 1.5px solid ${estadoColor[e]};`; + +const items = actual?.items ?? []; +const preview = items.slice(0, 3); +const extra = items.length - preview.length; --- -
+ diff --git a/src/pages/onboarding.astro b/src/pages/onboarding.astro new file mode 100644 index 0000000..c727b9a --- /dev/null +++ b/src/pages/onboarding.astro @@ -0,0 +1,57 @@ +--- +import Layout from '@/layouts/Layout.astro'; +import BrandMark from '@/components/BrandMark.astro'; +import PerfilForm from '@/components/profile/PerfilForm.tsx'; + +const user = Astro.locals.user!; +const profile = Astro.locals.profile!; + +const googleAvatarUrl = + (user.user_metadata as Record | undefined)?.avatar_url as string | undefined || + (user.user_metadata as Record | undefined)?.picture as string | undefined || + null; + +const { data: maestrosData } = await Astro.locals.supabase + .from('maestros') + .select('id, nombre') + .eq('activo', true) + .order('nombre'); +const maestros = (maestrosData ?? []) as { id: number; nombre: string }[]; + +const primerNombre = (profile.nombre ?? profile.email.split('@')[0]).split(/\s+/)[0]; +--- + +
+
+
+
+ +
+

+ Bienvenido, {primerNombre} +

+

+ Completa tu perfil para crear vales de préstamo. Puedes omitirlo, pero será requerido antes de solicitar material. +

+
+ +
+ +
+
+
+
diff --git a/src/pages/perfil.astro b/src/pages/perfil.astro new file mode 100644 index 0000000..78e9007 --- /dev/null +++ b/src/pages/perfil.astro @@ -0,0 +1,62 @@ +--- +import AppLayout from '@/layouts/AppLayout.astro'; +import PerfilForm from '@/components/profile/PerfilForm.tsx'; +import Avatar from '@/components/profile/Avatar.tsx'; +import { avatarInfo } from '@/lib/avatar'; + +const user = Astro.locals.user!; +const profile = Astro.locals.profile!; + +const googleAvatarUrl = + (user.user_metadata as Record | undefined)?.avatar_url as string | undefined || + (user.user_metadata as Record | undefined)?.picture as string | undefined || + null; + +const { data: maestrosData } = await Astro.locals.supabase + .from('maestros') + .select('id, nombre') + .eq('activo', true) + .order('nombre'); +const maestros = (maestrosData ?? []) as { id: number; nombre: string }[]; + +const info = avatarInfo({ + email: profile.email, + nombre: profile.nombre, + fotoPath: profile.foto_path, + googleAvatarUrl, +}); +--- + +
+
+ +
+

+ {profile.nombre ?? profile.email.split('@')[0]} +

+

{profile.email}

+ + {profile.rol} + +
+
+ +
+

Datos del perfil

+ +
+
+