lct-hack/frontend/src/pages/profile/Profile.tsx
2026-09-24 01:10:49 +03:00

139 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Профиль курсанта: радар компетенций, история попыток, дельта между ними.
//
// «Контроль подготовленности» из ТЗ — не таблица оценок, а инструмент
// планирования подготовки. Работа над ошибками превращается в измеримый цикл:
// посмотрел разбор → попробовал снова → увидел дельту (docs/product/MODES.md).
import { useEffect, useState } from "react";
import { COMPETENCY_LABELS, Radar } from "@/features/debrief/Radar";
import { useMe } from "@/features/auth/useAuth";
import { clock } from "@/features/timers/clock";
import { useProfile, useTrainees } from "@/shared/api/http";
const MODE_LABELS: Record<string, string> = {
training: "тренировочный",
exam: "контрольный",
self: "самостоятельный",
};
function sign(value: number | null, format: (value: number) => string): string {
if (value === null) return "—";
if (value === 0) return "без изменений";
return `${value > 0 ? "+" : "−"}${format(Math.abs(value))}`;
}
export function Profile() {
const url = new URLSearchParams(location.search);
const [traineeId, setTraineeId] = useState<string | null>(url.get("trainee"));
const me = useMe();
const isTrainee = me.data?.role === "trainee";
const trainees = useTrainees(!isTrainee);
const profile = useProfile(traineeId);
useEffect(() => {
if (isTrainee && me.data?.trainee_id) setTraineeId(me.data.trainee_id);
}, [isTrainee, me.data?.trainee_id]);
return (
<main className="page">
<h1>Профиль курсанта</h1>
<p><a href="/materials">Открыть назначенные учебные материалы →</a></p>
{!isTrainee && <p>
<select value={traineeId ?? ""} onChange={(event) => setTraineeId(event.target.value || null)}>
<option value="">— выберите курсанта —</option>
{trainees.data?.map((trainee) => (
<option key={trainee.id} value={trainee.id}>
{trainee.name}{trainee.group ? ` · ${trainee.group}` : ""}
</option>
))}
</select>
</p>}
{profile.data && (
<div className="columns">
<section>
<h2>
{profile.data.trainee.name}
{profile.data.trainee.group && ` · группа ${profile.data.trainee.group}`}
</h2>
<Radar
values={Object.entries(profile.data.competencies).map(([competency, value]) => ({
competency,
value,
}))}
/>
<p className="ref">
радар — среднее по попыткам; подсказок использовано всего: {profile.data.hints_total}
</p>
{profile.data.attempts.some((attempt) => attempt.score !== null) && (
<p><a href={`/api/trainees/${profile.data.trainee.id}/certificate.pdf`}>
Скачать сертификат PDF
</a></p>
)}
<h3>Дельта попыток</h3>
<table className="grid">
<tbody>
{profile.data.deltas.map((delta, index) => (
<tr key={index}>
<th>{delta.scenario_id} · {delta.from_attempt} → {delta.to_attempt}</th>
<td>
оценка {sign(delta.score, (value) => value.toFixed(0))}
{" · "}опрос {sign(delta.interview_ms, (value) => clock(value))}
{" · "}фактов {sign(delta.facts_got, (value) => String(value))}
</td>
</tr>
))}
{profile.data.deltas.length === 0 && (
<tr><td colSpan={2}>повторных попыток по сценарию ещё не было</td></tr>
)}
</tbody>
</table>
</section>
<section>
<h2>История занятий</h2>
<table className="grid">
<tbody>
{profile.data.attempts.map((attempt) => (
<tr key={attempt.session_id}>
<th>
{new Date(attempt.created_at).toLocaleString("ru-RU")}
<div className="ref">{MODE_LABELS[attempt.mode] ?? attempt.mode} · попытка {attempt.attempt}</div>
</th>
<td>
{attempt.scenario_id} · оценка {attempt.score?.toFixed(0) ?? "—"}
<div className="ref">
опрос {attempt.interview_ms === null ? "не завершён" : clock(attempt.interview_ms)}
{attempt.facts_required !== null && ` · факты ${attempt.facts_got}/${attempt.facts_required}`}
{attempt.hints ? ` · подсказок ${attempt.hints}` : ""}
{Object.keys(attempt.codes).length > 0 &&
` · ${Object.entries(attempt.codes).map(([code, count]) => `${code}×${count}`).join(", ")}`}
</div>
<a href={`/wall?session=${attempt.session_id}`}>разбор</a>
{attempt.recording_available && <>
{" · "}<a href={`/api/sessions/${attempt.session_id}/recording.wav`}>
запись WAV
</a>
</>}
</td>
</tr>
))}
{profile.data.attempts.length === 0 && <tr><td colSpan={2}>занятий не было</td></tr>}
</tbody>
</table>
</section>
</div>
)}
{profile.isError && <p className="violated">Профиль не загрузился.</p>}
{!traineeId && <p className="ref">Курсанты появляются после первого занятия.</p>}
<p className="ref">
Компетенции: {Object.values(COMPETENCY_LABELS).join(" · ")}
</p>
</main>
);
}