lct-hack/frontend/src/pages/trainee/Call.tsx

196 lines
10 KiB
TypeScript
Raw Normal View History

// АРМ курсанта: приём вызова и разговор голосом.
// Карточка КИО — lct-10, таймеры — lct-11.
feat: исход вызова — не каждый звонок заканчивается карточкой (lct-36) Весь продукт стоял на допущении «звонок → карточка → выезд». В билетах заказчика оно нарушается намеренно: «поругался с продавцом Мегафон» — справка, а вызов из Волгоградской области передаётся в систему-112 другого субъекта. Курсант, заведший карточку на такой вызов, занял расчёт зря, и прежняя оценка этого не видела — наоборот, награждала за полноту заполнения. - Outcome в домене, поле outcome в сценарии, событие call.resolve у курсанта и две кнопки на АРМ. Отдельное действие, а не «положил трубку»: система должна отличить осознанное решение от брошенного вызова. - Метрика outcome (E2, вес 3 — лишний выезд дороже неточного признака). - Там, где карточка не заводится, метрики карточки не считаются вовсе. Полнота опроса считается: передать вызов не значит не опрашивать. - call.resolve останавливает норматив опроса, как и передача карточки. Попутно (lct-34): библиотека стала вложенной — scenarios/tickets/, поля ticket и position, чек-листы для медицины, полиции и ЖКХ. Перенесён билет 1 целиком: три вызова, три классификатора, третий — из другого региона. Найдено по ходу: модификаторы списка оповещения не были подключены ни к чему. В классификаторе скорая добавляется к массовой драке признаком «пострадавшие», но взять его было неоткуда. Теперь модификаторы берутся из карточки — victims_count, life_threat, evacuation_needed, fire.gasified, — и список оповещения пересобирается при правке любого из этих полей. 161 тест зелёный (13 новых), make typecheck чистый.
2026-09-20 08:33:30 +03:00
import { useState } from "react";
lct-10: карточка КИО — форма, слияние правок, read-only Фронт не хранит карточку как истину: локально живут только правки курсанта, ещё не подтверждённые сервером. Распознанное сервером значение перекрывает набранное, эхо своей правки её подтверждает. Иначе автозаполнение и ручной ввод дерутся за одно поле — худший класс багов для отладки в последнюю ночь. Описание полей отдельно от отрисовки: по нему же карточка рисуется read-only на мониторе, пульте и АРМ ДДС. Сверка формы с контрактом встроена в make typecheck: все 40 полей домена на форме, а поле, добавленное в контракт и забытое в форме, теперь поймается само. Это пропущенный факт по вине интерфейса, а не курсанта. Открытый вопрос в карточке: нужно ли автозаполнение карточки из речи вообще — полнота КИО оценивается кодом E5, и если сервер впишет адрес сам, курсант перестанет за неё отвечать.
2026-09-17 21:16:51 +03:00
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";
import { ModeBanner, hintsAllowed } from "@/features/mode-banner/ModeBanner";
lct-16 и половина lct-19: разбор, отчёт, внешний монитор, эталон Эталонный диалог собирается кодом из фактов и чек-листа: написанный руками, он разошёлся бы с фактами при первой же правке сценария, и курсанта оштрафовали бы за правильный ответ. Отчёт: метрики фактом против норматива со ссылкой, отметка E1 на каждый недобытый факт с эталонным вопросом, расхождение самооценки — что курсант заметил сам, чего не заметил, что отметил зря. Не заметил — самое ценное для разбора. Внешний монитор — не отдельное приложение, а другой режим отрисовки тех же событий: крупный таймер опроса, ход разговора, карточка, после оценки разбор на весь экран. Коррекция преподавателем сохраняет автооценку рядом: видно, что скорректировано и кем. Найдено: все метрики весили одинаково, и курсант, не задавший ни одного вопроса, но заполнивший карточку руками, получал 87 из 100. Предварительные веса (полнота опроса — 4) дают 74; окончательные утверждает методист, вопрос записан в DEBRIEF.md.
2026-09-17 21:32:12 +03:00
import { Debrief } from "@/features/debrief/Debrief";
import { SelfAssessment } from "@/features/self-assessment/SelfAssessment";
import { InterviewTimer, Timers } from "@/features/timers/Timers";
lct-10: карточка КИО — форма, слияние правок, read-only Фронт не хранит карточку как истину: локально живут только правки курсанта, ещё не подтверждённые сервером. Распознанное сервером значение перекрывает набранное, эхо своей правки её подтверждает. Иначе автозаполнение и ручной ввод дерутся за одно поле — худший класс багов для отладки в последнюю ночь. Описание полей отдельно от отрисовки: по нему же карточка рисуется read-only на мониторе, пульте и АРМ ДДС. Сверка формы с контрактом встроена в make typecheck: все 40 полей домена на форме, а поле, добавленное в контракт и забытое в форме, теперь поймается само. Это пропущенный факт по вине интерфейса, а не курсанта. Открытый вопрос в карточке: нужно ли автозаполнение карточки из речи вообще — полнота КИО оценивается кодом E5, и если сервер впишет адрес сам, курсант перестанет за неё отвечать.
2026-09-17 21:16:51 +03:00
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<string, string> = {
connecting: "подключение",
open: "на связи",
reconnecting: "связь потеряна, переподключение",
closed: "отключено",
};
feat: исход вызова — не каждый звонок заканчивается карточкой (lct-36) Весь продукт стоял на допущении «звонок → карточка → выезд». В билетах заказчика оно нарушается намеренно: «поругался с продавцом Мегафон» — справка, а вызов из Волгоградской области передаётся в систему-112 другого субъекта. Курсант, заведший карточку на такой вызов, занял расчёт зря, и прежняя оценка этого не видела — наоборот, награждала за полноту заполнения. - Outcome в домене, поле outcome в сценарии, событие call.resolve у курсанта и две кнопки на АРМ. Отдельное действие, а не «положил трубку»: система должна отличить осознанное решение от брошенного вызова. - Метрика outcome (E2, вес 3 — лишний выезд дороже неточного признака). - Там, где карточка не заводится, метрики карточки не считаются вовсе. Полнота опроса считается: передать вызов не значит не опрашивать. - call.resolve останавливает норматив опроса, как и передача карточки. Попутно (lct-34): библиотека стала вложенной — scenarios/tickets/, поля ticket и position, чек-листы для медицины, полиции и ЖКХ. Перенесён билет 1 целиком: три вызова, три классификатора, третий — из другого региона. Найдено по ходу: модификаторы списка оповещения не были подключены ни к чему. В классификаторе скорая добавляется к массовой драке признаком «пострадавшие», но взять его было неоткуда. Теперь модификаторы берутся из карточки — victims_count, life_threat, evacuation_needed, fire.gasified, — и список оповещения пересобирается при правке любого из этих полей. 161 тест зелёный (13 новых), make typecheck чистый.
2026-09-20 08:33:30 +03:00
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() {
feat: исход вызова — не каждый звонок заканчивается карточкой (lct-36) Весь продукт стоял на допущении «звонок → карточка → выезд». В билетах заказчика оно нарушается намеренно: «поругался с продавцом Мегафон» — справка, а вызов из Волгоградской области передаётся в систему-112 другого субъекта. Курсант, заведший карточку на такой вызов, занял расчёт зря, и прежняя оценка этого не видела — наоборот, награждала за полноту заполнения. - Outcome в домене, поле outcome в сценарии, событие call.resolve у курсанта и две кнопки на АРМ. Отдельное действие, а не «положил трубку»: система должна отличить осознанное решение от брошенного вызова. - Метрика outcome (E2, вес 3 — лишний выезд дороже неточного признака). - Там, где карточка не заводится, метрики карточки не считаются вовсе. Полнота опроса считается: передать вызов не значит не опрашивать. - call.resolve останавливает норматив опроса, как и передача карточки. Попутно (lct-34): библиотека стала вложенной — scenarios/tickets/, поля ticket и position, чек-листы для медицины, полиции и ЖКХ. Перенесён билет 1 целиком: три вызова, три классификатора, третий — из другого региона. Найдено по ходу: модификаторы списка оповещения не были подключены ни к чему. В классификаторе скорая добавляется к массовой драке признаком «пострадавшие», но взять его было неоткуда. Теперь модификаторы берутся из карточки — victims_count, life_threat, evacuation_needed, fire.gasified, — и список оповещения пересобирается при правке любого из этих полей. 161 тест зелёный (13 новых), make typecheck чистый.
2026-09-20 08:33:30 +03:00
const [resolveComment, setResolveComment] = useState("");
const [resolved, setResolved] = useState<"consultation" | "transfer_region" | null>(null);
const sessionId = sessionIdFromUrl();
const call = useCall(sessionId);
if (!sessionId) {
return (
<main className="page">
<h1>АРМ курсанта</h1>
<p className="warn">В адресе нет номера занятия: откройте ссылку вида /trainee?session=…</p>
</main>
);
}
if (call.briefing) {
const services = display(call.card, "notify");
const notifiedServices = Array.isArray(services) ? services.map(String) : [];
return <main className="page arm-page">
<ModeBanner mode={call.briefing.mode} />
<header className="arm-topbar arm-topbar-text">
<div className="arm-topbar-mark">112</div>
<div className="arm-topbar-title"><strong>Карточка информационного обмена</strong><span>Текстовое упражнение · сценарий {call.briefing.scenario_id}</span></div>
<div className="arm-topbar-state"><span className={call.status === "open" ? "state-ok" : "state-warn"}>● {call.status === "open" ? "АРМ подключён" : STATUS_LABEL[call.status]}</span><small>{call.cardSubmitted ? "Карточка отправлена" : "Карточка заполняется"}</small></div>
</header>
<section className="arm-briefing">
<div className="arm-briefing-label"><strong>Учебная вводная</strong><span>Текстовый режим · без входящего звонка</span></div>
<div className="arm-briefing-text">{call.briefing.text}</div>
</section>
<div className="arm-card-wrap">
<KioCard state={call.card} required={call.briefing.required_fields}
readOnly={call.cardSubmitted || call.status !== "open"} referenceArm onChange={call.patchKio} />
</div>
<footer className="arm-footer">
<span className="arm-footer-services">Службы:
{notifiedServices.length
? notifiedServices.map((service) => <b key={service}>{service}</b>)
: <em>появятся после выбора признаков</em>}
</span>
{!call.cardSubmitted && <button type="button" className="arm-primary" disabled={call.status !== "open"}
onClick={call.submitCard}>Сохранить и проверить</button>}
{call.cardSubmitted && <strong>{call.briefing.handoff_to_dds ? "Карточка передана ДДС" : "Карточка отправлена"}</strong>}
</footer>
{call.error && <p className="state-violated arm-message" role="alert">{call.error}</p>}
{call.cardSubmitted && !call.report && !call.reportError && <p className="arm-message">
{call.briefing.handoff_to_dds
? <>Карточка передана в ДДС. <a href={`/dds?session=${sessionId}&role=dds_01`}>Открыть АРМ ДДС</a> и завершить реагирование для итоговой оценки.</>
: "Карточка отправлена. Ожидаем итоговую оценку…"}
</p>}
{call.reportError && <p className="state-violated arm-message" role="alert">{call.reportError}</p>}
{call.report && <Debrief report={call.report} />}
</main>;
}
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 (
<main className="page arm-page">
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 />
<ModeBanner mode={call.incoming?.mode} />
<header className="arm-topbar">
<div className="arm-topbar-mark">112</div>
<div className="arm-phone-cell"><small>Канал связи</small><strong className={call.status === "open" ? "state-ok" : "state-warn"}>{STATUS_LABEL[call.status]}</strong><span>{call.micOn ? "● Микрофон включён" : "Микрофон выключен"}</span></div>
<div className="arm-phone-cell arm-phone-main"><small>Входящий вызов</small><strong>{call.incoming?.caller_number || "Ожидание вызова"}</strong><span>{call.incoming ? `Сценарий ${call.incoming.scenario_id} · ${call.callerSpeaking ? "заявитель говорит" : "заявитель молчит"}` : "Карточка не назначена"}</span></div>
<div className="arm-topbar-title"><strong>АРМ оператора 112</strong><span>Карточка информационного обмена</span></div>
<div className="arm-topbar-clock"><InterviewTimer timers={call.timers} /></div>
</header>
<div className="arm-call-actions">
{call.phase === "incoming" && !call.micOn && <button type="button" className="arm-answer" onClick={() => void call.answer()}>☎ Ответить на вызов</button>}
{call.micOn && <span className="arm-live">● Разговор идёт</span>}
{call.micOn && hintsAllowed(call.incoming?.mode) && <button type="button" onClick={call.hint}>Подсказка</button>}
<span className="arm-call-actions-note">{call.phase === "ended" ? "Вызов завершён" : "Заполните адрес и признаки происшествия со слов заявителя"}</span>
</div>
{call.error && <p className="violated arm-message" role="alert">{call.error}</p>}
<div className="arm-card-wrap">
lct-10: карточка КИО — форма, слияние правок, read-only Фронт не хранит карточку как истину: локально живут только правки курсанта, ещё не подтверждённые сервером. Распознанное сервером значение перекрывает набранное, эхо своей правки её подтверждает. Иначе автозаполнение и ручной ввод дерутся за одно поле — худший класс багов для отладки в последнюю ночь. Описание полей отдельно от отрисовки: по нему же карточка рисуется read-only на мониторе, пульте и АРМ ДДС. Сверка формы с контрактом встроена в make typecheck: все 40 полей домена на форме, а поле, добавленное в контракт и забытое в форме, теперь поймается само. Это пропущенный факт по вине интерфейса, а не курсанта. Открытый вопрос в карточке: нужно ли автозаполнение карточки из речи вообще — полнота КИО оценивается кодом E5, и если сервер впишет адрес сам, курсант перестанет за неё отвечать.
2026-09-17 21:16:51 +03:00
<KioCard
state={call.card}
required={call.incoming?.required_fields ?? []}
readOnly={!call.micOn}
referenceArm
lct-10: карточка КИО — форма, слияние правок, read-only Фронт не хранит карточку как истину: локально живут только правки курсанта, ещё не подтверждённые сервером. Распознанное сервером значение перекрывает набранное, эхо своей правки её подтверждает. Иначе автозаполнение и ручной ввод дерутся за одно поле — худший класс багов для отладки в последнюю ночь. Описание полей отдельно от отрисовки: по нему же карточка рисуется read-only на мониторе, пульте и АРМ ДДС. Сверка формы с контрактом встроена в make typecheck: все 40 полей домена на форме, а поле, добавленное в контракт и забытое в форме, теперь поймается само. Это пропущенный факт по вине интерфейса, а не курсанта. Открытый вопрос в карточке: нужно ли автозаполнение карточки из речи вообще — полнота КИО оценивается кодом E5, и если сервер впишет адрес сам, курсант перестанет за неё отвечать.
2026-09-17 21:16:51 +03:00
onChange={call.patchKio}
/>
</div>
<footer className="arm-footer">
<span>Службы · список оповещения рассчитывается по признакам</span>
<div className="arm-footer-actions">
lct-10: карточка КИО — форма, слияние правок, read-only Фронт не хранит карточку как истину: локально живут только правки курсанта, ещё не подтверждённые сервером. Распознанное сервером значение перекрывает набранное, эхо своей правки её подтверждает. Иначе автозаполнение и ручной ввод дерутся за одно поле — худший класс багов для отладки в последнюю ночь. Описание полей отдельно от отрисовки: по нему же карточка рисуется read-only на мониторе, пульте и АРМ ДДС. Сверка формы с контрактом встроена в make typecheck: все 40 полей домена на форме, а поле, добавленное в контракт и забытое в форме, теперь поймается само. Это пропущенный факт по вине интерфейса, а не курсанта. Открытый вопрос в карточке: нужно ли автозаполнение карточки из речи вообще — полнота КИО оценивается кодом E5, и если сервер впишет адрес сам, курсант перестанет за неё отвечать.
2026-09-17 21:16:51 +03:00
<button
type="button"
className="arm-primary"
disabled={!call.micOn || (!service && !autoRoute)}
onClick={() => call.dispatch(service)}
lct-10: карточка КИО — форма, слияние правок, read-only Фронт не хранит карточку как истину: локально живут только правки курсанта, ещё не подтверждённые сервером. Распознанное сервером значение перекрывает набранное, эхо своей правки её подтверждает. Иначе автозаполнение и ручной ввод дерутся за одно поле — худший класс багов для отладки в последнюю ночь. Описание полей отдельно от отрисовки: по нему же карточка рисуется read-only на мониторе, пульте и АРМ ДДС. Сверка формы с контрактом встроена в make typecheck: все 40 полей домена на форме, а поле, добавленное в контракт и забытое в форме, теперь поймается само. Это пропущенный факт по вине интерфейса, а не курсанта. Открытый вопрос в карточке: нужно ли автозаполнение карточки из речи вообще — полнота КИО оценивается кодом E5, и если сервер впишет адрес сам, курсант перестанет за неё отвечать.
2026-09-17 21:16:51 +03:00
>
Передать в ДДС
</button>
{call.micOn && <button type="button" onClick={call.hangup}>Завершить вызов</button>}
</div>
</footer>
<div className="arm-secondary">
feat: исход вызова — не каждый звонок заканчивается карточкой (lct-36) Весь продукт стоял на допущении «звонок → карточка → выезд». В билетах заказчика оно нарушается намеренно: «поругался с продавцом Мегафон» — справка, а вызов из Волгоградской области передаётся в систему-112 другого субъекта. Курсант, заведший карточку на такой вызов, занял расчёт зря, и прежняя оценка этого не видела — наоборот, награждала за полноту заполнения. - Outcome в домене, поле outcome в сценарии, событие call.resolve у курсанта и две кнопки на АРМ. Отдельное действие, а не «положил трубку»: система должна отличить осознанное решение от брошенного вызова. - Метрика outcome (E2, вес 3 — лишний выезд дороже неточного признака). - Там, где карточка не заводится, метрики карточки не считаются вовсе. Полнота опроса считается: передать вызов не значит не опрашивать. - call.resolve останавливает норматив опроса, как и передача карточки. Попутно (lct-34): библиотека стала вложенной — scenarios/tickets/, поля ticket и position, чек-листы для медицины, полиции и ЖКХ. Перенесён билет 1 целиком: три вызова, три классификатора, третий — из другого региона. Найдено по ходу: модификаторы списка оповещения не были подключены ни к чему. В классификаторе скорая добавляется к массовой драке признаком «пострадавшие», но взять его было неоткуда. Теперь модификаторы берутся из карточки — victims_count, life_threat, evacuation_needed, fire.gasified, — и список оповещения пересобирается при правке любого из этих полей. 161 тест зелёный (13 новых), make typecheck чистый.
2026-09-20 08:33:30 +03:00
{/* Не каждый вызов заканчивается карточкой: справка и чужой регион —
отдельные исходы, и курсант выбирает их сам (docs/spec/TICKETS.md). */}
<details className="arm-resolve">
feat: исход вызова — не каждый звонок заканчивается карточкой (lct-36) Весь продукт стоял на допущении «звонок → карточка → выезд». В билетах заказчика оно нарушается намеренно: «поругался с продавцом Мегафон» — справка, а вызов из Волгоградской области передаётся в систему-112 другого субъекта. Курсант, заведший карточку на такой вызов, занял расчёт зря, и прежняя оценка этого не видела — наоборот, награждала за полноту заполнения. - Outcome в домене, поле outcome в сценарии, событие call.resolve у курсанта и две кнопки на АРМ. Отдельное действие, а не «положил трубку»: система должна отличить осознанное решение от брошенного вызова. - Метрика outcome (E2, вес 3 — лишний выезд дороже неточного признака). - Там, где карточка не заводится, метрики карточки не считаются вовсе. Полнота опроса считается: передать вызов не значит не опрашивать. - call.resolve останавливает норматив опроса, как и передача карточки. Попутно (lct-34): библиотека стала вложенной — scenarios/tickets/, поля ticket и position, чек-листы для медицины, полиции и ЖКХ. Перенесён билет 1 целиком: три вызова, три классификатора, третий — из другого региона. Найдено по ходу: модификаторы списка оповещения не были подключены ни к чему. В классификаторе скорая добавляется к массовой драке признаком «пострадавшие», но взять его было неоткуда. Теперь модификаторы берутся из карточки — victims_count, life_threat, evacuation_needed, fire.gasified, — и список оповещения пересобирается при правке любого из этих полей. 161 тест зелёный (13 новых), make typecheck чистый.
2026-09-20 08:33:30 +03:00
<summary>Закрыть вызов без карточки</summary>
<p>
<input
type="text"
value={resolveComment}
placeholder="что сказали заявителю, куда передали"
onChange={(event) => setResolveComment(event.target.value)}
/>
</p>
<p>
<button
type="button"
disabled={!call.micOn || resolved !== null}
onClick={() => {
call.resolve("consultation", resolveComment);
setResolved("consultation");
}}
>
Справка, выезд не нужен
</button>{" "}
<button
type="button"
disabled={!call.micOn || resolved !== null}
onClick={() => {
call.resolve("transfer_region", resolveComment);
setResolved("transfer_region");
}}
>
Передать в другой регион
</button>
</p>
{resolved && <p className="warn">Вызов закрыт: {RESOLVE_LABELS[resolved]}</p>}
</details>
<section className="arm-support">
<h2>Нормативы и разговор</h2>
<Timers timers={call.timers} />
<table className="grid">
<tbody>
{call.lines.map((line, index) => (
<tr key={index}>
<th>{line.speaker === "caller" ? "звонящий" : "оператор"}</th>
<td className={line.partial ? "warn" : ""}>{line.text}</td>
</tr>
))}
{call.lines.length === 0 && <tr><td colSpan={2}>—</td></tr>}
</tbody>
</table>
lct-10: карточка КИО — форма, слияние правок, read-only Фронт не хранит карточку как истину: локально живут только правки курсанта, ещё не подтверждённые сервером. Распознанное сервером значение перекрывает набранное, эхо своей правки её подтверждает. Иначе автозаполнение и ручной ввод дерутся за одно поле — худший класс багов для отладки в последнюю ночь. Описание полей отдельно от отрисовки: по нему же карточка рисуется read-only на мониторе, пульте и АРМ ДДС. Сверка формы с контрактом встроена в make typecheck: все 40 полей домена на форме, а поле, добавленное в контракт и забытое в форме, теперь поймается само. Это пропущенный факт по вине интерфейса, а не курсанта. Открытый вопрос в карточке: нужно ли автозаполнение карточки из речи вообще — полнота КИО оценивается кодом E5, и если сервер впишет адрес сам, курсант перестанет за неё отвечать.
2026-09-17 21:16:51 +03:00
</section>
</div>
{call.phase === "ended" && !call.selfAssessed && call.checklist.length > 0 && (
<SelfAssessment checklist={call.checklist} onSubmit={call.submitSelfAssessment} />
)}
{call.selfAssessed && !call.report && <p className="arm-message">Самооценка принята, оценка считается…</p>}
{call.report && <Debrief report={call.report} />}
</main>
);
}