2026-09-17 21:42:12 +03:00
|
|
|
|
// Профиль курсанта: радар компетенций, история попыток, дельта между ними.
|
|
|
|
|
|
//
|
|
|
|
|
|
// «Контроль подготовленности» из ТЗ — не таблица оценок, а инструмент
|
|
|
|
|
|
// планирования подготовки. Работа над ошибками превращается в измеримый цикл:
|
|
|
|
|
|
// посмотрел разбор → попробовал снова → увидел дельту (docs/product/MODES.md).
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
import { useEffect, useState } from "react";
|
2026-09-17 21:42:12 +03:00
|
|
|
|
|
|
|
|
|
|
import { COMPETENCY_LABELS, Radar } from "@/features/debrief/Radar";
|
2026-09-24 01:10:49 +03:00
|
|
|
|
import { useMe } from "@/features/auth/useAuth";
|
2026-09-17 21:42:12 +03:00
|
|
|
|
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"));
|
2026-09-24 01:10:49 +03:00
|
|
|
|
const me = useMe();
|
|
|
|
|
|
const isTrainee = me.data?.role === "trainee";
|
|
|
|
|
|
const trainees = useTrainees(!isTrainee);
|
2026-09-17 21:42:12 +03:00
|
|
|
|
const profile = useProfile(traineeId);
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (isTrainee && me.data?.trainee_id) setTraineeId(me.data.trainee_id);
|
|
|
|
|
|
}, [isTrainee, me.data?.trainee_id]);
|
|
|
|
|
|
|
2026-09-17 21:42:12 +03:00
|
|
|
|
return (
|
|
|
|
|
|
<main className="page">
|
|
|
|
|
|
<h1>Профиль курсанта</h1>
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
<p><a href="/materials">Открыть назначенные учебные материалы →</a></p>
|
|
|
|
|
|
|
|
|
|
|
|
{!isTrainee && <p>
|
2026-09-17 21:42:12 +03:00
|
|
|
|
<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>
|
2026-09-24 01:10:49 +03:00
|
|
|
|
</p>}
|
2026-09-17 21:42:12 +03:00
|
|
|
|
|
|
|
|
|
|
{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>
|
2026-09-24 01:10:49 +03:00
|
|
|
|
{profile.data.attempts.some((attempt) => attempt.score !== null) && (
|
|
|
|
|
|
<p><a href={`/api/trainees/${profile.data.trainee.id}/certificate.pdf`}>
|
|
|
|
|
|
Скачать сертификат PDF
|
|
|
|
|
|
</a></p>
|
|
|
|
|
|
)}
|
2026-09-17 21:42:12 +03:00
|
|
|
|
|
|
|
|
|
|
<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>
|
2026-09-24 01:10:49 +03:00
|
|
|
|
{attempt.recording_available && <>
|
|
|
|
|
|
{" · "}<a href={`/api/sessions/${attempt.session_id}/recording.wav`}>
|
|
|
|
|
|
запись WAV
|
|
|
|
|
|
</a>
|
|
|
|
|
|
</>}
|
2026-09-17 21:42:12 +03:00
|
|
|
|
</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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|