Complete training workflow and acceptance hardening

This commit is contained in:
andreysk0304 2026-09-26 18:12:27 +03:00 • committed by gglamer
commit 7237265833
243 changed files with 17014 additions and 1500 deletions

View file

@ -7,9 +7,14 @@
import { useEffect, useState } from "react";
import { COMPETENCY_LABELS, Radar } from "@/features/debrief/Radar";
import { KioCard } from "@/features/kio-card/KioCard";
import { setNested } from "@/features/kio-card/merge";
import { useMe } from "@/features/auth/useAuth";
import { clock } from "@/features/timers/clock";
import { useProfile, useTrainees } from "@/shared/api/http";
import {
ApiError, useCreateScenarioSubmission, useProfile, useScenarioSubmissions, useTrainees,
} from "@/shared/api/http";
import type { Level } from "@/shared/types/generated";
const MODE_LABELS: Record<string, string> = {
training: "тренировочный",
@ -23,13 +28,35 @@ function sign(value: number | null, format: (value: number) => string): string {
return `${value > 0 ? "+" : "−"}${format(Math.abs(value))}`;
}
const EMPTY_STUDENT_KIO: Record<string, unknown> = { language: "ru", signs: [], notify: [] };
export function Profile() {
const url = new URLSearchParams(location.search);
const [traineeId, setTraineeId] = useState<string | null>(url.get("trainee"));
const [submissionTitle, setSubmissionTitle] = useState("");
const [submissionLevel, setSubmissionLevel] = useState<Level>("L1");
const [submissionKio, setSubmissionKio] = useState<Record<string, unknown>>(EMPTY_STUDENT_KIO);
const me = useMe();
const isTrainee = me.data?.role === "trainee";
const trainees = useTrainees(!isTrainee);
const profile = useProfile(traineeId);
const submissions = useScenarioSubmissions(Boolean(isTrainee));
const createSubmission = useCreateScenarioSubmission();
const submissionErrorCode = createSubmission.error instanceof ApiError
? createSubmission.error.code
: null;
const submissionError = submissionErrorCode === "trainee_group_required_for_review"
? "Попросите преподавателя включить вас в свою учебную группу, затем отправьте КИО ещё раз."
: submissionErrorCode === "instructor_group_required_for_review"
? "Для вашей группы пока не назначен преподаватель, который сможет проверить КИО."
: createSubmission.error?.message;
const submissionReady = typeof submissionKio.incident_type === "string"
&& typeof submissionKio.description === "string" && submissionKio.description.trim().length >= 20
&& Number.isInteger(submissionKio.incident_group)
&& Array.isArray(submissionKio.signs) && submissionKio.signs.length > 0
&& (typeof submissionKio.address === "string" && submissionKio.address.trim().length > 0
|| typeof submissionKio.street === "string" && submissionKio.street.trim().length > 0
&& typeof submissionKio.building === "string" && submissionKio.building.trim().length > 0);
useEffect(() => {
if (isTrainee && me.data?.trainee_id) setTraineeId(me.data.trainee_id);
@ -41,8 +68,44 @@ export function Profile() {
<p><a href="/materials">Открыть назначенные учебные материалы →</a></p>
{isTrainee && <section className="student-scenario-submit">
<h2>Отправить КИО на проверку</h2>
<p className="ref">Черновик увидит преподаватель вашей группы. Только после утверждения карточка появится в банке ДДС. Используйте вымышленные или обезличенные данные; не указывайте реальные имена и телефоны.</p>
<form onSubmit={(event) => {
event.preventDefault();
createSubmission.mutate({
title: submissionTitle,
level: submissionLevel,
kio: submissionKio,
}, { onSuccess: () => {
setSubmissionTitle("");
setSubmissionKio(EMPTY_STUDENT_KIO);
} });
}}>
<label>Название<input required minLength={3} maxLength={200} value={submissionTitle}
onChange={(event) => setSubmissionTitle(event.target.value)} /></label>
<label>Сложность<select value={submissionLevel} onChange={(event) => setSubmissionLevel(event.target.value as Level)}>
<option value="L1">L1</option><option value="L2">L2</option><option value="L3">L3</option>
</select></label>
<p className="ref">Заполните карточку КИО. Для отправки нужны описание от 20 символов, адрес, категория и хотя бы один признак происшествия. Код ЕКП и список служб рассчитываются автоматически; системные поля заполнит сервер.</p>
<KioCard state={{ server: submissionKio, pending: {} }} referenceArm onChange={(path, value) =>
setSubmissionKio((previous) => setNested(previous, path, value))} />
<button type="submit" disabled={createSubmission.isPending || !submissionReady}>Сохранить черновик преподавателю</button>
{createSubmission.isError && <p role="alert" className="state-violated">Не удалось отправить: {submissionError}</p>}
{createSubmission.isSuccess && <p role="status" className="state-ok">Черновик КИО отправлен и ожидает проверки.</p>}
</form>
<h3>Мои предложения</h3>
<ul>
{submissions.data?.map((item) => <li key={item.id}>
{item.title} · {item.level} — {item.status === "pending" ? "на проверке" : item.status === "approved" ? "утверждено, доступно в банке ДДС" : "нужно доработать"}
{item.review_comment && <span className="ref"> · комментарий: {item.review_comment}</span>}
</li>)}
{!submissions.data?.length && <li className="ref">Предложений пока нет.</li>}
</ul>
</section>}
{!isTrainee && <p>
<select value={traineeId ?? ""} onChange={(event) => setTraineeId(event.target.value || null)}>
<select aria-label="Курсант для просмотра профиля" value={traineeId ?? ""} onChange={(event) => setTraineeId(event.target.value || null)}>
<option value="">— выберите курсанта —</option>
{trainees.data?.map((trainee) => (
<option key={trainee.id} value={trainee.id}>
@ -68,6 +131,19 @@ export function Profile() {
<p className="ref">
радар — среднее по попыткам; подсказок использовано всего: {profile.data.hints_total}
</p>
<h3>Рекомендации к следующему занятию</h3>
{profile.data.recommendations.length > 0 ? (
<ul>
{profile.data.recommendations.map((item) => (
<li key={item.code}>
<strong>{item.code} · {item.title}:</strong> {item.recommendation}
<span className="ref"> · в последней оценённой попытке: {item.occurrences}</span>
</li>
))}
</ul>
) : (
<p className="ref">Для последней оценённой попытки замечаний нет.</p>
)}
{profile.data.attempts.some((attempt) => attempt.score !== null) && (
<p><a href={`/api/trainees/${profile.data.trainee.id}/certificate.pdf`}>
Скачать сертификат PDF