// АРМ диспетчера ДДС. // // Аудио здесь нет вообще: приходит снимок карточки от оператора 112, // дальше — подтверждение по нормативу, возврат на уточнение и отметки времени. // Возврат карточки — самая ценная механика цепочки: неполнота КИО становится // сорванным выездом с конкретной причиной (docs/arch/CONTRACT.md). 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, type CardState } from "@/features/kio-card/merge"; import { stationChannel, type ChannelStatus } from "@/shared/api/ws"; import { sessionIdFromUrl } from "@/shared/api/session"; const ACK_LIMIT_S = 4; // норматив подтверждения получения карточки const FIELDS = GROUPS.flatMap((group) => group.fields.filter((field) => !field.readOnly).map((field) => ({ path: field.path, label: field.label })), ); 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 [acked, setAcked] = useState(false); const [bounced, setBounced] = useState(null); const [missing, setMissing] = useState([]); const [comment, setComment] = useState(""); const [zone, setZone] = useState(null); const [marks, setMarks] = useState([]); const channel = useRef | null>(null); useEffect(() => { if (!sessionId) return; const ch = stationChannel(sessionId, role, { onStatus: setStatus, onEvent: (event) => { if (event.type === "card.received") { setCard(applyState(emptyCard, event.card as unknown as Record)); setFrom(event.from_operator); setReceivedAt(Date.now()); } }, }).connect(); channel.current = ch; return () => ch.close(); }, [sessionId, role]); // Обратный отсчёт норматива: пока карточка не подтверждена, счёт идёт. useEffect(() => { if (receivedAt === null || acked) return; const timer = setInterval(() => setElapsed((Date.now() - receivedAt) / 1000), 200); return () => clearInterval(timer); }, [receivedAt, acked]); if (!sessionId) { return (

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

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

); } const mark = (label: string) => setMarks((prev) => [`${new Date().toLocaleTimeString("ru-RU")} — ${label}`, ...prev]); return (

АРМ диспетчера ДДС · {role}

{card && !acked && ( )}
Канал{status}
Карточка{card ? `от оператора ${from}` : "ожидание карточки от 112"}
Подтверждение ACK_LIMIT_S ? "state-violated" : "state-ok"}> {elapsed.toFixed(1)} с из {ACK_LIMIT_S} с
{card && ( <>

{" "}

{!bounced && (
Чего не хватает для выезда
{FIELDS.map((field) => ( ))}

setComment(event.target.value)} />

)}

Зона ответственности:{" "} {" "}

{" "}

Карточка от оператора 112

{/* Снимок: после передачи он не меняется. */} )} {marks.length > 0 && ( <>

Отметки времени

{marks.map((line, index) => )}
{line}
)}
); }