diff --git a/backend/app/api/ws/call.py b/backend/app/api/ws/call.py
index aab37f9..bdd7487 100644
--- a/backend/app/api/ws/call.py
+++ b/backend/app/api/ws/call.py
@@ -130,6 +130,15 @@ async def _handle(session_id: UUID, state, event) -> None:
hub.to_observers(session_id, KioState(kio=state.kio))
hub.to_observers(session_id, TimerTick(timers=state.timers.snapshot()))
+ case "call.resolve":
+ # Курсант закрывает вызов не карточкой. Отдельное действие, а не
+ # «положил трубку»: система должна отличить осознанное решение
+ # от брошенного вызова (docs/spec/TICKETS.md).
+ state.resolved_outcome = event.outcome.value
+ state.resolve_comment = event.comment
+ state.on_event("call.resolve")
+ hub.to_observers(session_id, state.snapshot())
+
case "callback.dial":
state.on_event("callback.dial")
diff --git a/backend/app/domain/classifiers.py b/backend/app/domain/classifiers.py
index 8fdee65..a5edad9 100644
--- a/backend/app/domain/classifiers.py
+++ b/backend/app/domain/classifiers.py
@@ -27,6 +27,28 @@ class IncidentType(StrEnum):
ERA_GLONASS = "era_glonass"
+class Outcome(StrEnum):
+ """Чем заканчивается вызов.
+
+ Весь продукт по умолчанию считает, что звонок → карточка → выезд. В билетах
+ заказчика это не всегда так: «поругался с продавцом „Мегафон“» — справка,
+ а вызов из Волгоградской области передаётся в систему-112 другого субъекта
+ (docs/spec/TICKETS.md). Курсант, заведший карточку на такой вызов, ошибся,
+ и система обязана это увидеть.
+ """
+
+ CARD = "card" # заполнить КИО и передать в ДДС
+ CONSULTATION = "consultation" # справка, карточка не заводится
+ TRANSFER_REGION = "transfer_region" # передать в систему-112 другого субъекта
+
+
+OUTCOME_LABELS: dict[Outcome, str] = {
+ Outcome.CARD: "Карточка и передача в ДДС",
+ Outcome.CONSULTATION: "Справочно-консультационная помощь",
+ Outcome.TRANSFER_REGION: "Передача в систему-112 другого региона",
+}
+
+
class Level(StrEnum):
"""Уровень сложности сценария."""
diff --git a/backend/app/domain/events.py b/backend/app/domain/events.py
index 6686a47..75f3c6f 100644
--- a/backend/app/domain/events.py
+++ b/backend/app/domain/events.py
@@ -17,7 +17,7 @@ from uuid import UUID
from pydantic import BaseModel, Field
-from app.domain.classifiers import DDSCode, IncidentType, Level
+from app.domain.classifiers import DDSCode, IncidentType, Level, Outcome
from app.domain.kio import KIO, Coords
from app.domain.statuses import ServiceStatus, StationSnapshot
from app.domain.taxonomy import Finding
@@ -278,12 +278,26 @@ class CallbackDial(BaseModel):
type: Literal["callback.dial"] = "callback.dial"
+class CallResolve(BaseModel):
+ """Курсант решил, что карточка здесь не заводится.
+
+ Не «завершить звонок», а именно «этот вызов закрывается иначе»: справкой
+ или передачей в другой регион. Решение оценивается наравне с выбором
+ признаков — это та же классификация, только на шаг раньше.
+ """
+
+ type: Literal["call.resolve"] = "call.resolve"
+ outcome: Outcome
+ comment: str = ""
+
+
TraineeToServer = Annotated[
CallAnswer
| KioPatchIn
| HintRequest
| SelfAssessmentSubmit
| DdsDispatch
+ | CallResolve
| CallHangup
| CallbackDial,
Field(discriminator="type"),
diff --git a/backend/app/domain/kio.py b/backend/app/domain/kio.py
index 5f0809f..035bacd 100644
--- a/backend/app/domain/kio.py
+++ b/backend/app/domain/kio.py
@@ -159,6 +159,28 @@ def missing_fields(card: KIO, required: list[str]) -> list[str]:
return empty
+#: Поля карточки, которые меняют состав списка оповещения. В боевом АРМ это
+#: те же «признаки», только не про тип происшествия, а про обстоятельства:
+#: пострадавшие поднимают скорую, газификация — МОСГАЗ (domain/ekp.py).
+MODIFIER_FIELDS: frozenset[str] = frozenset(
+ {"signs", "victims_count", "life_threat", "evacuation_needed", "fire.gasified"}
+)
+
+
+def modifiers(card: KIO) -> list[str]:
+ """Какие условия оповещения включены обстоятельствами вызова."""
+ keys: list[str] = []
+ if card.victims_count:
+ keys.append("casualties")
+ if card.life_threat:
+ keys.append("life_threat")
+ if card.evacuation_needed:
+ keys.append("evacuation")
+ if card.fire is not None and card.fire.gasified:
+ keys.append("gas")
+ return keys
+
+
def derive_incident(card: KIO) -> KIO:
"""Пересчитать код происшествия и список оповещения по признакам.
@@ -168,11 +190,13 @@ def derive_incident(card: KIO) -> KIO:
from app.domain import ekp
found = ekp.by_signs(card.signs)
+ keys = modifiers(card)
data = card.model_dump()
data["incident_code"] = found.code if found else None
+ computed = ekp.notify_list(found.code, keys) if found else []
# Добавленную вручную службу не теряем: удалить её оператор не может.
- manual = [name for name in card.notify if name not in ekp.notify_list(data["incident_code"] or "")]
- data["notify"] = (ekp.notify_list(found.code) if found else []) + manual
+ manual = [name for name in card.notify if name not in ekp.notify_list(data["incident_code"] or "", keys)]
+ data["notify"] = computed + manual
return KIO.model_validate(data)
@@ -193,4 +217,7 @@ def apply_patch(card: KIO, fields: dict[str, Any]) -> KIO:
nested[tail] = value
data[head] = nested
updated = KIO.model_validate(data)
- return derive_incident(updated) if "signs" in fields else updated
+ # Пересчитываем не только на смену признаков: пострадавшие поднимают скорую,
+ # газификация — МОСГАЗ, и список оповещения обязан это отразить сразу.
+ touched = MODIFIER_FIELDS & set(fields)
+ return derive_incident(updated) if touched else updated
diff --git a/backend/app/scenarios/loader.py b/backend/app/scenarios/loader.py
index da8f241..d11d9b8 100644
--- a/backend/app/scenarios/loader.py
+++ b/backend/app/scenarios/loader.py
@@ -65,7 +65,12 @@ def _derive_ground_truth(scenario: Scenario) -> Scenario:
f"версии {ekp.reference().version}"
)
scenario.ground_truth.incident_code = found.code
- scenario.ground_truth.notify = ekp.notify_list(found.code)
+ # Эталонный список оповещения — с учётом обстоятельств вызова: пятеро
+ # пострадавших поднимают скорую, и без них эталон был бы неполон.
+ keys: list[str] = []
+ if scenario.ground_truth.victims:
+ keys.append("casualties")
+ scenario.ground_truth.notify = ekp.notify_list(found.code, keys)
return scenario
@@ -154,14 +159,32 @@ def load_file(path: Path, root: Path) -> Scenario:
return _derive_ground_truth(scenario)
+#: Подкаталоги, которые сценариями не являются.
+NOT_SCENARIOS = {"checklists", "pregenerated"}
+
+
+def scenario_files(root: Path) -> list[Path]:
+ """Файлы сценариев, включая подкаталоги вроде `tickets/`.
+
+ Библиотека перестала быть плоской, когда в неё поехали билеты заказчика:
+ тридцать два билета по три вызова — это девяносто шесть файлов, и держать
+ их в одном каталоге рядом с чек-листами нельзя (docs/spec/TICKETS.md).
+ """
+ return sorted(
+ path
+ for path in root.rglob("*.yaml")
+ if not set(path.relative_to(root).parts[:-1]) & NOT_SCENARIOS
+ )
+
+
def load_library(root: Path) -> list[Scenario]:
- """Все сценарии каталога. Подкаталог `checklists/` — не сценарии."""
+ """Все сценарии каталога и его подкаталогов."""
if not root.exists():
raise ScenarioError(f"каталог сценариев не найден: {root}")
scenarios: list[Scenario] = []
seen: dict[str, Path] = {}
- for path in sorted(root.glob("*.yaml")):
+ for path in scenario_files(root):
scenario = load_file(path, root)
if scenario.id in seen:
raise ScenarioError(f"{path.name}: id {scenario.id} уже занят {seen[scenario.id].name}")
diff --git a/backend/app/scenarios/schema.py b/backend/app/scenarios/schema.py
index 97bef07..99abffa 100644
--- a/backend/app/scenarios/schema.py
+++ b/backend/app/scenarios/schema.py
@@ -7,7 +7,7 @@
from pydantic import BaseModel, ConfigDict, Field, model_validator
-from app.domain.classifiers import DDSCode, IncidentType, Level
+from app.domain.classifiers import DDSCode, IncidentType, Level, Outcome
from app.domain.events import Mood
@@ -145,6 +145,15 @@ class Scenario(Strict):
# Из них загрузчик выводит код и список оповещения (domain/ekp.py).
signs: list[str] = Field(default_factory=list, max_length=3)
+ # Билет — единица занятия у заказчика: три вызова подряд, разные службы
+ # (docs/spec/TICKETS.md). Преподаватель выбирает билет, а не сценарий.
+ ticket: int | None = None
+ position: int | None = Field(default=None, ge=1, le=3, description="Номер вызова в билете")
+
+ # Чем вызов заканчивается правильно. По умолчанию — карточка и выезд;
+ # справка и передача в другой регион разбираются в lct-36.
+ outcome: Outcome = Outcome.CARD
+
facts: list[Fact] = []
checklist: list[ChecklistItem] = []
required_fields: list[str] = Field(default_factory=list)
@@ -156,6 +165,12 @@ class Scenario(Strict):
era_glonass: EraGlonass | None = None
tree: Tree = Tree()
+ @model_validator(mode="after")
+ def ticket_needs_position(self):
+ if (self.ticket is None) != (self.position is None):
+ raise ValueError("ticket и position задаются вместе: билет без номера вызова неполон")
+ return self
+
@model_validator(mode="after")
def era_only_for_era_type(self):
if self.era_glonass is not None and self.type is not IncidentType.ERA_GLONASS:
diff --git a/backend/app/scoring/gost.py b/backend/app/scoring/gost.py
index 544a976..6b31561 100644
--- a/backend/app/scoring/gost.py
+++ b/backend/app/scoring/gost.py
@@ -10,6 +10,7 @@ import re
from dataclasses import dataclass, field
from app.domain import ekp
+from app.domain.classifiers import OUTCOME_LABELS, Outcome
from app.domain.events import CallEndReason, Metric
from app.domain.kio import KIO, missing_fields
from app.domain.taxonomy import ERRORS, Competency, Finding, FindingSource
@@ -112,6 +113,8 @@ def evaluate(
timers: SessionTimers,
revealed_facts: list[str] | None,
refined_facts: list[str] | None = None,
+ resolved_outcome: str | None = None,
+ dispatched: bool = False,
end_reason: CallEndReason | None = None,
bounced_fields: list[str] | None = None,
) -> GostResult:
@@ -223,6 +226,26 @@ def evaluate(
finding=f"Карточка ушла в ДДС {actual}, верная — {truth.dds.value}",
)
+ # ── исход вызова, E2 ──
+ #
+ # Не каждый звонок заканчивается карточкой: «поругался с продавцом» — справка,
+ # вызов из другого субъекта передаётся туда (docs/spec/TICKETS.md). Курсант,
+ # заведший карточку на такой вызов, сделал лишнюю работу и занял расчёт.
+ expected_outcome = scenario.outcome
+ if expected_outcome is not Outcome.CARD:
+ actual = resolved_outcome or ("card" if dispatched else "ничего не решено")
+ build.add(
+ "outcome", "Исход вызова",
+ OUTCOME_LABELS.get(Outcome(actual), actual) if actual in set(Outcome) else actual,
+ OUTCOME_LABELS[expected_outcome],
+ passed=actual == expected_outcome.value,
+ ref="классификатор происшествий, группа 23 «Прочие происшествия»",
+ finding=(
+ f"Вызов закрыт как «{actual}», правильно — "
+ f"«{OUTCOME_LABELS[expected_outcome]}»"
+ ),
+ )
+
# ── подтверждение места происшествия, E1 ──
#
# Адрес заявителя не всегда адрес происшествия: он называет тот, где стоит
@@ -241,6 +264,11 @@ def evaluate(
)
# ── карточка, E5 ──
+ #
+ # Там, где карточка не заводится, пустые поля — не ошибка, а правильное
+ # поведение: штрафовать за них значит учить заполнять лишнее.
+ if expected_outcome is not Outcome.CARD:
+ return build.result
if truth.address:
written = kio.address or " ".join(filter(None, [kio.street, kio.building]))
expected = _normalize_address(truth.address)
diff --git a/backend/app/scoring/taxonomy.py b/backend/app/scoring/taxonomy.py
index cd45e9e..ead5fab 100644
--- a/backend/app/scoring/taxonomy.py
+++ b/backend/app/scoring/taxonomy.py
@@ -14,6 +14,7 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = {
"checklist_completeness": (ErrorCode.E1, Competency.INTERVIEW),
"address_refined": (ErrorCode.E1, Competency.INTERVIEW),
"interview_time": (ErrorCode.E3, Competency.NORMS),
+ "outcome": (ErrorCode.E2, Competency.ROUTING),
"incident_signs": (ErrorCode.E2, Competency.ROUTING),
"incident_type": (ErrorCode.E2, Competency.ROUTING),
"dds_choice": (ErrorCode.E2, Competency.ROUTING),
@@ -32,6 +33,7 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = {
METRIC_WEIGHTS: dict[str, float] = {
"checklist_completeness": 4.0,
"address_refined": 2.0,
+ "outcome": 3.0,
"incident_signs": 2.0,
"incident_type": 2.0,
"dds_choice": 2.0,
diff --git a/backend/app/session/finish.py b/backend/app/session/finish.py
index 0feca6a..a94975a 100644
--- a/backend/app/session/finish.py
+++ b/backend/app/session/finish.py
@@ -35,6 +35,8 @@ async def finish(session_id: UUID, state) -> None:
refined_facts=list(state.slots.refined) if state.slots else None,
end_reason=state.end_reason,
bounced_fields=state.bounced_fields,
+ resolved_outcome=state.resolved_outcome,
+ dispatched=state.dispatched_card is not None,
)
# Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут
# рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии
diff --git a/backend/app/session/state.py b/backend/app/session/state.py
index 6f8829b..5405632 100644
--- a/backend/app/session/state.py
+++ b/backend/app/session/state.py
@@ -93,6 +93,9 @@ class SessionState:
dds_log: list[tuple[str, datetime, str | None]] = field(default_factory=list)
#: Отметки служб по списку оповещения — предмет второго режима занятия.
status_log: list[StatusEntry] = field(default_factory=list)
+ #: Чем курсант закрыл вызов, если не карточкой (lct-36).
+ resolved_outcome: str | None = None
+ resolve_comment: str = ""
def on_event(self, event_type: str) -> None:
"""Единственная точка, где событие двигает таймеры."""
diff --git a/backend/app/session/timers.py b/backend/app/session/timers.py
index 6c4e85f..1b45710 100644
--- a/backend/app/session/timers.py
+++ b/backend/app/session/timers.py
@@ -34,6 +34,9 @@ STARTS: dict[str, tuple[TimerCode, ...]] = {
STOPS: dict[str, tuple[TimerCode, ...]] = {
"call.answer": (TimerCode.ANSWER,),
"dds.dispatch": (TimerCode.INTERVIEW,),
+ # Справка и передача в другой регион заканчивают опрос так же, как передача
+ # карточки: норматив опроса не должен тикать после решения (lct-36).
+ "call.resolve": (TimerCode.INTERVIEW,),
"card.ack": (TimerCode.DDS_ACK,),
"zone.decision": (TimerCode.ZONE_CHECK,),
"crew.arrived": (TimerCode.CLOSE,),
diff --git a/backend/tests/test_ekp.py b/backend/tests/test_ekp.py
index feefa07..4da2e5c 100644
--- a/backend/tests/test_ekp.py
+++ b/backend/tests/test_ekp.py
@@ -97,3 +97,26 @@ def test_changing_signs_recalculates_the_card():
def test_every_level_offers_something(level):
prefix = ["жилой дом", "подъезд"][: level - 1]
assert ekp.signs_at_level(level, prefix), f"уровень {level} пуст"
+
+
+def test_victims_bring_the_ambulance():
+ """Модификатор приходит не из классификатора, а из карточки: пострадавшие
+ в поле `victims_count` поднимают скорую (docs/spec/DATASET.md)."""
+ from app.domain.kio import KIO, derive_incident
+
+ quiet = derive_incident(KIO(signs=["Драка", "Улица общественное место", "Массовая (от 10 человек)"]))
+ hurt = derive_incident(
+ KIO(signs=["Драка", "Улица общественное место", "Массовая (от 10 человек)"], victims_count=5)
+ )
+ assert "СМП" not in quiet.notify
+ assert "СМП" in hurt.notify
+ assert set(quiet.notify) < set(hurt.notify), "модификатор только добавляет службы"
+
+
+def test_gasified_object_brings_mosgaz():
+ from app.domain.kio import KIO, FireDetails, derive_incident
+
+ card = derive_incident(
+ KIO(signs=["жилой дом", "балкон", "открытое пламя"], fire=FireDetails(gasified=True))
+ )
+ assert "МОСГАЗ" in card.notify
diff --git a/backend/tests/test_outcomes.py b/backend/tests/test_outcomes.py
new file mode 100644
index 0000000..a1184b9
--- /dev/null
+++ b/backend/tests/test_outcomes.py
@@ -0,0 +1,136 @@
+"""Исход вызова: не каждый звонок заканчивается карточкой.
+
+Пункты приёмки lct-36. Материал — билет 1 заказчика: третий вызов в нём
+из Волгоградской области, и правильное действие — передать, а не заводить
+карточку (docs/spec/TICKETS.md).
+"""
+
+from pathlib import Path
+
+import pytest
+
+from app.domain.classifiers import Outcome
+from app.domain.kio import KIO
+from app.scenarios.loader import load_file, load_library
+from app.scoring.gost import evaluate
+from app.session.timers import SessionTimers
+
+LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
+
+
+@pytest.fixture(scope="module")
+def other_region():
+ return load_file(LIBRARY / "tickets" / "t01-3-child-other-region.yaml", LIBRARY)
+
+
+@pytest.fixture(scope="module")
+def ordinary():
+ return load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
+
+
+def timeline() -> SessionTimers:
+ timers = SessionTimers()
+ timers.on_event("call.incoming", now=0.0)
+ timers.on_event("call.answer", now=5.0)
+ return timers
+
+
+def score(scenario, *, resolved=None, dispatched=False, kio=None):
+ return evaluate(
+ scenario=scenario,
+ kio=kio or KIO(),
+ timers=timeline(),
+ revealed_facts=[fact.id for fact in scenario.facts],
+ refined_facts=[fact.id for fact in scenario.facts if fact.refine_on],
+ resolved_outcome=resolved,
+ dispatched=dispatched,
+ )
+
+
+def test_default_outcome_is_a_card(ordinary):
+ assert ordinary.outcome is Outcome.CARD
+
+
+def test_transfer_is_declared_by_the_scenario(other_region):
+ assert other_region.outcome is Outcome.TRANSFER_REGION
+
+
+def test_correct_transfer_passes(other_region):
+ result = score(other_region, resolved="transfer_region")
+ metric = next(m for m in result.metrics if m.key == "outcome")
+ assert metric.passed
+
+
+def test_filing_a_card_instead_of_transferring_is_an_error(other_region):
+ """Курсант завёл карточку и отправил наряд в другой субъект — это ошибка,
+ которую прежняя оценка не видела вовсе."""
+ result = score(other_region, dispatched=True)
+ metric = next(m for m in result.metrics if m.key == "outcome")
+ assert not metric.passed
+ assert any(finding.code.value == "E2" for finding in result.findings)
+
+
+def test_doing_nothing_is_also_an_error(other_region):
+ result = score(other_region)
+ assert not next(m for m in result.metrics if m.key == "outcome").passed
+
+
+def test_empty_card_is_not_punished_when_no_card_is_expected(other_region):
+ """Штрафовать за пустые поля там, где карточка не заводится, значит учить
+ заполнять лишнее."""
+ result = score(other_region, resolved="transfer_region")
+ keys = {metric.key for metric in result.metrics}
+ assert "required_fields" not in keys
+ assert "address" not in keys
+ assert "victims_count" not in keys
+
+
+def test_ordinary_call_has_no_outcome_metric(ordinary):
+ """У обычного вызова исход не обсуждается — метрики просто нет,
+ а не «пройдена»."""
+ result = score(ordinary, dispatched=True)
+ assert "outcome" not in {metric.key for metric in result.metrics}
+
+
+def test_interview_still_counts_for_a_transfer(other_region):
+ """Передать вызов — не значит не опрашивать: адрес и состояние ребёнка
+ другому региону всё равно нужны."""
+ result = score(other_region, resolved="transfer_region")
+ assert "checklist_completeness" in {metric.key for metric in result.metrics}
+
+
+# ── билет как единица занятия ──
+
+
+def test_ticket_one_is_three_calls_of_different_services():
+ """Билет собран так, чтобы за одно занятие курсант прошёл разные службы."""
+ ticket = [s for s in load_library(LIBRARY) if s.ticket == 1]
+ assert len(ticket) == 3
+ assert sorted(s.position for s in ticket) == [1, 2, 3]
+ assert len({s.type for s in ticket}) == 3
+
+
+def test_ticket_and_position_go_together():
+ from pydantic import ValidationError
+
+ from app.scenarios.schema import Scenario
+
+ with pytest.raises(ValidationError):
+ Scenario.model_validate(
+ {
+ "id": "broken",
+ "title": "Билет без номера вызова",
+ "type": "fire",
+ "level": "L1",
+ "ticket": 4,
+ "persona": {"base": "calm"},
+ "first_line": "Алло",
+ }
+ )
+
+
+def test_mass_fight_notifies_both_police_and_medics():
+ """Тот случай, где выбор одной службы был бы неверен по построению."""
+ scenario = load_file(LIBRARY / "tickets" / "t01-2-mass-fight.yaml", LIBRARY)
+ assert "МВД" in scenario.ground_truth.notify
+ assert "СМП" in scenario.ground_truth.notify
diff --git a/frontend/src/features/call/useCall.ts b/frontend/src/features/call/useCall.ts
index d58aca9..a0745b0 100644
--- a/frontend/src/features/call/useCall.ts
+++ b/frontend/src/features/call/useCall.ts
@@ -13,6 +13,7 @@ import { CALLER_RATE, Playback } from "@/shared/audio/playback";
import { type CardState, applyPatch, edit, empty as emptyCard } from "@/features/kio-card/merge";
import type { ChecklistItem } from "@/features/self-assessment/SelfAssessment";
import type {
+ Outcome,
CallIncoming,
DDSCode,
ServerToTrainee,
@@ -186,12 +187,20 @@ export function useCall(sessionId: string | null) {
channel.current?.send({ type: "dds.dispatch", service });
}, []);
+ /** Закрыть вызов не карточкой: справкой или передачей в другой регион.
+ * Отдельное действие, а не «положил трубку»: система должна отличить
+ * осознанное решение от брошенного вызова (docs/spec/TICKETS.md). */
+ const resolve = useCallback((outcome: Outcome, comment: string) => {
+ channel.current?.send({ type: "call.resolve", outcome, comment });
+ }, []);
+
const submitSelfAssessment = useCallback((missed: string[], comment: string) => {
channel.current?.send({ type: "self_assessment.submit", missed, comment });
setSelfAssessed(true);
}, []);
return {
+ resolve,
status, phase, incoming, lines, timers, callerSpeaking, micOn, error, card,
checklist, selfAssessed, scoreReady, report,
answer, hangup, hint, patchKio, dispatch, submitSelfAssessment,
diff --git a/frontend/src/pages/trainee/Call.tsx b/frontend/src/pages/trainee/Call.tsx
index 023021a..5b589f4 100644
--- a/frontend/src/pages/trainee/Call.tsx
+++ b/frontend/src/pages/trainee/Call.tsx
@@ -1,6 +1,8 @@
// АРМ курсанта: приём вызова и разговор голосом.
// Карточка КИО — lct-10, таймеры — lct-11.
+import { useState } from "react";
+
import { KioCard } from "@/features/kio-card/KioCard";
import { StandStatus } from "@/features/health/StandStatus";
import { ModeBanner, hintsAllowed } from "@/features/mode-banner/ModeBanner";
@@ -18,7 +20,14 @@ const STATUS_LABEL: Record
+ setResolveComment(event.target.value)} + /> +
++ {" "} + +
+ {resolved &&Вызов закрыт: {RESOLVE_LABELS[resolved]}
} +