// АРМ курсанта: приём вызова и разговор голосом. // Карточка КИО — lct-10, таймеры — lct-11. import { useEffect, useState } from "react"; import { Link, useLocation, useNavigate } from "react-router-dom"; import { KioCard } from "@/features/kio-card/KioCard"; import { StandStatus } from "@/features/health/StandStatus"; import { ModeBanner, hintsAllowed } from "@/features/mode-banner/ModeBanner"; import { Debrief } from "@/features/debrief/Debrief"; import { SelfAssessment } from "@/features/self-assessment/SelfAssessment"; import { InterviewTimer, Timers } from "@/features/timers/Timers"; import { display } from "@/features/kio-card/merge"; import { useCall } from "@/features/call/useCall"; import { sessionIdFromUrl } from "@/shared/api/session"; import type { DDSCode } from "@/shared/types/generated"; const STATUS_LABEL: Record = { connecting: "подключение", open: "на связи", reconnecting: "связь потеряна, переподключение", closed: "отключено", }; const RESOLVE_LABELS = { consultation: "справочно-консультационная помощь", transfer_region: "передан в систему-112 другого региона", } as const; function ddsCode(value: unknown): DDSCode | undefined { return value === "01" || value === "02" || value === "03" || value === "04" || value === "gkh" ? value : undefined; } export function Call() { const [textTurn, setTextTurn] = useState(""); const [resolveComment, setResolveComment] = useState(""); const [resolved, setResolved] = useState<"consultation" | "transfer_region" | null>(null); const sessionId = sessionIdFromUrl(); const call = useCall(sessionId); const location = useLocation(); const navigate = useNavigate(); const isCompletionScreen = location.pathname === "/trainee/completed"; useEffect(() => { if (call.report && sessionId && !isCompletionScreen) { navigate(`/trainee/completed?session=${sessionId}`, { replace: true }); } }, [call.report, isCompletionScreen, navigate, sessionId]); if (!sessionId) { return (

АРМ курсанта

В адресе нет номера занятия: откройте ссылку вида /trainee?session=…

); } if (isCompletionScreen) { if (!call.report) return

Занятие завершено

{call.reportError ?

{call.reportError}

:

Получаем итог занятия…

} Вернуться к профилю
; const report = call.report; const nextSteps = report.metrics.filter((metric) => !metric.passed).slice(0, 3); const completedCount = Math.max(0, report.metrics.length - report.failed_metrics); return
Упражнение завершено

{report.passed ? "Зачёт" : "Не зачтено"}

{report.exercise === "card" ? "Заполнение карточки КИО" : report.exercise === "dds" ? "Работа диспетчера ДДС" : "Приём вызова 112"} · {report.scenario_id}

Ваш результат{report.score_final.toFixed(0)} / 100

{completedCount} критериев выполнено · {report.failed_metrics} требуют внимания

{nextSteps.length > 0 &&

Что потренировать в следующий раз

    {nextSteps.map((metric) =>
  1. {metric.title}{metric.fact}
  2. )}
}
Подробный разбор занятия
; } if (call.briefing) { const services = display(call.card, "notify"); const notifiedServices = Array.isArray(services) ? services.map(String) : []; // Some already-open demo backends still send the old multi-line briefing. // Keep only the caller's opening line; never dump the answer key into view. const openingLine = call.briefing.text.split(/\r?\n/)[1] ?? call.briefing.text.split(/\r?\n/)[0] ?? "Алло? Вы меня слышите?"; return
112
Карточка информационного обменаТекстовое упражнение · сценарий {call.briefing.scenario_id}
● {call.status === "open" ? "АРМ подключён" : STATUS_LABEL[call.status]}{call.cardSubmitted ? "Карточка отправлена" : "Карточка заполняется"}
Диалог с заявителем{call.cardSubmitted ? "Диалог завершён" : "Задайте вопрос"}
{!call.lines.length &&

Заявитель{openingLine}

} {call.lines.map((line, index) =>

{line.speaker === "caller" ? "Заявитель" : "Вы"}{line.text} {line.speaker === "caller" && {line.source === "local_llm" ? "Qwen · ответ модели" : "Сценарный ответ"}}

)}
{ event.preventDefault(); if (call.sendText(textTurn)) setTextTurn(""); }}> setTextTurn(event.target.value)} />
{call.error &&

{call.error}

}
Службы: {notifiedServices.length ? notifiedServices.map((service) => {service}) : появятся после выбора признаков} {!call.cardSubmitted && } {call.cardSubmitted && {call.briefing.handoff_to_dds ? "Карточка передана ДДС" : "Карточка отправлена"}}
{call.error &&

{call.error}

} {call.cardSubmitted && !call.report && !call.reportError &&

{call.briefing.handoff_to_dds ? <>Карточка передана в ДДС. Открыть АРМ ДДС и завершить реагирование для итоговой оценки. : "Карточка отправлена. Ожидаем итоговую оценку…"}

} {call.reportError &&

{call.reportError}

} {call.report && }
; } const service = ddsCode(display(call.card, "dds")); const notify = display(call.card, "notify"); const autoRoute = Boolean(display(call.card, "incident_code")) && Array.isArray(notify) && notify.length > 0; return (
112
Канал связи{STATUS_LABEL[call.status]}{call.micOn ? "● Микрофон включён" : "Микрофон выключен"}
Входящий вызов{call.incoming?.caller_number || "Ожидание вызова"}{call.incoming ? `Сценарий ${call.incoming.scenario_id} · ${call.callerSpeaking ? "заявитель говорит" : "заявитель молчит"}` : "Карточка не назначена"}
АРМ оператора 112Карточка информационного обмена
{(call.phase === "incoming" || call.phase === "talking") && !call.micOn && } {call.micOn && ● Разговор идёт} {call.micOn && hintsAllowed(call.incoming?.mode) && } {call.phase === "ended" ? "Вызов завершён" : !call.micOn && call.phase === "incoming" ? "Поля КИО разблокируются после ответа на входящий вызов" : !call.micOn && call.phase === "talking" ? "Разговор продолжается. Нажмите кнопку, чтобы включить звук и микрофон на этом устройстве" : call.micOn ? "Заполните адрес и признаки происшествия со слов заявителя" : "Ожидайте входящего вызова"}
{call.error &&

{call.error}

}
Службы · список оповещения рассчитывается по признакам
{call.micOn && }
{/* Не каждый вызов заканчивается карточкой: справка и чужой регион — отдельные исходы, и курсант выбирает их сам (docs/spec/TICKETS.md). */}
Закрыть вызов без карточки

setResolveComment(event.target.value)} />

{" "}

{resolved &&

Вызов закрыт: {RESOLVE_LABELS[resolved]}

}

Нормативы и разговор

{call.lines.map((line, index) => ( ))} {call.lines.length === 0 && }
{line.speaker === "caller" ? "звонящий" : "оператор"} {line.text}
—
{call.phase === "ended" && !call.selfAssessed && call.checklist.length > 0 && ( )} {call.selfAssessed && !call.report &&

Самооценка принята, оценка считается…

} {call.report && }
); }