From e6d283e1849813dab96e1aae6775741bf4b74587 Mon Sep 17 00:00:00 2001 From: Nurfog Date: Mon, 28 Sep 2026 10:58:45 -0300 Subject: [PATCH] feat: frontend alumnos sam-pruebas (RUT+clave, rendir y mis resultados) --- .env.example | 7 + .gitignore | 13 + README.md | 32 + docker-compose.yml | 13 + frontend/Dockerfile | 27 + frontend/app/api/auth/login/route.ts | 27 + frontend/app/api/auth/logout/route.ts | 9 + frontend/app/api/auth/me/route.ts | 16 + frontend/app/components/SamShell.tsx | 113 +++ frontend/app/globals.css | 402 +++++++++ frontend/app/layout.tsx | 38 + frontend/app/lib/api.ts | 133 +++ frontend/app/lib/mis.ts | 40 + frontend/app/lib/session.tsx | 58 ++ frontend/app/login/page.tsx | 107 +++ frontend/app/page.tsx | 57 ++ frontend/app/rendir/page.tsx | 197 +++++ frontend/app/resultados/page.tsx | 69 ++ frontend/middleware.ts | 23 + frontend/next-env.d.ts | 7 + frontend/next.config.ts | 8 + frontend/package-lock.json | 1096 +++++++++++++++++++++++++ frontend/package.json | 22 + frontend/public/favicon.png | Bin 0 -> 3949 bytes frontend/public/logo-sam.png | Bin 0 -> 99143 bytes frontend/tsconfig.json | 41 + 26 files changed, 2555 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/app/api/auth/login/route.ts create mode 100644 frontend/app/api/auth/logout/route.ts create mode 100644 frontend/app/api/auth/me/route.ts create mode 100644 frontend/app/components/SamShell.tsx create mode 100644 frontend/app/globals.css create mode 100644 frontend/app/layout.tsx create mode 100644 frontend/app/lib/api.ts create mode 100644 frontend/app/lib/mis.ts create mode 100644 frontend/app/lib/session.tsx create mode 100644 frontend/app/login/page.tsx create mode 100644 frontend/app/page.tsx create mode 100644 frontend/app/rendir/page.tsx create mode 100644 frontend/app/resultados/page.tsx create mode 100644 frontend/middleware.ts create mode 100644 frontend/next-env.d.ts create mode 100644 frontend/next.config.ts create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.png create mode 100644 frontend/public/logo-sam.png create mode 100644 frontend/tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3e071d4 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# SAM Rendir Test — frontend alumnos (el backend vive en el repo ICHN/sam) + +# URL pública del backend SAM (se hornea en el build de Next.js) +NEXT_PUBLIC_API_URL=https://api.devops.norteamericano.cl + +# URL server-side para el login (route handler). Si se omite usa NEXT_PUBLIC_API_URL. +# API_URL=https://api.devops.norteamericano.cl diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5633ff2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Dependencias y build (frontend Next.js) +**/node_modules/ +**/.next/ +**/out/ +*.log + +# Entornos locales (solo .env.example se versiona) +.env + +# Editores y herramientas locales +.vscode/ +.orca/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..c3c6f92 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# SAM Rendir Test (sam-rendir-test) + +Frontend de **alumnos** para rendir pruebas y ver resultados. +UI estilo sam-portal (sidebar colapsable + hero, Next.js 16 + Bootstrap 5). + +El backend vive en https://git.norteamericano.cl/ICHN/sam +(`backend/SAM.API/Controllers/SamPruebasController.cs`, ruta `api/sam-pruebas`). +El frontend de **creadores** vive en https://git.norteamericano.cl/ICHN/sam-test. + +## Login + +El alumno ingresa con su **RUT + clave** (la misma de su ficha). +El frontend reenvía a `POST /api/sam-pruebas/login-alumno` de SAM.API, +que valida contra `sige_sam_v3.alumnos` (plan central) o +`sige_sam_empresa.alumnos` (empresas) y responde con el `origen` detectado. +Sesión en cookie httpOnly (`sam_pruebas_alumno`, 12h). + +## Páginas + +| Ruta | Descripción | +|------|-------------| +| `/login` | Ingreso RUT + clave | +| `/` | Mis cursos con matrícula activa y sus pruebas asignadas | +| `/rendir` | Elegir prueba asignada, responder y entregar (corrección automática) | +| `/resultados` | Historial de mis rendiciones | + +## Desarrollo + +``` +cd frontend && npm install && npm run dev # :3000 +NEXT_PUBLIC_API_URL=https://api.devops.norteamericano.cl npm run build +``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6a84cbb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +services: + # Frontend alumnos: login idAlumno+clave (sige_sam_v3 / sige_sam_empresa) + frontend: + build: + context: ./frontend + args: + NEXT_PUBLIC_API_URL: https://api.devops.norteamericano.cl + image: sam-rendir-test-frontend:1.0 + environment: + - NEXT_PUBLIC_API_URL=https://api.devops.norteamericano.cl + ports: + - "127.0.0.1:3011:3000" + restart: unless-stopped diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..a50baa3 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,27 @@ +FROM node:22-alpine AS base + +FROM base AS deps +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci || npm install + +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +ENV NEXT_TELEMETRY_DISABLED=1 +ARG NEXT_PUBLIC_API_URL=https://api.devops.norteamericano.cl +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL +RUN npm run build + +FROM base AS runner +WORKDIR /app +ENV NODE_ENV=production +RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +USER nextjs +EXPOSE 3000 +ENV PORT=3000 +CMD ["node", "server.js"] diff --git a/frontend/app/api/auth/login/route.ts b/frontend/app/api/auth/login/route.ts new file mode 100644 index 0000000..1b06ad1 --- /dev/null +++ b/frontend/app/api/auth/login/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server"; + +const COOKIE = "sam_pruebas_alumno"; + +/** Login alumno: reenvía a SAM.API POST /api/sam-pruebas/login-alumno + * (valida idAlumno + Contrasena en sige_sam_v3 o sige_sam_empresa). */ +export async function POST(req: Request) { + const body = await req.json().catch(() => ({})); + const base = process.env.API_URL || process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000"; + const r = await fetch(`${base}/api/sam-pruebas/login-alumno`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ idAlumno: (body.idAlumno || "").trim(), password: body.password || "" }) + }); + const data = await r.json().catch(() => null); + if (!r.ok || !data?.success) { + return NextResponse.json({ success: false, message: "Credenciales inválidas" }, { status: 401 }); + } + const res = NextResponse.json({ success: true, user: data.user }); + res.cookies.set(COOKIE, encodeURIComponent(JSON.stringify(data.user)), { + httpOnly: true, + path: "/", + maxAge: 60 * 60 * 12, + sameSite: "lax" + }); + return res; +} diff --git a/frontend/app/api/auth/logout/route.ts b/frontend/app/api/auth/logout/route.ts new file mode 100644 index 0000000..ce4242c --- /dev/null +++ b/frontend/app/api/auth/logout/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from "next/server"; + +const COOKIE = "sam_pruebas_alumno"; + +export async function POST() { + const res = NextResponse.json({ success: true }); + res.cookies.set(COOKIE, "", { httpOnly: true, path: "/", maxAge: 0 }); + return res; +} diff --git a/frontend/app/api/auth/me/route.ts b/frontend/app/api/auth/me/route.ts new file mode 100644 index 0000000..ced0d16 --- /dev/null +++ b/frontend/app/api/auth/me/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; + +const COOKIE = "sam_pruebas_alumno"; + +/** Alumno conectado (según cookie de sesión). */ +export async function GET() { + const jar = await cookies(); + const raw = jar.get(COOKIE)?.value; + if (!raw) return NextResponse.json({ user: null }, { status: 401 }); + try { + return NextResponse.json({ user: JSON.parse(decodeURIComponent(raw)) }); + } catch { + return NextResponse.json({ user: null }, { status: 401 }); + } +} diff --git a/frontend/app/components/SamShell.tsx b/frontend/app/components/SamShell.tsx new file mode 100644 index 0000000..ffd543a --- /dev/null +++ b/frontend/app/components/SamShell.tsx @@ -0,0 +1,113 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { useSession } from "../lib/session"; + +interface SamShellProps { + title: string; + subtitle?: string; + icon?: string; + activeHref?: string; + children: React.ReactNode; +} + +// Plantilla estilo sam-portal: sidebar colapsable 78px <-> 250px, +// iconos boxicons, hero con degradado y perfil con salida. +export default function SamShell({ title, subtitle, icon = "bx-layer", activeHref, children }: SamShellProps) { + const [open, setOpen] = useState(true); + const { user, loading, logout } = useSession(); + const router = useRouter(); + + const menu = [ + { href: "/", label: "Mis cursos", icon: "bx-home" }, + { href: "/rendir", label: "Rendir", icon: "bx-pencil" }, + { href: "/resultados", label: "Mis resultados", icon: "bx-bar-chart-alt-2" } + ]; + + const salir = async () => { + await logout(); + router.push("/login"); + }; + + return ( +
+
+
+ + SAM + Pruebas + + setOpen(!open)} + role="button" + aria-label="Alternar menú" + /> +
+
    + {menu.map((item) => ( +
  • + + + {item.label} + + {item.label} +
  • + ))} +
+
+
+ +
+
{loading ? "…" : user ? `${user.nombres} ${user.apPaterno}` : "Invitado"}
+
{user ? user.email : "SAM Pruebas"}
+
+ {user && ( + + )} +
+
+
+ +
+
+
+
+
+ + + +
+ SAM Pruebas · ICHN +
{title}
+ {subtitle && {subtitle}} +
+
+
+
+ {user && ( +
+ + + {user.nombres} {user.apPaterno} + +
+ )} +
+
+ {children} +
+
+
+ ); +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..de37d5c --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,402 @@ +/* SAM Pruebas — tema portado de sam-portal (Portal IT / inventario.sam) */ +:root { + --primary: #00285d; + --card-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); + --radius: 12px; + --transition: 0.25s ease; +} + +body { + font-family: "Fira Sans", sans-serif; + background-color: #f0f2f5; + min-height: 100vh; +} + +/* ============ Login legacy (sidenav sólido #00285d) ============ */ +.login-page .sidenav { + background: #00285d; + overflow-x: hidden; + padding-top: 20px; + box-shadow: 4px 0 20px rgba(0, 0, 0, 0.15); +} +.login-page .login-main-text { + padding: 60px; +} +.login-page .login-main-text img { + width: 80px; + height: 80px; + object-fit: contain; +} +.login-page .main { + padding: 0 24px; +} +@media screen and (max-width: 767px) { + .login-page .login-main-text { + padding: 20px; + } + .login-page .login-main-text h1 { + font-size: 1.5rem; + } + .login-page .main { + padding: 0 12px; + } +} +@media screen and (min-width: 768px) { + .login-page .main { + margin-left: 40%; + } + .login-page .sidenav { + width: 40%; + position: fixed; + z-index: 1; + top: 0; + left: 0; + height: 100vh; + } +} + +/* ============ SamShell: sidebar colapsable + contenido ============ */ +.sam-shell { + --inv-navy: #16243d; + --inv-blue: #015fc9; + --inv-bg: #f2f5f9; + --inv-muted: #6c788b; + font-family: var(--font-work-sans), system-ui, sans-serif; + color: var(--inv-navy); +} +.sam-shell .sam-sidebar { + position: fixed; + top: 0; + left: 0; + height: 100vh; + width: 78px; + background: linear-gradient(180deg, #16243d 0%, #015fc9 150%); + z-index: 100; + transition: width var(--transition); + display: flex; + flex-direction: column; + overflow-x: hidden; +} +.sam-shell.open .sam-sidebar { + width: 250px; +} +.sam-sidebar .logo-details { + height: 60px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 14px; + color: #fff; + white-space: nowrap; +} +.sam-sidebar .logo-details .logo_name { + display: flex; + align-items: center; + gap: 8px; + font-weight: 700; + font-size: 1.1rem; + opacity: 0; + transition: opacity var(--transition); +} +.sam-shell.open .logo-details .logo_name { + opacity: 1; +} +.sam-sidebar .logo-details #btn { + font-size: 1.5rem; + cursor: pointer; + min-width: 50px; + text-align: center; +} +.sam-sidebar .nav-list { + list-style: none; + padding: 8px; + margin: 0; + overflow-y: auto; + flex: 1; +} +.sam-sidebar .nav-list li { + position: relative; + margin: 4px 0; +} +.sam-sidebar .nav-list li a { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + border-radius: 8px; + color: #dbe4f3; + text-decoration: none; + white-space: nowrap; +} +.sam-sidebar .nav-list li a i { + font-size: 1.4rem; + min-width: 30px; + text-align: center; +} +.sam-sidebar .nav-list li a:hover { + background: rgba(255, 255, 255, 0.12); + color: #fff; +} +.sam-sidebar .nav-list li a.active { + background: #fff; + color: #00285d; + font-weight: 600; +} +.sam-sidebar .links_name { + opacity: 0; + transition: opacity var(--transition); + white-space: nowrap; +} +.sam-shell.open .links_name { + opacity: 1; +} +.sam-shell:not(.open) .links_name, +.sam-shell:not(.open) .name_job, +.sam-shell:not(.open) .logo-details .logo_name .brand-text { + display: none; +} +.sam-shell:not(.open) .logo-details { + justify-content: center; + padding: 0; +} +.sam-shell:not(.open) .profile-details { + justify-content: center; +} +.sam-shell:not(.open) .profile-details .bx-log-out { + margin-left: 0 !important; +} +.sam-sidebar .tooltip { + position: absolute; + left: calc(100% + 12px); + top: 50%; + transform: translateY(-50%); + background: #fff; + color: #00285d; + padding: 4px 10px; + border-radius: 6px; + font-size: 0.85rem; + white-space: nowrap; + opacity: 0; + pointer-events: none; + box-shadow: var(--card-shadow); +} +.sam-shell:not(.open) .nav-list li:hover .tooltip { + opacity: 1; +} +.sam-shell.open .tooltip { + display: none; +} +.sam-sidebar .profile { + padding: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.15); + color: #dbe4f3; +} +.sam-sidebar .profile-details { + display: flex; + align-items: center; + gap: 10px; + white-space: nowrap; +} +.sam-sidebar .profile-details > i { + font-size: 1.8rem; +} +.sam-sidebar .name_job { + opacity: 0; +} +.sam-shell.open .name_job { + opacity: 1; +} +.sam-sidebar .name_job .name { + font-size: 0.9rem; + font-weight: 600; + color: #fff; +} +.sam-sidebar .name_job .job { + font-size: 0.75rem; + opacity: 0.7; +} +.sam-shell .home-section { + margin-left: 78px; + background: var(--inv-bg); + min-height: 100vh; + padding: 16px 20px 40px; + transition: margin-left var(--transition); +} +.sam-shell.open .home-section { + margin-left: 250px; +} +@media screen and (max-width: 767px) { + .sam-shell .home-section, + .sam-shell.open .home-section { + margin-left: 0; + padding: 12px 12px 40px; + } + .sam-shell .sam-sidebar { + transform: translateX(-100%); + width: 250px; + } + .sam-shell.open .sam-sidebar { + transform: translateX(0); + } + .sam-shell .links_name, + .sam-shell .name_job, + .sam-shell .logo-details .logo_name { + opacity: 1; + } +} + +/* ============ Hero superior ============ */ +.sam-shell .sam-toolbar.sam-hero { + position: relative; + overflow: hidden; + align-items: center; + padding: 1rem 1.25rem; + color: #fff; + border: none; + border-radius: 16px; + background: linear-gradient(135deg, #16243d 0%, #015fc9 100%); + box-shadow: 0 0.5rem 1.25rem rgba(22, 36, 61, 0.13); +} +.sam-shell .sam-hero::after { + content: ""; + position: absolute; + width: 240px; + height: 240px; + right: -75px; + bottom: -145px; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 50%; + box-shadow: 0 0 0 28px rgba(255, 255, 255, 0.04), + 0 0 0 56px rgba(255, 255, 255, 0.03); + pointer-events: none; +} +.sam-shell .sam-hero-main { + position: relative; + z-index: 1; + display: flex; + align-items: center; + gap: 0.75rem; + min-width: 0; +} +.sam-shell .sam-hero-icon { + display: inline-flex; + flex: 0 0 44px; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 12px; + background: rgba(255, 255, 255, 0.14); + font-size: 1.45rem; +} +.sam-shell .sam-eyebrow { + display: block; + margin-bottom: 0.2rem; + color: rgba(255, 255, 255, 0.7); + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} +.sam-shell .sam-hero h5 { + color: #fff; + font-size: clamp(1.15rem, 2vw, 1.4rem); +} +.sam-shell .sam-hero-sub { + color: rgba(255, 255, 255, 0.78); + font-size: 0.82rem; +} +.sam-shell .sam-hero-side { + position: relative; + z-index: 1; + display: inline-flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.4rem 1rem; + padding: 0.55rem 0.7rem; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 12px; + background: rgba(255, 255, 255, 0.1); + font-size: 0.82rem; +} +.sam-shell .sam-hero-side i { + color: #9ed0ff; +} +@media (max-width: 575.98px) { + .sam-shell .sam-toolbar.sam-hero { + flex-direction: column; + align-items: stretch; + } + .sam-shell .sam-hero-side { + justify-content: flex-start; + margin-top: 0.6rem; + } +} + +/* ============ Tarjetas, encabezados, tablas y botones ============ */ +.sam-shell .section-header { + display: flex; + align-items: center; + gap: 0.8rem; + padding: 0.9rem 1.25rem; + border: none; + border-radius: 14px; + color: #fff; + background: linear-gradient(135deg, #16243d 0%, #015fc9 100%); + box-shadow: 0 0.35rem 1rem rgba(22, 36, 61, 0.12); + font-weight: 600; + font-size: 1.1rem; +} +.sam-shell .section-header .badge { + margin-left: auto; +} +.sam-shell .app-card { + background: #fff; + border: 1px solid rgba(22, 36, 61, 0.09); + border-radius: 16px; + box-shadow: 0 0.35rem 1rem rgba(22, 36, 61, 0.055); + transition: transform var(--transition), box-shadow var(--transition); +} +.sam-shell .app-card:hover { + transform: translateY(-2px); + box-shadow: 0 0.6rem 1.4rem rgba(22, 36, 61, 0.1); +} +.sam-shell .btn-primary { + background-color: var(--inv-blue); + border-color: var(--inv-blue); +} +.sam-shell .btn-primary:hover { + background-color: #014fa8; + border-color: #014fa8; +} +.sam-shell .btn-outline-primary { + color: var(--inv-blue); + border-color: var(--inv-blue); +} +.sam-shell .btn-outline-primary:hover { + background-color: var(--inv-blue); + border-color: var(--inv-blue); + color: #fff; +} +.sam-shell .table thead th { + color: var(--inv-muted); + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.sam-shell .text-muted { + color: var(--inv-muted) !important; +} +.row-cards > [class*="col-"] { + margin-bottom: 16px; +} +.sam-shell .bx-border { + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..0b9896a --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,38 @@ +import type { Metadata } from "next"; +import { Fira_Sans, Work_Sans } from "next/font/google"; +import "bootstrap/dist/css/bootstrap.min.css"; +import "./globals.css"; +import { SessionProvider } from "./lib/session"; + +const firaSans = Fira_Sans({ + variable: "--font-fira-sans", + subsets: ["latin"], + weight: ["400", "500", "600", "700"] +}); + +const workSans = Work_Sans({ + variable: "--font-work-sans", + subsets: ["latin"], + weight: ["400", "500", "600", "700"] +}); + +export const metadata: Metadata = { + title: "SAM Pruebas · Alumnos", + description: "Rendir pruebas y ver mis resultados — cursos SAM y empresas", + icons: { + icon: "/favicon.png" + } +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + + + {children} + + + ); +} diff --git a/frontend/app/lib/api.ts b/frontend/app/lib/api.ts new file mode 100644 index 0000000..1b83d3e --- /dev/null +++ b/frontend/app/lib/api.ts @@ -0,0 +1,133 @@ +// Cliente del backend SAM: GET /api/sam-pruebas/* (repo ICHN/sam, prod desplegado). +const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:5000"; +const B = `${API_BASE}/api/sam-pruebas`; + +async function req(path: string, init?: RequestInit) { + const r = await fetch(B + path, { cache: "no-store", ...init }); + if (!r.ok) { + const t = await r.text().catch(() => ""); + throw new Error(t.slice(0, 300) || `HTTP ${r.status}`); + } + if (r.status === 204) return null; + return r.json(); +} + +export const apiGet = (path: string) => req(path); +export const apiPost = (path: string, body: unknown) => + req(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); +export const apiPut = (path: string, body: unknown) => + req(path, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); + +/** JSON.parse tolerante (columnas ::text que pueden venir null o malformadas). */ +export function parseJSON(s: string | null | undefined): any { + if (!s) return null; + try { + return JSON.parse(s); + } catch { + return null; + } +} + +export interface Creador { + idUsuario: string; + nombres: string; + apPaterno: string; + apMaterno?: string; + email: string; + tipo?: string; +} +export interface Curso { + idCursos: number; + nombreCurso: string; + nivelCurso?: number; + duracion?: number; +} +export interface Alumno { + idAlumno: string; + nombres: string; + apPaterno: string; + apMaterno?: string; + email: string; + idCursoAbierto: number; + idCursos: number; + nombreCurso: string; +} +export interface Template { + id: string; + nombre: string; + descripcion?: string; + idCursos?: number; + nivel?: string; + tipoCurso: string; + tipoPrueba: string; + duracionMin: number; + puntajeAprobacion: number; + puntajeTotal: number; + instrucciones?: string; + usoCount: number; + creadoPor?: string; +} +export interface Pregunta { + id: string; + templateId: string; + sectionId?: string; + orden: number; + tipoPregunta: string; + pregunta: string; + opciones?: string; + respuestaCorrecta?: string; + explicacion?: string; + puntaje: number; + metadata?: string; +} +export interface BancoItem { + id: string; + pregunta: string; + tipoPregunta: string; + puntaje: number; + dificultad: string; + skill?: string; + activo: boolean; + usoCount: number; +} +export interface Asignacion { + id: string; + templateId: string; + origen: string; + idCursos?: number; + idCursoAbierto?: number; + fechaInicio?: string; + fechaFin?: string; + activo: boolean; + asignadoPor?: string; +} +export interface Rendicion { + id: string; + asignacionId: string; + idAlumno: string; + intento: number; + puntajeObtenido?: number; + puntajeMaximo?: number; + aprobado?: boolean; + inicio: string; + termino?: string; + respuestas: string; +} + +export const TIPOS_PRUEBA = ["CA", "MWT", "MOT", "FOT", "FWT"]; +export const TIPOS_PREGUNTA = [ + "multiple-choice", + "true-false", + "short-answer", + "essay", + "matching", + "ordering", + "fill-in-the-blanks", + "audio-response" +]; +/** Tipos con corrección automática en el frontend. */ +export const AUTO_TYPES = ["multiple-choice", "true-false", "short-answer"]; + +export function nombreCompleto(a: { nombres: string; apPaterno: string; apMaterno?: string }) { + return `${a.nombres} ${a.apPaterno}${a.apMaterno ? " " + a.apMaterno : ""}`; +} diff --git a/frontend/app/lib/mis.ts b/frontend/app/lib/mis.ts new file mode 100644 index 0000000..f896a29 --- /dev/null +++ b/frontend/app/lib/mis.ts @@ -0,0 +1,40 @@ +import { useEffect, useState } from "react"; +import { apiGet, type Alumno } from "./api"; +import { useSession } from "./session"; + +export * from "./api"; + +export interface MiCurso { + idCursos: number; + nombreCurso: string; +} + +/** Cursos con matrícula activa del alumno conectado. */ +export function useMisCursos() { + const { user } = useSession(); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + useEffect(() => { + if (!user) { + setLoading(false); + return; + } + setLoading(true); + apiGet(`/alumnos?origen=${user.origen}&idAlumno=${encodeURIComponent(user.idAlumno)}`) + .then((r: Alumno[]) => { + setRows(r); + setLoading(false); + }) + .catch((e: any) => { + setError(e.message); + setLoading(false); + }); + }, [user]); + + const cursos: MiCurso[] = [ + ...new Map(rows.map((r) => [r.idCursos, { idCursos: r.idCursos, nombreCurso: r.nombreCurso }])).values() + ]; + return { cursos, rows, loading, error }; +} diff --git a/frontend/app/lib/session.tsx b/frontend/app/lib/session.tsx new file mode 100644 index 0000000..52cd028 --- /dev/null +++ b/frontend/app/lib/session.tsx @@ -0,0 +1,58 @@ +"use client"; +import { createContext, useContext, useEffect, useState } from "react"; + +export interface AlumnoUser { + idAlumno: string; + nombres: string; + apPaterno: string; + apMaterno?: string; + email: string; + /** De qué tabla viene: SAM (sige_sam_v3.alumnos) o EMPRESA (sige_sam_empresa.alumnos). */ + origen: string; +} + +interface SessionCtx { + user: AlumnoUser | null; + loading: boolean; + login: (idAlumno: string, password: string) => Promise; + logout: () => Promise; +} + +const Ctx = createContext(null); + +export function SessionProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch("/api/auth/me") + .then((r) => (r.ok ? r.json() : null)) + .then((d) => setUser(d?.user ?? null)) + .catch(() => setUser(null)) + .finally(() => setLoading(false)); + }, []); + + async function login(idAlumno: string, password: string) { + const r = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ idAlumno, password }) + }); + const d = await r.json().catch(() => null); + if (!r.ok || !d?.success) throw new Error(d?.message || "Credenciales inválidas"); + setUser(d.user); + } + + async function logout() { + await fetch("/api/auth/logout", { method: "POST" }); + setUser(null); + } + + return {children}; +} + +export function useSession() { + const ctx = useContext(Ctx); + if (!ctx) throw new Error("useSession fuera de SessionProvider"); + return ctx; +} diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx new file mode 100644 index 0000000..3c18d53 --- /dev/null +++ b/frontend/app/login/page.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useState } from "react"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; +import { useSession } from "../lib/session"; + +function LoginForm({ next }: { next: string }) { + const { login } = useSession(); + const router = useRouter(); + const [rut, setRut] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(null); + try { + await login(rut.trim(), password); + router.push(next.startsWith("/") ? next : "/"); + router.refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "Error al ingresar"); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+ SAM +

Mis Pruebas

+

Welcome | Bienvenido/a

+

Rinde con tu RUT y clave

+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ RUT | CLAVE +
+ {error && ( +
+
{error}
+
+ )} +
+
+ setRut(e.target.value)} + className="form-control shadow mb-2" + required + placeholder="RUT (ej. 12345678-9)" + autoComplete="username" + /> + setPassword(e.target.value)} + className="form-control shadow mb-2" + required + placeholder="Clave" + autoComplete="current-password" + /> +
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+ ); +} + +export default async function Login({ + searchParams +}: { + searchParams: Promise<{ next?: string }>; +}) { + const sp = await searchParams; + return ; +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 0000000..e618049 --- /dev/null +++ b/frontend/app/page.tsx @@ -0,0 +1,57 @@ +"use client"; +import { useEffect, useState } from "react"; +import SamShell from "./components/SamShell"; +import { apiGet, nombreCompleto, useMisCursos, type Asignacion, type Template } from "./lib/mis"; +import { useSession } from "./lib/session"; + +export default function Home() { + const { user } = useSession(); + const [templates, setTemplates] = useState([]); + const [asignaciones, setAsignaciones] = useState>({}); + const { cursos, loading, error } = useMisCursos(); + + useEffect(() => { + apiGet("/templates").then(setTemplates).catch(() => {}); + }, []); + + useEffect(() => { + if (!user || cursos.length === 0) return; + cursos.forEach((c) => { + apiGet(`/asignaciones?origen=${user.origen}&idCursos=${c.idCursos}`) + .then((as: Asignacion[]) => setAsignaciones((m) => ({ ...m, [c.idCursos]: as }))) + .catch(() => {}); + }); + }, [user, cursos]); + + const tplNombre = (id: string) => templates.find((t) => t.id === id)?.nombre ?? "Prueba"; + const total = Object.values(asignaciones).reduce((s, as) => s + as.length, 0); + + return ( + + {loading &&
} + {error &&
{error}
} +
+ {cursos.map((c) => ( +
+
+
{c.nombreCurso}
+ {(asignaciones[c.idCursos] ?? []).length === 0 ? ( +

Sin pruebas asignadas por ahora.

+ ) : ( +
    + {(asignaciones[c.idCursos] ?? []).map((a) => ( +
  • {tplNombre(a.templateId)}
  • + ))} +
+ )} + Rendir +
+
+ ))} +
+ {cursos.length > 0 && ( +

Tienes {total} prueba(s) asignada(s) en {cursos.length} curso(s) con matrícula activa.

+ )} + + ); +} diff --git a/frontend/app/rendir/page.tsx b/frontend/app/rendir/page.tsx new file mode 100644 index 0000000..6863a1e --- /dev/null +++ b/frontend/app/rendir/page.tsx @@ -0,0 +1,197 @@ +"use client"; +import { useEffect, useState } from "react"; +import SamShell from "../components/SamShell"; +import { + apiGet, + apiPost, + apiPut, + AUTO_TYPES, + parseJSON, + useMisCursos, + type Asignacion, + type Pregunta, + type Template +} from "../lib/mis"; +import { useSession } from "../lib/session"; + +type Fase = "seleccion" | "rindiendo" | "resultado"; + +export default function Rendir() { + const { user } = useSession(); + const { cursos } = useMisCursos(); + const [templates, setTemplates] = useState([]); + const [asignaciones, setAsignaciones] = useState<(Asignacion & { cursoNombre: string })[]>([]); + const [asignacionId, setAsignacionId] = useState(""); + const [preguntas, setPreguntas] = useState([]); + const [respuestas, setRespuestas] = useState>({}); + const [rendicionId, setRendicionId] = useState(""); + const [fase, setFase] = useState("seleccion"); + const [nota, setNota] = useState<{ ob: number; mx: number; ap: boolean } | null>(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + apiGet("/templates").then(setTemplates).catch(() => {}); + }, []); + + useEffect(() => { + if (!user || cursos.length === 0) return; + (async () => { + const todas: (Asignacion & { cursoNombre: string })[] = []; + for (const c of cursos) { + try { + const as: Asignacion[] = await apiGet(`/asignaciones?origen=${user.origen}&idCursos=${c.idCursos}`); + as.forEach((a) => todas.push({ ...a, cursoNombre: c.nombreCurso })); + } catch { + /* sin asignaciones */ + } + } + setAsignaciones(todas); + })(); + }, [user, cursos]); + + const asig = asignaciones.find((a) => a.id === asignacionId); + const tpl = templates.find((t) => t.id === asig?.templateId); + + async function iniciar(e: React.FormEvent) { + e.preventDefault(); + setError(""); + if (!user || !asig || !tpl) return; + setLoading(true); + try { + const r = await apiPost("/rendiciones/iniciar", { asignacionId, idAlumno: user.idAlumno, respuestas: "[]" }); + setRendicionId(r.id); + setPreguntas(await apiGet(`/templates/${tpl.id}/preguntas`)); + setRespuestas({}); + setFase("rindiendo"); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + } + + function esCorrecta(p: Pregunta, val: string): boolean | null { + if (p.tipoPregunta === "multiple-choice") { + return val !== "" && Number(val) === Number(parseJSON(p.respuestaCorrecta)); + } + if (p.tipoPregunta === "true-false") { + const exp = parseJSON(p.respuestaCorrecta); + return val !== "" && (val === "true") === (exp === true || exp === "true"); + } + if (p.tipoPregunta === "short-answer") { + const exp = (parseJSON(p.respuestaCorrecta) ?? p.respuestaCorrecta ?? "").toString().trim().toLowerCase(); + return val.trim() !== "" && val.trim().toLowerCase() === exp; + } + return null; + } + + async function entregar() { + setError(""); + const auto = preguntas.filter((p) => AUTO_TYPES.includes(p.tipoPregunta)); + let ob = 0; + const mx = auto.reduce((s, p) => s + p.puntaje, 0); + const detalle = preguntas.map((p) => { + const v = respuestas[p.id] ?? ""; + const c = esCorrecta(p, v); + if (c === true) ob += p.puntaje; + return { preguntaId: p.id, tipo: p.tipoPregunta, respuesta: v, correcta: c }; + }); + try { + await apiPut(`/rendiciones/${rendicionId}/cerrar`, { + puntajeObtenido: ob, + puntajeMaximo: mx, + respuestas: JSON.stringify(detalle) + }); + const umbral = tpl?.puntajeAprobacion ?? 70; + setNota({ ob, mx, ap: mx > 0 ? ob >= (mx * umbral) / 100 : true }); + setFase("resultado"); + } catch (err: any) { + setError(err.message); + } + } + + function renderPregunta(p: Pregunta) { + const v = respuestas[p.id] ?? ""; + const set = (val: string) => setRespuestas({ ...respuestas, [p.id]: val }); + if (p.tipoPregunta === "multiple-choice") { + const opts = (parseJSON(p.opciones) as string[]) ?? []; + return ( +
+ {opts.map((o, idx) => ( +
+ set(String(idx))} /> + +
+ ))} +
+ ); + } + if (p.tipoPregunta === "true-false") { + return ( +
+ {(["true", "false"] as const).map((b) => ( +
+ set(b)} /> + +
+ ))} +
+ ); + } + if (p.tipoPregunta === "short-answer") { + return set(e.target.value)} placeholder="Tu respuesta" />; + } + return