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