feat: frontend alumnos sam-pruebas (RUT+clave, rendir y mis resultados)
This commit is contained in:
commit
e6d283e184
26 files changed
+2555
No files matched your search
@@ -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
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 : ""}`;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 || "/"} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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).*)"]
|
||||
};
|
||||
Vendored
+7
@@ -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.
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
reactStrictMode: true
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+1096
File diff suppressed because it is too large.
Load diff
@@ -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 |
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user