// Пульт преподавателя. // // Компоновка от того, по чему его оценит действующий преподаватель: задать // сценарий и не вмешиваться, видеть работу курсанта вживую, получить готовый // разбор, показать группе (docs/arch/FRONTEND.md). import { useEffect, useRef, useState } from "react"; import { Debrief } from "@/features/debrief/Debrief"; import { Director } from "@/features/instructor/Director"; import { ScenarioEditor } from "@/features/instructor/ScenarioEditor"; import { useControl } from "@/features/instructor/useControl"; import { KioCard } from "@/features/kio-card/KioCard"; import { StandStatus } from "@/features/health/StandStatus"; import { ModeBanner } from "@/features/mode-banner/ModeBanner"; import { useObserve } from "@/features/observe/useObserve"; import { InterviewTimer, Timers } from "@/features/timers/Timers"; import { useIncidentGroups, useScenarios, useSessions, useTrainees } from "@/shared/api/http"; import { sessionIdFromUrl } from "@/shared/api/session"; import type { SessionMode, SessionReport } from "@/shared/types/generated"; const MODES: { value: SessionMode; label: string }[] = [ { value: "training", label: "тренировочный" }, { value: "exam", label: "контрольный" }, { value: "self", label: "самостоятельный" }, ]; export function Instructor() { const [sessionId, setSessionId] = useState(sessionIdFromUrl()); const [scenarioId, setScenarioId] = useState(""); const [ticketFilter, setTicketFilter] = useState(""); const [categoryFilter, setCategoryFilter] = useState(""); const [scenarioSearch, setScenarioSearch] = useState(""); const [extraScenarioIds, setExtraScenarioIds] = useState([]); const [mode, setMode] = useState("training"); const [exercise, setExercise] = useState<"dds" | "call" | "card" | "card_dds">("dds"); const [decisionTimeLimit, setDecisionTimeLimit] = useState(30); const [allowedErrors, setAllowedErrors] = useState(0); const [requireCorrectGrammar, setRequireCorrectGrammar] = useState(true); const [scoreWeightsText, setScoreWeightsText] = useState("{}"); const [scoreWeightsError, setScoreWeightsError] = useState(""); const [lessonScoreWeights, setLessonScoreWeights] = useState>({}); const [ddsArrivalIntervalSeconds, setDdsArrivalIntervalSeconds] = useState(60); const [ddsMaxWaiting, setDdsMaxWaiting] = useState(3); const [trainee, setTrainee] = useState(""); const [traineeId, setTraineeId] = useState(""); const [noteFor, setNoteFor] = useState(null); // Запускается только занятие, созданное этой кнопкой. Открытая из истории // ссылка на идущее занятие не должна запускать его заново и сбрасывать. const toStart = useRef(null); const [noteText, setNoteText] = useState(""); const [overrideReport, setOverrideReport] = useState(null); const [overrideScore, setOverrideScore] = useState(""); const [overrideComment, setOverrideComment] = useState(""); const [overrideError, setOverrideError] = useState(""); const [overrideBusy, setOverrideBusy] = useState(false); const scenarios = useScenarios(); const incidentGroups = useIncidentGroups(); const history = useSessions(); const trainees = useTrainees(); const session = useObserve(sessionId); const control = useControl(sessionId); useEffect(() => { if (!session.report) return; setOverrideReport(null); setOverrideScore(String(session.report.score_final)); setOverrideComment(session.report.override_comment ?? ""); }, [session.report?.session_id, session.report?.score_final, session.report?.override_comment]); const visibleScenarios = (scenarios.data ?? []).filter((item) => (!ticketFilter || item.ticket === Number(ticketFilter)) && (!categoryFilter || item.ekp_group === Number(categoryFilter)) && (!scenarioSearch.trim() || `${item.title} ${item.id}`.toLocaleLowerCase("ru-RU") .includes(scenarioSearch.trim().toLocaleLowerCase("ru-RU"))) ).sort((a, b) => (a.ticket ?? 99) - (b.ticket ?? 99) || (a.position ?? 0) - (b.position ?? 0)); const selectedTicketScenarios = ticketFilter ? (scenarios.data ?? []).filter((item) => item.ticket === Number(ticketFilter)) .sort((a, b) => (a.position ?? 0) - (b.position ?? 0)) : []; const [wholeTicket, setWholeTicket] = useState(false); const canStartSelectedExercise = exercise === "dds" && wholeTicket ? selectedTicketScenarios.length === 3 : Boolean(scenarioId); useEffect(() => { if (!visibleScenarios.some((item) => item.id === scenarioId)) { setScenarioId(visibleScenarios[0]?.id ?? ""); } }, [scenarios.data, ticketFilter, categoryFilter, scenarioSearch, scenarioId]); function startLesson() { if (trainees.data?.length && !traineeId) return; let parsedWeights: unknown; try { parsedWeights = JSON.parse(scoreWeightsText); } catch { setScoreWeightsError("Введите корректный JSON, например {\"dds_ack\": 2}."); return; } if (!parsedWeights || typeof parsedWeights !== "object" || Array.isArray(parsedWeights) || Object.values(parsedWeights).some((value) => typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 10)) { setScoreWeightsError("Задайте объект «ключ метрики: вес от 0 до 10»."); return; } setScoreWeightsError(""); setLessonScoreWeights(parsedWeights as Record); const id = crypto.randomUUID(); // Номер занятия живёт в адресе: монитор и АРМ курсанта открываются ссылкой. window.history.replaceState(null, "", `/instructor?session=${id}`); toStart.current = id; setSessionId(id); } async function saveScoreOverride(report: SessionReport) { const score = Number(overrideScore); if (!overrideScore.trim() || !Number.isFinite(score) || score < 0 || score > 100 || !overrideComment.trim()) { setOverrideError("Укажите оценку от 0 до 100 и обязательное обоснование."); return; } setOverrideBusy(true); setOverrideError(""); try { const response = await fetch(`/api/sessions/${report.session_id}/report`, { method: "PATCH", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ score_final: score, comment: overrideComment.trim() }), }); const data = await response.json().catch(() => null); if (!response.ok) { const detail = data && typeof data === "object" && "detail" in data ? data.detail : response.statusText; throw new Error(typeof detail === "string" ? detail : "Не удалось сохранить корректировку."); } const updated = data as SessionReport; setOverrideReport(updated); setOverrideScore(String(updated.score_final)); setOverrideComment(updated.override_comment ?? ""); } catch (cause) { setOverrideError(cause instanceof Error ? cause.message : "Не удалось сохранить корректировку."); } finally { setOverrideBusy(false); } } // Занятие запускается, когда канал управления открылся. useEffect(() => { if (!sessionId || control.status !== "open" || toStart.current !== sessionId) return; toStart.current = null; const selected = trainees.data?.find((item) => item.id === traineeId); const primaryScenarioId = exercise === "dds" && wholeTicket ? selectedTicketScenarios[0]?.id ?? scenarioId : scenarioId; const scenarioIds = exercise === "dds" ? (wholeTicket ? selectedTicketScenarios.map((item) => item.id) : [scenarioId, ...extraScenarioIds.filter((id) => id !== scenarioId)]) .filter((id, index, all) => id && all.indexOf(id) === index) : undefined; control.start(primaryScenarioId, mode, selected?.name || trainee || "Демо-курсант", exercise === "card_dds" ? "card" : exercise, undefined, selected?.id, selected?.service ?? undefined, scenarioIds && scenarioIds.length > 1 ? scenarioIds : undefined, ddsArrivalIntervalSeconds, ddsMaxWaiting, exercise === "card_dds", { decision_time_limit_seconds: decisionTimeLimit, allowed_errors: allowedErrors, require_correct_grammar: requireCorrectGrammar, score_weights: lessonScoreWeights, }); }, [sessionId, control, scenarioId, extraScenarioIds, mode, trainee, traineeId, trainees.data, exercise, decisionTimeLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios, ddsArrivalIntervalSeconds, ddsMaxWaiting, lessonScoreWeights]); const links = sessionId ? { trainee: `${location.origin}/trainee?session=${sessionId}`, wall: `${location.origin}/wall?session=${sessionId}`, dds: `${location.origin}/dds?session=${sessionId}&role=dds_01`, } : null; return (

Рабочее место преподавателя

Перейти к ИИ-формирователю сценария ↓ Учебные материалы и назначения →

Занятие

Сценарий
setScenarioSearch(event.target.value)} />
{visibleScenarios.length} из {scenarios.data?.length ?? 0} карточек (96 — билеты заказчика) {exercise === "dds" && ticketFilter && } {exercise === "dds" && !wholeTicket &&
Дополнительные карточки ({extraScenarioIds.length})

Интервал и максимум ожидающих задаются в критериях занятия. Таймер каждой карточки начинается при поступлении и идёт, пока курсант занят другой.

{scenarios.data?.filter((scenario) => scenario.id !== scenarioId).map((scenario) => )}
}
Упражнение
Режим {MODES.map((item) => ( ))}
Курсант {trainees.data?.length ? ( ) : ( <> setTrainee(event.target.value)} /> Без учётной записи: личный отчёт курсанту недоступен. )}
Критерии занятия {exercise === "dds" && <> } {exercise !== "dds" && }