Implement local task 09 training workflows

This commit is contained in:
andreysk0304 2026-09-21 17:40:54 +03:00
commit cec84ffcd0
45 changed files with 2679 additions and 366 deletions

View file

@ -60,5 +60,5 @@ export function useLogout() {
export const SCREENS: Record<Role, string[]> = {
admin: ["/admin", "/profile"],
instructor: ["/instructor", "/wall", "/profile", "/dds"],
trainee: ["/trainee", "/profile"],
trainee: ["/trainee", "/dds", "/profile"],
};

View file

@ -10,11 +10,12 @@ import { Ambience } from "@/shared/audio/ambience";
import { type Capture, startCapture } from "@/shared/audio/capture";
import { EnergyGate } from "@/shared/audio/levels";
import { CALLER_RATE, Playback } from "@/shared/audio/playback";
import { type CardState, applyPatch, edit, empty as emptyCard } from "@/features/kio-card/merge";
import { type CardState, applyPatch, applyState, edit, empty as emptyCard } from "@/features/kio-card/merge";
import type { ChecklistItem } from "@/features/self-assessment/SelfAssessment";
import type {
Outcome,
CallIncoming,
CardBriefing,
DDSCode,
ServerToTrainee,
SessionReport,
@ -36,6 +37,8 @@ export function useCall(sessionId: string | null) {
const [status, setStatus] = useState<ChannelStatus>("connecting");
const [phase, setPhase] = useState<CallPhase>("waiting");
const [incoming, setIncoming] = useState<CallIncoming | null>(null);
const [briefing, setBriefing] = useState<CardBriefing | null>(null);
const [cardSubmitted, setCardSubmitted] = useState(false);
const [lines, setLines] = useState<Line[]>([]);
const [timers, setTimers] = useState<TimerSnapshot[]>([]);
const [callerSpeaking, setCallerSpeaking] = useState(false);
@ -45,6 +48,7 @@ export function useCall(sessionId: string | null) {
const [selfAssessed, setSelfAssessed] = useState(false);
const [scoreReady, setScoreReady] = useState(false);
const [report, setReport] = useState<SessionReport | null>(null);
const [reportError, setReportError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const channel = useRef<ReturnType<typeof callChannel> | null>(null);
@ -53,6 +57,7 @@ export function useCall(sessionId: string | null) {
const gate = useRef(new EnergyGate());
const outbox = useRef<Record<string, unknown>>({});
const patchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const textMode = useRef(false);
const append = useCallback((line: Line) => {
setLines((prev) => {
@ -67,6 +72,16 @@ export function useCall(sessionId: string | null) {
onStatus: setStatus,
onBinary: (frame) => audio.current?.playback.enqueue(frame),
onEvent: (event: ServerToTrainee) => {
if (event.type === "card.briefing") {
const briefing = event;
textMode.current = true;
setBriefing(briefing);
setIncoming(null);
setCard(applyState(emptyCard, { ...briefing.card }));
setCardSubmitted(false);
setError(null);
return;
}
switch (event.type) {
case "call.incoming":
setIncoming(event);
@ -110,6 +125,7 @@ export function useCall(sessionId: string | null) {
case "call.ended":
setPhase("ended");
audio.current?.ambience.stop();
if (textMode.current) { setCardSubmitted(true); break; }
// Чек-лист открывается только после звонка: во время него это
// содержимое подсказок.
void fetch(`/api/sessions/${sessionId}/checklist`)
@ -119,10 +135,16 @@ export function useCall(sessionId: string | null) {
break;
case "score.ready":
setScoreReady(true);
setReportError(null);
void fetch(`/api/sessions/${sessionId}/report`)
.then((response) => (response.ok ? response.json() : null))
.then((response) => {
if (response.status === 403) throw new Error("Отчёт недоступен: занятие привязано к другому обучающемуся.");
if (response.status === 409) throw new Error("Оценка ещё не готова. Обновите страницу позже.");
if (!response.ok) throw new Error(`Не удалось загрузить отчёт (HTTP ${response.status}).`);
return response.json();
})
.then(setReport)
.catch(() => setReport(null));
.catch((cause: unknown) => setReportError(cause instanceof Error ? cause.message : "Не удалось загрузить отчёт."));
break;
case "error":
setError(event.message);
@ -183,8 +205,25 @@ export function useCall(sessionId: string | null) {
}, PATCH_DEBOUNCE_MS);
}, []);
const dispatch = useCallback((service: DDSCode) => {
channel.current?.send({ type: "dds.dispatch", service });
const dispatch = useCallback((service?: DDSCode) => {
channel.current?.send(service ? { type: "dds.dispatch", service } : { type: "dds.dispatch" });
}, []);
const submitCard = useCallback(() => {
if (patchTimer.current) clearTimeout(patchTimer.current);
patchTimer.current = null;
const fields = outbox.current;
outbox.current = {};
if (Object.keys(fields).length && !channel.current?.send({ type: "kio.patch", fields })) {
setError("Канал отключён: карточка не отправлена.");
return;
}
if (channel.current?.send({ type: "card.submit" })) {
setCardSubmitted(true);
setError(null);
} else {
setError("Канал отключён: карточка не отправлена.");
}
}, []);
/** Закрыть вызов не карточкой: справкой или передачей в другой регион.
@ -201,8 +240,8 @@ export function useCall(sessionId: string | null) {
return {
resolve,
status, phase, incoming, lines, timers, callerSpeaking, micOn, error, card,
checklist, selfAssessed, scoreReady, report,
answer, hangup, hint, patchKio, dispatch, submitSelfAssessment,
status, phase, incoming, briefing, cardSubmitted, lines, timers, callerSpeaking, micOn, error, card,
checklist, selfAssessed, scoreReady, report, reportError,
answer, hangup, hint, patchKio, dispatch, submitCard, submitSelfAssessment,
};
}

View file

@ -23,6 +23,10 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
Разбор · оценка {report.score_final.toFixed(0)} из 100
{report.overridden_by && ` · скорректировано (${report.overridden_by}), автооценка ${report.score_auto.toFixed(0)}`}
</h2>
<p className="ref">
Скачать отчёт: <a href={`/api/sessions/${report.session_id}/report.csv`}>CSV</a>
{" · "}<a href={`/api/sessions/${report.session_id}/report.pdf`}>PDF</a>
</p>
<div className="columns">
<section>

View file

@ -0,0 +1,189 @@
import { useState } from "react";
import type { ScenarioSummary } from "@/shared/api/http";
type JsonObject = Record<string, unknown>;
type Draft = { id: string; status: "draft"; generation: "template_copy"; body: JsonObject };
type Validation = { valid: boolean; errors: string[]; ground_truth?: JsonObject };
async function api<T>(path: string, method = "GET", body?: unknown): Promise<T> {
const response = await fetch(path, {
method,
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
const data: unknown = await response.json().catch(() => null);
if (!response.ok) {
const detail = data && typeof data === "object" && "detail" in data ? data.detail : response.statusText;
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
}
return data as T;
}
function isObject(value: unknown): value is JsonObject {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
/** Сервер принимает JSON Merge Patch, поэтому удалённое в редакторе поле
* передаётся как null, а неизменённые поля не отправляются. */
function diff(before: JsonObject, after: JsonObject): JsonObject {
const patch: JsonObject = {};
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
if (!(key in after)) {
patch[key] = null;
} else if (!(key in before)) {
patch[key] = after[key];
} else if (isObject(before[key]) && isObject(after[key])) {
const nested = diff(before[key], after[key]);
if (Object.keys(nested).length) patch[key] = nested;
} else if (JSON.stringify(before[key]) !== JSON.stringify(after[key])) {
patch[key] = after[key];
}
}
return patch;
}
function pretty(body: JsonObject): string {
return JSON.stringify(body, null, 2);
}
export function ScenarioEditor({
scenarios,
onApproved,
}: {
scenarios: ScenarioSummary[];
onApproved: (id: string) => void | Promise<void>;
}) {
const [sourceId, setSourceId] = useState("");
const [title, setTitle] = useState("");
const [draftId, setDraftId] = useState("");
const [draft, setDraft] = useState<Draft | null>(null);
const [bodyText, setBodyText] = useState("");
const [validation, setValidation] = useState<Validation | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
function openDraft(value: Draft) {
setDraft(value);
setDraftId(value.id);
setBodyText(pretty(value.body));
setValidation(null);
setError("");
setNotice("");
}
async function perform(work: () => Promise<void>) {
setBusy(true);
setError("");
try {
await work();
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
setBusy(false);
}
}
async function create() {
await perform(async () => {
const created = await api<Draft>("/api/scenarios/drafts/from-template", "POST", {
source_id: sourceId || scenarios[0]?.id,
...(title.trim() ? { title: title.trim() } : {}),
});
openDraft(created);
setNotice("Создан шаблонный черновик. Это копия сценария, не генерация ИИ.");
});
}
async function reopen() {
if (!draftId.trim()) return;
await perform(async () => {
openDraft(await api<Draft>(`/api/scenarios/drafts/${encodeURIComponent(draftId.trim())}`));
});
}
async function save(): Promise<Draft> {
if (!draft) throw new Error("Сначала создайте или откройте черновик");
let parsed: unknown;
try {
parsed = JSON.parse(bodyText);
} catch (cause) {
throw new Error(`Некорректный JSON: ${cause instanceof Error ? cause.message : String(cause)}`);
}
if (!isObject(parsed)) throw new Error("Тело сценария должно быть JSON-объектом");
if (parsed.id !== draft.id) throw new Error("id черновика менять нельзя");
const patch = diff(draft.body, parsed);
if (!Object.keys(patch).length) return draft;
const updated = await api<Draft>(`/api/scenarios/drafts/${encodeURIComponent(draft.id)}`, "PATCH", patch);
setDraft(updated);
setBodyText(pretty(updated.body));
setValidation(null);
return updated;
}
async function validate() {
await perform(async () => {
const current = await save();
const result = await api<Validation>(`/api/scenarios/drafts/${encodeURIComponent(current.id)}/validate`, "POST");
setValidation(result);
setNotice(result.valid ? "Черновик прошёл проверку и может быть утверждён." : "Найдены ошибки в сценарии.");
});
}
async function approve() {
await perform(async () => {
const current = await save();
if (!validation?.valid) throw new Error("Сначала проверьте черновик кнопкой «Проверить»");
await api(`/api/scenarios/drafts/${encodeURIComponent(current.id)}/approve`, "POST");
setDraft(null);
setValidation(null);
setNotice("Сценарий утверждён и доступен для нового занятия.");
await onApproved(current.id);
});
}
return (
<section className="scenario-editor">
<h2>Редактор учебных сценариев</h2>
<p className="ref">Создание работает локально: копия утверждённого шаблона, затем правка и проверка преподавателем.</p>
<div className="scenario-editor-create">
<label>Шаблон
<select value={sourceId || scenarios[0]?.id || ""} onChange={(event) => setSourceId(event.target.value)}>
{scenarios.map((scenario) => <option key={scenario.id} value={scenario.id}>{scenario.title} · {scenario.level}</option>)}
</select>
</label>
<label>Название нового сценария
<input value={title} onChange={(event) => setTitle(event.target.value)} placeholder="необязательно" maxLength={200} />
</label>
<button type="button" onClick={create} disabled={busy || !scenarios.length}>Создать черновик</button>
</div>
<div className="scenario-editor-open">
<label>Номер черновика
<input value={draftId} onChange={(event) => setDraftId(event.target.value)} placeholder="draft-…" />
</label>
<button type="button" onClick={reopen} disabled={busy || !draftId.trim()}>Открыть</button>
</div>
{draft && (
<>
<p className="ref">Черновик {draft.id}. Факты и эталон доступны только преподавателю до утверждения.</p>
<label className="scenario-editor-json">JSON тела сценария
<textarea value={bodyText} onChange={(event) => { setBodyText(event.target.value); setValidation(null); }} spellCheck={false} />
</label>
<div className="scenario-editor-actions">
<button type="button" onClick={() => void perform(async () => { await save(); setNotice("Черновик сохранён."); })} disabled={busy}>Сохранить</button>
<button type="button" onClick={validate} disabled={busy}>Проверить</button>
<button type="button" className="start" onClick={approve} disabled={busy || !validation?.valid}>Утвердить</button>
</div>
</>
)}
{validation && <div className={validation.valid ? "scenario-editor-result" : "scenario-editor-result violated"}>
{validation.valid ? <><strong>Проверка пройдена</strong><pre>{pretty(validation.ground_truth ?? {})}</pre></> :
<><strong>Ошибки проверки</strong><ul>{validation.errors.map((item, index) => <li key={`${index}-${item}`}>{item}</li>)}</ul></>}
</div>}
{error && <p className="violated" role="alert">{error}</p>}
{notice && <p className="state-ok" role="status">{notice}</p>}
</section>
);
}

View file

@ -25,14 +25,18 @@ export function useControl(sessionId: string | null) {
}, [sessionId]);
const start = useCallback(
(scenarioId: string, mode: SessionMode, trainee: string, group?: string) =>
channel.current?.send({
(scenarioId: string, mode: SessionMode, trainee: string, exercise: "dds" | "call" | "card" = "dds", group?: string, traineeId?: string) => {
const message = {
type: "scenario.start",
scenario_id: scenarioId,
mode,
trainee,
group_id: group || null,
}),
trainee_id: traineeId || null,
exercise,
} as const;
channel.current?.send(message);
},
[],
);

View file

@ -1,282 +1,281 @@
// АРМ диспетчера ДДС.
//
// Аудио здесь нет вообще: приходит снимок карточки от оператора 112,
// дальше — подтверждение по нормативу, возврат на уточнение и отметки времени.
// Возврат карточки — самая ценная механика цепочки: неполнота КИО становится
// сорванным выездом с конкретной причиной (docs/arch/CONTRACT.md).
// Учебное АРМ ДДС. Звонок бригаде имитируется внутри приложения, без 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, type CardState } from "@/features/kio-card/merge";
import { stationChannel, type ChannelStatus } from "@/shared/api/ws";
import type { ServiceStatus, StationSnapshot } from "@/shared/types/generated";
import { SERVICE_STATUS_LABELS, CARD_STATUS_LABELS, ALARMING } from "./statuses";
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; // норматив подтверждения, ПП РФ № 1931 и памятка АРМ-112
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<ReportPhase, string> = {
dispatched: "Доклад о выезде", arrived: "Доклад о прибытии",
working: "Доклад о ходе работ", completed: "Доклад о завершении работ",
};
const FIELDS = GROUPS.flatMap((group) =>
group.fields.filter((field) => !field.readOnly).map((field) => ({ path: field.path, label: field.label })),
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<ChannelStatus>("connecting");
const [card, setCard] = useState<CardState | null>(null);
const [from, setFrom] = useState<string>("");
const [from, setFrom] = useState("");
const [receivedAt, setReceivedAt] = useState<number | null>(null);
const [elapsed, setElapsed] = useState(0);
const [acked, setAcked] = useState(false);
const [bounced, setBounced] = useState<string[] | null>(null);
const [station, setStation] = useState<DdsSnapshot | null>(null);
const [lastReport, setLastReport] = useState<PhoneReport | null>(null);
const [dialing, setDialing] = useState(false);
const [error, setError] = useState("");
const [ended, setEnded] = useState(false);
const [report, setReport] = useState<SessionReport | null>(null);
const [reportState, setReportState] = useState<"idle" | "loading" | "ready" | "error">("idle");
const [reportError, setReportError] = useState("");
const [missing, setMissing] = useState<string[]>([]);
const [comment, setComment] = useState("");
const [bounceComment, setBounceComment] = useState("");
const [bounced, setBounced] = useState(false);
const [zone, setZone] = useState<boolean | null>(null);
const [marks, setMarks] = useState<string[]>([]);
const [station, setStation] = useState<StationSnapshot | null>(null);
const channel = useRef<ReturnType<typeof stationChannel> | 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: setStatus,
onStatus: (next) => { setStatus(next); if (next !== "open") setDialing(false); },
onEvent: (event) => {
if (event.type === "station.state") setStation(event.snapshot);
if (event.type === "error") mark(`отклонено: ${event.message}`);
if (event.type === "card.received") {
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<string, unknown>));
setFrom(event.from_operator);
setReceivedAt(Date.now());
// Норматив идёт с передачи карточки сервером, в том числе если
// диспетчер открыл АРМ уже после начала занятия.
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 () => ch.close();
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 || acked) return;
if (receivedAt === null || primaryRecorded) return;
const timer = setInterval(() => setElapsed((Date.now() - receivedAt) / 1000), 200);
return () => clearInterval(timer);
}, [receivedAt, acked]);
}, [receivedAt, primaryRecorded]);
if (!sessionId) {
return (
<main className="page">
<h1>АРМ диспетчера ДДС</h1>
<p className="warn">Откройте ссылку вида /dds?session=…&role=dds_01</p>
</main>
);
}
const send = (command: DdsCommand): boolean => channel.current?.send(command as StationToServer) ?? false;
if (!sessionId) return <main className="page"><h1>АРМ диспетчера ДДС</h1>
<p className="warn">Откройте ссылку занятия вида /dds?session=…&role=dds_01</p></main>;
const mark = (label: string) => setMarks((prev) => [`${new Date().toLocaleTimeString("ru-RU")} — ${label}`, ...prev]);
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 (
<main className="page">
<h1>АРМ диспетчера ДДС · {role}</h1>
<table className="grid">
<tbody>
<tr><th>Канал</th><td className={status === "open" ? "" : "warn"}>{status}</td></tr>
<tr><th>Карточка</th><td>{card ? `от оператора ${from}` : "ожидание карточки от 112"}</td></tr>
{card && !acked && (
<tr>
<th>Подтверждение</th>
<td className={elapsed > ACK_LIMIT_S ? "state-violated" : "state-ok"}>
{elapsed.toFixed(1)} с из {ACK_LIMIT_S} с
</td>
</tr>
)}
</tbody>
</table>
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>
{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">
<div className="dds-panel-title"><h2>Итог упражнения</h2></div>
{report ? <div style={{ padding: 12 }}>
<p><strong>Итоговая оценка: {report.score_final.toFixed(0)} из 100</strong>
{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>
<h3>Не выполнены нормативы</h3>
<table className="dds-registry"><tbody>
{report.metrics.filter((metric) => !metric.passed).map((metric) => <tr key={metric.key}>
<th>{metric.title}</th><td>{metric.fact} · норматив: {metric.norm}</td></tr>)}
{report.metrics.every((metric) => metric.passed) && <tr><td>Все проверенные нормативы выполнены.</td></tr>}
</tbody></table>
<h3>Замечания</h3>
<table className="dds-registry"><tbody>
{report.findings.map((finding, index) => <tr key={`${finding.code}-${index}`}>
<th>{finding.code}</th><td>{finding.summary}<div className="ref">{finding.fact}{finding.norm && ` · ${finding.norm}`}</div></td></tr>)}
{report.findings.length === 0 && <tr><td>Замечаний нет.</td></tr>}
</tbody></table>
</div> : <div style={{ padding: 12 }}>
{reportState === "loading" ? "Подготавливаем разбор…" : reportError || "Ожидаем итоговую оценку…"}
{reportState === "error" && <button type="button" onClick={() => {
setReportState("loading");
void fetch(`/api/sessions/${sessionId}/report`, { credentials: "same-origin" }).then(async (response) => {
if (response.status === 403) throw new Error("Отчёт недоступен: это занятие другого обучающегося.");
if (response.status === 409) throw new Error("Оценка ещё не готова. Попробуйте получить разбор чуть позже.");
if (!response.ok) throw new Error(`Не удалось получить отчёт (HTTP ${response.status}).`);
setReport(await response.json() as SessionReport);
setReportState("ready");
}).catch((cause: unknown) => {
setReportError(cause instanceof Error ? cause.message : "Не удалось получить отчёт.");
setReportState("error");
});
}}>Повторить запрос</button>}
</div>}
</section>}
{card && (
<>
<p>
<button type="button" disabled={acked} onClick={() => {
channel.current?.send({ type: "card.ack" });
setAcked(true);
mark("карточка принята");
}}>Принять карточку</button>{" "}
<button type="button" disabled={Boolean(bounced)} onClick={() => {
if (!missing.length) return;
channel.current?.send({ type: "card.bounce", missing_fields: missing, comment });
setBounced(missing);
mark(`возврат на уточнение: ${missing.join(", ")}`);
}}>Вернуть на уточнение</button>
</p>
<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>
</section>
{!bounced && (
<details>
<summary>Чего не хватает для выезда</summary>
<div className="bounce-fields">
{FIELDS.map((field) => (
<label key={field.path}>
<input type="checkbox" checked={missing.includes(field.path)}
onChange={() => setMissing((prev) =>
prev.includes(field.path) ? prev.filter((item) => item !== field.path) : [...prev, field.path])} />{" "}
{field.label}
</label>
))}
</div>
<p>
<input type="text" value={comment} placeholder="куда ехать без этажа"
onChange={(event) => setComment(event.target.value)} />
</p>
</details>
)}
<p>
Зона ответственности:{" "}
<button type="button" disabled={zone !== null} onClick={() => {
channel.current?.send({ type: "zone.decision", in_zone: true });
setZone(true); mark("наша зона");
}}>наша</button>{" "}
<button type="button" disabled={zone !== null} onClick={() => {
channel.current?.send({ type: "zone.decision", in_zone: false });
setZone(false); mark("не наша зона");
}}>не наша</button>
</p>
<p>
<button type="button" onClick={() => {
channel.current?.send({ type: "crew.dispatched", at: new Date().toISOString() });
mark("приказ на выезд");
}}>Приказ на выезд</button>{" "}
<button type="button" onClick={() => {
channel.current?.send({ type: "crew.arrived", at: new Date().toISOString() });
mark("прибытие на место");
}}>Прибытие</button>
</p>
{station && station.services.length > 0 && (
<>
<h2>Статусы реагирования</h2>
<p>
Статус карточки:{" "}
<b className={ALARMING.includes(station.card) ? "state-violated" : "state-ok"}>
{CARD_STATUS_LABELS[station.card]}
</b>
</p>
<table className="grid">
<thead>
<tr><th>Служба</th><th>Статус</th><th>Отметить</th></tr>
</thead>
<tbody>
{station.services.map((service) => (
<StatusRow
key={service}
service={service}
current={station.statuses[service]}
available={station.available[service] ?? []}
onSet={(status, comment) => {
channel.current?.send({ type: "card.status", service, status, comment });
mark(`${service}: ${SERVICE_STATUS_LABELS[status]}`);
}}
/>
))}
</tbody>
</table>
</>
)}
<h2>Карточка от оператора 112</h2>
{/* Снимок: после передачи он не меняется. */}
<KioCard state={card} readOnly />
</>
)}
{station && station.log.length > 0 && (
<>
<h2>Журнал отметок</h2>
<table className="grid">
<tbody>
{station.log.map((entry, index) => (
<tr key={index}>
<th>{entry.service}</th>
<td>
{SERVICE_STATUS_LABELS[entry.status]}
{entry.comment && <> — {entry.comment}</>}
</td>
</tr>
))}
</tbody>
</table>
</>
)}
{marks.length > 0 && (
<>
<h2>Отметки времени</h2>
<table className="grid">
<tbody>{marks.map((line, index) => <tr key={index}><td>{line}</td></tr>)}</tbody>
</table>
</>
)}
</main>
);
{card && <div className="dds-main"><div className="dds-left">
<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>
<div><small>Заявитель</small><strong>{field(card, "caller_name")}</strong></div>
<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>
<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}
current={station.statuses[service]} available={station.available[service] ?? []}
disabled={!isConnected || ended} reports={reports.filter((report) => report.service === service)}
phoneExercise={from === "учебный сценарий"}
onSet={(next, comment) => channel.current?.send({ type: "card.status", service, status: next, comment })} />)}
</tbody></table></div> : <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>
<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>
<p className="dds-hint">Выберите бригаду вручную, свяжитесь со старшим группы и внесите его доклад в карточку.</p>
<label htmlFor="dds-crew">Дежурная бригада</label>
<select id="dds-crew" value={selectedCrew} disabled={!isConnected || ended || !station?.crew_options?.length}
onChange={(event) => send({ type: "crew.select", crew: event.target.value })}>
<option value="">Выберите бригаду</option>
{station?.crew_options?.map((crew) => <option key={crew} value={crew}>{crew}</option>)}
</select>
<button type="button" className="dds-dial" disabled={!crewAccepted || !isConnected || ended || dialing}
onClick={() => { if (send({ type: "phone.dial" })) setDialing(true); }}>
☎ {dialing ? "Соединение…" : "Позвонить старшему группы"}</button>
{selectedCrew && !crewAccepted && <p className="dds-hint state-warn">Перед звонком поставьте статус «Принята» для службы этой бригады.</p>}
{latest ? <div className="dds-report" aria-live="polite"><small>{REPORT_LABELS[latest.phase]} · {time(latest.at)}</small>
<strong>{latest.crew}</strong><p>{latest.text}</p>
<span>Прослушайте доклад и поставьте соответствующий статус реагирования.</span></div>
: <div className="dds-phone-idle">Доклады поступят после исходящего учебного звонка.</div>}
{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>
<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}
onClick={() => { channel.current?.send({ type: "zone.decision", in_zone: false }); setZone(false); }}>Не наша</button></div>
<details className="dds-details"><summary>Вернуть карточку на уточнение</summary>
<div className="bounce-fields">{FIELDS.map((item) => <label key={item.path}><input type="checkbox" checked={missing.includes(item.path)}
onChange={() => setMissing((previous) => previous.includes(item.path) ? previous.filter((path) => path !== item.path) : [...previous, item.path])} /> {item.label}</label>)}</div>
<input type="text" value={bounceComment} placeholder="Что необходимо уточнить" onChange={(event) => setBounceComment(event.target.value)} />
<button type="button" disabled={!missing.length || bounced || !isConnected || ended} onClick={() => {
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>}
</main>;
}
/** Одна служба списка оповещения.
*
* Доступные статусы приходят с сервера: автомат переходов живёт там, и
* дублировать его на фронте нельзя — разъедется. Поле комментария появляется
* у отказов, потому что без комментария сервер такую отметку не примет. */
function StatusRow({
service, current, available, onSet,
}: {
service: string;
current: ServiceStatus;
available: ServiceStatus[];
onSet: (status: ServiceStatus, comment: string) => void;
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<ServiceStatus | "">("");
const [comment, setComment] = useState("");
const needsComment = chosen === "declined" || chosen === "refused";
if (available.length === 0) {
return (
<tr>
<th>{service}</th>
<td>{SERVICE_STATUS_LABELS[current]}</td>
<td>карточка закрыта для редактирования</td>
</tr>
);
}
return (
<tr>
<th>{service}</th>
<td>{SERVICE_STATUS_LABELS[current]}</td>
<td>
<select value={chosen} onChange={(event) => setChosen(event.target.value as ServiceStatus)}>
<option value="">—</option>
{available.map((status) => (
<option key={status} value={status}>{SERVICE_STATUS_LABELS[status]}</option>
))}
</select>{" "}
{needsComment && (
<input
type="text"
value={comment}
placeholder="причина и куда передана информация"
onChange={(event) => setComment(event.target.value)}
/>
)}{" "}
<button
type="button"
disabled={!chosen || (needsComment && !comment.trim())}
onClick={() => {
if (!chosen) return;
onSet(chosen, comment);
setChosen("");
setComment("");
}}
>
Поставить
</button>
</td>
</tr>
);
const reportNeeded: Partial<Record<ServiceStatus, ReportPhase>> = {
responding: "dispatched", arrived: "arrived", working: "working", completed: "completed",
};
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">
<select value={chosen} disabled={disabled} aria-label={`Следующий статус ${service}`}
onChange={(event) => setChosen(event.target.value as ServiceStatus)}>
<option value="">Выберите статус</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>;
}

View file

@ -8,13 +8,14 @@ import { useEffect, useRef, useState } from "react";
import { Debrief } from "@/features/debrief/Debrief";
import { Director } from "@/features/instructor/Director";
import { ScenarioEditor } from "@/features/instructor/ScenarioEditor";
import { useControl } from "@/features/instructor/useControl";
import { KioCard } from "@/features/kio-card/KioCard";
import { StandStatus } from "@/features/health/StandStatus";
import { ModeBanner } from "@/features/mode-banner/ModeBanner";
import { useObserve } from "@/features/observe/useObserve";
import { InterviewTimer, Timers } from "@/features/timers/Timers";
import { useScenarios, useSessions } from "@/shared/api/http";
import { useScenarios, useSessions, useTrainees } from "@/shared/api/http";
import { sessionIdFromUrl } from "@/shared/api/session";
import type { SessionMode } from "@/shared/types/generated";
@ -28,7 +29,9 @@ export function Instructor() {
const [sessionId, setSessionId] = useState<string | null>(sessionIdFromUrl());
const [scenarioId, setScenarioId] = useState("");
const [mode, setMode] = useState<SessionMode>("training");
const [exercise, setExercise] = useState<"dds" | "call" | "card">("dds");
const [trainee, setTrainee] = useState("");
const [traineeId, setTraineeId] = useState("");
const [noteFor, setNoteFor] = useState<string | null>(null);
// Запускается только занятие, созданное этой кнопкой. Открытая из истории
// ссылка на идущее занятие не должна запускать его заново и сбрасывать.
@ -37,6 +40,7 @@ export function Instructor() {
const scenarios = useScenarios();
const history = useSessions();
const trainees = useTrainees();
const session = useObserve(sessionId);
const control = useControl(sessionId);
@ -45,6 +49,7 @@ export function Instructor() {
}, [scenarios.data, scenarioId]);
function startLesson() {
if (trainees.data?.length && !traineeId) return;
const id = crypto.randomUUID();
// Номер занятия живёт в адресе: монитор и АРМ курсанта открываются ссылкой.
window.history.replaceState(null, "", `/instructor?session=${id}`);
@ -56,8 +61,9 @@ export function Instructor() {
useEffect(() => {
if (!sessionId || control.status !== "open" || toStart.current !== sessionId) return;
toStart.current = null;
control.start(scenarioId, mode, trainee || "Курсант");
}, [sessionId, control, scenarioId, mode, trainee]);
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 links = sessionId
? {
@ -90,6 +96,17 @@ export function Instructor() {
</select>
</td>
</tr>
<tr>
<th>Упражнение</th>
<td>
<label className="mode-choice"><input type="radio" checked={exercise === "dds"}
onChange={() => setExercise("dds")} /> Диспетчер ДДС · готовая карточка</label>
<label className="mode-choice"><input type="radio" checked={exercise === "call"}
onChange={() => setExercise("call")} /> Оператор 112 · звонок</label>
<label className="mode-choice"><input type="radio" checked={exercise === "card"}
onChange={() => setExercise("card")} /> Оператор 112 · текстовая вводная</label>
</td>
</tr>
<tr>
<th>Режим</th>
<td>
@ -108,14 +125,27 @@ export function Instructor() {
<tr>
<th>Курсант</th>
<td>
<input type="text" value={trainee} placeholder="фамилия"
onChange={(event) => setTrainee(event.target.value)} />
{trainees.data?.length ? (
<select value={traineeId} onChange={(event) => setTraineeId(event.target.value)}>
<option value="">Выберите учётную запись</option>
{trainees.data.map((item) => (
<option key={item.id} value={item.id}>{item.name}{item.group ? ` · ${item.group}` : ""}</option>
))}
</select>
) : (
<>
<input type="text" value={trainee} placeholder="имя для демо"
onChange={(event) => setTrainee(event.target.value)} />
<small className="ref">Без учётной записи: личный отчёт курсанту недоступен.</small>
</>
)}
</td>
</tr>
</tbody>
</table>
<p>
<button type="button" className="start" onClick={startLesson} disabled={!scenarioId}>
<button type="button" className="start" onClick={startLesson}
disabled={!scenarioId || trainees.isLoading || Boolean(trainees.data?.length && !traineeId)}>
Начать занятие
</button>
</p>
@ -127,12 +157,12 @@ export function Instructor() {
</p>
)}
{sessionId && !session.ended && (
<Director onInject={control.inject} disabled={control.status !== "open"} error={session.error} />
exercise === "call" && <Director onInject={control.inject} disabled={control.status !== "open"} error={session.error} />
)}
{links && (
<table className="grid">
<tbody>
<tr><th>АРМ курсанта</th><td><a href={links.trainee}>открыть</a></td></tr>
<tr><th>АРМ курсанта</th><td><a href={exercise === "dds" ? links.dds : links.trainee}>открыть {exercise === "dds" ? "ДДС" : "112"}</a></td></tr>
<tr><th>Внешний монитор</th><td><a href={links.wall}>открыть</a></td></tr>
<tr><th>АРМ ДДС</th><td><a href={links.dds}>открыть</a></td></tr>
</tbody>
@ -204,6 +234,11 @@ export function Instructor() {
</section>
</div>
<ScenarioEditor scenarios={scenarios.data ?? []} onApproved={async (id) => {
setScenarioId(id);
await scenarios.refetch();
}} />
{session.report && <Debrief report={session.report} />}
</main>
);

View file

@ -12,6 +12,7 @@ import { InterviewTimer, Timers } from "@/features/timers/Timers";
import { display } from "@/features/kio-card/merge";
import { useCall } from "@/features/call/useCall";
import { sessionIdFromUrl } from "@/shared/api/session";
import type { DDSCode } from "@/shared/types/generated";
const STATUS_LABEL: Record<string, string> = {
connecting: "подключение",
@ -25,6 +26,11 @@ const RESOLVE_LABELS = {
transfer_region: "передан в систему-112 другого региона",
} as const;
function ddsCode(value: unknown): DDSCode | undefined {
return value === "01" || value === "02" || value === "03" || value === "04" || value === "gkh"
? value : undefined;
}
export function Call() {
const [resolveComment, setResolveComment] = useState("");
const [resolved, setResolved] = useState<"consultation" | "transfer_region" | null>(null);
@ -40,6 +46,39 @@ export function Call() {
);
}
if (call.briefing) {
return <main className="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>}
</section>
{call.report && <Debrief report={call.report} />}
</main>;
}
const service = ddsCode(display(call.card, "dds"));
const notify = display(call.card, "notify");
const autoRoute = Boolean(display(call.card, "incident_code")) && Array.isArray(notify) && notify.length > 0;
return (
<main className="page">
<StandStatus />
@ -85,8 +124,8 @@ export function Call() {
<p>
<button
type="button"
disabled={!call.micOn || !display(call.card, "dds")}
onClick={() => call.dispatch(display(call.card, "dds") as never)}
disabled={!call.micOn || (!service && !autoRoute)}
onClick={() => call.dispatch(service)}
>
Передать в ДДС
</button>

View file

@ -19,7 +19,7 @@ export interface CallAnswer {
type: "call.answer";
}
export type CallEndReason = "hangup" | "dropped" | "instructor";
export type CallEndReason = "hangup" | "dropped" | "instructor" | "complete";
export interface CallEnded {
type: "call.ended";
@ -77,6 +77,16 @@ export interface CardBounce {
comment?: string;
}
/** Текстовая вводная упражнения 112; эталонные значения сюда не входят. */
export interface CardBriefing {
type: "card.briefing";
scenario_id: string;
mode: SessionMode;
text: string;
required_fields: Array<string>;
card?: KIO | null;
}
/** Снимок КИО. После передачи не меняется — оператор не дописывает задним числом. */
export interface CardReceived {
type: "card.received";
@ -88,6 +98,10 @@ export interface CardReceived {
/** Статус карточки целиком — считается из статусов служб, руками не ставится. */
export type CardStatus = "registered" | "processed" | "checked" | "not_notified" | "refused" | "not_completed" | "completed";
export interface CardSubmit {
type: "card.submit";
}
/** Шесть осей радара. Проекция уже посчитанных метрик, без пересчёта весов. */
export type Competency = "intake" | "interview" | "card" | "routing" | "norms" | "communication";
@ -112,13 +126,18 @@ export interface CrewDispatched {
at: string;
}
export interface CrewSelect {
type: "crew.select";
crew: string;
}
/** Дежурно-диспетчерская служба, в которую уходит карточка. */
export type DDSCode = "01" | "02" | "03" | "04" | "gkh";
/** Передача в ДДС. Замораживает карточку снимком и останавливает опрос (`interview`). */
/** Передача в ДДС. При маршрутизации по ЕКП служба уже есть в notify. */
export interface DdsDispatch {
type: "dds.dispatch";
service: DDSCode;
service?: DDSCode | null;
}
/** `immediate` — только для обрыва связи: рвёт TTS на полуслове. */
@ -152,6 +171,8 @@ export interface ErrorEvent {
/** Коды канала `error`. Фронт разбирает код, а не текст сообщения: текст — для человека, код — для поведения интерфейса. */
export type ErrorKind = "session_not_found" | "call_not_started" | "hint_denied_in_exam" | "models_warming_up" | "directive_needs_network" | "scenario_invalid" | "unsupported_event" | "forbidden" | "internal";
export type Exercise = "call" | "dds" | "card";
/** Отметка в разборе. `fact` и `norm` — то самое обоснование. */
export interface Finding {
code: ErrorCode;
@ -306,6 +327,27 @@ export type Outcome = "card" | "consultation" | "transfer_region";
/** `auto` — сервер распознал факт из речи, `operator` — эхо правки курсанта. */
export type PatchSource = "auto" | "operator";
export interface PhoneDial {
type: "phone.dial";
}
export interface PhoneReport {
type: "phone.report";
service: string;
crew: string;
phase: "dispatched" | "arrived" | "working" | "completed";
text: string;
at: string;
}
export interface PhoneReportRecord {
service: string;
crew: string;
phase: string;
text: string;
at: string;
}
/** Уточняющие поля 02 — полиция. */
export interface PoliceDetails {
offence_kind?: string | null;
@ -346,8 +388,10 @@ export interface ScenarioStart {
type: "scenario.start";
scenario_id: string;
trainee: string;
trainee_id?: string | null;
group_id?: string | null;
mode: SessionMode;
exercise?: Exercise;
}
/** Коррекция оценки. Автооценка сохраняется рядом. */
@ -432,6 +476,7 @@ export interface SessionSnapshot {
scenario_title: string;
level: Level;
mode: SessionMode;
exercise?: Exercise;
trainee_name?: string | null;
started_at?: string | null;
kio: KIO;
@ -448,6 +493,10 @@ export interface SessionStop {
export type Speaker = "caller" | "operator";
export interface StationFinish {
type: "station.finish";
}
/** Что видит диспетчер: службы, их статусы и что доступно дальше. Все поля обязательные, хотя списки и могут быть пустыми: снимок всегда собирается целиком, а необязательное поле уезжает в TypeScript как `| undefined` и заставляет фронт проверять то, чего не бывает. */
export interface StationSnapshot {
services: Array<string>;
@ -455,6 +504,9 @@ export interface StationSnapshot {
available: Record<string, Array<ServiceStatus>>;
card: CardStatus;
log: Array<StatusEntry>;
crew_options?: Array<string>;
crew_selected?: string | null;
phone_reports?: Array<PhoneReportRecord>;
}
/** Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше. */
@ -552,17 +604,17 @@ export interface ZoneDecision {
// Союзы по каналам — смотри docs/arch/CONTRACT.md#каналы
export type ServerToTrainee = CallIncoming | CallStarted | SttPartial | SttFinal | CallerUtterance | TtsBegin | TtsEnd | TtsCancel | BgStart | BgStop | EraData | KioPatchOut | HintShown | TimerTick | CallEnded | ScoreReady | ErrorEvent;
export type ServerToTrainee = CallIncoming | CardBriefing | CallStarted | SttPartial | SttFinal | CallerUtterance | TtsBegin | TtsEnd | TtsCancel | BgStart | BgStop | EraData | KioPatchOut | HintShown | TimerTick | CallEnded | ScoreReady | ErrorEvent;
export type TraineeToServer = CallAnswer | KioPatchIn | HintRequest | SelfAssessmentSubmit | DdsDispatch | CallResolve | CallHangup | CallbackDial;
export type TraineeToServer = CallAnswer | CardSubmit | KioPatchIn | HintRequest | SelfAssessmentSubmit | DdsDispatch | CallResolve | CallHangup | CallbackDial;
export type ServerToObserver = SessionSnapshot | TranscriptAppend | KioState | TimerTick | ModeSet | HintShown | BgStart | BgStop | CallerUtterance | SessionEnded | ScoreReady | InstructorNoteShown | ReferenceStarted | ErrorEvent;
export type InstructorToServer = ScenarioStart | DirectorInject | ReferencePlay | InstructorNoteAdd | ScoreOverride | ScenarioPublish | SessionStop;
export type ServerToStation = CardReceived | StationState | TimerTick | SessionEnded | ErrorEvent;
export type ServerToStation = CardReceived | StationState | PhoneReport | TimerTick | SessionEnded | ScoreReady | ErrorEvent;
export type StationToServer = CardAck | CardBounce | ServiceStatusSet | ZoneDecision | CrewDispatched | CrewArrived;
export type StationToServer = CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | StationFinish | ZoneDecision | CrewDispatched | CrewArrived;
export type EventName =
"bg.start"
@ -577,10 +629,13 @@ export type EventName =
| "caller.utterance"
| "card.ack"
| "card.bounce"
| "card.briefing"
| "card.received"
| "card.status"
| "card.submit"
| "crew.arrived"
| "crew.dispatched"
| "crew.select"
| "dds.dispatch"
| "director.inject"
| "era.data"
@ -592,6 +647,8 @@ export type EventName =
| "kio.patch"
| "kio.state"
| "mode.set"
| "phone.dial"
| "phone.report"
| "reference.play"
| "reference.started"
| "scenario.publish"
@ -602,6 +659,7 @@ export type EventName =
| "session.ended"
| "session.snapshot"
| "session.stop"
| "station.finish"
| "station.state"
| "stt.final"
| "stt.partial"

View file

@ -95,6 +95,21 @@ button:disabled { opacity: 0.5; cursor: default; }
.director-buttons .danger { border-color: var(--violated); color: var(--violated); font-weight: 600; }
.director input[type="text"] { width: 60%; }
/* Преподаватель редактирует копию шаблона до публикации. */
.scenario-editor { margin-top: 24px; padding: 12px 16px 16px; background: var(--panel); border: 1px solid var(--line); }
.scenario-editor h2 { margin-top: 0; }
.scenario-editor-create, .scenario-editor-open, .scenario-editor-actions { display: flex; align-items: end; flex-wrap: wrap; gap: 8px 12px; margin: 12px 0; }
.scenario-editor-create label, .scenario-editor-open label { display: grid; gap: 4px; min-width: 220px; font-size: 13px; }
.scenario-editor-create select, .scenario-editor-create input, .scenario-editor-open input { box-sizing: border-box; width: 100%; min-height: 34px; padding: 4px 6px; border: 1px solid var(--line); font: inherit; background: var(--panel); }
.scenario-editor-create label:first-child { flex: 1 1 350px; }
.scenario-editor-open label { flex: 1 1 350px; }
.scenario-editor-json { display: grid; gap: 6px; font-weight: 600; }
.scenario-editor-json textarea { box-sizing: border-box; width: 100%; min-height: 320px; padding: 10px; border: 1px solid var(--line); font: 13px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; resize: vertical; }
.scenario-editor-result { padding: 10px 12px; border: 1px solid var(--ok); background: #e7f4e9; }
.scenario-editor-result.violated { border-color: var(--violated); background: #fde8e8; }
.scenario-editor-result pre { margin: 8px 0 0; overflow-x: auto; white-space: pre-wrap; font-size: 12px; }
.scenario-editor-result ul { margin: 8px 0 0; }
/* Возврат карточки: список полей в две колонки, чтобы диспетчер не скроллил. */
.bounce-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 16px; margin: 8px 0; }
@ -102,3 +117,57 @@ button:disabled { opacity: 0.5; cursor: default; }
.stand { padding: 6px 24px; margin: -16px -24px 12px; font-weight: 600; }
.stand-warn { background: #fff4d6; border-bottom: 1px solid var(--warn); }
.stand-bad { background: #fde8e8; border-bottom: 1px solid var(--violated); color: var(--violated); }
/* АРМ ДДС: реестр остаётся плотной таблицей, карточка и связь — рядом. */
.dds-workspace { max-width: 1800px; margin: 0 auto; }
.dds-header { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 8px 0 16px; }
.dds-header h1 { margin: 2px 0 0; }
.dds-header h1 span { color: #576273; font-size: 15px; font-weight: 400; }
.dds-eyebrow { color: #576273; font-size: 12px; text-transform: uppercase; letter-spacing: .06em; }
.dds-connection { white-space: nowrap; font-size: 13px; }
.dds-panel { background: var(--panel); border: 1px solid var(--line); margin-bottom: 14px; min-width: 0; }
.dds-panel-title { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 9px 12px; background: #e8ebef; border-bottom: 1px solid var(--line); }
.dds-panel-title h2 { margin: 0; font-size: 15px; }
.dds-panel-title span { font-size: 12px; color: #576273; }
.dds-main { display: grid; grid-template-columns: minmax(0, 1fr) 340px; align-items: start; gap: 14px; margin-top: 14px; }
.dds-left, .dds-right { min-width: 0; }
.dds-table-wrap { width: 100%; overflow-x: auto; }
.dds-registry { width: 100%; border-collapse: collapse; font-size: 13px; }
.dds-registry th, .dds-registry td { padding: 8px 10px; border-right: 1px solid #d4d8dd; border-bottom: 1px solid #d4d8dd; text-align: left; vertical-align: top; }
.dds-registry th { background: #f1f3f5; white-space: nowrap; font-weight: 600; }
.dds-registry .dds-selected { background: #e8f0fb; box-shadow: inset 3px 0 #316798; }
.dds-registry td:last-child, .dds-registry th:last-child { border-right: 0; }
.dds-empty { padding: 20px !important; color: #66717b; }
.dds-summary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0; }
.dds-summary > div { display: grid; gap: 3px; padding: 12px; border-right: 1px solid #e0e3e6; border-bottom: 1px solid #e0e3e6; }
.dds-summary small { color: #66717b; }
.dds-summary strong { font-weight: 500; overflow-wrap: anywhere; }
.dds-details { padding: 10px 12px; }
.dds-details summary { cursor: pointer; color: #23577e; font-weight: 600; }
.dds-details .kio { padding-top: 12px; }
.dds-deadline { padding: 10px 12px; font-weight: 600; border-bottom: 1px solid #e0e3e6; }
.dds-hint { margin: 10px 12px; color: #56616c; font-size: 12px; line-height: 1.4; }
.dds-status-action { display: flex; align-items: center; flex-wrap: wrap; gap: 5px; }
.dds-status-action select { max-width: 190px; }
.dds-status-action input { min-width: 180px; flex: 1; }
.dds-status-action button { padding: 4px 8px; }
.dds-alert { padding: 10px 12px; margin: 0 0 12px; color: var(--violated); background: #fde8e8; border: 1px solid var(--violated); }
.dds-done { color: var(--ok); background: #e7f4e9; border-color: var(--ok); }
.dds-phone > label { display: block; margin: 14px 12px 5px; font-size: 13px; font-weight: 600; }
.dds-phone > select { display: block; width: calc(100% - 24px); margin: 0 12px; padding: 7px; box-sizing: border-box; }
.dds-dial { display: block; margin: 12px; width: calc(100% - 24px); text-align: center; background: #23577e; border-color: #23577e; color: white; font-weight: 600; }
.dds-dial:disabled { background: #70869a; }
.dds-report { margin: 12px; padding: 12px; border: 1px solid #8eafc8; background: #edf5fb; }
.dds-report small, .dds-report strong, .dds-report span { display: block; }
.dds-report small, .dds-report span { color: #56616c; font-size: 12px; }
.dds-report strong { margin-top: 4px; }
.dds-report p { line-height: 1.45; }
.dds-phone-idle { margin: 12px; padding: 18px 12px; border: 1px dashed #aab4bf; color: #66717b; text-align: center; font-size: 13px; }
.dds-reports { margin: 10px 0; padding-left: 22px; font-size: 12px; }
.dds-reports li { padding: 5px 0; border-bottom: 1px solid #e0e3e6; }
.dds-zone { padding: 12px; display: flex; align-items: center; flex-wrap: wrap; gap: 5px; font-size: 13px; }
.dds-secondary .bounce-fields { max-height: 200px; overflow-y: auto; font-size: 12px; }
.dds-secondary input[type="text"] { max-width: 100%; margin: 4px 0; }
.dds-finish { display: block; margin: 12px; }
@media (max-width: 900px) { .dds-main { grid-template-columns: 1fr; } .dds-right { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; } }
@media (max-width: 640px) { .dds-right, .dds-summary { display: block; } .dds-header { align-items: flex-start; flex-direction: column; } }

View file

@ -7,8 +7,8 @@ export default defineConfig({
server: {
port: 5173,
proxy: {
"/api": { target: "http://localhost:8000", changeOrigin: true },
"/ws": { target: "ws://localhost:8000", ws: true },
"/api": { target: process.env.VITE_BACKEND_URL || "http://localhost:8000", changeOrigin: true },
"/ws": { target: process.env.VITE_WS_BACKEND_URL || "ws://localhost:8000", ws: true },
},
},
resolve: { alias: { "@": "/src" } },