198 lines
7.5 KiB
TypeScript
198 lines
7.5 KiB
TypeScript
"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>
|
|
);
|
|
}
|