feat: frontend alumnos sam-pruebas (RUT+clave, rendir y mis resultados)

This commit is contained in:
Nurfog committed 2026-09-28 10:58:45 -03:00
commit e6d283e184
26 files changed
+2555

No files matched your search

+7
View File
@@ -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
+13
View File
@@ -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
+32
View File
@@ -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
```
+13
View File
@@ -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
+27
View File
@@ -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"]
+27
View File
@@ -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;
}
+9
View File
@@ -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;
}
+16
View File
@@ -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 });
}
}
+113
View File
@@ -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 (
<div className={`sam-shell${open ? " open" : ""}`}>
<div className="sam-sidebar">
<div className="logo-details">
<span className="logo_name">
<Image src="/favicon.png" alt="SAM" width={28} height={28} />
<span className="brand-text">Pruebas</span>
</span>
<i
className={`bx ${open ? "bx-menu-alt-right" : "bx-menu"}`}
id="btn"
onClick={() => setOpen(!open)}
role="button"
aria-label="Alternar menú"
/>
</div>
<ul className="nav-list">
{menu.map((item) => (
<li key={item.href}>
<Link href={item.href} className={activeHref === item.href ? "active" : ""}>
<i className={`bx ${item.icon}`} />
<span className="links_name">{item.label}</span>
</Link>
<span className="tooltip">{item.label}</span>
</li>
))}
</ul>
<div className="profile">
<div className="profile-details">
<i className="bx bx-user-circle" />
<div className="name_job">
<div className="name">{loading ? "…" : user ? `${user.nombres} ${user.apPaterno}` : "Invitado"}</div>
<div className="job">{user ? user.email : "SAM Pruebas"}</div>
</div>
{user && (
<i
className="bx bx-log-out ms-auto"
role="button"
title="Cerrar sesión"
onClick={salir}
style={{ fontSize: "1.4rem", cursor: "pointer" }}
/>
)}
</div>
</div>
</div>
<section className="home-section">
<div className="container-fluid">
<div className="row sam-toolbar sam-hero align-items-center mb-3">
<div className="col-md-7">
<div className="sam-hero-main">
<span className="sam-hero-icon">
<i className={`bx ${icon}`} />
</span>
<div className="min-w-0">
<span className="sam-eyebrow">SAM Pruebas · ICHN</span>
<h5 className="mb-0 fw-bold">{title}</h5>
{subtitle && <small className="sam-hero-sub">{subtitle}</small>}
</div>
</div>
</div>
<div className="col-md-5 text-end">
{user && (
<div className="sam-hero-side">
<span>
<i className="bx bx-user me-1" />
{user.nombres} {user.apPaterno}
</span>
</div>
)}
</div>
</div>
{children}
</div>
</section>
</div>
);
}
+402
View File
@@ -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;
}
+38
View File
@@ -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 (
<html lang="es" className={`${firaSans.variable} ${workSans.variable}`}>
<head>
<link href="https://unpkg.com/boxicons@2.1.4/css/boxicons.min.css" rel="stylesheet" />
</head>
<body style={{ fontFamily: "var(--font-fira-sans), sans-serif" }}>
<SessionProvider>{children}</SessionProvider>
</body>
</html>
);
}
+133
View File
@@ -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 : ""}`;
}
+40
View File
@@ -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<Alumno[]>([]);
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 };
}
+58
View File
@@ -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<void>;
logout: () => Promise<void>;
}
const Ctx = createContext<SessionCtx | null>(null);
export function SessionProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<AlumnoUser | null>(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 <Ctx.Provider value={{ user, loading, login, logout }}>{children}</Ctx.Provider>;
}
export function useSession() {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useSession fuera de SessionProvider");
return ctx;
}
+107
View File
@@ -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<string | null>(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 (
<div className="login-page">
<div className="sidenav">
<div className="login-main-text text-light text-center">
<Image src="/logo-sam.png" alt="SAM" width={80} height={80} />
<h1>Mis Pruebas</h1>
<h3>Welcome | Bienvenido/a</h3>
<h3>Rinde con tu RUT y clave</h3>
</div>
</div>
<div className="main">
<div className="row justify-content-center align-items-center" style={{ minHeight: "85vh" }}>
<div className="col-md-12">
<div className="row">
<div className="col-md-2" />
<div className="col-md-8">
<div className="card alert alert-secondary">
<div className="card-body">
<form onSubmit={submit}>
<div className="row">
<div className="col-md-12 mb-3 text-center">
<strong>RUT | CLAVE</strong>
</div>
{error && (
<div className="col-md-12 mb-3">
<div className="alert alert-danger py-2 mb-0">{error}</div>
</div>
)}
<div className="col-md-3" />
<div className="col-md-6">
<input
type="text"
value={rut}
onChange={(e) => setRut(e.target.value)}
className="form-control shadow mb-2"
required
placeholder="RUT (ej. 12345678-9)"
autoComplete="username"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="form-control shadow mb-2"
required
placeholder="Clave"
autoComplete="current-password"
/>
<div className="d-grid">
<button type="submit" disabled={loading} className="btn btn-success btn-sm">
{loading ? <span className="spinner-border spinner-border-sm" /> : "Ingresar"}
</button>
</div>
</div>
<div className="col-md-3" />
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
export default async function Login({
searchParams
}: {
searchParams: Promise<{ next?: string }>;
}) {
const sp = await searchParams;
return <LoginForm next={sp?.next || "/"} />;
}
+57
View File
@@ -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<Template[]>([]);
const [asignaciones, setAsignaciones] = useState<Record<number, Asignacion[]>>({});
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 (
<SamShell title="Mis cursos" subtitle={user ? `${nombreCompleto(user)} · ${user.origen === "SAM" ? "Plan central" : "Empresas"}` : ""} icon="bx-home" activeHref="/">
{loading && <div className="spinner-border" role="status" />}
{error && <div className="alert alert-danger">{error}</div>}
<div className="row row-cards">
{cursos.map((c) => (
<div className="col-md-6" key={c.idCursos}>
<div className="app-card p-3 h-100">
<h6><i className="bx bx-book me-1" /> {c.nombreCurso}</h6>
{(asignaciones[c.idCursos] ?? []).length === 0 ? (
<p className="text-muted mb-0">Sin pruebas asignadas por ahora.</p>
) : (
<ul className="mb-2">
{(asignaciones[c.idCursos] ?? []).map((a) => (
<li key={a.id}>{tplNombre(a.templateId)}</li>
))}
</ul>
)}
<a href="/rendir" className="btn btn-success btn-sm">Rendir</a>
</div>
</div>
))}
</div>
{cursos.length > 0 && (
<p className="text-muted">Tienes {total} prueba(s) asignada(s) en {cursos.length} curso(s) con matrícula activa.</p>
)}
</SamShell>
);
}
+197
View File
@@ -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<Template[]>([]);
const [asignaciones, setAsignaciones] = useState<(Asignacion & { cursoNombre: string })[]>([]);
const [asignacionId, setAsignacionId] = useState("");
const [preguntas, setPreguntas] = useState<Pregunta[]>([]);
const [respuestas, setRespuestas] = useState<Record<string, string>>({});
const [rendicionId, setRendicionId] = useState("");
const [fase, setFase] = useState<Fase>("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 (
<div>
{opts.map((o, idx) => (
<div className="form-check" key={idx}>
<input className="form-check-input" type="radio" name={p.id} checked={v === String(idx)} onChange={() => set(String(idx))} />
<label className="form-check-label">{o}</label>
</div>
))}
</div>
);
}
if (p.tipoPregunta === "true-false") {
return (
<div>
{(["true", "false"] as const).map((b) => (
<div className="form-check form-check-inline" key={b}>
<input className="form-check-input" type="radio" name={p.id} checked={v === b} onChange={() => set(b)} />
<label className="form-check-label">{b === "true" ? "Verdadero" : "Falso"}</label>
</div>
))}
</div>
);
}
if (p.tipoPregunta === "short-answer") {
return <input className="form-control" value={v} onChange={(e) => set(e.target.value)} placeholder="Tu respuesta" />;
}
return <textarea className="form-control" rows={3} value={v} onChange={(e) => set(e.target.value)} placeholder="Tu respuesta (la revisa el profesor)" />;
}
return (
<SamShell title="Rendir" subtitle={tpl ? tpl.nombre : "Elige una de tus pruebas asignadas."} icon="bx-pencil" activeHref="/rendir">
{error && <div className="alert alert-danger">{error}</div>}
{loading && <div className="spinner-border" role="status" />}
{fase === "seleccion" && (
<form onSubmit={iniciar} className="row g-2">
<div className="col-md-8">
<label className="form-label">Mi prueba asignada</label>
<select className="form-select" value={asignacionId} onChange={(e) => setAsignacionId(e.target.value)} required>
<option value="">Seleccione…</option>
{asignaciones.map((a) => (
<option key={a.id} value={a.id}>
{templates.find((t) => t.id === a.templateId)?.nombre ?? "Prueba"} — {a.cursoNombre}
</option>
))}
</select>
</div>
<div className="col-md-4 d-flex align-items-end">
<button className="btn btn-success w-100" disabled={!asignacionId}>Iniciar prueba</button>
</div>
{asignaciones.length === 0 && <p className="text-muted">No tienes pruebas asignadas por ahora.</p>}
</form>
)}
{fase === "rindiendo" && (
<div>
<div className="alert alert-info">{tpl?.nombre} — {tpl?.duracionMin} min — responde y entrega.</div>
{preguntas.map((p, i) => (
<div className="card mb-2" key={p.id}>
<div className="card-body">
<p className="mb-2"><strong>{i + 1}.</strong> {p.pregunta} <span className="badge bg-secondary">{p.puntaje} pts</span></p>
{renderPregunta(p)}
</div>
</div>
))}
<button className="btn btn-primary" onClick={entregar} disabled={preguntas.length === 0}>Entregar prueba</button>
</div>
)}
{fase === "resultado" && nota && (
<div className={`alert ${nota.ap ? "alert-success" : "alert-warning"}`}>
<h4>{nota.ap ? "Aprobado" : "No aprobado"}</h4>
<p className="mb-0">Puntaje: {nota.ob}/{nota.mx} (las preguntas manuales las revisa el profesor).</p>
<a className="btn btn-outline-secondary mt-2" href="/resultados">Ver mis resultados</a>
</div>
)}
</SamShell>
);
}
+69
View File
@@ -0,0 +1,69 @@
"use client";
import { useEffect, useState } from "react";
import SamShell from "../components/SamShell";
import { apiGet, useMisCursos, type Asignacion, type Rendicion, type Template } from "../lib/mis";
import { useSession } from "../lib/session";
export default function Resultados() {
const { user } = useSession();
const { cursos } = useMisCursos();
const [templates, setTemplates] = useState<Template[]>([]);
const [rows, setRows] = useState<(Rendicion & { prueba: string; curso: string })[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
apiGet("/templates").then(setTemplates).catch(() => {});
}, []);
useEffect(() => {
if (!user || cursos.length === 0) return;
(async () => {
setLoading(true);
try {
const todas: (Rendicion & { prueba: string; curso: string })[] = [];
for (const c of cursos) {
const as: Asignacion[] = await apiGet(`/asignaciones?origen=${user.origen}&idCursos=${c.idCursos}`);
for (const a of as) {
const rs: Rendicion[] = await apiGet(`/asignaciones/${a.id}/rendiciones`);
const tpl = templates.find((t) => t.id === a.templateId)?.nombre ?? "Prueba";
rs.filter((r) => r.idAlumno === user.idAlumno).forEach((r) =>
todas.push({ ...r, prueba: tpl, curso: c.nombreCurso })
);
}
}
setRows(todas.sort((x, y) => y.inicio.localeCompare(x.inicio)));
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
})();
}, [user, cursos, templates]);
return (
<SamShell title="Mis resultados" subtitle="Historial de tus rendiciones." icon="bx-bar-chart-alt-2" activeHref="/resultados">
{error && <div className="alert alert-danger">{error}</div>}
{loading && <div className="spinner-border" role="status" />}
<table className="table table-striped">
<thead><tr><th>Prueba</th><th>Curso</th><th>Puntaje</th><th>Estado</th><th>Fecha</th></tr></thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td>{r.prueba}</td>
<td>{r.curso}</td>
<td>{r.puntajeObtenido ?? "—"}/{r.puntajeMaximo ?? "—"}</td>
<td>
{!r.termino ? <span className="badge bg-secondary">Sin cerrar</span>
: r.aprobado ? <span className="badge bg-success">Aprobado</span>
: <span className="badge bg-danger">Reprobado</span>}
</td>
<td>{new Date(r.inicio).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
{rows.length === 0 && !loading && <p className="text-muted">Aún no tienes rendiciones.</p>}
</SamShell>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const COOKIE = "sam_pruebas_alumno";
// Rutas que no requieren sesión.
const PUBLICAS = ["/login"];
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
if (pathname.startsWith("/api/")) return NextResponse.next();
if (PUBLICAS.some((p) => pathname === p || pathname.startsWith(p + "/"))) return NextResponse.next();
if (!req.cookies.get(COOKIE)) {
const url = req.nextUrl.clone();
url.pathname = "/login";
url.searchParams.set("next", pathname);
return NextResponse.redirect(url);
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.png|logo-sam.png|.*\\.svg).*)"]
};
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+8
View File
@@ -0,0 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
reactStrictMode: true
};
export default nextConfig;
+1096
View File
File diff suppressed because it is too large. Load diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "sam-pruebas-rendir",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"bootstrap": "^5.3.3",
"next": "^16.3.5",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"typescript": "^5.6.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

+41
View File
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}