// Учебное АРМ ДДС. Звонок бригаде имитируется внутри приложения, без SIP. import { useEffect, useRef, useState } from "react"; import { GROUPS } from "@/features/kio-card/fields"; import { KioCard } from "@/features/kio-card/KioCard"; import { applyState, empty as emptyCard, readPath, type CardState } from "@/features/kio-card/merge"; import { sessionIdFromUrl } from "@/shared/api/session"; import { stationChannel, type ChannelStatus } from "@/shared/api/ws"; import type { 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 CompletedCard { card_id: string; scenario_id: string; score_auto: number } interface DdsSnapshot extends StationSnapshot { crew_options?: string[]; crew_selected?: string | null; phone_reports?: PhoneReport[]; card_id?: string | null; card_index?: number; card_total?: number; reply_text?: string; completed_cards?: CompletedCard[]; } const REPORT_LABELS: Record = { dispatched: "Доклад о выезде", arrived: "Доклад о прибытии", working: "Доклад о ходе работ", completed: "Доклад о завершении работ", }; const FIELDS = GROUPS.flatMap((group) => group.fields.filter((item) => !item.readOnly).map((item) => ({ path: item.path, label: item.label })), ); 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 [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 [missing, setMissing] = useState([]); const [bounceComment, setBounceComment] = useState(""); const [bounced, setBounced] = useState(false); 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); 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); 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); setMissing([]); setBounceComment(""); setBounced(false); setZone(null); setReport(null); setReportState("idle"); setView("registry"); 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); setError(""); } }, }).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 ?? reports.at(-1) ?? null; 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 activeIsCompleted = completedCards.some((item) => item.card_id === cardId); 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 showActive = card && !activeIsCompleted && (!query || [cardId, field(card, "address"), field(card, "description"), field(card, "incident_type")] .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) => )} {showActive && card && } {!showActive && !visibleCompletedCards.length && }
СвязиЧСОпер.АРМНомерДатаВремяТип происшествияПостр.АдресСтатус службы
▾—0ДДС {item.card_id.slice(0, 8).toUpperCase()}—— Сценарий {item.scenario_id}—— Отработана · {item.score_auto.toFixed(0)} / 100
{field(card, "is_emergency") === "true" ? "Да" : "Нет"} 0ДДС {receivedAt ? new Date(receivedAt).toLocaleDateString("ru-RU") : "—"} {receivedAt ? new Date(receivedAt).toLocaleTimeString("ru-RU") : "—"} {field(card, "incident_code")} · {field(card, "incident_type")} Описание: {field(card, "description")} {field(card, "victims_count")} {field(card, "address")} {station?.services.map((service) => `${service}: ${SERVICE_STATUS_LABELS[station.statuses[service]]}`).join("; ") || "Добавлена"}
{query ? "По запросу ничего не найдено." : "Ожидание готовой карточки от преподавателя или оператора 112…"}
{cardTotal > cardIndex &&

Остальные {cardTotal - cardIndex} карточки поступят после завершения текущей.

}
} {card && view === "card" &&
Происшествие {cardId.slice(0, 8).toUpperCase()} · карточка {cardIndex} из {cardTotal}
☎Отключениезаписи звонков · список SMS
☎АОН{field(card, "caller_number")}
☎предоставленный{field(card, "caller_contact")}
☎телефон на место{field(card, "caller_contact")}
Происшествие {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?.services.join(", ") || field(card, "notify")}
{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) => report.service === service)} phoneExercise={from === "учебный сценарий"} crewOptions={station.crew_options?.filter((crew) => 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 || "—"}
}