// Пульт преподавателя. // // Компоновка от того, по чему его оценит действующий преподаватель: задать // сценарий и не вмешиваться, видеть работу курсанта вживую, получить готовый // разбор, показать группе (docs/arch/FRONTEND.md). import { useEffect, useRef, useState } from "react"; import { Debrief } from "@/features/debrief/Debrief"; import { Director } from "@/features/instructor/Director"; import { ScoreWeightsEditor } from "@/features/instructor/ScoreWeightsEditor"; 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 { CARD_STATUS_LABELS, SERVICE_STATUS_LABELS } from "@/pages/dds/statuses"; import { DEMO_MODE } from "@/features/auth/useAuth"; import { useActiveSessions, useIncidentGroups, useReviewScenarioSubmission, useScenarioSubmissions, useScenarios, useSessions, useTrainees, } from "@/shared/api/http"; import { sessionIdFromUrl } from "@/shared/api/session"; import type { Level, SessionMode, SessionReport } from "@/shared/types/generated"; const MODES: { value: SessionMode; label: string; hint: string }[] = [ { value: "training", label: "Тренировочный", hint: "Подсказки доступны" }, { value: "exam", label: "Контрольный", hint: "Подсказки отключены" }, { value: "self", label: "Самостоятельная работа", hint: "Курсант работает самостоятельно; подсказки доступны" }, ]; function levelLabel(level: Level): string { const labels: Record = { L1: "L1 · простой вызов", L2: "L2 · заявитель в стрессе", L3: "L3 · обрыв связи / обратный вызов", }; return labels[level]; } function modeLabel(mode: SessionMode): string { return MODES.find((item) => item.value === mode)?.label ?? mode; } function sessionDateLabel(value: string | null): string | null { if (!value) return null; const date = new Date(value); if (Number.isNaN(date.getTime())) return null; return new Intl.DateTimeFormat("ru-RU", { dateStyle: "medium", timeStyle: "short" }).format(date); } function LessonNumberInput({ label, value, min, max, fallback, onCommit, }: { label: string; value: number; min: number; max: number; fallback: number; onCommit: (value: number) => void; }) { const [draft, setDraft] = useState(String(value)); useEffect(() => setDraft(String(value)), [value]); function commit() { const parsed = draft.trim() ? Number(draft) : fallback; const normalized = Number.isFinite(parsed) ? Math.max(min, Math.min(max, parsed)) : fallback; setDraft(String(normalized)); onCommit(normalized); } return setDraft(event.target.value)} onBlur={commit} />; } export function Instructor() { const [sessionId, setSessionId] = useState(sessionIdFromUrl()); const [scenarioId, setScenarioId] = useState(""); const [ticketFilter, setTicketFilter] = useState(""); const [categoryFilter, setCategoryFilter] = useState(""); const [levelFilter, setLevelFilter] = useState(""); const [scenarioSearch, setScenarioSearch] = useState(""); const [cardSource, setCardSource] = useState<"system" | "trainee" | "mixed">("system"); const [submissionReviewComments, setSubmissionReviewComments] = useState>({}); const [extraScenarioIds, setExtraScenarioIds] = useState([]); const [randomScenario, setRandomScenario] = useState(false); const [mode, setMode] = useState("training"); const [exercise, setExercise] = useState<"dds" | "call" | "card" | "card_dds">("dds"); const [decisionTimeLimit, setDecisionTimeLimit] = useState(30); const [cardFillTimeLimit, setCardFillTimeLimit] = useState(180); const [ddsCardWorkTimeLimit, setDdsCardWorkTimeLimit] = useState(180); const [allowedErrors, setAllowedErrors] = useState(0); const [requireCorrectGrammar, setRequireCorrectGrammar] = useState(true); 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 [startPendingId, setStartPendingId] = useState(null); const startPendingRef = useRef(null); const [startError, setStartError] = useState(""); const startTimeout = 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 scenarioSubmissions = useScenarioSubmissions(); const reviewScenarioSubmission = useReviewScenarioSubmission(); const incidentGroups = useIncidentGroups(); const history = useSessions(); const activeSessions = useActiveSessions(); const trainees = useTrainees(); const session = useObserve(sessionId); const control = useControl(sessionId); const pendingSubmissions = (scenarioSubmissions.data ?? []).filter((item) => item.status === "pending"); const ddsExcludedScenarios = (scenarios.data ?? []).filter((item) => item.outcome !== "card"); 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) => (exercise !== "dds" || item.outcome === "card") && (cardSource === "mixed" || item.source === cardSource) && (!ticketFilter || cardSource === "trainee" || item.source === "trainee" || item.ticket === Number(ticketFilter)) && (!categoryFilter || item.source === "trainee" || item.ekp_group === Number(categoryFilter)) && (!levelFilter || item.level === levelFilter) && (!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 selectedScenario = visibleScenarios.find((item) => item.id === scenarioId); const hasScenarioFilters = Boolean(ticketFilter || categoryFilter || levelFilter || scenarioSearch.trim() || cardSource !== "system"); function clearScenarioFilters() { setTicketFilter(""); setCategoryFilter(""); setLevelFilter(""); setScenarioSearch(""); setCardSource("system"); setWholeTicket(false); } const selectedTicketScenarios = ticketFilter ? (scenarios.data ?? []).filter((item) => item.ticket === Number(ticketFilter)) .filter((item) => item.source === "system") .filter((item) => exercise !== "dds" || item.outcome === "card") .sort((a, b) => (a.position ?? 0) - (b.position ?? 0)) : []; const [wholeTicket, setWholeTicket] = useState(false); const canStartSelectedExercise = exercise === "dds" && wholeTicket ? selectedTicketScenarios.length > 0 : Boolean(scenarioId); const startDisabledReason = scenarios.isLoading || trainees.isLoading ? "Загружаются сценарии и список курсантов…" : !canStartSelectedExercise ? "Выберите подходящий сценарий или билет перед запуском." : Boolean(trainees.data?.length && (!traineeId || (traineeId === "__demo__" && !DEMO_MODE))) ? DEMO_MODE ? "Выберите курсанта из списка или пункт «Демо-курсант»." : "Сначала выберите курсанта из списка." : ""; const selectedTraineeLabel = trainees.data?.find((item) => item.id === traineeId)?.name ?? (traineeId === "__demo__" ? trainee.trim() || "Демо-курсант" : trainee.trim()); const exerciseLabel = exercise === "dds" ? "Диспетчер ДДС" : exercise === "call" ? "Оператор 112 · звонок" : exercise === "card" ? "Оператор 112 · текстовая вводная" : "Полный цикл 112 → ДДС"; useEffect(() => () => { if (startTimeout.current !== null) window.clearTimeout(startTimeout.current); }, []); useEffect(() => { if (!sessionId || sessionId !== startPendingId) return; if (session.title) { if (startTimeout.current !== null) window.clearTimeout(startTimeout.current); startTimeout.current = null; startPendingRef.current = null; setStartPendingId(null); setStartError(""); return; } // observe may connect just before scenario.start and report a transient // session_not_found. Do not present that expected startup race as failure. if (session.error && session.error !== "Занятие не запущено") { if (startTimeout.current !== null) window.clearTimeout(startTimeout.current); startTimeout.current = null; toStart.current = null; startPendingRef.current = null; setStartPendingId(null); setStartError(session.error); } }, [sessionId, startPendingId, session.title, session.error]); useEffect(() => { if (!visibleScenarios.some((item) => item.id === scenarioId)) { setScenarioId(visibleScenarios[0]?.id ?? ""); } }, [scenarios.data, ticketFilter, categoryFilter, levelFilter, scenarioSearch, scenarioId, exercise, cardSource]); function startLesson() { if (startPendingId || toStart.current || startDisabledReason) return; if (trainees.data?.length && (!traineeId || (traineeId === "__demo__" && !DEMO_MODE))) return; const id = crypto.randomUUID(); // Номер занятия живёт в адресе: монитор и АРМ курсанта открываются ссылкой. window.history.replaceState(null, "", `/instructor?session=${id}`); toStart.current = id; startPendingRef.current = id; setStartPendingId(id); setStartError(""); setSessionId(id); startTimeout.current = window.setTimeout(() => { if (startPendingRef.current !== id) return; startPendingRef.current = null; if (toStart.current === id) toStart.current = null; startTimeout.current = null; setStartPendingId(null); setStartError("Не удалось запустить занятие. Проверьте подключение и нажмите «Повторить запуск»."); }, 15_000); } 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); } } function openActiveSession(item: NonNullable[number]) { toStart.current = null; startPendingRef.current = null; if (startTimeout.current !== null) window.clearTimeout(startTimeout.current); startTimeout.current = null; setStartPendingId(null); window.history.replaceState(null, "", `/instructor?session=${item.session_id}`); setSessionId(item.session_id); setScenarioId(item.scenario_id); setExercise(item.exercise); setMode(item.mode); } // Занятие запускается, когда канал управления открылся. useEffect(() => { if (!sessionId || control.openSessionId !== sessionId || toStart.current !== sessionId) return; const selected = trainees.data?.find((item) => item.id === traineeId); const primaryScenarioId = exercise === "dds" && wholeTicket ? selectedTicketScenarios[0]?.id ?? scenarioId : scenarioId; const scenarioIds = exercise === "dds" || exercise === "card_dds" ? (exercise === "dds" && wholeTicket ? selectedTicketScenarios.map((item) => item.id) : [scenarioId, ...extraScenarioIds.filter((id) => id !== scenarioId && visibleScenarios.some((item) => item.id === id))]) .filter((id) => scenarios.data?.find((item) => item.id === id)?.outcome === "card") .filter((id, index, all) => id && all.indexOf(id) === index) : undefined; const randomScenarioIds = randomScenario && exercise !== "call" && !(exercise === "dds" && wholeTicket) ? visibleScenarios.filter((item) => item.outcome === "card").map((item) => item.id) : undefined; const sent = control.start(sessionId, { scenarioId: primaryScenarioId, mode, trainee: selected?.name || trainee || "Демо-курсант", exercise: exercise === "card_dds" ? "card" : exercise, traineeId: selected?.id, ddsService: selected?.service ?? undefined, scenarioIds: scenarioIds && scenarioIds.length > 1 ? scenarioIds : undefined, randomScenarioIds, ddsArrivalIntervalSeconds, ddsMaxWaiting, handoffToDds: exercise === "card_dds", criteria: { decision_time_limit_seconds: decisionTimeLimit, card_fill_time_limit_seconds: cardFillTimeLimit, dds_card_work_time_limit_seconds: ddsCardWorkTimeLimit, allowed_errors: allowedErrors, require_correct_grammar: requireCorrectGrammar, score_weights: lessonScoreWeights, }, }); if (sent) toStart.current = null; }, [sessionId, control, scenarioId, extraScenarioIds, mode, trainee, traineeId, trainees.data, exercise, decisionTimeLimit, cardFillTimeLimit, ddsCardWorkTimeLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios, ddsArrivalIntervalSeconds, ddsMaxWaiting, lessonScoreWeights, scenarios.data, randomScenario, visibleScenarios]); 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 (

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

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

Занятия группы в реальном времени {activeSessions.isLoading ? "Обновляем список…" : activeSessions.data?.length ? `${activeSessions.data.length} активных` : "Сейчас нет активных занятий"}

Список ваших активных сессий обновляется автоматически каждые 2 секунды.

{(activeSessions.data ?? []).map((item) => ( ))} {!activeSessions.data?.length && }
КурсантУпражнение · карточкаХодДДСЗадержки
{item.trainee_name || "Демо-курсант"}{modeLabel(item.mode)} · {Math.floor(item.elapsed_seconds / 60)} мин {item.elapsed_seconds % 60} с {item.scenario_title}{item.scenario_id}{item.exercise === "dds" && ` · ${item.dds_open_cards}/${item.dds_card_total} карточек открыто`} {Object.entries(item.dds_statuses).map(([service, status]) => {service}: {status}
)} {!Object.keys(item.dds_statuses).length && (item.exercise === "dds" ? "Ожидает первичного решения" : "Упражнение выполняется")} {item.dds_snapshot &&
Карточки и доклады ({item.dds_snapshot.queue_cards?.length ?? 0}) {item.dds_snapshot.crew_selected &&

Бригада: {item.dds_snapshot.crew_selected}

} {item.dds_snapshot.phone_pending &&

Идёт связь с бригадой {item.dds_snapshot.phone_pending.crew}.

} {item.dds_snapshot.queue_cards?.map((card) =>
{card.active ? "Сейчас в работе: " : "Ожидает: "}{card.title} {card.address && {card.address}} {card.managed_service ? `${card.managed_service} · ` : ""}{SERVICE_STATUS_LABELS[card.service_status]} · {CARD_STATUS_LABELS[card.card_status]} {card.timer_stopped ? `Первичная реакция за ${Math.ceil(card.elapsed_ms / 1000)} с` : card.elapsed_ms > card.limit_ms ? `Просрочена первичная реакция · ${Math.ceil((card.elapsed_ms - card.limit_ms) / 1000)} с` : `На первичное решение осталось ${Math.ceil((card.limit_ms - card.elapsed_ms) / 1000)} с`}
)} {item.dds_snapshot.pending_cards_count ?

Ещё поступят по сценарию: {item.dds_snapshot.pending_cards_count}

: null} {item.dds_snapshot.log.length > 0 &&
Последние отметки служб {item.dds_snapshot.log.slice(-4).reverse().map((entry, index) =>

{entry.service} · {SERVICE_STATUS_LABELS[entry.status]} {entry.comment && {entry.comment}}

)}
} {item.dds_snapshot.phone_reports?.length ?
Доклады бригады {item.dds_snapshot.phone_reports.map((report, index) =>

{report.crew} · {report.phase === "dispatched" ? "выезд" : report.phase === "arrived" ? "прибытие" : report.phase === "working" ? "ход работ" : report.phase === "completed" ? "завершение" : report.phase} {report.text}

)}
: null}
}
{item.exercise === "dds" ? `Открытых карточек: ${item.dds_open_cards}` : "—"} {item.dds_overdue_cards || item.dds_work_overdue_cards ? `Просрочено: первичная реакция ${item.dds_overdue_cards}; отработка карточки ${item.dds_work_overdue_cards}` : "Нормативы не нарушены"}
{activeSessions.isLoading ? "Загружаем активные занятия…" : "Активных занятий пока нет."}
0}> Карточки курсантов на проверку {scenarioSubmissions.isLoading ? "Проверяем очередь…" : pendingSubmissions.length ? `${pendingSubmissions.length} ожидают решения` : "Новых карточек нет"}

Только ваши учебные группы. Утверждение создаёт карточку для очереди ДДС; преподаватель проверяет её факты до публикации.

{pendingSubmissions.map((item) => (
{item.title}{item.author_name} · {item.level} · {item.incident_type}
{item.kio ?
Проверить КИО целиком
: <>

{item.description}

{item.address &&

Адрес: {item.address}

} {item.victims !== null &&

Пострадавших: {item.victims}

} }