lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
// Пульт преподавателя.
//
// Компоновка от того, по чему е г о оценит действующий преподаватель: задать
// сценарий и не вмешиваться, видеть работу курсанта вживую, получить готовый
// разбор, показать группе (docs/arch/FRONTEND.md).
import { useEffect , useRef , useState } from "react" ;
import { Debrief } from "@/features/debrief/Debrief" ;
2026-09-17 21:38:43 +03:00
import { Director } from "@/features/instructor/Director" ;
2026-09-26 18:12:27 +03:00
import { ScoreWeightsEditor } from "@/features/instructor/ScoreWeightsEditor" ;
2026-09-21 17:40:54 +03:00
import { ScenarioEditor } from "@/features/instructor/ScenarioEditor" ;
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
import { useControl } from "@/features/instructor/useControl" ;
import { KioCard } from "@/features/kio-card/KioCard" ;
lct-21: make demo, честный README, готовность стенда
make demo поднимает стенд целиком: проверяет модели, базу, миграции,
сценарии, бэкенд с голосом в офлайн-режиме и фронт. Обкатан заранее,
как и требует карточка, — и сразу поймал ошибку: `cd backend && команда &`
уводит в фон всю связку, родительская оболочка остаётся на месте,
и фронт запускался не из репозитория.
Пять диалогов подряд через make demo: без сбоев, ход 965–1210 мс,
первая реплика 78–115 мс. make dev проверен целиком — образ фронта
собирается, страница отдаётся.
README переписан под факт: два шага до стенда и таблица того, что
работает, вместо обещаний. Экран готовности показывает курсанту, почему
звонящий молчит, вместо тишины без объяснений.
В Makefile записано, что make test и make demo нельзя запускать
одновременно: они просят у uv разные группы зависимостей, uv пересобирает
одно окружение, и второй запуск молча ждёт блокировку.
2026-09-17 22:24:48 +03:00
import { StandStatus } from "@/features/health/StandStatus" ;
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
import { ModeBanner } from "@/features/mode-banner/ModeBanner" ;
import { useObserve } from "@/features/observe/useObserve" ;
import { InterviewTimer , Timers } from "@/features/timers/Timers" ;
2026-09-26 18:12:27 +03:00
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" ;
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
import { sessionIdFromUrl } from "@/shared/api/session" ;
2026-09-26 18:12:27 +03:00
import type { Level , SessionMode , SessionReport } from "@/shared/types/generated" ;
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
2026-09-26 18:12:27 +03:00
const MODES : { value : SessionMode ; label : string ; hint : string } [ ] = [
{ value : "training" , label : "Тренировочный" , hint : "Подсказки доступны" } ,
{ value : "exam" , label : "Контрольный" , hint : "Подсказки отключены" } ,
{ value : "self" , label : "Самостоятельная работа" , hint : "Курсант работает самостоятельно; подсказки доступны" } ,
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
] ;
2026-09-26 18:12:27 +03:00
function levelLabel ( level : Level ) : string {
const labels : Record < Level , string > = {
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 < input type = "number" min = { min } max = { max } aria-label = { label } value = { draft }
onChange = { ( event ) = > setDraft ( event . target . value ) } onBlur = { commit } / > ;
}
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
export function Instructor() {
const [ sessionId , setSessionId ] = useState < string | null > ( sessionIdFromUrl ( ) ) ;
const [ scenarioId , setScenarioId ] = useState ( "" ) ;
2026-09-21 20:10:57 +03:00
const [ ticketFilter , setTicketFilter ] = useState ( "" ) ;
const [ categoryFilter , setCategoryFilter ] = useState ( "" ) ;
2026-09-26 18:12:27 +03:00
const [ levelFilter , setLevelFilter ] = useState < Level | "" > ( "" ) ;
2026-09-21 20:10:57 +03:00
const [ scenarioSearch , setScenarioSearch ] = useState ( "" ) ;
2026-09-26 18:12:27 +03:00
const [ cardSource , setCardSource ] = useState < "system" | "trainee" | "mixed" > ( "system" ) ;
const [ submissionReviewComments , setSubmissionReviewComments ] = useState < Record < string , string > > ( { } ) ;
2026-09-21 19:56:13 +03:00
const [ extraScenarioIds , setExtraScenarioIds ] = useState < string [ ] > ( [ ] ) ;
2026-09-26 18:12:27 +03:00
const [ randomScenario , setRandomScenario ] = useState ( false ) ;
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
const [ mode , setMode ] = useState < SessionMode > ( "training" ) ;
2026-09-21 20:10:57 +03:00
const [ exercise , setExercise ] = useState < "dds" | "call" | "card" | "card_dds" > ( "dds" ) ;
2026-09-24 01:10:49 +03:00
const [ decisionTimeLimit , setDecisionTimeLimit ] = useState ( 30 ) ;
2026-09-26 18:12:27 +03:00
const [ cardFillTimeLimit , setCardFillTimeLimit ] = useState ( 180 ) ;
const [ ddsCardWorkTimeLimit , setDdsCardWorkTimeLimit ] = useState ( 180 ) ;
2026-09-24 01:10:49 +03:00
const [ allowedErrors , setAllowedErrors ] = useState ( 0 ) ;
const [ requireCorrectGrammar , setRequireCorrectGrammar ] = useState ( true ) ;
const [ lessonScoreWeights , setLessonScoreWeights ] = useState < Record < string , number > > ( { } ) ;
const [ ddsArrivalIntervalSeconds , setDdsArrivalIntervalSeconds ] = useState ( 60 ) ;
const [ ddsMaxWaiting , setDdsMaxWaiting ] = useState ( 3 ) ;
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
const [ trainee , setTrainee ] = useState ( "" ) ;
2026-09-21 17:40:54 +03:00
const [ traineeId , setTraineeId ] = useState ( "" ) ;
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
const [ noteFor , setNoteFor ] = useState < string | null > ( null ) ;
// Запускается только занятие, созданное этой кнопкой. Открытая из истории
// ссылка на идущее занятие не должна запускать е г о заново и сбрасывать.
const toStart = useRef < string | null > ( null ) ;
2026-09-26 18:12:27 +03:00
const [ startPendingId , setStartPendingId ] = useState < string | null > ( null ) ;
const startPendingRef = useRef < string | null > ( null ) ;
const [ startError , setStartError ] = useState ( "" ) ;
const startTimeout = useRef < number | null > ( null ) ;
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
const [ noteText , setNoteText ] = useState ( "" ) ;
2026-09-24 01:10:49 +03:00
const [ overrideReport , setOverrideReport ] = useState < SessionReport | null > ( null ) ;
const [ overrideScore , setOverrideScore ] = useState ( "" ) ;
const [ overrideComment , setOverrideComment ] = useState ( "" ) ;
const [ overrideError , setOverrideError ] = useState ( "" ) ;
const [ overrideBusy , setOverrideBusy ] = useState ( false ) ;
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
const scenarios = useScenarios ( ) ;
2026-09-26 18:12:27 +03:00
const scenarioSubmissions = useScenarioSubmissions ( ) ;
const reviewScenarioSubmission = useReviewScenarioSubmission ( ) ;
2026-09-21 20:10:57 +03:00
const incidentGroups = useIncidentGroups ( ) ;
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
const history = useSessions ( ) ;
2026-09-26 18:12:27 +03:00
const activeSessions = useActiveSessions ( ) ;
2026-09-21 17:40:54 +03:00
const trainees = useTrainees ( ) ;
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
const session = useObserve ( sessionId ) ;
const control = useControl ( sessionId ) ;
2026-09-26 18:12:27 +03:00
const pendingSubmissions = ( scenarioSubmissions . data ? ? [ ] ) . filter ( ( item ) = > item . status === "pending" ) ;
const ddsExcludedScenarios = ( scenarios . data ? ? [ ] ) . filter ( ( item ) = > item . outcome !== "card" ) ;
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
2026-09-24 01:10:49 +03:00
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 ] ) ;
2026-09-21 20:10:57 +03:00
const visibleScenarios = ( scenarios . data ? ? [ ] ) . filter ( ( item ) = >
2026-09-26 18:12:27 +03:00
( 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 ) &&
2026-09-21 20:10:57 +03:00
( ! 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 ) ) ;
2026-09-26 18:12:27 +03:00
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 ) ;
}
2026-09-24 01:10:49 +03:00
const selectedTicketScenarios = ticketFilter
? ( scenarios . data ? ? [ ] ) . filter ( ( item ) = > item . ticket === Number ( ticketFilter ) )
2026-09-26 18:12:27 +03:00
. filter ( ( item ) = > item . source === "system" )
. filter ( ( item ) = > exercise !== "dds" || item . outcome === "card" )
2026-09-24 01:10:49 +03:00
. sort ( ( a , b ) = > ( a . position ? ? 0 ) - ( b . position ? ? 0 ) )
: [ ] ;
const [ wholeTicket , setWholeTicket ] = useState ( false ) ;
const canStartSelectedExercise = exercise === "dds" && wholeTicket
2026-09-26 18:12:27 +03:00
? selectedTicketScenarios . length > 0
2026-09-24 01:10:49 +03:00
: Boolean ( scenarioId ) ;
2026-09-26 18:12:27 +03:00
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 ] ) ;
2026-09-21 20:10:57 +03:00
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
useEffect ( ( ) = > {
2026-09-21 20:10:57 +03:00
if ( ! visibleScenarios . some ( ( item ) = > item . id === scenarioId ) ) {
setScenarioId ( visibleScenarios [ 0 ] ? . id ? ? "" ) ;
}
2026-09-26 18:12:27 +03:00
} , [ scenarios . data , ticketFilter , categoryFilter , levelFilter , scenarioSearch , scenarioId , exercise , cardSource ] ) ;
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
function startLesson() {
2026-09-26 18:12:27 +03:00
if ( startPendingId || toStart . current || startDisabledReason ) return ;
if ( trainees . data ? . length && ( ! traineeId || ( traineeId === "__demo__" && ! DEMO_MODE ) ) ) return ;
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
const id = crypto . randomUUID ( ) ;
// Номер занятия живёт в адресе: монитор и А Р М курсанта открываются ссылкой.
window . history . replaceState ( null , "" , ` /instructor?session= ${ id } ` ) ;
toStart . current = id ;
2026-09-26 18:12:27 +03:00
startPendingRef . current = id ;
setStartPendingId ( id ) ;
setStartError ( "" ) ;
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
setSessionId ( id ) ;
2026-09-26 18:12:27 +03:00
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 ) ;
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
}
2026-09-24 01:10:49 +03:00
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 ) ;
}
}
2026-09-26 18:12:27 +03:00
function openActiveSession ( item : NonNullable < typeof activeSessions.data > [ 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 ) ;
}
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
// Занятие запускается, когда канал управления открылся.
useEffect ( ( ) = > {
2026-09-26 18:12:27 +03:00
if ( ! sessionId || control . openSessionId !== sessionId || toStart . current !== sessionId ) return ;
2026-09-21 17:40:54 +03:00
const selected = trainees . data ? . find ( ( item ) = > item . id === traineeId ) ;
2026-09-24 01:10:49 +03:00
const primaryScenarioId = exercise === "dds" && wholeTicket
? selectedTicketScenarios [ 0 ] ? . id ? ? scenarioId : scenarioId ;
2026-09-26 18:12:27 +03:00
const scenarioIds = exercise === "dds" || exercise === "card_dds"
? ( exercise === "dds" && wholeTicket
2026-09-24 01:10:49 +03:00
? selectedTicketScenarios . map ( ( item ) = > item . id )
2026-09-26 18:12:27 +03:00
: [ scenarioId , . . . extraScenarioIds . filter ( ( id ) = > id !== scenarioId &&
visibleScenarios . some ( ( item ) = > item . id === id ) ) ] )
. filter ( ( id ) = > scenarios . data ? . find ( ( item ) = > item . id === id ) ? . outcome === "card" )
2026-09-24 01:10:49 +03:00
. filter ( ( id , index , all ) = > id && all . indexOf ( id ) === index )
: undefined ;
2026-09-26 18:12:27 +03:00
const randomScenarioIds = randomScenario && exercise !== "call"
&& ! ( exercise === "dds" && wholeTicket )
? visibleScenarios . filter ( ( item ) = > item . outcome === "card" ) . map ( ( item ) = > item . id )
: undefined ;
2026-09-26 20:59:50 +00:00
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 : {
2026-09-24 01:10:49 +03:00
decision_time_limit_seconds : decisionTimeLimit ,
2026-09-26 18:12:27 +03:00
card_fill_time_limit_seconds : cardFillTimeLimit ,
dds_card_work_time_limit_seconds : ddsCardWorkTimeLimit ,
2026-09-24 01:10:49 +03:00
allowed_errors : allowedErrors ,
require_correct_grammar : requireCorrectGrammar ,
score_weights : lessonScoreWeights ,
2026-09-26 20:59:50 +00:00
} ,
} ) ;
2026-09-26 18:12:27 +03:00
if ( sent ) toStart . current = null ;
2026-09-24 01:10:49 +03:00
} , [ sessionId , control , scenarioId , extraScenarioIds , mode , trainee , traineeId , trainees . data ,
2026-09-26 18:12:27 +03:00
exercise , decisionTimeLimit , cardFillTimeLimit , ddsCardWorkTimeLimit , allowedErrors , requireCorrectGrammar , wholeTicket , selectedTicketScenarios ,
ddsArrivalIntervalSeconds , ddsMaxWaiting , lessonScoreWeights , scenarios . data ,
randomScenario , visibleScenarios ] ) ;
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
const links = sessionId
? {
trainee : ` ${ location . origin } /trainee?session= ${ sessionId } ` ,
wall : ` ${ location . origin } /wall?session= ${ sessionId } ` ,
2026-09-17 21:45:47 +03:00
dds : ` ${ location . origin } /dds?session= ${ sessionId } &role=dds_01 ` ,
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
}
: null ;
return (
2026-09-26 18:12:27 +03:00
< main className = { ` page instructor-page ${ session . report ? " has-report" : "" } ` } >
lct-21: make demo, честный README, готовность стенда
make demo поднимает стенд целиком: проверяет модели, базу, миграции,
сценарии, бэкенд с голосом в офлайн-режиме и фронт. Обкатан заранее,
как и требует карточка, — и сразу поймал ошибку: `cd backend && команда &`
уводит в фон всю связку, родительская оболочка остаётся на месте,
и фронт запускался не из репозитория.
Пять диалогов подряд через make demo: без сбоев, ход 965–1210 мс,
первая реплика 78–115 мс. make dev проверен целиком — образ фронта
собирается, страница отдаётся.
README переписан под факт: два шага до стенда и таблица того, что
работает, вместо обещаний. Экран готовности показывает курсанту, почему
звонящий молчит, вместо тишины без объяснений.
В Makefile записано, что make test и make demo нельзя запускать
одновременно: они просят у uv разные группы зависимостей, uv пересобирает
одно окружение, и второй запуск молча ждёт блокировку.
2026-09-17 22:24:48 +03:00
< StandStatus / >
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
< ModeBanner mode = { session . mode ? ? mode } / >
< h1 > Р а б о ч е е м е с т о п р е п о д а в а т е л я < / h1 >
2026-09-24 01:10:49 +03:00
< p className = "workspace-links" >
< a href = "#ai-scenario-editor" > П е р е й т и к И И - ф о р м и р о в а т е л ю с ц е н а р и я ↓ < / a >
< a href = "/materials" > У ч е б н ы е м а т е р и а л ы и н а з н а ч е н и я → < / a >
< / p >
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
2026-09-26 18:12:27 +03:00
< details className = "instructor-live-group instructor-secondary-panel" open = { Boolean ( activeSessions . data ? . length ) } >
< summary >
< span > З а н я т и я г р у п п ы в р е а л ь н о м в р е м е н и < / span >
< small > { activeSessions . isLoading ? "Обновляем список…" : activeSessions . data ? . length
? ` ${ activeSessions . data . length } активных ` : "Сейчас нет активных занятий" } < / small >
< / summary >
< p className = "ref" role = "status" aria-live = "polite" >
С п и с о к в а ш и х а к т и в н ы х с е с с и й о б н о в л я е т с я а в т о м а т и ч е с к и к а ж д ы е 2 с е к у н д ы .
< / p >
< div className = "dds-table-wrap" > < table className = "grid" >
< thead > < tr > < th > К у р с а н т < / th > < th > У п р а ж н е н и е · к а р т о ч к а < / th > < th > Х о д < / th > < th > Д Д С < / th > < th > З а д е р ж к и < / th > < th > < / th > < / tr > < / thead >
< tbody >
{ ( activeSessions . data ? ? [ ] ) . map ( ( item ) = > (
< tr key = { item . session_id } >
< td > { item . trainee_name || "Демо-курсант" } < small className = "ref" > { modeLabel ( item . mode ) } · { Math . floor ( item . elapsed_seconds / 60 ) } м и н { item . elapsed_seconds % 60 } с < / small > < / td >
< td > { item . scenario_title } < small className = "ref" > { item . scenario_id } { item . exercise === "dds" && ` · ${ item . dds_open_cards } / ${ item . dds_card_total } карточек открыто ` } < / small > < / td >
< td > { Object . entries ( item . dds_statuses ) . map ( ( [ service , status ] ) = >
< span key = { service } > { service } : { status } < br / > < / span > ) }
{ ! Object . keys ( item . dds_statuses ) . length && ( item . exercise === "dds" ? "Ожидает первичного решения" : "Упражнение выполняется" ) }
{ item . dds_snapshot && < details className = "active-session-details" >
< summary > К а р т о ч к и и д о к л а д ы ( { item . dds_snapshot . queue_cards ? . length ? ? 0 } ) < / summary >
{ item . dds_snapshot . crew_selected && < p > < strong > Б р и г а д а : < / strong > { item . dds_snapshot . crew_selected } < / p > }
{ item . dds_snapshot . phone_pending && < p className = "ref" role = "status" >
И д ё т с в я з ь с б р и г а д о й { item . dds_snapshot . phone_pending . crew } .
< / p > }
{ item . dds_snapshot . queue_cards ? . map ( ( card ) = > < article className = "active-session-card" key = { card . card_id } >
< strong > { card . active ? "Сейчас в работе: " : "Ожидает: " } { card . title } < / strong >
{ card . address && < span > { card . address } < / span > }
< small > { card . managed_service ? ` ${ card . managed_service } · ` : "" } { SERVICE_STATUS_LABELS [ card . service_status ] } · { CARD_STATUS_LABELS [ card . card_status ] } < / small >
< small > { 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 ) } с ` } < / small >
< / article > ) }
{ item . dds_snapshot . pending_cards_count ? < p className = "ref" > Е щ ё п о с т у п я т п о с ц е н а р и ю : { item . dds_snapshot . pending_cards_count } < / p > : null }
{ item . dds_snapshot . log . length > 0 && < div className = "active-session-log" >
< strong > П о с л е д н и е о т м е т к и с л у ж б < / strong >
{ item . dds_snapshot . log . slice ( - 4 ) . reverse ( ) . map ( ( entry , index ) = > < p key = { ` ${ entry . at } - ${ index } ` } >
< span > { entry . service } · { SERVICE_STATUS_LABELS [ entry . status ] } < / span >
{ entry . comment && < small > { entry . comment } < / small > }
< / p > ) }
< / div > }
{ item . dds_snapshot . phone_reports ? . length ? < div className = "active-session-log" >
< strong > Д о к л а д ы б р и г а д ы < / strong >
{ item . dds_snapshot . phone_reports . map ( ( report , index ) = > < p key = { ` ${ report . at } - ${ index } ` } >
< span > { report . crew } · { report . phase === "dispatched" ? "выезд" : report . phase === "arrived" ? "прибытие" : report . phase === "working" ? "ход работ" : report . phase === "completed" ? "завершение" : report . phase } < / span >
< small > { report . text } < / small >
< / p > ) }
< / div > : null }
< / details > } < / td >
< td > { item . exercise === "dds" ? ` Открытых карточек: ${ item . dds_open_cards } ` : "—" } < / td >
< td className = { item . dds_overdue_cards ? "state-violated" : "state-ok" } >
{ item . dds_overdue_cards || item . dds_work_overdue_cards
? ` Просрочено: первичная реакция ${ item . dds_overdue_cards } ; отработка карточки ${ item . dds_work_overdue_cards } `
: "Нормативы не нарушены" }
< / td >
< td > < button type = "button" onClick = { ( ) = > openActiveSession ( item ) } >
{ sessionId === item . session_id ? "Открыто" : "Наблюдать" }
< / button > < / td >
< / tr >
) ) }
{ ! activeSessions . data ? . length && < tr > < td colSpan = { 6 } >
{ activeSessions . isLoading ? "Загружаем активные занятия…" : "Активных занятий пока нет." }
< / td > < / tr > }
< / tbody >
< / table > < / div >
< / details >
< details className = "student-scenario-review instructor-secondary-panel" open = { pendingSubmissions . length > 0 } >
< summary >
< span > К а р т о ч к и к у р с а н т о в н а п р о в е р к у < / span >
< small > { scenarioSubmissions . isLoading ? "Проверяем очередь…" : pendingSubmissions . length
? ` ${ pendingSubmissions . length } ожидают решения ` : "Новых карточек нет" } < / small >
< / summary >
< p className = "ref" > Т о л ь к о в а ш и у ч е б н ы е г р у п п ы . У т в е р ж д е н и е с о з д а ё т к а р т о ч к у д л я о ч е р е д и Д Д С ; п р е п о д а в а т е л ь п р о в е р я е т е ё ф а к т ы д о п у б л и к а ц и и . < / p >
{ pendingSubmissions . map ( ( item ) = > (
< article className = "student-scenario-proposal" key = { item . id } >
< header > < strong > { item . title } < / strong > < span > { item . author_name } · { item . level } · { item . incident_type } < / span > < / header >
{ item . kio ? < details > < summary > П р о в е р и т ь К И О ц е л и к о м < / summary >
< KioCard state = { { server : item.kio , pending : { } } } readOnly / >
< / details > : < > < p > { item . description } < / p >
{ item . address && < p > < strong > А д р е с : < / strong > { item . address } < / p > }
{ item . victims !== null && < p > < strong > П о с т р а д а в ш и х : < / strong > { item . victims } < / p > }
< / > }
< label > К о м м е н т а р и й п р е п о д а в а т е л я < textarea maxLength = { 1000 } value = { submissionReviewComments [ item . id ] ? ? "" }
onChange = { ( event ) = > setSubmissionReviewComments ( ( previous ) = > ( { . . . previous , [ item . id ] : event . target . value } ) ) } / > < / label >
< footer >
< button type = "button" disabled = { reviewScenarioSubmission . isPending }
onClick = { ( ) = > reviewScenarioSubmission . mutate ( { id : item.id , decision : "approve" , comment : submissionReviewComments [ item . id ] } ) } >
П р о в е р и л ( а ) , у т в е р д и т ь в б а н к Д Д С
< / button >
< button type = "button" disabled = { reviewScenarioSubmission . isPending || ! ( submissionReviewComments [ item . id ] ? ? "" ) . trim ( ) }
onClick = { ( ) = > reviewScenarioSubmission . mutate ( { id : item.id , decision : "reject" , comment : submissionReviewComments [ item . id ] } ) } >
В е р н у т ь н а д о р а б о т к у
< / button >
{ reviewScenarioSubmission . isError && < span role = "alert" className = "state-violated" > { reviewScenarioSubmission . error . message } < / span > }
< / footer >
< / article >
) ) }
{ ! scenarioSubmissions . isLoading && ! pendingSubmissions . length &&
< p className = "ref" > П р е д л о ж е н и я к у р с а н т о в и з в а ш и х г р у п п п о я в я т с я з д е с ь . < / p > }
< / details >
< div className = { ` instructor-layout ${ sessionId ? " has-session" : " is-idle" } ` } >
2026-09-21 20:10:57 +03:00
< section >
2026-09-26 18:12:27 +03:00
< h2 > Н а с т р о и т ь з а н я т и е < / h2 >
< p className = "lesson-setup-intro" > < strong > Ч е т ы р е ш а г а д о з а п у с к а < / strong > < span > В ы б е р и т е с ц е н а р и й , у п р а ж н е н и е , р е ж и м и к у р с а н т а . О с т а л ь н ы е н а с т р о й к и у ж е п о д г о т о в л е н ы — п р и н е о б х о д и м о с т и и х м о ж н о р а с к р ы т ь н и ж е . < / span > < / p >
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
< table className = "grid" >
< tbody >
< tr >
2026-09-26 18:12:27 +03:00
< th > < span className = "lesson-step-title" > < i > 1 < / i > С ц е н а р и й < / span > < / th >
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
< td >
2026-09-26 18:12:27 +03:00
< details className = "scenario-filters-details" >
< summary > Ф и л ь т р ы с ц е н а р и е в < span > { hasScenarioFilters ? "изменены · нажмите, чтобы настроить" : "необязательно · нажмите, чтобы открыть" } < / span > < / summary >
< div className = "instructor-scenario-filters" >
< label > < span > И с т о ч н и к к а р т о ч е к < / span > < select aria-label = "Источник карточек" value = { cardSource } onChange = { ( event ) = > {
setCardSource ( event . target . value as "system" | "trainee" | "mixed" ) ;
setWholeTicket ( false ) ;
} } >
< option value = "system" > С и с т е м н ы е к а р т о ч к и < / option >
< option value = "trainee" > С ф о р м и р о в а н н ы е к у р с а н т а м и < / option >
< option value = "mixed" > С и с т е м н ы е + к у р с а н т ы < / option >
< / select > < / label >
< label > < span > К а т е г о р и я Е К П < / span > < select aria-label = "Категория происшествия" value = { categoryFilter } onChange = { ( event ) = > setCategoryFilter ( event . target . value ) } >
2026-09-21 20:10:57 +03:00
< option value = "" > В с е 23 к а т е г о р и и Е К П < / option >
{ incidentGroups . data ? . groups . map ( ( group ) = >
< option key = { group . id } value = { group . id } > { group . title } < / option > ) }
2026-09-26 18:12:27 +03:00
< / select > < / label >
< label > < span > Б и л е т < / span > < select aria-label = "Билет заказчика" value = { ticketFilter } onChange = { ( event ) = > {
2026-09-24 01:10:49 +03:00
setTicketFilter ( event . target . value ) ;
setWholeTicket ( false ) ;
} } >
2026-09-21 20:10:57 +03:00
< option value = "" > В с е 32 б и л е т а < / option >
{ Array . from ( { length : 32 } , ( _ , index ) = > < option key = { index } value = { index + 1 } > Б и л е т { index + 1 } < / option > ) }
2026-09-26 18:12:27 +03:00
< / select > < / label >
< label > < span > С л о ж н о с т ь < / span > < select aria-label = "Уровень сложности" value = { levelFilter }
onChange = { ( event ) = > setLevelFilter ( event . target . value as Level | "" ) } >
< option value = "" > В с е у р о в н и с л о ж н о с т и < / option >
< option value = "L1" > L1 · п р о с т о й в ы з о в < / option >
< option value = "L2" > L2 · з а я в и т е л ь в с т р е с с е < / option >
< option value = "L3" > L3 · о б р ы в с в я з и / о б р а т н ы й в ы з о в < / option >
< / select > < / label >
< / div >
{ hasScenarioFilters && < button className = "scenario-filters-reset" type = "button" onClick = { clearScenarioFilters } > С б р о с и т ь ф и л ь т р ы < / button > }
< / details >
< div className = "instructor-scenario-filters scenario-search-only" >
< label className = "instructor-filter-search" > < span > Н а й т и к а р т о ч к у < / span > < input type = "search" aria-label = "Поиск сценария" value = { scenarioSearch }
placeholder = "Название или номер" onChange = { ( event ) = > setScenarioSearch ( event . target . value ) } / > < / label >
2026-09-21 20:10:57 +03:00
< / div >
2026-09-26 18:12:27 +03:00
< label className = "instructor-primary-scenario" > < span > С ц е н а р и й з а н я т и я < / span >
< select aria-label = "Сценарий занятия" value = { scenarioId } disabled = { exercise === "dds" && wholeTicket } onChange = { ( event ) = > {
setScenarioId ( event . target . value ) ;
setExtraScenarioIds ( ( previous ) = > previous . filter ( ( id ) = > id !== event . target . value ) ) ;
} } >
{ ! visibleScenarios . length && < option value = "" > К а р т о ч к и н е н а й д е н ы < / option > }
{ visibleScenarios . map ( ( scenario ) = > (
< option key = { scenario . id } value = { scenario . id } >
{ scenario . source === "trainee" ? "От курсанта · " : scenario . ticket ? ` Билет ${ scenario . ticket } , вызов ${ scenario . position } · ` : "" } { scenario . title } · { levelLabel ( scenario . level ) }
< / option >
) ) }
< / select >
< / label >
{ ! scenarios . isLoading && visibleScenarios . length === 0 && < div className = "scenario-empty-state" role = "status" >
< strong > П о д х о д я щ и х с ц е н а р и е в н е н а й д е н о < / strong >
< span > И з м е н и т е п о и с к и л и ф и л ь т р ы . М о ж н о б ы с т р о в е р н у т ь с я к с и с т е м н ы м к а р т о ч к а м . < / span >
{ hasScenarioFilters && < button type = "button" onClick = { clearScenarioFilters } > С б р о с и т ь ф и л ь т р ы < / button > }
< / div > }
{ selectedScenario && < div className = "selected-scenario-summary" aria-live = "polite" >
< span > Д л я з а н я т и я в ы б р а н о < / span >
< strong > { selectedScenario . title } < / strong >
< small > { selectedScenario . source === "trainee" ? "Предложение курсанта" : selectedScenario . ticket ? ` Билет ${ selectedScenario . ticket } ` : "Учебный сценарий" } · { levelLabel ( selectedScenario . level ) } < / small >
{ ( selectedScenario . dds || selectedScenario . topics ? . length || selectedScenario . outcome ) && < div className = "selected-scenario-details" >
{ selectedScenario . dds && < span > < b > Д л я Д Д С : < / b > { selectedScenario . dds } < / span > }
{ Boolean ( selectedScenario . topics ? . length ) && < span > < b > Т е м ы : < / b > { selectedScenario . topics ! . join ( " · " ) } < / span > }
{ selectedScenario . outcome && < span > < b > Т и п : < / b > { selectedScenario . outcome === "card" ? "карточка происшествия" : selectedScenario . outcome === "consultation" ? "консультация" : "передача в другой регион" } < / span > }
< / div > }
< / div > }
< small className = "ref scenario-count" >
Н а й д е н о : { visibleScenarios . length } и з { scenarios . data ? . length ? ? 0 } с ц е н а р и е в
{ exercise === "dds" && ddsExcludedScenarios . length > 0 &&
` · ${ ddsExcludedScenarios . length } не являются карточками ДДС ` }
< / small >
{ exercise !== "call" && ! ( exercise === "dds" && wholeTicket ) && < label className = "mode-choice" >
< input type = "checkbox" checked = { randomScenario }
disabled = { ! visibleScenarios . some ( ( item ) = > item . outcome === "card" ) }
onChange = { ( event ) = > setRandomScenario ( event . target . checked ) } / >
{ " Случайный порядок карточек из отфильтрованного набора" }
< small className = "ref" > — п о р я д о к ф о р м и р у е т с е р в е р ; н о в ы е к а р т о ч к и п о с т у п а ю т п о з а д а н н о м у т е м п у , з а н я т и е м о ж н о з а в е р ш и т ь в л ю б о й м о м е н т < / small >
< / label > }
{ exercise === "dds" && ticketFilter && cardSource === "system" && < label className = "mode-choice" >
< input type = "checkbox" checked = { wholeTicket && selectedTicketScenarios . length > 0 }
disabled = { selectedTicketScenarios . length === 0 }
2026-09-24 01:10:49 +03:00
onChange = { ( event ) = > {
if ( event . target . checked && selectedTicketScenarios [ 0 ] ) {
setScenarioId ( selectedTicketScenarios [ 0 ] . id ) ;
setExtraScenarioIds ( [ ] ) ;
}
setWholeTicket ( event . target . checked ) ;
} } / >
2026-09-26 18:12:27 +03:00
{ ` Запустить из билета ${ ticketFilter } карточки для ДДС ( ${ selectedTicketScenarios . length } ) ` }
{ exercise === "dds" && selectedTicketScenarios . length < 3 &&
< small className = "ref" > — в ы з о в ы б е з к а р т о ч к и в Д Д С н е в к л ю ч а ю т с я в о ч е р е д ь < / small > }
{ selectedTicketScenarios . length === 0 && < small className = "ref" > — н е т к а р т о ч е к д л я у п р а ж н е н и я Д Д С < / small > }
2026-09-24 01:10:49 +03:00
< / label > }
2026-09-26 18:12:27 +03:00
{ ( exercise === "dds" || exercise === "card_dds" ) && ! wholeTicket && < details className = "scenario-queue" >
< summary > { exercise === "card_dds" ? "Готовые карточки после КИО курсанта" : "Дополнительные карточки" } ( { extraScenarioIds . length } ) < / summary >
< p className = "ref" > { exercise === "card_dds" ? "Сначала курсант заполнит и передаст КИО, затем карточка попадёт в ДДС первой; отмеченные сценарии поступят следом." : "Интервал и максимум ожидающих задаются в критериях занятия. Таймер каждой карточки начинается при поступлении и идёт, пока курсант занят другой." } < / p >
{ visibleScenarios . filter ( ( scenario ) = > scenario . id !== scenarioId && scenario . outcome === "card" ) . map ( ( scenario ) = >
2026-09-21 19:56:13 +03:00
< label key = { scenario . id } className = "mode-choice" >
< input type = "checkbox" checked = { extraScenarioIds . includes ( scenario . id ) } onChange = { ( ) = >
setExtraScenarioIds ( ( previous ) = > previous . includes ( scenario . id )
? previous . filter ( ( id ) = > id !== scenario . id ) : [ . . . previous , scenario . id ] ) } / >
{ " " } { scenario . title } · { scenario . level }
< / label > ) }
< / details > }
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
< / td >
< / tr >
2026-09-21 17:40:54 +03:00
< tr >
2026-09-26 18:12:27 +03:00
< th > < span className = "lesson-step-title" > < i > 2 < / i > У п р а ж н е н и е < / span > < / th >
2026-09-21 17:40:54 +03:00
< td >
2026-09-26 18:12:27 +03:00
< div className = "exercise-options" >
< label className = "exercise-option" > < input type = "radio" checked = { exercise === "dds" }
onChange = { ( ) = > setExercise ( "dds" ) } / > < span > < strong > Д и с п е т ч е р Д Д С < / strong > < small > Р а з о б р а т ь г о т о в у ю к а р т о ч к у и п е р е д а т ь е ё с л у ж б а м . < / small > < / span > < / label >
< label className = "exercise-option" > < input type = "radio" checked = { exercise === "call" }
onChange = { ( ) = > setExercise ( "call" ) } / > < span > < strong > О п е р а т о р 112 · з в о н о к < / strong > < small > П р и н я т ь у ч е б н ы й в ы з о в и з а п о л н и т ь к а р т о ч к у . < / small > < / span > < / label >
< label className = "exercise-option" > < input type = "radio" checked = { exercise === "card" }
onChange = { ( ) = > setExercise ( "card" ) } / > < span > < strong > О п е р а т о р 112 · т е к с т о в а я в в о д н а я < / strong > < small > О т р а б о т а т ь з а п о л н е н и е К И О б е з а у д и о з в о н к а . < / small > < / span > < / label >
< label className = "exercise-option" > < input type = "radio" checked = { exercise === "card_dds" }
onChange = { ( ) = > setExercise ( "card_dds" ) } / > < span > < strong > П о л н ы й ц и к л 112 → Д Д С < / strong > < small > К у р с а н т з а п о л н и т К И О , з а т е м о т р а б о т а е т г о т о в ы е к а р т о ч к и в Д Д С . < / small > < / span > < / label >
< / div >
{ exercise === "call" && < p className = "ref" > В р е ж и м е з в о н к а п о л я К И О о т к р о ю т с я п о с л е о т в е т а н а в ы з о в ; б р а у з е р у п о н а д о б я т с я р а з р е ш е н и я н а м и к р о ф о н и з в у к . Д л я д е м о н с т р а ц и и б е з а у д и о в ы б е р и т е « О п е р а т о р 112 · т е к с т о в а я в в о д н а я » — е ё п о л я д о с т у п н ы с р а з у . < / p > }
2026-09-21 17:40:54 +03:00
< / td >
< / tr >
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
< tr >
2026-09-26 18:12:27 +03:00
< th > < span className = "lesson-step-title" > < i > 3 < / i > Р е ж и м < / span > < / th >
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
< td >
{ MODES . map ( ( item ) = > (
2026-09-26 18:12:27 +03:00
< label key = { item . value } className = "mode-choice lesson-mode-choice" >
< input
type = "radio"
checked = { mode === item . value }
onChange = { ( ) = > setMode ( item . value ) }
/ > { " " }
< span > < strong > { item . label } < / strong > < small > { item . hint } < / small > < / span >
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
< / label >
) ) }
< / td >
< / tr >
< tr >
2026-09-26 18:12:27 +03:00
< th > < span className = "lesson-step-title" > < i > 4 < / i > К у р с а н т < / span > < / th >
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
< td >
2026-09-21 17:40:54 +03:00
{ trainees . data ? . length ? (
2026-09-26 18:12:27 +03:00
< >
< select aria-label = "Учётная запись курсанта" value = { traineeId } onChange = { ( event ) = > setTraineeId ( event . target . value ) } >
< option value = "" > В ы б е р и т е у ч ё т н у ю з а п и с ь < / option >
{ trainees . data . map ( ( item ) = > (
< option key = { item . id } value = { item . id } > { item . name } { item . group ? ` · ${ item . group } ` : "" } { item . service ? ` · ${ item . service } ` : " · служба не назначена" } < / option >
) ) }
{ DEMO_MODE && < option value = "__demo__" > Д е м о - к у р с а н т б е з у ч ё т н о й з а п и с и < / option > }
< / select >
{ DEMO_MODE && traineeId === "__demo__" && < label className = "demo-trainee-name" >
И м я д л я э т о г о з а н я т и я
< input type = "text" aria-label = "Имя демо-курсанта" value = { trainee }
placeholder = "Например, Иван Петров" onChange = { ( event ) = > setTrainee ( event . target . value ) } / >
< small className = "ref" > У ч ё т н а я з а п и с ь и п е р с о н а л ь н ы й о т ч ё т н е с о з д а ю т с я ; э т о б е з о п а с н ы й р е ж и м д л я д е м о н с т р а ц и и . < / small >
< / label > }
< / >
2026-09-21 17:40:54 +03:00
) : (
< >
2026-09-26 18:12:27 +03:00
< input type = "text" aria-label = "Имя демо-курсанта" value = { trainee } placeholder = "имя для демо"
2026-09-21 17:40:54 +03:00
onChange = { ( event ) = > setTrainee ( event . target . value ) } / >
< small className = "ref" > Б е з у ч ё т н о й записи : личный о т ч ё т к у р с а н т у н е д о с т у п е н . < / small >
< / >
) }
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
< / td >
< / tr >
2026-09-24 01:10:49 +03:00
< tr >
2026-09-26 18:12:27 +03:00
< th > О ц е н и в а н и е < / th >
2026-09-24 01:10:49 +03:00
< td >
2026-09-26 18:12:27 +03:00
< details className = "lesson-criteria-details" >
< summary > Н а с т р о и т ь н о р м а т и в ы и к р и т е р и и о ц е н к и < / summary >
< p className = "ref" >
{ exercise === "dds"
? ` По умолчанию: решение за ${ decisionTimeLimit } с , отработка карточки за ${ ddsCardWorkTimeLimit } с ; новые карточки поступают каждые ${ ddsArrivalIntervalSeconds } с . `
: exercise === "card_dds"
? ` По умолчанию: заполнение КИО за ${ cardFillTimeLimit } с , отработка карточки ДДС за ${ ddsCardWorkTimeLimit } с . `
: exercise === "card"
? ` По умолчанию: заполнение КИО за ${ cardFillTimeLimit } с . `
: "Для звонка действуют нормативы приёма вызова по ГОСТ." }
< / p >
2026-09-24 01:10:49 +03:00
{ exercise === "dds" && < >
< label className = "lesson-criterion" >
И н т е р в а л п о с т у п л е н и я к а р т о ч е к , с е к у н д ( 0 — б е з з а д е р ж к и ; д е й с т в у е т л и м и т о ж и д а н и я )
2026-09-26 18:12:27 +03:00
< LessonNumberInput label = "Интервал поступления карточек ДДС, секунд"
value = { ddsArrivalIntervalSeconds } min = { 0 } max = { 300 } fallback = { 0 }
onCommit = { setDdsArrivalIntervalSeconds } / >
2026-09-24 01:10:49 +03:00
< / label >
< label className = "lesson-criterion" >
М а к с и м у м о ж и д а ю щ и х к а р т о ч е к
2026-09-26 18:12:27 +03:00
< LessonNumberInput label = "Максимум ожидающих карточек ДДС"
value = { ddsMaxWaiting } min = { 1 } max = { 10 } fallback = { 1 } onCommit = { setDdsMaxWaiting } / >
2026-09-24 01:10:49 +03:00
< / label >
< / > }
2026-09-26 18:12:27 +03:00
{ ( exercise === "dds" || exercise === "card_dds" ) && < label className = "lesson-criterion" >
2026-09-24 01:10:49 +03:00
Р е ш е н и е Д Д С , с е к у н д
2026-09-26 18:12:27 +03:00
< LessonNumberInput label = "Лимит решения ДДС, секунд" value = { decisionTimeLimit }
min = { 5 } max = { 300 } fallback = { 30 } onCommit = { setDecisionTimeLimit } / >
< / label > }
{ ( exercise === "card" || exercise === "card_dds" ) && < label className = "lesson-criterion" >
Н о р м а т и в з а п о л н е н и я к а р т о ч к и К И О , с е к у н д
< LessonNumberInput label = "Норматив заполнения карточки КИО, секунд"
value = { cardFillTimeLimit } min = { 30 } max = { 1800 } fallback = { 180 } onCommit = { setCardFillTimeLimit } / >
< small className = "ref" > П о у м о л ч а н и ю 180 с е к у н д ; в о т ч ё т е б у д у т ф а к т и о т к л о н е н и е о т н о р м а т и в а . < / small >
< / label > }
{ ( exercise === "dds" || exercise === "card_dds" ) && < label className = "lesson-criterion" >
Н о р м а т и в о т р а б о т к и к а р т о ч к и Д Д С , с е к у н д
< LessonNumberInput label = "Норматив отработки карточки ДДС, секунд"
value = { ddsCardWorkTimeLimit } min = { 30 } max = { 1800 } fallback = { 180 }
onCommit = { setDdsCardWorkTimeLimit } / >
< small className = "ref" > П о у м о л ч а н и ю 180 с е к у н д ; н е з а м е н я е т 30 - с е к у н д н о е п е р в и ч н о е р е ш е н и е . < / small >
< / label > }
2026-09-24 01:10:49 +03:00
< label className = "lesson-criterion" >
Д о п у с т и м о н а р у ш е н н ы х м е т р и к
2026-09-26 18:12:27 +03:00
< LessonNumberInput label = "Допустимое число ошибок" value = { allowedErrors }
min = { 0 } max = { 50 } fallback = { 0 } onCommit = { setAllowedErrors } / >
2026-09-24 01:10:49 +03:00
< / label >
{ exercise !== "dds" && < label className = "mode-choice" >
< input type = "checkbox" checked = { requireCorrectGrammar }
onChange = { ( event ) = > setRequireCorrectGrammar ( event . target . checked ) } / >
Г р а м м а т и к а к а р т о ч к и о п е р а т о р а 112 в х о д и т в о ц е н к у
< / label > }
2026-09-26 18:12:27 +03:00
< ScoreWeightsEditor exercise = { exercise === "card_dds" ? "card_dds" : exercise }
values = { lessonScoreWeights } onChange = { setLessonScoreWeights } / >
2026-09-24 01:10:49 +03:00
< small className = "ref" > Н о р м а т и в ы Г О С Т д л я п р и ё м а в ы з о в а н е и з м е н я ю т с я . < / small >
2026-09-26 18:12:27 +03:00
< / details >
2026-09-24 01:10:49 +03:00
< / td >
< / tr >
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
< / tbody >
< / table >
2026-09-26 18:12:27 +03:00
< div className = "lesson-launch-summary" aria-label = "Проверка настроек занятия" >
< strong > П е р е д з а п у с к о м < / strong >
< ul >
< li className = { canStartSelectedExercise ? "is-ready" : "is-missing" } >
< span aria-hidden = "true" > { canStartSelectedExercise ? "✓" : "1" } < / span >
< span > < b > С ц е н а р и й < / b > < small > { selectedScenario ? . title ? ? ( wholeTicket ? ` Билет ${ ticketFilter } ` : "Выберите сценарий выше" ) } < / small > < / span >
< / li >
< li className = "is-ready" >
< span aria-hidden = "true" > ✓ < / span > < span > < b > У п р а ж н е н и е и р е ж и м < / b > < small > { exerciseLabel } · { MODES . find ( ( item ) = > item . value === mode ) ? . label } < / small > < / span >
< / li >
< li className = { selectedTraineeLabel ? "is-ready" : "is-missing" } >
< span aria-hidden = "true" > { selectedTraineeLabel ? "✓" : "3" } < / span >
< span > < b > К у р с а н т < / b > < small > { selectedTraineeLabel || "Выберите участника выше" } < / small > < / span >
< / li >
< / ul >
< / div >
< div className = "lesson-start-bar" >
2026-09-21 17:40:54 +03:00
< button type = "button" className = "start" onClick = { startLesson }
2026-09-26 18:12:27 +03:00
disabled = { Boolean ( startDisabledReason ) || Boolean ( startPendingId ) } >
{ startPendingId ? "Создаём занятие…" : session . title ? "Начать новое занятие" : "Начать занятие" }
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
< / button >
2026-09-26 18:12:27 +03:00
{ startDisabledReason && < small className = "ref" role = "status" > { startDisabledReason } < / small > }
{ startPendingId && < small className = "ref" role = "status" > П о д к л ю ч а е м с я к з а н я т и ю . Н е н а ж и м а й т е п о в т о р н о — к н о п к а с т а н е т д о с т у п н а п о с л е з а п у с к а . < / small > }
{ startError && < small className = "ref" role = "alert" > { startError } < button type = "button" onClick = { startLesson } > П о в т о р и т ь з а п у с к < / button > < / small > }
{ links && session . title && < small className = "ref" > П о с л е з а п у с к а п о я в я т с я о т д е л ь н ы е с с ы л к и д л я к у р с а н т а , Д Д С и м о н и т о р а . < / small > }
< / div >
{ sessionId && session . title && (
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
< p >
{ session . ended ? "занятие завершено" : (
2026-09-27 16:35:13 +03:00
< >
< button type = "button" onClick = { control . stop } > З а в е р ш и т ь з а н я т и е < / button > { " " }
{ session . paused ? (
< button type = "button" onClick = { control . resume } > В о з о б н о в и т ь < / button >
) : (
< button type = "button" onClick = { control . pause } > П а у з а < / button >
) }
{ session . paused && < small className = "ref" role = "status" > З а н я т и е н а п а у з е — в р е м я н е и д ё т < / small > }
< / >
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
) }
< / p >
) }
2026-09-26 18:12:27 +03:00
{ sessionId && session . title && ! session . ended && (
2026-09-21 17:40:54 +03:00
exercise === "call" && < Director onInject = { control . inject } disabled = { control . status !== "open" } error = { session . error } / >
2026-09-17 21:38:43 +03:00
) }
2026-09-26 18:12:27 +03:00
{ links && startPendingId && (
< p className = "ref" role = "status" > С о з д а ё м з а н я т и е . С с ы л к и п о я в я т с я а в т о м а т и ч е с к и п о с л е п о д к л ю ч е н и я . < / p >
2026-09-24 01:10:49 +03:00
) }
{ links && session . title && (
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
< table className = "grid" >
< tbody >
2026-09-21 17:40:54 +03:00
< tr > < th > А Р М к у р с а н т а < / th > < td > < a href = { exercise === "dds" ? links.dds : links.trainee } > о т к р ы т ь { exercise === "dds" ? "ДДС" : "112" } < / a > < / td > < / tr >
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
< tr > < th > В н е ш н и й м о н и т о р < / th > < td > < a href = { links . wall } > о т к р ы т ь < / a > < / td > < / tr >
2026-09-17 21:45:47 +03:00
< tr > < th > А Р М Д Д С < / th > < td > < a href = { links . dds } > о т к р ы т ь < / a > < / td > < / tr >
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
< / tbody >
< / table >
) }
< / section >
< section >
< h2 > Х о д з а н я т и я < / h2 >
2026-09-26 18:12:27 +03:00
{ ! sessionId && < div className = "lesson-empty-state" >
< strong > З д е с ь п о я в и т с я х о д з а н я т и я < / strong >
< span > П о с л е з а п у с к а з д е с ь б у д у т д и а л о г , т а й м е р ы и к а р т о ч к а к у р с а н т а . < / span >
< / div > }
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
{ sessionId && (
< >
< InterviewTimer timers = { session . timers } / >
< p className = "ref" >
к а н а л : { session . status } · п о д с к а з о к и с п о л ь з о в а н о : { session . hintsUsed }
< / p >
< h3 > Т р а н с к р и п т — к л и к п о р е п л и к е д о б а в л я е т п о м е т к у < / h3 >
< table className = "grid" >
< tbody >
{ session . transcript . map ( ( line ) = > (
< tr key = { line . ref } onClick = { ( ) = > setNoteFor ( line . ref ) } className = "clickable" >
< th > { line . speaker === "caller" ? "звонящий" : "оператор" } < / th >
< td >
{ line . text }
{ session . notes . has ( line . ref ) && (
< div className = "ref" > п о м е т к а : { session . notes . get ( line . ref ) } < / div >
) }
{ noteFor === line . ref && (
< div >
2026-09-26 18:12:27 +03:00
< input type = "text" aria-label = "Пометка преподавателя к реплике" value = { noteText } autoFocus
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
placeholder = "здесь надо было предупредить о прибытии"
onChange = { ( event ) = > setNoteText ( event . target . value ) } / > { " " }
< button type = "button" onClick = { ( ) = > {
if ( noteText . trim ( ) ) control . note ( line . ref , noteText . trim ( ) ) ;
setNoteText ( "" ) ;
setNoteFor ( null ) ;
} } > С о х р а н и т ь < / button >
< / div >
) }
< / td >
< / tr >
) ) }
{ session . transcript . length === 0 && < tr > < td colSpan = { 2 } > — < / td > < / tr > }
< / tbody >
< / table >
< h3 > Н о р м а т и в ы < / h3 >
< Timers timers = { session . timers } / >
< h3 > К а р т о ч к а к у р с а н т а < / h3 >
2026-09-26 18:12:27 +03:00
< p className = "ref instructor-readonly-note" > Э т о п р о с м о т р преподавателя : редактировать к а р т о ч к у з д е с ь н е л ь з я . П о с л е з а п у с к а о т к р о й т е о т д е л ь н у ю с с ы л к у « А Р М к у р с а н т а » — в в о д в ы п о л н я е т с я т а м . < / p >
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
{ /* Только чтение: преподаватель управляет ситуацией, а не работой обучаемого. */ }
< KioCard state = { session . card } required = { session . requiredFields } readOnly / >
< / >
) }
< / section >
2026-09-21 20:10:57 +03:00
< section >
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
< h2 > И с т о р и я г р у п п ы < / h2 >
2026-09-21 19:56:13 +03:00
< p > < a href = "/profile" > п р о ф и л и к у р с а н т о в < / a > · < a href = "/groups" > а н а л и т и к а г р у п п ы < / a > < / p >
2026-09-26 18:12:27 +03:00
{ history . data ? . length ? < div className = "lesson-history-list" >
{ history . data . slice ( 0 , 15 ) . map ( ( item ) = > {
const scenario = scenarios . data ? . find ( ( candidate ) = > candidate . id === item . scenario_id ) ;
const completed = Boolean ( item . ended_at ) ;
const dateLabel = sessionDateLabel ( item . started_at ? ? item . ended_at ) ;
return < a key = { item . session_id } href = { ` /instructor?session= ${ item . session_id } ` } >
< strong > { scenario ? . title ? ? "Учебное занятие" } < / strong >
< span > { modeLabel ( item . mode ) } · п о п ы т к а { item . attempt } { dateLabel && ` · ${ dateLabel } ` } < / span >
< small className = { completed ? "lesson-history-complete" : "lesson-history-active" } >
{ completed ? "Завершено · открыть результаты →" : "Занятие ещё идёт · открыть ход занятия →" }
< / small >
< / a > ;
} ) }
< / div > : < div className = "lesson-history-empty" >
< strong > И с т о р и я з а н я т и й п о к а п у с т а < / strong >
< span > З а п у щ е н н ы е з а н я т и я и и х р е з у л ь т а т ы п о я в я т с я з д е с ь а в т о м а т и ч е с к и . < / span >
< / div > }
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
< / section >
< / div >
2026-09-24 01:10:49 +03:00
< ScenarioEditor scenarios = { scenarios . data ? ? [ ] } selectedScenarioId = { scenarioId } onApproved = { async ( id ) = > {
setTicketFilter ( "" ) ;
setCategoryFilter ( "" ) ;
setScenarioSearch ( "" ) ;
2026-09-21 17:40:54 +03:00
await scenarios . refetch ( ) ;
2026-09-24 01:10:49 +03:00
setScenarioId ( id ) ;
} } onLibraryChanged = { async ( ) = > { await scenarios . refetch ( ) ; } } / >
2026-09-21 17:40:54 +03:00
2026-09-24 01:10:49 +03:00
{ session . report && < >
2026-09-26 18:12:27 +03:00
< Debrief report = { overrideReport ? . session_id === session . report . session_id
? overrideReport : session.report } / >
< section className = "debrief debrief-score-override" >
< h2 > Р у ч н а я к о р р е к т и р о в к а о ц е н к и < / h2 >
< p className = "ref" > А в т о м а т и ч е с к а я о ц е н к а : { session . report . score_auto . toFixed ( 1 ) } и з 100 . М е н я й т е е ё т о л ь к о п р и необходимости : причина с о х р а н и т с я в ж у р н а л е в м е с т е с в а ш и м л о г и н о м . < / p >
2026-09-24 01:10:49 +03:00
< label > И т о г о в а я о ц е н к а , 0 – 100
< input type = "number" min = { 0 } max = { 100 } step = { 0.1 }
value = { overrideScore }
onChange = { ( event ) = > setOverrideScore ( event . target . value ) } / >
< / label >
< label > О б я з а т е л ь н о е о б о с н о в а н и е
< textarea rows = { 3 } maxLength = { 2000 } value = { overrideComment }
onChange = { ( event ) = > setOverrideComment ( event . target . value ) }
placeholder = "Почему итоговая оценка отличается от автоматической" / >
< / label >
< button type = "button" disabled = { overrideBusy || ! overrideComment . trim ( ) }
onClick = { ( ) = > void saveScoreOverride ( session . report ! ) } >
2026-09-26 18:12:27 +03:00
{ overrideBusy ? "Сохраняем…" : "Сохранить оценку и обоснование" }
2026-09-24 01:10:49 +03:00
< / button >
{ overrideError && < p className = "violated" role = "alert" > { overrideError } < / p > }
< / section >
< / > }
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
< / main >
) ;
}