// Учебное АРМ ДДС. Проверяемый текстовый протокол дополняется настоящим // локальным SIP/WebRTC-телефоном; оба контура явно подписаны в интерфейсе. import { useEffect, useRef, useState } from "react"; import { KioCard } from "@/features/kio-card/KioCard"; import { applyState, empty as emptyCard, readPath, type CardState } from "@/features/kio-card/merge"; import { SipPhone } from "@/features/sip/SipPhone"; import { sessionIdFromUrl } from "@/shared/api/session"; import { stationChannel, type ChannelStatus } from "@/shared/api/ws"; import type { DdsQueueCard, ServiceStatus, SessionReport, StationSnapshot, StationToServer } from "@/shared/types/generated"; import { ALARMING, CARD_STATUS_LABELS, SERVICE_STATUS_LABELS } from "./statuses"; const ACK_LIMIT_S = 30; type ReportPhase = "dispatched" | "arrived" | "working" | "completed"; interface PhoneReport { service: string; crew: string; phase: ReportPhase; text: string; at: string } interface PhoneLine { service: string; crew: string; speaker: "dispatcher" | "crew"; text: string; at: string } interface PhonePending { service: string; crew: string; phase: ReportPhase } interface CompletedCard { card_id: string; scenario_id: string; score_auto: number } interface DdsSnapshot extends StationSnapshot { recipient_services?: string[]; managed_service?: string | null; crew_options?: string[]; crew_selected?: string | null; phone_reports?: PhoneReport[]; phone_lines?: PhoneLine[]; phone_pending?: PhonePending | null; card_id?: string | null; card_index?: number; card_total?: number; reply_text?: string; completed_cards?: CompletedCard[]; queue_cards?: DdsQueueCard[]; } const REPORT_LABELS: Record = { dispatched: "Доклад о выезде", arrived: "Доклад о прибытии", working: "Доклад о ходе работ", completed: "Доклад о завершении работ", }; function field(card: CardState, path: string): string { const value = readPath(card.server, path); return value === null || value === undefined || value === "" ? "—" : String(value); } function time(value: string): string { const parsed = new Date(value); return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleTimeString("ru-RU"); } function serviceTitle(service: string): string { return service.startsWith("Служба ") ? service : `Служба ${service}`; } export function Dds() { const sessionId = sessionIdFromUrl(); const role = new URLSearchParams(location.search).get("role") ?? "dds_01"; const [status, setStatus] = useState("connecting"); const [view, setView] = useState<"registry" | "card">("registry"); const [pane, setPane] = useState<"status" | "phone" | "reply" | "full" | "journal" | "more" | null>(null); const [activeService, setActiveService] = useState(null); const [search, setSearch] = useState(""); const [clockNow, setClockNow] = useState(() => new Date()); const [card, setCard] = useState(null); const [from, setFrom] = useState(""); const [receivedAt, setReceivedAt] = useState(null); const [elapsed, setElapsed] = useState(0); const [station, setStation] = useState(null); const [lastReport, setLastReport] = useState(null); const [dialing, setDialing] = useState(false); const [brief, setBrief] = useState({ address: "", incident: "", request: "" }); const [checkText, setCheckText] = useState(""); const [error, setError] = useState(""); const [ended, setEnded] = useState(false); const [report, setReport] = useState(null); const [reportState, setReportState] = useState<"idle" | "loading" | "ready" | "error">("idle"); const [reportError, setReportError] = useState(""); const [zone, setZone] = useState(null); const [reply, setReply] = useState({ text: "", dirty: false }); const [advancePending, setAdvancePending] = useState(false); const channel = useRef | null>(null); const activeCardId = useRef(null); const openCardAfterSwitch = useRef(false); useEffect(() => { if (!sessionId) return; const controller = new AbortController(); async function loadReport() { setReportState("loading"); setReportError(""); try { const response = await fetch(`/api/sessions/${sessionId}/report`, { credentials: "same-origin", signal: controller.signal, }); if (!response.ok) { if (response.status === 403) throw new Error("Отчёт недоступен: это занятие другого обучающегося."); if (response.status === 409) throw new Error("Оценка ещё не готова. Попробуйте получить разбор чуть позже."); throw new Error(`Не удалось получить отчёт (HTTP ${response.status}).`); } setReport(await response.json() as SessionReport); setReportState("ready"); } catch (cause) { if (controller.signal.aborted) return; setReportState("error"); setReportError(cause instanceof Error ? cause.message : "Не удалось получить отчёт."); } } const ch = stationChannel(sessionId, role, { onStatus: (next) => { setStatus(next); if (next !== "open") setDialing(false); }, onEvent: (event) => { if (event.type === "station.state") { const snapshot = event.snapshot as DdsSnapshot; setStation(snapshot); if (snapshot.card_id == null && snapshot.pending_cards_count > 0) { activeCardId.current = null; setCard(null); setView("registry"); setPane(null); setAdvancePending(false); } setActiveService((previous) => previous && snapshot.services.includes(previous) ? previous : snapshot.services[0] ?? null); setReply((previous) => { const saved = snapshot.reply_text ?? ""; if (previous.text === saved) return { text: saved, dirty: false }; return previous.dirty ? previous : { text: saved, dirty: false }; }); setError(""); } else if (event.type === "card.received") { const nextId = String(event.card.card_id); if (activeCardId.current !== nextId) { activeCardId.current = nextId; setStation(null); setReply({ text: "", dirty: false }); setLastReport(null); setDialing(false); setBrief({ address: "", incident: "", request: "" }); setCheckText(""); setZone(null); setReport(null); setReportState("idle"); setView(openCardAfterSwitch.current ? "card" : "registry"); openCardAfterSwitch.current = false; setPane(null); setActiveService(null); } setCard(applyState(emptyCard, { ...event.card })); setFrom(event.from_operator); // Норматив идёт с передачи карточки сервером, в том числе если // диспетчер открыл АРМ уже после начала занятия. const transmittedAt = new Date(event.at).getTime(); setReceivedAt(Number.isNaN(transmittedAt) ? Date.now() : transmittedAt); setEnded(false); setAdvancePending(false); } else if (event.type === "error") { setError(event.message); setDialing(false); setAdvancePending(false); } else if (event.type === "session.ended") { setEnded(true); setDialing(false); setAdvancePending(false); setPane(null); setView("registry"); } else if (event.type === "score.ready") { void loadReport(); } else if (event.type === "phone.report") { setLastReport(event); setDialing(false); setBrief({ address: "", incident: "", request: "" }); setCheckText(""); setError(""); } else if (event.type === "phone.line") setDialing(false); }, }).connect(); channel.current = ch; return () => { controller.abort(); ch.close(); channel.current = null; }; }, [sessionId, role]); const primaryRecorded = Boolean(station?.services.length && station.services.every((service) => ["accepted", "declined", "responding", "arrived", "working", "completed", "refused"] .includes(station.statuses[service]))); useEffect(() => { if (receivedAt === null || primaryRecorded) return; const timer = setInterval(() => setElapsed((Date.now() - receivedAt) / 1000), 200); return () => clearInterval(timer); }, [receivedAt, primaryRecorded]); useEffect(() => { const timer = setInterval(() => setClockNow(new Date()), 1000); return () => clearInterval(timer); }, []); const send = (command: StationToServer): boolean => channel.current?.send(command) ?? false; if (!sessionId) return

АРМ диспетчера ДДС

Откройте ссылку занятия вида /dds?session=…&role=dds_01

; const reports = station?.phone_reports ?? []; const selectedCrew = station?.crew_selected ?? ""; const latest = (lastReport?.crew === selectedCrew ? lastReport : null) ?? reports.filter((item) => item.crew === selectedCrew).at(-1) ?? null; const phoneCall = station?.phone_pending ?? null; const phoneLines = (station?.phone_lines ?? []).filter((line) => line.crew === selectedCrew); const isConnected = status === "open"; const cardId = card ? String(readPath(card.server, "card_id") ?? "") : ""; const cardIndex = station?.card_index ?? 1; const cardTotal = station?.card_total ?? 1; const completedCards = station?.completed_cards ?? []; const queueCards = station?.queue_cards ?? []; const query = search.trim().toLocaleLowerCase("ru-RU"); const visibleCompletedCards = completedCards.filter((item) => !query || `${item.card_id} ${item.scenario_id}`.toLocaleLowerCase("ru-RU").includes(query)); const visibleQueueCards = queueCards.filter((item) => !query || [item.card_id, item.address ?? "", item.description ?? "", item.incident_type ?? "", item.title] .some((value) => value.toLocaleLowerCase("ru-RU").includes(query))); const crewAccepted = Boolean(station?.services.some((service) => selectedCrew.startsWith(`${service} —`) && ["accepted", "responding", "arrived", "working"].includes(station.statuses[service]))); return
112 ГБУ Система 112 · учебное АРМ ДДС {role} {isConnected ? "● На связи" : `● ${status}`}
{error &&
Действие не принято: {error}
} {ended &&
Занятие завершено. Оценка появится после расчёта.
} {(ended || reportState !== "idle") &&

Итог упражнения

{report ?

Итоговая оценка: {report.score_final.toFixed(0)} из 100 {report.score_final !== report.score_auto && ` · автооценка ${report.score_auto.toFixed(0)}`}

Скачать отчёт: CSV {" · "}PDF

{Boolean(report.card_results?.length) && <>

Результаты по карточкам

{report.card_results?.map((item, index) =>
Карточка {index + 1} · {item.scenario_id} · {item.score_auto.toFixed(0)} / 100

Ответ диспетчера: {item.reply_text || "не внесён"}

{item.metrics.filter((metric) => !metric.passed).map((metric) =>

{metric.title}: {metric.fact} · норматив {metric.norm}

)} {item.findings.map((finding, findingIndex) =>

{finding.code}: {finding.summary}

)}
)} }

Не выполнены нормативы

{report.metrics.filter((metric) => !metric.passed).map((metric) => )} {report.metrics.every((metric) => metric.passed) && }
{metric.title}{metric.fact} · норматив: {metric.norm}
Все проверенные нормативы выполнены.

Замечания

{report.findings.map((finding, index) => )} {report.findings.length === 0 && }
{finding.code}{finding.summary}
{finding.fact}{finding.norm && ` · ${finding.norm}`}
Замечаний нет.
:
{reportState === "loading" ? "Подготавливаем разбор…" : reportError || "Ожидаем итоговую оценку…"} {reportState === "error" && }
}
} {view === "registry" && <>

Поиск происшествий

setSearch(event.target.value)} placeholder="Введите номер или адрес" />
{clockNow.toLocaleDateString("ru-RU", { weekday: "long", day: "numeric", month: "long", year: "numeric" })} {clockNow.toLocaleTimeString("ru-RU")}Учебный контур

Список происшествий⌃

Карточка {cardIndex} из {cardTotal} · обработано {completedCards.length}
{visibleCompletedCards.map((item) => )} {visibleQueueCards.map((item) => { const received = new Date(item.received_at); const liveElapsed = item.timer_stopped ? item.elapsed_ms / 1000 : Math.max( item.elapsed_ms / 1000, (clockNow.getTime() - received.getTime()) / 1000, ); const openCard = () => { if (item.active) { setView("card"); return; } openCardAfterSwitch.current = true; if (!send({ type: "card.open", card_id: item.card_id })) { openCardAfterSwitch.current = false; } }; return })} {!visibleQueueCards.length && !visibleCompletedCards.length && }
СвязиЧСОпер.АРМНомерДатаВремяТип происшествияПостр.АдресСтатус службы
▾—0ДДС {item.card_id.slice(0, 8).toUpperCase()}—— Сценарий {item.scenario_id}—— Отработана · {item.score_auto.toFixed(0)} / 100
— 0ДДС {received.toLocaleDateString("ru-RU")} {received.toLocaleTimeString("ru-RU")} {item.title} · {item.incident_type || "тип не указан"} Описание: {item.description || "—"} {item.victims_count ?? "—"} {item.address || "—"} {item.managed_service || "ДДС"}: {SERVICE_STATUS_LABELS[item.service_status]} item.limit_ms ? "state-violated" : ""}> {item.timer_stopped ? "решение за" : "до первичного решения"} {liveElapsed.toFixed(0)} с
{query ? "По запросу ничего не найдено." : "Ожидание готовой карточки от преподавателя или оператора 112…"}
{queueCards.length > 1 &&

Одновременно в работе {queueCards.length} карточек. Таймер каждой идёт независимо от того, какая строка открыта.

} {station && station.pending_cards_count > 0 &&

{station.next_arrival_in_seconds === null ? `Осталось карточек: ${station.pending_cards_count}. Все карточки уже поступили; необработанные находятся в реестре.` : station.next_arrival_in_seconds > 0 ? `Осталось карточек: ${station.pending_cards_count}. Следующее поступление примерно через ${station.next_arrival_in_seconds} с.` : queueCards.filter((item) => !item.active).length >= station.max_waiting_cards ? `Осталось карточек: ${station.pending_cards_count}. Очередь ожидания заполнена (${station.max_waiting_cards}); новые поступят, когда освободится место.` : `Осталось карточек: ${station.pending_cards_count}. Ожидается следующее поступление.`}

}
} {card && view === "card" &&
Происшествие {cardId.slice(0, 8).toUpperCase()} · карточка {cardIndex} из {cardTotal}
☎Отключениезаписи звонков · список SMS
☎АОН{field(card, "caller_number")}
☎предоставленный{field(card, "caller_contact")}
☎телефон на место{field(card, "phone_on_scene")}
Происшествие {cardId.slice(0, 8).toUpperCase()} Сохр. {receivedAt ? new Date(receivedAt).toLocaleString("ru-RU") : "—"} Опер., АРМ ДДС
ФИО заявителя: {field(card, "caller_name")} Пострадавшие: {field(card, "victims_count")}   ЧС: {field(card, "is_emergency") === "true" ? "да" : "нет"} ✎
{field(card, "address")}{field(card, "street")} {field(card, "building")}
{receivedAt ? new Date(receivedAt).toLocaleString("ru-RU") : "—"}   {field(card, "description")}
Происшествие {field(card, "incident_code")}
{field(card, "description")}
Класс: {field(card, "incident_type")}
Получатели карточки: {station?.recipient_services?.join(", ") || field(card, "notify")}
Ваша ДДС: {station?.managed_service || "служба не назначена"}
{pane &&
{({ status: "Статусы службы", phone: "IP-телефон", reply: "Ответ диспетчера", full: "Карточка информационного обмена", journal: "Журнал реагирования", more: "Дополнение" })[pane]}
{pane === "full" &&

Карточка обращения

Источник: {from || "112"}
Адрес{field(card, "address")}
Заявитель{field(card, "caller_name")}
Телефон{field(card, "caller_contact")}
Происшествие{field(card, "description")}
Полная карточка информационного обмена
} {pane === "status" &&

Реагирование служб

{station ? CARD_STATUS_LABELS[station.card] : "—"}
{!primaryRecorded && receivedAt !== null &&
ACK_LIMIT_S ? "state-violated" : "state-ok"}`}> Первичное решение «Принята» / «Не принята»: {elapsed.toFixed(0)} из {ACK_LIMIT_S} секунд
} {station && station.services.length > 0 ? <>
Служба {activeService ?? station.services[0]} {station.log.filter((entry) => entry.service === (activeService ?? station.services[0])).map((entry, index) =>
› {time(entry.at)} {SERVICE_STATUS_LABELS[entry.status]} {entry.comment || "—"}
)} {!station.log.some((entry) => entry.service === (activeService ?? station.services[0])) &&
› Карточка получена службойОжидается первичное решение
}
{station.services.filter((service) => service === (activeService ?? station.services[0])).map((service) => crew.startsWith(`${service} —`)) ?? []} selectedCrew={selectedCrew.startsWith(`${service} —`) ? selectedCrew : ""} onCrew={(crew) => send({ type: "crew.select", crew })} onSet={(next, comment) => channel.current?.send({ type: "card.status", service, status: next, comment })} />)} :

Ожидаем состав служб по карточке.

}

Сначала примите карточку или укажите причину отказа. Последующие статусы ставятся по фактически полученной информации; телефон — один из возможных каналов.

} {pane === "journal" && station && station.log.length > 0 &&

Журнал реагирования

{station.log.map((entry, index) => )}
ВремяСлужбаСтатусКомментарий
{time(entry.at)}{entry.service} {SERVICE_STATUS_LABELS[entry.status]}{entry.comment || "—"}
}