lct-18: профиль курсанта — радар, история попыток, дельта
Работа над ошибками становится измеримым циклом: посмотрел разбор → попробовал снова → увидел дельту по оценке, времени опроса и добытым фактам. В отчёт занятия добавлена сводка числами — время опроса, факты, подсказки, коды ошибок. Без неё дельту пришлось бы вытаскивать разбором текста метрик («94 с»), а это путь к тихим ошибкам. Радар — среднее по попыткам: одна неудачная попытка не определяет курсанта. Проверено сквозным тестом на живой базе: два занятия одного курсанта дают две попытки и посчитанную дельту. Проверка доступности базы в тесте была ложной — синхронного драйвера в проекте нет, и тест молча пропускался.
This commit is contained in:
parent
57f4c858b6
commit
38082e2af3
8 changed files with 412 additions and 0 deletions
|
|
@ -2,6 +2,7 @@ import { createBrowserRouter, Navigate } from "react-router-dom";
|
|||
|
||||
import { Call } from "@/pages/trainee/Call";
|
||||
import { Instructor } from "@/pages/instructor/Instructor";
|
||||
import { Profile } from "@/pages/profile/Profile";
|
||||
import { Wall } from "@/pages/wall/Wall";
|
||||
import { Stub } from "@/shared/ui/Stub";
|
||||
|
||||
|
|
@ -12,5 +13,6 @@ export const router = createBrowserRouter([
|
|||
{ path: "/trainee", element: <Call /> },
|
||||
{ path: "/instructor", element: <Instructor /> },
|
||||
{ path: "/wall", element: <Wall /> },
|
||||
{ path: "/profile", element: <Profile /> },
|
||||
{ path: "/dds", element: <Stub title="АРМ диспетчера ДДС" card="lct-20" /> },
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -185,6 +185,7 @@ export function Instructor() {
|
|||
|
||||
<section style={{ flex: "0 0 260px" }}>
|
||||
<h2>История группы</h2>
|
||||
<p><a href="/profile">профили курсантов</a></p>
|
||||
<table className="grid">
|
||||
<tbody>
|
||||
{history.data?.slice(0, 15).map((item) => (
|
||||
|
|
|
|||
120
frontend/src/pages/profile/Profile.tsx
Normal file
120
frontend/src/pages/profile/Profile.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
// Профиль курсанта: радар компетенций, история попыток, дельта между ними.
|
||||
//
|
||||
// «Контроль подготовленности» из ТЗ — не таблица оценок, а инструмент
|
||||
// планирования подготовки. Работа над ошибками превращается в измеримый цикл:
|
||||
// посмотрел разбор → попробовал снова → увидел дельту (docs/product/MODES.md).
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { COMPETENCY_LABELS, Radar } from "@/features/debrief/Radar";
|
||||
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 trainees = useTrainees();
|
||||
const profile = useProfile(traineeId);
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<h1>Профиль курсанта</h1>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -70,6 +70,51 @@ export const useSessions = (params: { trainee?: string; group?: string; mode?: S
|
|||
});
|
||||
};
|
||||
|
||||
export interface TraineeInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
group: string | null;
|
||||
}
|
||||
|
||||
export interface Attempt {
|
||||
session_id: string;
|
||||
scenario_id: string;
|
||||
mode: SessionMode;
|
||||
attempt: number;
|
||||
created_at: string;
|
||||
score: number | null;
|
||||
interview_ms: number | null;
|
||||
facts_got: number | null;
|
||||
facts_required: number | null;
|
||||
hints: number | null;
|
||||
codes: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
trainee: TraineeInfo;
|
||||
attempts: Attempt[];
|
||||
competencies: Record<string, number>;
|
||||
deltas: {
|
||||
scenario_id: string;
|
||||
from_attempt: number;
|
||||
to_attempt: number;
|
||||
score: number | null;
|
||||
interview_ms: number | null;
|
||||
facts_got: number | null;
|
||||
}[];
|
||||
hints_total: number;
|
||||
}
|
||||
|
||||
export const useTrainees = () =>
|
||||
useQuery({ queryKey: ["trainees"], queryFn: () => request<TraineeInfo[]>("/api/trainees") });
|
||||
|
||||
export const useProfile = (traineeId: string | null) =>
|
||||
useQuery({
|
||||
queryKey: ["profile", traineeId],
|
||||
queryFn: () => request<Profile>(`/api/trainees/${traineeId}/profile`),
|
||||
enabled: Boolean(traineeId),
|
||||
});
|
||||
|
||||
export const useCreateSession = () =>
|
||||
useMutation({
|
||||
mutationFn: (body: { scenario_id: string; mode: SessionMode; trainee?: string; group?: string }) =>
|
||||
|
|
|
|||
Loading…
Reference in a new issue