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

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

No files matched your search

+133
View File
@@ -0,0 +1,133 @@
// Cliente del backend SAM: GET /api/sam-pruebas/* (repo ICHN/sam, prod desplegado).
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:5000";
const B = `${API_BASE}/api/sam-pruebas`;
async function req(path: string, init?: RequestInit) {
const r = await fetch(B + path, { cache: "no-store", ...init });
if (!r.ok) {
const t = await r.text().catch(() => "");
throw new Error(t.slice(0, 300) || `HTTP ${r.status}`);
}
if (r.status === 204) return null;
return r.json();
}
export const apiGet = (path: string) => req(path);
export const apiPost = (path: string, body: unknown) =>
req(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
export const apiPut = (path: string, body: unknown) =>
req(path, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
/** JSON.parse tolerante (columnas ::text que pueden venir null o malformadas). */
export function parseJSON(s: string | null | undefined): any {
if (!s) return null;
try {
return JSON.parse(s);
} catch {
return null;
}
}
export interface Creador {
idUsuario: string;
nombres: string;
apPaterno: string;
apMaterno?: string;
email: string;
tipo?: string;
}
export interface Curso {
idCursos: number;
nombreCurso: string;
nivelCurso?: number;
duracion?: number;
}
export interface Alumno {
idAlumno: string;
nombres: string;
apPaterno: string;
apMaterno?: string;
email: string;
idCursoAbierto: number;
idCursos: number;
nombreCurso: string;
}
export interface Template {
id: string;
nombre: string;
descripcion?: string;
idCursos?: number;
nivel?: string;
tipoCurso: string;
tipoPrueba: string;
duracionMin: number;
puntajeAprobacion: number;
puntajeTotal: number;
instrucciones?: string;
usoCount: number;
creadoPor?: string;
}
export interface Pregunta {
id: string;
templateId: string;
sectionId?: string;
orden: number;
tipoPregunta: string;
pregunta: string;
opciones?: string;
respuestaCorrecta?: string;
explicacion?: string;
puntaje: number;
metadata?: string;
}
export interface BancoItem {
id: string;
pregunta: string;
tipoPregunta: string;
puntaje: number;
dificultad: string;
skill?: string;
activo: boolean;
usoCount: number;
}
export interface Asignacion {
id: string;
templateId: string;
origen: string;
idCursos?: number;
idCursoAbierto?: number;
fechaInicio?: string;
fechaFin?: string;
activo: boolean;
asignadoPor?: string;
}
export interface Rendicion {
id: string;
asignacionId: string;
idAlumno: string;
intento: number;
puntajeObtenido?: number;
puntajeMaximo?: number;
aprobado?: boolean;
inicio: string;
termino?: string;
respuestas: string;
}
export const TIPOS_PRUEBA = ["CA", "MWT", "MOT", "FOT", "FWT"];
export const TIPOS_PREGUNTA = [
"multiple-choice",
"true-false",
"short-answer",
"essay",
"matching",
"ordering",
"fill-in-the-blanks",
"audio-response"
];
/** Tipos con corrección automática en el frontend. */
export const AUTO_TYPES = ["multiple-choice", "true-false", "short-answer"];
export function nombreCompleto(a: { nombres: string; apPaterno: string; apMaterno?: string }) {
return `${a.nombres} ${a.apPaterno}${a.apMaterno ? " " + a.apMaterno : ""}`;
}
+40
View File
@@ -0,0 +1,40 @@
import { useEffect, useState } from "react";
import { apiGet, type Alumno } from "./api";
import { useSession } from "./session";
export * from "./api";
export interface MiCurso {
idCursos: number;
nombreCurso: string;
}
/** Cursos con matrícula activa del alumno conectado. */
export function useMisCursos() {
const { user } = useSession();
const [rows, setRows] = useState<Alumno[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
if (!user) {
setLoading(false);
return;
}
setLoading(true);
apiGet(`/alumnos?origen=${user.origen}&idAlumno=${encodeURIComponent(user.idAlumno)}`)
.then((r: Alumno[]) => {
setRows(r);
setLoading(false);
})
.catch((e: any) => {
setError(e.message);
setLoading(false);
});
}, [user]);
const cursos: MiCurso[] = [
...new Map(rows.map((r) => [r.idCursos, { idCursos: r.idCursos, nombreCurso: r.nombreCurso }])).values()
];
return { cursos, rows, loading, error };
}
+58
View File
@@ -0,0 +1,58 @@
"use client";
import { createContext, useContext, useEffect, useState } from "react";
export interface AlumnoUser {
idAlumno: string;
nombres: string;
apPaterno: string;
apMaterno?: string;
email: string;
/** De qué tabla viene: SAM (sige_sam_v3.alumnos) o EMPRESA (sige_sam_empresa.alumnos). */
origen: string;
}
interface SessionCtx {
user: AlumnoUser | null;
loading: boolean;
login: (idAlumno: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
const Ctx = createContext<SessionCtx | null>(null);
export function SessionProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<AlumnoUser | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/auth/me")
.then((r) => (r.ok ? r.json() : null))
.then((d) => setUser(d?.user ?? null))
.catch(() => setUser(null))
.finally(() => setLoading(false));
}, []);
async function login(idAlumno: string, password: string) {
const r = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ idAlumno, password })
});
const d = await r.json().catch(() => null);
if (!r.ok || !d?.success) throw new Error(d?.message || "Credenciales inválidas");
setUser(d.user);
}
async function logout() {
await fetch("/api/auth/logout", { method: "POST" });
setUser(null);
}
return <Ctx.Provider value={{ user, loading, login, logout }}>{children}</Ctx.Provider>;
}
export function useSession() {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useSession fuera de SessionProvider");
return ctx;
}