// Учебное АРМ ДДС. Звонок бригаде имитируется внутри приложения, без 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 DdsSnapshot extends StationSnapshot { crew_options?: string[]; crew_selected?: string | null; phone_reports?: PhoneReport[]; } type DdsCommand = { type: "crew.select"; crew: string } | { type: "phone.dial" } | { type: "station.finish" }; 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"); } export function Dds() { const sessionId = sessionIdFromUrl(); const role = new URLSearchParams(location.search).get("role") ?? "dds_01"; const [status, setStatus] = useState("connecting"); 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 channel = useRef | null>(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") { setStation(event.snapshot as DdsSnapshot); setError(""); } else if (event.type === "card.received") { setCard(applyState(emptyCard, event.card as unknown as Record)); setFrom(event.from_operator); // Норматив идёт с передачи карточки сервером, в том числе если // диспетчер открыл АРМ уже после начала занятия. const transmittedAt = new Date(event.at).getTime(); setReceivedAt(Number.isNaN(transmittedAt) ? Date.now() : transmittedAt); setEnded(false); } else if (event.type === "error") { setError(event.message); setDialing(false); } else if (event.type === "session.ended") { setEnded(true); setDialing(false); } else if (event.type === "score.ready") { void loadReport(); } else if ((event as { type: string }).type === "phone.report") { setLastReport(event as unknown as PhoneReport); 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]); const send = (command: DdsCommand): boolean => channel.current?.send(command as StationToServer) ?? 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 crewAccepted = Boolean(station?.services.some((service) => selectedCrew.startsWith(`${service} —`) && ["accepted", "responding", "arrived", "working"].includes(station.statuses[service]))); return
Учебный контур · АРМ системы-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

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

{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" && }
}
}

Реестр обращений

Входящие карточки
{card ? : }
№ карточкиВремяАдрес происшествияТипСлужбыСтатусРешение ДДС
{sessionId.slice(0, 8).toUpperCase()}{time(field(card, "registered_at"))} {field(card, "address")}{field(card, "incident_type")} {station?.services.join(", ") || field(card, "notify")} {station ? CARD_STATUS_LABELS[station.card] : "Получена"} {station?.services.map((service) => `${service}: ${SERVICE_STATUS_LABELS[station.statuses[service]]}`).join("; ") || "ожидается"}
Ожидание готовой карточки от преподавателя или оператора 112…
{card &&

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

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

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

{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 ?
{station.services.map((service) => report.service === service)} phoneExercise={from === "учебный сценарий"} onSet={(next, comment) => channel.current?.send({ type: "card.status", service, status: next, comment })} />)}
СлужбаТекущий статусСледующее действие
:

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

}

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

{station && station.log.length > 0 &&

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

{station.log.map((entry, index) => )}
ВремяСлужбаСтатусКомментарий
{time(entry.at)}{entry.service} {SERVICE_STATUS_LABELS[entry.status]}{entry.comment || "—"}
}
}
; } function StatusRow({ service, current, available, disabled, reports, phoneExercise, onSet }: { service: string; current: ServiceStatus; available: ServiceStatus[]; disabled: boolean; reports: PhoneReport[]; phoneExercise: boolean; onSet: (status: ServiceStatus, comment: string) => void; }) { const [chosen, setChosen] = useState(""); const [comment, setComment] = useState(""); const needsComment = chosen === "declined" || chosen === "refused"; const reportNeeded: Partial> = { responding: "dispatched", arrived: "arrived", working: "working", completed: "completed", }; const matchingReport = !chosen || !phoneExercise || !reportNeeded[chosen] || reports.some((report) => report.phase === reportNeeded[chosen]); return {service}{SERVICE_STATUS_LABELS[current]} {available.length ?
{needsComment && setComment(event.target.value)} />} {chosen && !matchingReport && Сначала получите доклад по телефону.}
: "Карточка закрыта для редактирования"}; }