Implement DDS exercise, customer UI and local demo
This commit is contained in:
parent
cec84ffcd0
commit
0526b11f91
46 changed files with 2504 additions and 253 deletions
|
|
@ -12,12 +12,17 @@ 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[];
|
||||
}
|
||||
type DdsCommand = { type: "crew.select"; crew: string } | { type: "phone.dial" } | { type: "station.finish" };
|
||||
const REPORT_LABELS: Record<ReportPhase, string> = {
|
||||
dispatched: "Доклад о выезде", arrived: "Доклад о прибытии",
|
||||
working: "Доклад о ходе работ", completed: "Доклад о завершении работ",
|
||||
|
|
@ -35,10 +40,19 @@ function time(value: string): string {
|
|||
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<ChannelStatus>("connecting");
|
||||
const [view, setView] = useState<"registry" | "card">("registry");
|
||||
const [pane, setPane] = useState<"status" | "phone" | "reply" | "full" | "journal" | "more" | null>(null);
|
||||
const [activeService, setActiveService] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [clockNow, setClockNow] = useState(() => new Date());
|
||||
const [card, setCard] = useState<CardState | null>(null);
|
||||
const [from, setFrom] = useState("");
|
||||
const [receivedAt, setReceivedAt] = useState<number | null>(null);
|
||||
|
|
@ -55,7 +69,10 @@ export function Dds() {
|
|||
const [bounceComment, setBounceComment] = useState("");
|
||||
const [bounced, setBounced] = useState(false);
|
||||
const [zone, setZone] = useState<boolean | null>(null);
|
||||
const [reply, setReply] = useState({ text: "", dirty: false });
|
||||
const [advancePending, setAdvancePending] = useState(false);
|
||||
const channel = useRef<ReturnType<typeof stationChannel> | null>(null);
|
||||
const activeCardId = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
|
|
@ -84,20 +101,49 @@ export function Dds() {
|
|||
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(""); }
|
||||
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") {
|
||||
setCard(applyState(emptyCard, event.card as unknown as Record<string, unknown>));
|
||||
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);
|
||||
} else if (event.type === "error") { setError(event.message); setDialing(false); }
|
||||
else if (event.type === "session.ended") { setEnded(true); setDialing(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 as { type: string }).type === "phone.report") {
|
||||
setLastReport(event as unknown as PhoneReport);
|
||||
else if (event.type === "phone.report") {
|
||||
setLastReport(event);
|
||||
setDialing(false);
|
||||
setError("");
|
||||
}
|
||||
|
|
@ -116,7 +162,12 @@ export function Dds() {
|
|||
return () => clearInterval(timer);
|
||||
}, [receivedAt, primaryRecorded]);
|
||||
|
||||
const send = (command: DdsCommand): boolean => channel.current?.send(command as StationToServer) ?? false;
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setClockNow(new Date()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const send = (command: StationToServer): boolean => channel.current?.send(command) ?? false;
|
||||
if (!sessionId) return <main className="page"><h1>АРМ диспетчера ДДС</h1>
|
||||
<p className="warn">Откройте ссылку занятия вида /dds?session=…&role=dds_01</p></main>;
|
||||
|
||||
|
|
@ -124,15 +175,29 @@ export function Dds() {
|
|||
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 <main className="page dds-workspace">
|
||||
<header className="dds-header"><div><div className="dds-eyebrow">Учебный контур · АРМ системы-112</div>
|
||||
<h1>Дежурно-диспетчерская служба <span>{role}</span></h1></div>
|
||||
<div className={`dds-connection ${isConnected ? "state-ok" : "state-warn"}`}>
|
||||
{isConnected ? "● Канал подключён" : `● ${status}`}</div></header>
|
||||
<header className="dds-app-top">
|
||||
<span className="dds-brand">112</span>
|
||||
<span className="dds-app-name">ГБУ Система 112 · учебное АРМ ДДС</span>
|
||||
<span className="dds-top-role">{role}</span>
|
||||
<span className={`dds-connection ${isConnected ? "state-ok" : "state-warn"}`}>
|
||||
{isConnected ? "● На связи" : `● ${status}`}</span>
|
||||
</header>
|
||||
{error && <div className="dds-alert" role="alert">Действие не принято: {error}</div>}
|
||||
{ended && <div className="dds-alert dds-done">Занятие завершено. Оценка появится после расчёта.</div>}
|
||||
{(ended || reportState !== "idle") && <section className="dds-panel" aria-live="polite">
|
||||
|
|
@ -142,6 +207,17 @@ export function Dds() {
|
|||
{report.score_final !== report.score_auto && ` · автооценка ${report.score_auto.toFixed(0)}`}</p>
|
||||
<p className="ref">Скачать отчёт: <a href={`/api/sessions/${sessionId}/report.csv`}>CSV</a>
|
||||
{" · "}<a href={`/api/sessions/${sessionId}/report.pdf`}>PDF</a></p>
|
||||
{Boolean(report.card_results?.length) && <>
|
||||
<h3>Результаты по карточкам</h3>
|
||||
{report.card_results?.map((item, index) => <details className="dds-details" key={item.card_id}>
|
||||
<summary>Карточка {index + 1} · {item.scenario_id} · {item.score_auto.toFixed(0)} / 100</summary>
|
||||
<p><b>Ответ диспетчера:</b> {item.reply_text || "не внесён"}</p>
|
||||
{item.metrics.filter((metric) => !metric.passed).map((metric) =>
|
||||
<p key={metric.key} className="state-violated">{metric.title}: {metric.fact} · норматив {metric.norm}</p>)}
|
||||
{item.findings.map((finding, findingIndex) =>
|
||||
<p key={`${finding.code}-${findingIndex}`}>{finding.code}: {finding.summary}</p>)}
|
||||
</details>)}
|
||||
</>}
|
||||
<h3>Не выполнены нормативы</h3>
|
||||
<table className="dds-registry"><tbody>
|
||||
{report.metrics.filter((metric) => !metric.passed).map((metric) => <tr key={metric.key}>
|
||||
|
|
@ -172,20 +248,81 @@ export function Dds() {
|
|||
</div>}
|
||||
</section>}
|
||||
|
||||
<section className="dds-panel"><div className="dds-panel-title"><h2>Реестр обращений</h2><span>Входящие карточки</span></div>
|
||||
<div className="dds-table-wrap"><table className="dds-registry"><thead><tr>
|
||||
<th>№ карточки</th><th>Время</th><th>Адрес происшествия</th><th>Тип</th><th>Службы</th><th>Статус</th><th>Решение ДДС</th>
|
||||
</tr></thead><tbody>{card ? <tr className="dds-selected">
|
||||
<td>{sessionId.slice(0, 8).toUpperCase()}</td><td>{time(field(card, "registered_at"))}</td>
|
||||
<td>{field(card, "address")}</td><td>{field(card, "incident_type")}</td>
|
||||
<td>{station?.services.join(", ") || field(card, "notify")}</td>
|
||||
<td>{station ? CARD_STATUS_LABELS[station.card] : "Получена"}</td>
|
||||
<td>{station?.services.map((service) => `${service}: ${SERVICE_STATUS_LABELS[station.statuses[service]]}`).join("; ") || "ожидается"}</td>
|
||||
</tr> : <tr><td colSpan={7} className="dds-empty">Ожидание готовой карточки от преподавателя или оператора 112…</td></tr>}</tbody></table></div>
|
||||
{view === "registry" && <>
|
||||
<section className="dds-search">
|
||||
<div className="dds-search-main">
|
||||
<h1>Поиск происшествий</h1>
|
||||
<div className="dds-search-line"><label htmlFor="dds-search-input">Поиск по номеру, адресу или описанию</label>
|
||||
<input id="dds-search-input" type="search" value={search} onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Введите номер или адрес" />
|
||||
<button type="button" onClick={() => setSearch("")}>сбросить</button></div>
|
||||
</div>
|
||||
<div className="dds-clock"><small>{clockNow.toLocaleDateString("ru-RU", { weekday: "long", day: "numeric", month: "long", year: "numeric" })}</small>
|
||||
<strong>{clockNow.toLocaleTimeString("ru-RU")}</strong><span>Учебный контур</span></div>
|
||||
</section>
|
||||
<section className="dds-panel dds-register-panel"><div className="dds-panel-title"><h2>Список происшествий⌃</h2>
|
||||
<span>Карточка {cardIndex} из {cardTotal} · обработано {completedCards.length}</span></div>
|
||||
<div className="dds-table-wrap"><table className="dds-registry"><thead><tr>
|
||||
<th>Связи</th><th>ЧС</th><th>Опер.</th><th>АРМ</th><th>Номер</th><th>Дата</th><th>Время</th><th>Тип происшествия</th><th>Постр.</th><th>Адрес</th><th>Статус службы</th>
|
||||
</tr></thead><tbody>
|
||||
{visibleCompletedCards.map((item) => <tr key={item.card_id}>
|
||||
<td data-label="Связи">▾</td><td data-label="ЧС">—</td><td data-label="Опер.">0</td><td data-label="АРМ">ДДС</td>
|
||||
<td data-label="Номер">{item.card_id.slice(0, 8).toUpperCase()}</td><td data-label="Дата">—</td><td data-label="Время">—</td>
|
||||
<td data-label="Тип происшествия">Сценарий {item.scenario_id}</td><td data-label="Постр.">—</td><td data-label="Адрес">—</td>
|
||||
<td data-label="Статус службы">Отработана · {item.score_auto.toFixed(0)} / 100</td>
|
||||
</tr>)}
|
||||
{showActive && card && <tr className="dds-selected" aria-current="true">
|
||||
<td data-label="Связи"><button type="button" className="dds-row-open" onClick={() => setView("card")} aria-label="Открыть карточку">▾</button></td>
|
||||
<td data-label="ЧС">{field(card, "is_emergency") === "true" ? "Да" : "Нет"}</td>
|
||||
<td data-label="Опер.">0</td><td data-label="АРМ">ДДС</td>
|
||||
<td data-label="Номер"><button type="button" className="dds-row-link" onClick={() => setView("card")}>{cardId.slice(0, 8).toUpperCase()}</button></td>
|
||||
<td data-label="Дата">{receivedAt ? new Date(receivedAt).toLocaleDateString("ru-RU") : "—"}</td>
|
||||
<td data-label="Время">{receivedAt ? new Date(receivedAt).toLocaleTimeString("ru-RU") : "—"}</td>
|
||||
<td data-label="Тип происшествия">{field(card, "incident_code")} · {field(card, "incident_type")}
|
||||
<small>Описание: {field(card, "description")}</small></td>
|
||||
<td data-label="Постр.">{field(card, "victims_count")}</td>
|
||||
<td data-label="Адрес">{field(card, "address")}</td>
|
||||
<td data-label="Статус службы">{station?.services.map((service) => `${service}: ${SERVICE_STATUS_LABELS[station.statuses[service]]}`).join("; ") || "Добавлена"}</td>
|
||||
</tr>}
|
||||
{!showActive && !visibleCompletedCards.length && <tr><td colSpan={11} className="dds-empty">
|
||||
{query ? "По запросу ничего не найдено." : "Ожидание готовой карточки от преподавателя или оператора 112…"}</td></tr>}
|
||||
</tbody></table></div>
|
||||
{cardTotal > cardIndex && <p className="dds-hint">Остальные {cardTotal - cardIndex} карточки поступят после завершения текущей.</p>}
|
||||
</section>
|
||||
</>}
|
||||
|
||||
{card && <div className="dds-main"><div className="dds-left">
|
||||
<section className="dds-panel"><div className="dds-panel-title"><h2>Карточка обращения</h2>
|
||||
{card && view === "card" && <div className="dds-case-layout">
|
||||
<div className="dds-case-top"><button type="button" onClick={() => { setView("registry"); setPane(null); }}>← Список происшествий</button>
|
||||
<span>Происшествие {cardId.slice(0, 8).toUpperCase()} · карточка {cardIndex} из {cardTotal}</span></div>
|
||||
<div className="dds-call-strip">
|
||||
<div className="dds-call-off"><span className="dds-call-icon">☎</span><strong>Отключение</strong><small>записи звонков · список SMS</small></div>
|
||||
<div><span className="dds-call-icon">☎</span><small>АОН</small><strong>{field(card, "caller_number")}</strong></div>
|
||||
<div><span className="dds-call-icon">☎</span><small>предоставленный</small><strong>{field(card, "caller_contact")}</strong></div>
|
||||
<div><span className="dds-call-icon">☎</span><small>телефон на место</small><strong>{field(card, "caller_contact")}</strong></div>
|
||||
<div className="dds-call-incident"><strong>Происшествие {cardId.slice(0, 8).toUpperCase()}</strong>
|
||||
<small>Сохр. {receivedAt ? new Date(receivedAt).toLocaleString("ru-RU") : "—"}</small>
|
||||
<small>Опер., АРМ ДДС</small></div>
|
||||
<div className="dds-case-top-buttons"><button type="button" onClick={() => setPane("full")}>просмотр</button>
|
||||
<button type="button" onClick={() => setPane("more")}>дополнение</button></div>
|
||||
</div>
|
||||
<div className="dds-case-meta"><span>ФИО заявителя: <b>{field(card, "caller_name")}</b></span>
|
||||
<span>Пострадавшие: {field(card, "victims_count")} ЧС: {field(card, "is_emergency") === "true" ? "да" : "нет"}</span>
|
||||
<span>✎</span></div>
|
||||
<div className="dds-case-summary">
|
||||
<div className="dds-case-address"><strong>{field(card, "address")}</strong><span>{field(card, "street")} {field(card, "building")}</span>
|
||||
<div><b>{receivedAt ? new Date(receivedAt).toLocaleString("ru-RU") : "—"}</b> {field(card, "description")}</div></div>
|
||||
<div className="dds-case-classification"><strong>Происшествие {field(card, "incident_code")}</strong>
|
||||
<div><b>{field(card, "description")}</b></div>
|
||||
<div>Класс: <b>{field(card, "incident_type")}</b></div>
|
||||
<div>Список оповещения: <b>{station?.services.join(", ") || field(card, "notify")}</b></div></div>
|
||||
</div>
|
||||
<div className="dds-case-stage">
|
||||
{pane && <div className={`dds-case-drawer dds-case-drawer-${pane}`}>
|
||||
<header className="dds-drawer-title"><strong>{({ status: "Статусы службы", phone: "IP-телефон", reply: "Ответ диспетчера",
|
||||
full: "Карточка информационного обмена", journal: "Журнал реагирования", more: "Дополнение" })[pane]}</strong>
|
||||
<button type="button" onClick={() => setPane(null)} aria-label="Закрыть панель">×</button></header>
|
||||
<div className="dds-main"><div className="dds-left">
|
||||
{pane === "full" && <section className="dds-panel"><div className="dds-panel-title"><h2>Карточка обращения</h2>
|
||||
<span>Источник: {from || "112"}</span></div>
|
||||
<div className="dds-summary">
|
||||
<div><small>Адрес</small><strong>{field(card, "address")}</strong></div>
|
||||
|
|
@ -193,28 +330,39 @@ export function Dds() {
|
|||
<div><small>Телефон</small><strong>{field(card, "caller_contact")}</strong></div>
|
||||
<div><small>Происшествие</small><strong>{field(card, "description")}</strong></div>
|
||||
</div><details className="dds-details"><summary>Полная карточка информационного обмена</summary><KioCard state={card} readOnly /></details>
|
||||
</section>
|
||||
<section className="dds-panel"><div className="dds-panel-title"><h2>Реагирование служб</h2>
|
||||
</section>}
|
||||
{pane === "status" && <section className="dds-panel"><div className="dds-panel-title"><h2>Реагирование служб</h2>
|
||||
<span className={station && ALARMING.includes(station.card) ? "state-violated" : "state-ok"}>
|
||||
{station ? CARD_STATUS_LABELS[station.card] : "—"}</span></div>
|
||||
{!primaryRecorded && receivedAt !== null && <div className={`dds-deadline ${elapsed > ACK_LIMIT_S ? "state-violated" : "state-ok"}`}>
|
||||
Первичное решение «Принята» / «Не принята»: {elapsed.toFixed(0)} из {ACK_LIMIT_S} секунд</div>}
|
||||
{station && station.services.length > 0 ? <div className="dds-table-wrap"><table className="dds-registry dds-status-table">
|
||||
<thead><tr><th>Служба</th><th>Текущий статус</th><th>Следующее действие</th></tr></thead><tbody>
|
||||
{station.services.map((service) => <StatusRow key={service} service={service}
|
||||
{station && station.services.length > 0 ? <>
|
||||
<div className="dds-status-history"><strong>Служба {activeService ?? station.services[0]}</strong>
|
||||
<button type="button" className="dds-status-close" onClick={() => setPane(null)} aria-label="Закрыть статусы">×</button>
|
||||
{station.log.filter((entry) => entry.service === (activeService ?? station.services[0])).map((entry, index) =>
|
||||
<div key={`${entry.at}-${index}`}><span>› {time(entry.at)} {SERVICE_STATUS_LABELS[entry.status]}</span>
|
||||
<span>{entry.comment || "—"}</span></div>)}
|
||||
{!station.log.some((entry) => entry.service === (activeService ?? station.services[0])) &&
|
||||
<div><span>› Карточка получена службой</span><span>Ожидается первичное решение</span></div>}
|
||||
</div>
|
||||
{station.services.filter((service) => service === (activeService ?? station.services[0])).map((service) =>
|
||||
<StatusRow key={`${cardId}:${service}`} service={service}
|
||||
current={station.statuses[service]} available={station.available[service] ?? []}
|
||||
disabled={!isConnected || ended} reports={reports.filter((report) => 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 })} />)}
|
||||
</tbody></table></div> : <p className="dds-empty">Ожидаем состав служб по карточке.</p>}
|
||||
</> : <p className="dds-empty">Ожидаем состав служб по карточке.</p>}
|
||||
<p className="dds-hint">Сначала примите карточку или укажите причину отказа. Последующие статусы в занятии ДДС подтверждаются докладами по телефону.</p>
|
||||
</section>
|
||||
{station && station.log.length > 0 && <section className="dds-panel"><div className="dds-panel-title"><h2>Журнал реагирования</h2></div>
|
||||
</section>}
|
||||
{pane === "journal" && station && station.log.length > 0 && <section className="dds-panel"><div className="dds-panel-title"><h2>Журнал реагирования</h2></div>
|
||||
<div className="dds-table-wrap"><table className="dds-registry"><thead><tr><th>Время</th><th>Служба</th><th>Статус</th><th>Комментарий</th></tr></thead>
|
||||
<tbody>{station.log.map((entry, index) => <tr key={`${entry.at}-${index}`}><td>{time(entry.at)}</td><td>{entry.service}</td>
|
||||
<td>{SERVICE_STATUS_LABELS[entry.status]}</td><td>{entry.comment || "—"}</td></tr>)}</tbody></table></div></section>}
|
||||
</div><aside className="dds-right">
|
||||
<section className="dds-panel dds-phone"><div className="dds-panel-title"><h2>IP-телефон</h2><span>Учебная имитация</span></div>
|
||||
{pane === "phone" && <section className="dds-panel dds-phone"><div className="dds-panel-title"><h2>IP-телефон</h2><span>Учебная имитация</span></div>
|
||||
<p className="dds-hint">Выберите бригаду вручную, свяжитесь со старшим группы и внесите его доклад в карточку.</p>
|
||||
<label htmlFor="dds-crew">Дежурная бригада</label>
|
||||
<select id="dds-crew" value={selectedCrew} disabled={!isConnected || ended || !station?.crew_options?.length}
|
||||
|
|
@ -233,8 +381,21 @@ export function Dds() {
|
|||
{reports.length > 0 && <details className="dds-details"><summary>История докладов ({reports.length})</summary>
|
||||
<ol className="dds-reports">{reports.map((report, index) => <li key={`${report.at}-${index}`}><b>{REPORT_LABELS[report.phase]}</b> · {time(report.at)}<br />{report.text}</li>)}</ol>
|
||||
</details>}
|
||||
</section>
|
||||
<section className="dds-panel dds-secondary"><div className="dds-panel-title"><h2>Дополнительные действия</h2></div>
|
||||
</section>}
|
||||
{pane === "reply" && <section className="dds-panel dds-reply">
|
||||
<div className="dds-panel-title"><h2>Ответ и комментарий диспетчера</h2>
|
||||
<span>{reply.dirty ? "Не сохранено" : "Сохранено"}</span></div>
|
||||
<label htmlFor="dds-reply-text">Свободный текст по текущей карточке</label>
|
||||
<textarea id="dds-reply-text" rows={7} value={reply.text}
|
||||
disabled={!isConnected || ended || advancePending}
|
||||
placeholder="Что сообщено старшему группы, какие сведения получены и почему принято решение"
|
||||
onChange={(event) => setReply({ text: event.target.value, dirty: true })} />
|
||||
<p className="dds-hint">Ответ сохраняется в журнале текущей карточки. Автооценка проверяет его наличие и минимальную полноту, но не грамотность или фактическую верность каждого предложения.</p>
|
||||
<button type="button" disabled={!cardId || !reply.dirty || !isConnected || ended || advancePending}
|
||||
onClick={() => { if (send({ type: "card.reply", card_id: cardId, text: reply.text })) setError(""); }}>
|
||||
Сохранить ответ</button>
|
||||
</section>}
|
||||
{pane === "more" && <section className="dds-panel dds-secondary"><div className="dds-panel-title"><h2>Дополнительные действия</h2></div>
|
||||
<div className="dds-zone">Зона ответственности: <button type="button" disabled={zone !== null || !isConnected || ended}
|
||||
onClick={() => { channel.current?.send({ type: "zone.decision", in_zone: true }); setZone(true); }}>Наша</button>
|
||||
<button type="button" disabled={zone !== null || !isConnected || ended}
|
||||
|
|
@ -247,15 +408,42 @@ export function Dds() {
|
|||
channel.current?.send({ type: "card.bounce", missing_fields: missing, comment: bounceComment }); setBounced(true);
|
||||
}}>Вернуть оператору</button>
|
||||
</details>
|
||||
<button type="button" className="dds-finish" disabled={!isConnected || ended} onClick={() => send({ type: "station.finish" })}>Завершить упражнение ДДС</button>
|
||||
</section>
|
||||
</aside></div>}
|
||||
<button type="button" className="dds-finish" disabled={!cardId || !isConnected || ended || advancePending}
|
||||
onClick={() => {
|
||||
if (reply.dirty && !send({ type: "card.reply", card_id: cardId, text: reply.text })) return;
|
||||
if (send({ type: "card.next", card_id: cardId })) setAdvancePending(true);
|
||||
}}>
|
||||
{cardIndex < cardTotal ? "Завершить карточку и открыть следующую" : "Завершить карточку и занятие"}
|
||||
</button>
|
||||
{cardTotal > 1 && <button type="button" disabled={!isConnected || ended || advancePending}
|
||||
onClick={() => { if (window.confirm("Завершить занятие досрочно? Остальные карточки не будут пройдены.")) send({ type: "station.finish" }); }}>
|
||||
Досрочно завершить занятие</button>}
|
||||
</section>}
|
||||
</aside></div></div>}
|
||||
</div>
|
||||
<nav className="dds-service-dock" aria-label="Действия с карточкой">
|
||||
<span className="dds-dock-label">Службы:</span>
|
||||
{station?.services.map((service) => <button type="button" className={pane === "status" && activeService === service ? "active" : ""}
|
||||
key={service} onClick={() => { setActiveService(service); setPane("status"); }}>
|
||||
<b>{serviceTitle(service)}</b><small>{SERVICE_STATUS_LABELS[station.statuses[service]]}</small>
|
||||
</button>)}
|
||||
<button type="button" onClick={() => setPane("phone")}><b>☎ Связь</b><small>Бригада</small></button>
|
||||
<button type="button" onClick={() => setPane("reply")}><b>✎ Ответ</b><small>{reply.dirty ? "Не сохранён" : "Комментарий"}</small></button>
|
||||
<button type="button" onClick={() => setPane("journal")}><b>Журнал</b><small>Статусы</small></button>
|
||||
<button type="button" disabled={!cardId || !isConnected || ended || advancePending}
|
||||
onClick={() => {
|
||||
if (reply.dirty && !send({ type: "card.reply", card_id: cardId, text: reply.text })) return;
|
||||
if (send({ type: "card.next", card_id: cardId })) setAdvancePending(true);
|
||||
}}><b>✓ Завершить</b><small>{cardIndex < cardTotal ? "Следующая карточка" : "Занятие"}</small></button>
|
||||
</nav>
|
||||
</div>}
|
||||
</main>;
|
||||
}
|
||||
|
||||
function StatusRow({ service, current, available, disabled, reports, phoneExercise, onSet }: {
|
||||
function StatusRow({ service, current, available, disabled, reports, phoneExercise, crewOptions, selectedCrew, onCrew, onSet }: {
|
||||
service: string; current: ServiceStatus; available: ServiceStatus[]; disabled: boolean;
|
||||
reports: PhoneReport[]; phoneExercise: boolean; onSet: (status: ServiceStatus, comment: string) => void;
|
||||
reports: PhoneReport[]; phoneExercise: boolean; crewOptions: string[]; selectedCrew: string;
|
||||
onCrew: (crew: string) => void; onSet: (status: ServiceStatus, comment: string) => void;
|
||||
}) {
|
||||
const [chosen, setChosen] = useState<ServiceStatus | "">("");
|
||||
const [comment, setComment] = useState("");
|
||||
|
|
@ -265,17 +453,32 @@ function StatusRow({ service, current, available, disabled, reports, phoneExerci
|
|||
};
|
||||
const matchingReport = !chosen || !phoneExercise || !reportNeeded[chosen] ||
|
||||
reports.some((report) => report.phase === reportNeeded[chosen]);
|
||||
return <tr><td>{service}</td><td><strong>{SERVICE_STATUS_LABELS[current]}</strong></td><td>
|
||||
{available.length ? <div className="dds-status-action">
|
||||
if (!available.length) return <p className="dds-hint">{serviceTitle(service)}: {SERVICE_STATUS_LABELS[current]}. Дальнейшие статусы недоступны.</p>;
|
||||
return <div className="dds-status-editor">
|
||||
<label>Статус
|
||||
<select value={chosen} disabled={disabled} aria-label={`Следующий статус ${service}`}
|
||||
onChange={(event) => setChosen(event.target.value as ServiceStatus)}>
|
||||
<option value="">Выберите статус</option>
|
||||
<option value="">{SERVICE_STATUS_LABELS[current]} →</option>
|
||||
{available.map((next) => <option key={next} value={next}>{SERVICE_STATUS_LABELS[next]}</option>)}
|
||||
</select>
|
||||
{needsComment && <input type="text" value={comment} placeholder="Причина отказа и кому передана информация"
|
||||
onChange={(event) => setComment(event.target.value)} />}
|
||||
{chosen && !matchingReport && <span className="state-warn">Сначала получите доклад по телефону.</span>}
|
||||
<button type="button" disabled={disabled || !chosen || (needsComment && !comment.trim()) || !matchingReport}
|
||||
onClick={() => { if (chosen) { onSet(chosen, comment.trim()); setChosen(""); setComment(""); } }}>Сохранить статус</button>
|
||||
</div> : "Карточка закрыта для редактирования"}</td></tr>;
|
||||
</label>
|
||||
<label>Номер наряда
|
||||
<select value={selectedCrew} disabled={disabled || !crewOptions.length} aria-label={`Наряд ${service}`}
|
||||
onChange={(event) => { if (event.target.value) onCrew(event.target.value); }}>
|
||||
<option value="">—</option>
|
||||
{crewOptions.map((crew) => <option value={crew} key={crew}>{crew}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>Комментарий
|
||||
<input type="text" value={comment} disabled={disabled} aria-label={`Комментарий к статусу ${service}`}
|
||||
placeholder={needsComment ? "Причина отказа и кому передана информация" : "Комментарий"}
|
||||
onChange={(event) => setComment(event.target.value)} />
|
||||
</label>
|
||||
<button type="button" className="dds-status-save" aria-label="Сохранить статус"
|
||||
disabled={disabled || !chosen || (needsComment && !comment.trim()) || !matchingReport}
|
||||
onClick={() => { if (chosen) { onSet(chosen, comment.trim()); setChosen(""); setComment(""); } }}>✓</button>
|
||||
<button type="button" className="dds-status-cancel" aria-label="Отменить изменение статуса"
|
||||
onClick={() => { setChosen(""); setComment(""); }}>×</button>
|
||||
{chosen && !matchingReport && <span className="dds-status-warning state-warn">Сначала получите доклад по телефону.</span>}
|
||||
</div>;
|
||||
}
|
||||
|
|
|
|||
94
frontend/src/pages/groups/Groups.tsx
Normal file
94
frontend/src/pages/groups/Groups.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useAssignTrainee, useCreateGroup, useGroupAnalytics, useGroups, useTrainees } from "@/shared/api/http";
|
||||
|
||||
export function Groups() {
|
||||
const groups = useGroups();
|
||||
const trainees = useTrainees();
|
||||
const create = useCreateGroup();
|
||||
const assign = useAssignTrainee();
|
||||
const [groupId, setGroupId] = useState<string | null>(null);
|
||||
const [groupName, setGroupName] = useState("");
|
||||
const [traineeId, setTraineeId] = useState("");
|
||||
const report = useGroupAnalytics(groupId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!groupId && groups.data?.length) setGroupId(groups.data[0].id);
|
||||
}, [groups.data, groupId]);
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<h1>Аналитика учебной группы</h1>
|
||||
<p><a href="/instructor">← к пульту преподавателя</a></p>
|
||||
<form onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
create.mutate(groupName.trim(), { onSuccess: (group) => {
|
||||
setGroupId(group.id);
|
||||
setGroupName("");
|
||||
} });
|
||||
}}>
|
||||
<label>Новая группа{" "}
|
||||
<input value={groupName} maxLength={120} onChange={(event) => setGroupName(event.target.value)} />
|
||||
</label>{" "}
|
||||
<button type="submit" disabled={!groupName.trim() || create.isPending}>Создать</button>
|
||||
{create.isError && <span className="warn"> Не удалось создать группу: возможно, имя занято.</span>}
|
||||
</form>
|
||||
<label>
|
||||
Группа{" "}
|
||||
<select value={groupId ?? ""} onChange={(event) => setGroupId(event.target.value || null)}>
|
||||
<option value="">— выберите группу —</option>
|
||||
{groups.data?.map((group) => (
|
||||
<option key={group.id} value={group.id}>{group.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{groupId && <form onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (traineeId) assign.mutate({ groupId, traineeId }, { onSuccess: () => setTraineeId("") });
|
||||
}}>
|
||||
<label>Добавить курсанта{" "}
|
||||
<select value={traineeId} onChange={(event) => setTraineeId(event.target.value)}>
|
||||
<option value="">— выберите курсанта —</option>
|
||||
{trainees.data?.map((trainee) => (
|
||||
<option key={trainee.id} value={trainee.id}>{trainee.name}{trainee.group ? ` · ${trainee.group}` : ""}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>{" "}
|
||||
<button type="submit" disabled={!traineeId || assign.isPending}>Назначить</button>
|
||||
{assign.isError && <span className="warn"> Не удалось назначить курсанта.</span>}
|
||||
</form>}
|
||||
{groups.isError && <p className="warn">Не удалось загрузить группы.</p>}
|
||||
{groups.data?.length === 0 && <p>Учебные группы ещё не заведены.</p>}
|
||||
{report.isError && <p className="warn">Не удалось загрузить аналитику.</p>}
|
||||
{report.data && (
|
||||
<>
|
||||
<p>
|
||||
Курсанты: {report.data.active_trainees} занимались из {report.data.enrolled_trainees} ·
|
||||
оценённых попыток: {report.data.scored_attempts} ·
|
||||
средний балл: {report.data.average_score?.toFixed(1) ?? "—"}
|
||||
</p>
|
||||
<p className="ref">
|
||||
Доли ошибок считаются среди курсантов с оценёнными попытками; повторные попытки
|
||||
одного человека не увеличивают долю. Рекомендации сформированы по правилам,
|
||||
не нейросетью.
|
||||
</p>
|
||||
<table className="grid">
|
||||
<thead><tr><th>Типичная ошибка</th><th>Курсанты</th><th>Рекомендация</th></tr></thead>
|
||||
<tbody>
|
||||
{report.data.errors.map((item) => (
|
||||
<tr key={item.code}>
|
||||
<th>{item.code} · {item.title}</th>
|
||||
<td>{item.affected_trainees} · {item.rate_percent.toFixed(1)}%</td>
|
||||
<td>{item.recommendation}</td>
|
||||
</tr>
|
||||
))}
|
||||
{report.data.errors.length === 0 && (
|
||||
<tr><td colSpan={3}>Пока нет ошибок в оценённых занятиях.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ const MODES: { value: SessionMode; label: string }[] = [
|
|||
export function Instructor() {
|
||||
const [sessionId, setSessionId] = useState<string | null>(sessionIdFromUrl());
|
||||
const [scenarioId, setScenarioId] = useState("");
|
||||
const [extraScenarioIds, setExtraScenarioIds] = useState<string[]>([]);
|
||||
const [mode, setMode] = useState<SessionMode>("training");
|
||||
const [exercise, setExercise] = useState<"dds" | "call" | "card">("dds");
|
||||
const [trainee, setTrainee] = useState("");
|
||||
|
|
@ -62,8 +63,11 @@ export function Instructor() {
|
|||
if (!sessionId || control.status !== "open" || toStart.current !== sessionId) return;
|
||||
toStart.current = null;
|
||||
const selected = trainees.data?.find((item) => item.id === traineeId);
|
||||
control.start(scenarioId, mode, selected?.name || trainee || "Демо-курсант", exercise, undefined, selected?.id);
|
||||
}, [sessionId, control, scenarioId, mode, trainee, traineeId, trainees.data, exercise]);
|
||||
const scenarioIds = exercise === "dds"
|
||||
? [scenarioId, ...extraScenarioIds.filter((id) => id !== scenarioId)] : undefined;
|
||||
control.start(scenarioId, mode, selected?.name || trainee || "Демо-курсант", exercise,
|
||||
undefined, selected?.id, scenarioIds && scenarioIds.length > 1 ? scenarioIds : undefined);
|
||||
}, [sessionId, control, scenarioId, extraScenarioIds, mode, trainee, traineeId, trainees.data, exercise]);
|
||||
|
||||
const links = sessionId
|
||||
? {
|
||||
|
|
@ -87,13 +91,27 @@ export function Instructor() {
|
|||
<tr>
|
||||
<th>Сценарий</th>
|
||||
<td>
|
||||
<select value={scenarioId} onChange={(event) => setScenarioId(event.target.value)}>
|
||||
<select value={scenarioId} onChange={(event) => {
|
||||
setScenarioId(event.target.value);
|
||||
setExtraScenarioIds((previous) => previous.filter((id) => id !== event.target.value));
|
||||
}}>
|
||||
{scenarios.data?.map((scenario) => (
|
||||
<option key={scenario.id} value={scenario.id}>
|
||||
{scenario.title} · {scenario.level}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{exercise === "dds" && <details className="scenario-queue">
|
||||
<summary>Дополнительные карточки ({extraScenarioIds.length})</summary>
|
||||
<p className="ref">Карточки поступят последовательно после завершения текущей.</p>
|
||||
{scenarios.data?.filter((scenario) => scenario.id !== scenarioId).map((scenario) =>
|
||||
<label key={scenario.id} className="mode-choice">
|
||||
<input type="checkbox" checked={extraScenarioIds.includes(scenario.id)} onChange={() =>
|
||||
setExtraScenarioIds((previous) => previous.includes(scenario.id)
|
||||
? previous.filter((id) => id !== scenario.id) : [...previous, scenario.id])} />
|
||||
{" "}{scenario.title} · {scenario.level}
|
||||
</label>)}
|
||||
</details>}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
|
@ -219,7 +237,7 @@ export function Instructor() {
|
|||
|
||||
<section style={{ flex: "0 0 260px" }}>
|
||||
<h2>История группы</h2>
|
||||
<p><a href="/profile">профили курсантов</a></p>
|
||||
<p><a href="/profile">профили курсантов</a> · <a href="/groups">аналитика группы</a></p>
|
||||
<table className="grid">
|
||||
<tbody>
|
||||
{history.data?.slice(0, 15).map((item) => (
|
||||
|
|
|
|||
|
|
@ -47,30 +47,36 @@ export function Call() {
|
|||
}
|
||||
|
||||
if (call.briefing) {
|
||||
return <main className="page">
|
||||
const services = display(call.card, "notify");
|
||||
const notifiedServices = Array.isArray(services) ? services.map(String) : [];
|
||||
return <main className="page arm-page">
|
||||
<ModeBanner mode={call.briefing.mode} />
|
||||
<h1>АРМ оператора 112 · учебная карточка</h1>
|
||||
<p className="ref">Текстовое упражнение: входящий голосовой вызов и микрофон не используются.</p>
|
||||
<table className="grid"><tbody>
|
||||
<tr><th>Канал</th><td className={call.status === "open" ? "state-ok" : "state-warn"}>{STATUS_LABEL[call.status]}</td></tr>
|
||||
<tr><th>Сценарий</th><td>{call.briefing.scenario_id}</td></tr>
|
||||
<tr><th>Состояние</th><td>{call.cardSubmitted ? "карточка отправлена на оценку" : "карточка заполняется"}</td></tr>
|
||||
</tbody></table>
|
||||
<section style={{ maxWidth: 900, marginTop: 16 }}>
|
||||
<h2>Вводная о происшествии</h2>
|
||||
<div style={{ background: "#fff", border: "1px solid var(--line)", padding: 16, whiteSpace: "pre-wrap", lineHeight: 1.5 }}>
|
||||
{call.briefing.text}
|
||||
</div>
|
||||
<p className="ref">Извлеките сведения из вводной, заполните поля карточки и проверьте предлагаемый список оповещения.</p>
|
||||
<h2>Карточка информационного обмена</h2>
|
||||
<KioCard state={call.card} required={call.briefing.required_fields}
|
||||
readOnly={call.cardSubmitted || call.status !== "open"} onChange={call.patchKio} />
|
||||
{!call.cardSubmitted && <p><button type="button" className="start" disabled={call.status !== "open"}
|
||||
onClick={call.submitCard}>Отправить карточку на проверку</button></p>}
|
||||
{call.error && <p className="state-violated" role="alert">{call.error}</p>}
|
||||
{call.cardSubmitted && !call.report && !call.reportError && <p>Карточка отправлена. Ожидаем итоговую оценку…</p>}
|
||||
{call.reportError && <p className="state-violated" role="alert">{call.reportError}</p>}
|
||||
<header className="arm-topbar arm-topbar-text">
|
||||
<div className="arm-topbar-mark">112</div>
|
||||
<div className="arm-topbar-title"><strong>Карточка информационного обмена</strong><span>Текстовое упражнение · сценарий {call.briefing.scenario_id}</span></div>
|
||||
<div className="arm-topbar-state"><span className={call.status === "open" ? "state-ok" : "state-warn"}>● {call.status === "open" ? "АРМ подключён" : STATUS_LABEL[call.status]}</span><small>{call.cardSubmitted ? "Карточка отправлена" : "Карточка заполняется"}</small></div>
|
||||
</header>
|
||||
<section className="arm-briefing">
|
||||
<div className="arm-briefing-label"><strong>Учебная вводная</strong><span>Текстовый режим · без входящего звонка</span></div>
|
||||
<div className="arm-briefing-text">{call.briefing.text}</div>
|
||||
</section>
|
||||
<div className="arm-card-wrap">
|
||||
<KioCard state={call.card} required={call.briefing.required_fields}
|
||||
readOnly={call.cardSubmitted || call.status !== "open"} referenceArm onChange={call.patchKio} />
|
||||
</div>
|
||||
<footer className="arm-footer">
|
||||
<span className="arm-footer-services">Службы:
|
||||
{notifiedServices.length
|
||||
? notifiedServices.map((service) => <b key={service}>{service}</b>)
|
||||
: <em>появятся после выбора признаков</em>}
|
||||
</span>
|
||||
{!call.cardSubmitted && <button type="button" className="arm-primary" disabled={call.status !== "open"}
|
||||
onClick={call.submitCard}>Сохранить и проверить</button>}
|
||||
{call.cardSubmitted && <strong>Карточка отправлена</strong>}
|
||||
</footer>
|
||||
{call.error && <p className="state-violated arm-message" role="alert">{call.error}</p>}
|
||||
{call.cardSubmitted && !call.report && !call.reportError && <p className="arm-message">Карточка отправлена. Ожидаем итоговую оценку…</p>}
|
||||
{call.reportError && <p className="state-violated arm-message" role="alert">{call.reportError}</p>}
|
||||
{call.report && <Debrief report={call.report} />}
|
||||
</main>;
|
||||
}
|
||||
|
|
@ -80,59 +86,53 @@ export function Call() {
|
|||
const autoRoute = Boolean(display(call.card, "incident_code")) && Array.isArray(notify) && notify.length > 0;
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<main className="page arm-page">
|
||||
<StandStatus />
|
||||
<ModeBanner mode={call.incoming?.mode} />
|
||||
<h1>АРМ оператора 112</h1>
|
||||
<InterviewTimer timers={call.timers} />
|
||||
<table className="grid">
|
||||
<tbody>
|
||||
<tr><th>Канал</th><td className={call.status === "open" ? "" : "warn"}>{STATUS_LABEL[call.status]}</td></tr>
|
||||
<tr><th>Вызов</th><td>{call.incoming ? `${call.incoming.caller_number} · сценарий ${call.incoming.scenario_id} · режим ${call.incoming.mode}` : "ожидание"}</td></tr>
|
||||
<tr><th>Гарнитура</th><td>{call.micOn ? "микрофон включён" : "выключен"}</td></tr>
|
||||
<tr><th>Звонящий</th><td>{call.callerSpeaking ? "говорит" : "молчит"}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<header className="arm-topbar">
|
||||
<div className="arm-topbar-mark">112</div>
|
||||
<div className="arm-phone-cell"><small>Канал связи</small><strong className={call.status === "open" ? "state-ok" : "state-warn"}>{STATUS_LABEL[call.status]}</strong><span>{call.micOn ? "● Микрофон включён" : "Микрофон выключен"}</span></div>
|
||||
<div className="arm-phone-cell arm-phone-main"><small>Входящий вызов</small><strong>{call.incoming?.caller_number || "Ожидание вызова"}</strong><span>{call.incoming ? `Сценарий ${call.incoming.scenario_id} · ${call.callerSpeaking ? "заявитель говорит" : "заявитель молчит"}` : "Карточка не назначена"}</span></div>
|
||||
<div className="arm-topbar-title"><strong>АРМ оператора 112</strong><span>Карточка информационного обмена</span></div>
|
||||
<div className="arm-topbar-clock"><InterviewTimer timers={call.timers} /></div>
|
||||
</header>
|
||||
|
||||
<p>
|
||||
{call.phase === "incoming" && !call.micOn && (
|
||||
<button type="button" onClick={() => void call.answer()}>Ответить</button>
|
||||
)}
|
||||
{call.micOn && <button type="button" onClick={call.hangup}>Завершить вызов</button>}{" "}
|
||||
{call.micOn && hintsAllowed(call.incoming?.mode) && (
|
||||
<button type="button" onClick={call.hint}>Подсказка</button>
|
||||
)}
|
||||
</p>
|
||||
<div className="arm-call-actions">
|
||||
{call.phase === "incoming" && !call.micOn && <button type="button" className="arm-answer" onClick={() => void call.answer()}>☎ Ответить на вызов</button>}
|
||||
{call.micOn && <span className="arm-live">● Разговор идёт</span>}
|
||||
{call.micOn && hintsAllowed(call.incoming?.mode) && <button type="button" onClick={call.hint}>Подсказка</button>}
|
||||
<span className="arm-call-actions-note">{call.phase === "ended" ? "Вызов завершён" : "Заполните адрес и признаки происшествия со слов заявителя"}</span>
|
||||
</div>
|
||||
|
||||
{call.error && <p className="violated">{call.error}</p>}
|
||||
{call.error && <p className="violated arm-message" role="alert">{call.error}</p>}
|
||||
|
||||
{call.phase === "ended" && !call.selfAssessed && call.checklist.length > 0 && (
|
||||
<SelfAssessment checklist={call.checklist} onSubmit={call.submitSelfAssessment} />
|
||||
)}
|
||||
{call.selfAssessed && !call.report && <p>Самооценка принята, оценка считается…</p>}
|
||||
{call.report && <Debrief report={call.report} />}
|
||||
|
||||
<div className="columns">
|
||||
<section>
|
||||
<h2>Карточка информационного обмена</h2>
|
||||
<div className="arm-card-wrap">
|
||||
<KioCard
|
||||
state={call.card}
|
||||
required={call.incoming?.required_fields ?? []}
|
||||
readOnly={!call.micOn}
|
||||
referenceArm
|
||||
onChange={call.patchKio}
|
||||
/>
|
||||
<p>
|
||||
</div>
|
||||
<footer className="arm-footer">
|
||||
<span>Службы · список оповещения рассчитывается по признакам</span>
|
||||
<div className="arm-footer-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="arm-primary"
|
||||
disabled={!call.micOn || (!service && !autoRoute)}
|
||||
onClick={() => call.dispatch(service)}
|
||||
>
|
||||
Передать в ДДС
|
||||
</button>
|
||||
</p>
|
||||
{call.micOn && <button type="button" onClick={call.hangup}>Завершить вызов</button>}
|
||||
</div>
|
||||
</footer>
|
||||
<div className="arm-secondary">
|
||||
{/* Не каждый вызов заканчивается карточкой: справка и чужой регион —
|
||||
отдельные исходы, и курсант выбирает их сам (docs/spec/TICKETS.md). */}
|
||||
<details>
|
||||
<details className="arm-resolve">
|
||||
<summary>Закрыть вызов без карточки</summary>
|
||||
<p>
|
||||
<input
|
||||
|
|
@ -166,12 +166,10 @@ export function Call() {
|
|||
</p>
|
||||
{resolved && <p className="warn">Вызов закрыт: {RESOLVE_LABELS[resolved]}</p>}
|
||||
</details>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Нормативы</h2>
|
||||
<section className="arm-support">
|
||||
<h2>Нормативы и разговор</h2>
|
||||
<Timers timers={call.timers} />
|
||||
<h2>Разговор</h2>
|
||||
<table className="grid">
|
||||
<table className="grid">
|
||||
<tbody>
|
||||
{call.lines.map((line, index) => (
|
||||
<tr key={index}>
|
||||
|
|
@ -184,6 +182,11 @@ export function Call() {
|
|||
</table>
|
||||
</section>
|
||||
</div>
|
||||
{call.phase === "ended" && !call.selfAssessed && call.checklist.length > 0 && (
|
||||
<SelfAssessment checklist={call.checklist} onSubmit={call.submitSelfAssessment} />
|
||||
)}
|
||||
{call.selfAssessed && !call.report && <p className="arm-message">Самооценка принята, оценка считается…</p>}
|
||||
{call.report && <Debrief report={call.report} />}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue