Complete training workflow and acceptance hardening
This commit is contained in:
parent
4c4b91064f
commit
7237265833
243 changed files with 17014 additions and 1500 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue