Complete DDS training workflow and delivery package

This commit is contained in:
andreysk0304 2026-09-24 01:10:49 +03:00
commit 4c4b91064f
229 changed files with 11969 additions and 1024 deletions

View file

@ -1,22 +1,30 @@
import { lazy, Suspense } from "react";
import { createBrowserRouter, Navigate } from "react-router-dom";
import { Call } from "@/pages/trainee/Call";
import { Instructor } from "@/pages/instructor/Instructor";
import { Dds } from "@/pages/dds/Dds";
import { Admin } from "@/pages/admin/Admin";
import { Profile } from "@/pages/profile/Profile";
import { Groups } from "@/pages/groups/Groups";
import { Wall } from "@/pages/wall/Wall";
import { RequireAuth } from "@/features/auth/Login";
import { DEMO_MODE } from "@/features/auth/useAuth";
const Call = lazy(() => import("@/pages/trainee/Call").then((module) => ({ default: module.Call })));
const Instructor = lazy(() => import("@/pages/instructor/Instructor").then((module) => ({ default: module.Instructor })));
const Dds = lazy(() => import("@/pages/dds/Dds").then((module) => ({ default: module.Dds })));
const Admin = lazy(() => import("@/pages/admin/Admin").then((module) => ({ default: module.Admin })));
const Profile = lazy(() => import("@/pages/profile/Profile").then((module) => ({ default: module.Profile })));
const Groups = lazy(() => import("@/pages/groups/Groups").then((module) => ({ default: module.Groups })));
const Wall = lazy(() => import("@/pages/wall/Wall").then((module) => ({ default: module.Wall })));
const Phone = lazy(() => import("@/pages/phone/Phone").then((module) => ({ default: module.Phone })));
const Materials = lazy(() => import("@/pages/materials/Materials").then((module) => ({ default: module.Materials })));
// Четыре интерфейса — одна SPA (docs/arch/FRONTEND.md).
// session_id живёт в URL: /instructor?session=..., монитор открывают ссылкой.
// Каждый экран за проверкой роли: курсант не открывает пульт даже по прямой
// ссылке. Сервер проверяет то же самое на сокетах — здесь только чтобы человек
// увидел понятное объяснение вместо пустого экрана (lct-23).
const guarded = (screen: string, element: JSX.Element) => (
<RequireAuth screen={screen}>{element}</RequireAuth>
<RequireAuth screen={screen}>
<Suspense fallback={<main className="page"><p>Загружаем рабочее место…</p></main>}>
{element}
</Suspense>
</RequireAuth>
);
export const router = createBrowserRouter([
@ -27,5 +35,7 @@ export const router = createBrowserRouter([
{ path: "/profile", element: guarded("/profile", <Profile />) },
{ path: "/groups", element: guarded("/groups", <Groups />) },
{ path: "/dds", element: guarded("/dds", <Dds />) },
{ path: "/phone", element: guarded("/phone", <Phone />) },
{ path: "/materials", element: guarded("/materials", <Materials />) },
{ path: "/admin", element: guarded("/admin", <Admin />) },
]);

View file

@ -23,6 +23,7 @@ function DemoLinks() {
<a href="/instructor" onClick={(event) => openScreen(event, "/instructor")}>Преподаватель</a>
<a href="/trainee" onClick={(event) => openScreen(event, "/trainee")}>Оператор 112</a>
<a href="/dds" onClick={(event) => openScreen(event, "/dds")}>ДДС</a>
<a href="/materials" onClick={(event) => openScreen(event, "/materials")}>Материалы</a>
</div>
</details>
</nav>;

View file

@ -73,7 +73,7 @@ export function useLogout() {
/** Какие экраны открыты роли. Повторяет domain/roles.py: список короткий
* и меняется редко, а держать его на сервере ради двух строк — лишний запрос. */
export const SCREENS: Record<Role, string[]> = {
admin: ["/admin", "/profile", "/groups"],
instructor: ["/instructor", "/wall", "/profile", "/dds", "/groups"],
trainee: ["/trainee", "/dds", "/profile"],
admin: ["/admin", "/profile", "/groups", "/materials"],
instructor: ["/instructor", "/wall", "/profile", "/dds", "/phone", "/groups", "/materials"],
trainee: ["/trainee", "/dds", "/phone", "/profile", "/materials"],
};

View file

@ -69,7 +69,20 @@ export function useCall(sessionId: string | null) {
useEffect(() => {
if (!sessionId) return;
const ch = callChannel(sessionId, {
onStatus: setStatus,
onStatus: (next) => {
setStatus(next);
if (next === "open") {
// Браузер не подтверждает доставку WebSocket-кадра. Всё, что ещё
// отмечено pending и не пришло эхом от сервера, повторяем после
// reconnect: kio.patch идемпотентен и не создаёт двойных действий.
queueMicrotask(() => setCard((current) => {
if (Object.keys(current.pending).length) {
channel.current?.send({ type: "kio.patch", fields: current.pending });
}
return current;
}));
}
},
onBinary: (frame) => audio.current?.playback.enqueue(frame),
onEvent: (event: ServerToTrainee) => {
if (event.type === "card.briefing") {

View file

@ -27,6 +27,16 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
Скачать отчёт: <a href={`/api/sessions/${report.session_id}/report.csv`}>CSV</a>
{" · "}<a href={`/api/sessions/${report.session_id}/report.pdf`}>PDF</a>
</p>
<p className={report.passed ? "state-ok" : "state-violated"}>
{report.passed ? "Зачёт" : "Не зачтено"}: нарушено {report.failed_metrics} метрик,
допустимо {report.criteria.allowed_errors}. Лимит решения ДДС —
{` ${report.criteria.decision_time_limit_seconds} с`}; грамматика
{report.criteria.require_correct_grammar ? " входит" : " не входит"} в оценку.
</p>
{Object.keys(report.criteria.score_weights ?? {}).length > 0 && <p className="ref">
Преподаватель переопределил веса: {Object.entries(report.criteria.score_weights ?? {})
.map(([key, weight]) => `${key} — ${weight}`).join("; ")}.
</p>}
<div className="columns">
<section>
@ -38,6 +48,7 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
<th>{metric.title}</th>
<td className={metric.passed ? "state-ok" : "state-violated"}>
{metric.fact} · норматив {metric.norm}
{` · вес ${metric.weight ?? 1}`}
{metric.ref && <span className="ref"> ({metric.ref})</span>}
</td>
</tr>

View file

@ -12,7 +12,7 @@ export function StandStatus() {
if (!health.data) return null;
if (health.data.demo_no_db) {
return <div className="stand stand-warn">Локальная демка: карточки 112 и ДДС работают без БД и голоса; история занятий не сохраняется.</div>;
return <div className="stand stand-warn">Локальная демка: карточки 112, ДДС и редактор работают без БД и голоса; история и черновики исчезнут после перезапуска.</div>;
}
const troubles: string[] = [];

View file

@ -61,7 +61,7 @@ export function Director({
рвёт звук немедленно.
</p>
<p>
<input type="text" value={free} placeholder="своя реплика — нужна сеть и LLM"
<input type="text" value={free} placeholder="своя реплика — нужна локальная модель"
onChange={(event) => setFree(event.target.value)} />{" "}
<button type="button" disabled={disabled || !free.trim()}
onClick={() => { fire(`своя реплика: ${free.trim()}`, free.trim()); setFree(""); }}>

View file

@ -3,7 +3,12 @@ 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 Draft = {
id: string;
status: "draft";
generation: "template_copy" | "ai_variant" | "ai_full";
body: JsonObject;
};
type Validation = { valid: boolean; errors: string[]; ground_truth?: JsonObject };
async function api<T>(path: string, method = "GET", body?: unknown): Promise<T> {
@ -48,15 +53,31 @@ function pretty(body: JsonObject): string {
return JSON.stringify(body, null, 2);
}
function stringField(body: JsonObject, key: string): string {
return typeof body[key] === "string" ? body[key] : "";
}
function generationLabel(generation: Draft["generation"]): string {
if (generation === "ai_full") return "новый сюжет и эталон локальной модели";
if (generation === "ai_variant") return "вариант локальной модели";
return "копия шаблона";
}
export function ScenarioEditor({
scenarios,
selectedScenarioId,
onApproved,
onLibraryChanged,
}: {
scenarios: ScenarioSummary[];
selectedScenarioId: string;
onApproved: (id: string) => void | Promise<void>;
onLibraryChanged: () => void | Promise<void>;
}) {
const [sourceId, setSourceId] = useState("");
const [title, setTitle] = useState("");
const [instruction, setInstruction] = useState("");
const [revisionComment, setRevisionComment] = useState("");
const [draftId, setDraftId] = useState("");
const [draft, setDraft] = useState<Draft | null>(null);
const [bodyText, setBodyText] = useState("");
@ -64,6 +85,10 @@ export function ScenarioEditor({
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [archiveCandidate, setArchiveCandidate] = useState("");
const [lastArchived, setLastArchived] = useState<{ id: string; title: string } | null>(null);
const effectiveSourceId = sourceId || selectedScenarioId || scenarios[0]?.id || "";
const effectiveSource = scenarios.find((scenario) => scenario.id === effectiveSourceId);
function openDraft(value: Draft) {
setDraft(value);
@ -89,7 +114,7 @@ export function ScenarioEditor({
async function create() {
await perform(async () => {
const created = await api<Draft>("/api/scenarios/drafts/from-template", "POST", {
source_id: sourceId || scenarios[0]?.id,
source_id: effectiveSourceId,
...(title.trim() ? { title: title.trim() } : {}),
});
openDraft(created);
@ -97,6 +122,30 @@ export function ScenarioEditor({
});
}
async function generate() {
await perform(async () => {
setNotice("Qwen формирует вариацию… Обычно это занимает несколько секунд.");
const created = await api<Draft>("/api/scenarios/drafts/generate", "POST", {
source_id: effectiveSourceId,
instruction: instruction.trim(),
});
openDraft(created);
setNotice("Локальная модель создала вариацию. Проверьте сюжет, факты и эталон перед утверждением.");
});
}
async function generateFull() {
await perform(async () => {
setNotice("Qwen формирует новый сюжет по полям… Обычно это занимает 10–30 секунд.");
const created = await api<Draft>("/api/scenarios/drafts/generate-from-description", "POST", {
source_id: effectiveSourceId,
description: instruction.trim(),
});
openDraft(created);
setNotice("Локальная модель создала новый сюжет, все ответы и мягкий эталон. Класс ЕКП и структура опроса взяты из выбранного каркаса; проверьте результат.");
});
}
async function reopen() {
if (!draftId.trim()) return;
await perform(async () => {
@ -132,6 +181,20 @@ export function ScenarioEditor({
});
}
async function revise() {
await perform(async () => {
setNotice("Qwen перерабатывает черновик по комментарию…");
const current = await save();
const updated = await api<Draft>(
`/api/scenarios/drafts/${encodeURIComponent(current.id)}/revise`, "POST",
{ comment: revisionComment.trim() },
);
openDraft(updated);
setRevisionComment("");
setNotice("Локальная модель переработала этот же черновик по комментарию. Проверьте изменения заново.");
});
}
async function approve() {
await perform(async () => {
const current = await save();
@ -144,30 +207,107 @@ export function ScenarioEditor({
});
}
async function archiveSelected() {
await perform(async () => {
const selected = scenarios.find((item) => item.id === effectiveSourceId);
if (!selected) throw new Error("Выберите сценарий для архивирования");
await api(`/api/scenarios/${encodeURIComponent(selected.id)}`, "DELETE");
setLastArchived({ id: selected.id, title: selected.title });
setArchiveCandidate("");
setSourceId("");
setNotice(`Сценарий «${selected.title}» перемещён в архив и больше не назначается.`);
await onLibraryChanged();
});
}
async function restoreLast() {
if (!lastArchived) return;
await perform(async () => {
await api(`/api/scenarios/${encodeURIComponent(lastArchived.id)}/restore`, "POST");
const restored = lastArchived;
setLastArchived(null);
setSourceId(restored.id);
setNotice(`Сценарий «${restored.title}» восстановлен в учебной библиотеке.`);
await onLibraryChanged();
});
}
return (
<section className="scenario-editor">
<h2>Редактор учебных сценариев</h2>
<p className="ref">Создание работает локально: копия утверждённого шаблона, затем правка и проверка преподавателем.</p>
<section className="scenario-editor" id="ai-scenario-editor" aria-busy={busy}>
<h2>ИИ-формирователь и редактор сценариев</h2>
<p className="ref">Можно сделать безопасную вариацию либо новый сюжет из описания. Выбранный утверждённый сценарий задаёт класс ЕКП и структуру опроса; при новом сюжете Qwen заново создаёт все ответы, адрес и число пострадавших. Публикация — только после вашей проверки.</p>
<div className="scenario-editor-create">
<label>Шаблон
<select value={sourceId || scenarios[0]?.id || ""} onChange={(event) => setSourceId(event.target.value)}>
<label>Каркас категории и опроса
<select value={effectiveSourceId} onChange={(event) => setSourceId(event.target.value)}>
{scenarios.map((scenario) => <option key={scenario.id} value={scenario.id}>{scenario.title} · {scenario.level}</option>)}
</select>
{effectiveSource && <small className="ref">
Фиксированный путь ЕКП: {effectiveSource.signs?.length
? effectiveSource.signs.join(" → ") : "без размеченных признаков"}
{effectiveSource.incident_code ? ` · код ${effectiveSource.incident_code}` : ""}
</small>}
</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-create">
<label>Указание для локальной модели
<input value={instruction} onChange={(event) => setInstruction(event.target.value)}
placeholder="Например: ночное возгорание, плотный дым, заявитель паникует"
maxLength={1000} />
<small className="ref">Описание меняет обстоятельства, но не показанный выше путь ЕКП.</small>
</label>
<button type="button" onClick={generate} disabled={busy || !scenarios.length || instruction.trim().length < 10}>
Создать вариант ИИ
</button>
<button type="button" onClick={generateFull}
disabled={busy || !scenarios.length || instruction.trim().length < 20}>
Создать новый сюжет и эталон
</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>
<div className="scenario-library-actions">
{!archiveCandidate ? <button type="button" disabled={busy || !effectiveSourceId || !effectiveSource?.can_manage}
onClick={() => setArchiveCandidate(effectiveSourceId)}>Архивировать выбранный сценарий</button> : <>
<span>Сценарий исчезнет из назначения, но история занятий сохранится.</span>
<button type="button" className="danger" disabled={busy}
onClick={archiveSelected}>Подтвердить архивирование</button>
<button type="button" disabled={busy} onClick={() => setArchiveCandidate("")}>Отмена</button>
</>}
{effectiveSource && !effectiveSource.can_manage && <small className="ref">
Базовые сценарии и сценарии других преподавателей доступны только для чтения.
</small>}
{lastArchived && <button type="button" disabled={busy} onClick={restoreLast}>
Вернуть «{lastArchived.title}» из архива
</button>}
</div>
{draft && (
<>
<p className="ref">Черновик {draft.id}. Факты и эталон доступны только преподавателю до утверждения. Поле score_weights позволяет задать веса метрик (0–10), например {"{\"address\": 3, \"dds_ack\": 2}"}.</p>
<p className="ref">Черновик {draft.id} ({generationLabel(draft.generation)}). Факты и эталон доступны только преподавателю до утверждения. Поле score_weights позволяет задать веса метрик (0–10), например {"{\"address\": 3, \"dds_ack\": 2}"}.</p>
<div className="scenario-editor-result">
<strong>Предпросмотр: {stringField(draft.body, "title")}</strong>
<p>Первая реплика: «{stringField(draft.body, "first_line")}»</p>
<p className="ref">Сюжетные факты — проверьте, что они согласуются друг с другом и с эталоном:</p>
<ul>{Array.isArray(draft.body.facts) && draft.body.facts.map((item, index) =>
isObject(item) ? <li key={`${index}-${String(item.id)}`}>{String(item.id)}: {String(item.value ?? "")}</li> : null
)}</ul>
</div>
<div className="scenario-editor-create">
<label>Комментарий для переработки
<input value={revisionComment} onChange={(event) => setRevisionComment(event.target.value)}
placeholder="Например: убери упоминание газа и сделай дым плотнее"
maxLength={1000} />
</label>
<button type="button" onClick={revise}
disabled={busy || revisionComment.trim().length < 10}>Переработать с ИИ</button>
</div>
<label className="scenario-editor-json">JSON тела сценария
<textarea value={bodyText} onChange={(event) => { setBodyText(event.target.value); setValidation(null); }} spellCheck={false} />
</label>

View file

@ -8,7 +8,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { controlChannel, type ChannelStatus } from "@/shared/api/ws";
import type { SessionMode } from "@/shared/types/generated";
import type { LessonCriteria, SessionMode } from "@/shared/types/generated";
export function useControl(sessionId: string | null) {
const [status, setStatus] = useState<ChannelStatus>("closed");
@ -25,7 +25,7 @@ export function useControl(sessionId: string | null) {
}, [sessionId]);
const start = useCallback(
(scenarioId: string, mode: SessionMode, trainee: string, exercise: "dds" | "call" | "card" = "dds", group?: string, traineeId?: string, scenarioIds?: string[], handoffToDds = false) => {
(scenarioId: string, mode: SessionMode, trainee: string, exercise: "dds" | "call" | "card" = "dds", group?: string, traineeId?: string, ddsService?: string, scenarioIds?: string[], ddsArrivalIntervalSeconds = 60, ddsMaxWaiting = 3, handoffToDds = false, criteria?: LessonCriteria) => {
const message = {
type: "scenario.start",
scenario_id: scenarioId,
@ -33,9 +33,13 @@ export function useControl(sessionId: string | null) {
trainee,
group_id: group || null,
trainee_id: traineeId || null,
dds_service: ddsService || null,
exercise,
handoff_to_dds: handoffToDds,
scenario_ids: scenarioIds?.length ? scenarioIds : undefined,
dds_arrival_interval_seconds: ddsArrivalIntervalSeconds,
dds_max_waiting: ddsMaxWaiting,
criteria,
} as const;
channel.current?.send(message);
},

View file

@ -74,7 +74,8 @@ function Row({
{referenceArm && field.path === "incident_type" ? "Что случилось?"
: referenceArm && field.path === "caller_number" ? "АОН"
: referenceArm && field.path === "caller_contact" ? "Предоставленный"
: referenceArm && field.path === "caller_name" ? "Фамилия и имя заявителя"
: referenceArm && field.path === "phone_on_scene" ? "Телефон на место"
: referenceArm && field.path === "caller_name" ? "Фамилия и имя заявителя"
: field.label}
{required && <span title="обязательное поле" className="required"> ·</span>}
</div>

View file

@ -61,6 +61,7 @@ export const GROUPS: FieldGroup[] = [
{ path: "caller_number", label: "Номер телефона", kind: "text", readOnly: true },
{ path: "caller_name", label: "ФИО", kind: "text" },
{ path: "caller_contact", label: "Контактный телефон", kind: "text" },
{ path: "phone_on_scene", label: "Телефон на место", kind: "text" },
{ path: "language", label: "Язык общения", kind: "text" },
],
},

View file

@ -48,6 +48,12 @@ export function useObserve(sessionId: string | null): ObservedSession {
useEffect(() => {
if (!sessionId) return;
// A report may already be archived when an instructor opens a history link;
// the observe socket is then intentionally unavailable after process restart.
void fetch(`/api/sessions/${sessionId}/report`, { credentials: "same-origin" })
.then((response) => (response.ok ? response.json() : null))
.then((saved) => { if (saved) setReport(saved as SessionReport); })
.catch(() => undefined);
const channel = observeChannel(sessionId, {
onStatus: setStatus,
onEvent: (event) => {

View file

@ -0,0 +1,57 @@
import { useEffect, useRef, useState } from "react";
import { type SipStatus, useSipPhone } from "./useSipPhone";
const LABEL: Record<SipStatus, string> = {
idle: "не подключён",
connecting: "подключение…",
registered: "зарегистрирован в Asterisk",
incoming: "входящий вызов",
calling: "вызов…",
connected: "разговор идёт",
error: "ошибка",
};
export function SipPhone({
defaultExtension = "6101", defaultTarget = "6102", title = "Локальный SIP/WebRTC",
}: {
defaultExtension?: string;
defaultTarget?: string;
title?: string;
}) {
const phone = useSipPhone();
const [extension, setExtension] = useState(defaultExtension);
const [password, setPassword] = useState("");
const [target, setTarget] = useState(defaultTarget);
const audio = useRef<HTMLAudioElement | null>(null);
const online = phone.status !== "idle" && phone.status !== "error";
const inCall = phone.status === "calling" || phone.status === "incoming" || phone.status === "connected";
useEffect(() => {
if (audio.current) audio.current.srcObject = phone.remoteStream;
}, [phone.remoteStream]);
return <section className="sip-phone" aria-label={title}>
<header><strong>{title}</strong><span className={phone.status === "registered" || phone.status === "connected" ? "state-ok" : "state-warn"}>● {LABEL[phone.status]}</span></header>
<p className="sip-phone-note">Звук идёт напрямую через локальный Asterisk. Пароль хранится только в памяти этой вкладки.</p>
{!online && <div className="sip-phone-settings">
<label>Внутренний номер<input aria-label="SIP внутренний номер" inputMode="numeric" value={extension}
onChange={(event) => setExtension(event.target.value)} /></label>
<label>Пароль SIP<input aria-label="Пароль SIP" type="password" autoComplete="off" value={password}
onChange={(event) => setPassword(event.target.value)} /></label>
<button type="button" disabled={!password || phone.status === "connecting"}
onClick={() => phone.connect({ extension, password })}>Подключить телефон</button>
</div>}
{online && <div className="sip-phone-controls">
<label>Номер собеседника<input aria-label="Номер SIP собеседника" inputMode="numeric" value={target}
disabled={inCall} onChange={(event) => setTarget(event.target.value)} /></label>
{phone.status === "registered" && <button type="button" onClick={() => phone.dial(target)}>Позвонить</button>}
{phone.status === "incoming" && <button type="button" className="sip-answer" onClick={phone.answer}>Ответить · {phone.peer}</button>}
{inCall && phone.status !== "incoming" && <span>{phone.peer || target}</span>}
{inCall && <button type="button" className="sip-hangup" onClick={phone.hangup}>Завершить</button>}
{!inCall && <button type="button" onClick={phone.disconnect}>Отключить</button>}
</div>}
{phone.error && <p className="state-violated" role="alert">{phone.error}</p>}
<audio ref={audio} autoPlay playsInline aria-label="Звук SIP-собеседника" />
</section>;
}

View file

@ -0,0 +1,171 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { UA, WebSocketInterface } from "jssip";
import type { RTCSession } from "jssip/lib/RTCSession";
import type { RTCSessionEvent } from "jssip/lib/UA";
export type SipStatus =
| "idle" | "connecting" | "registered" | "incoming" | "calling" | "connected" | "error";
export interface SipCredentials {
extension: string;
password: string;
domain?: string;
}
const media = { audio: true, video: false } satisfies MediaStreamConstraints;
export function useSipPhone() {
const uaRef = useRef<UA | null>(null);
const sessionRef = useRef<RTCSession | null>(null);
const registeredRef = useRef(false);
const domainRef = useRef(location.hostname);
const [status, setStatus] = useState<SipStatus>("idle");
const [error, setError] = useState("");
const [peer, setPeer] = useState("");
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);
const clearSession = useCallback(() => {
sessionRef.current = null;
setPeer("");
setRemoteStream(null);
setStatus(registeredRef.current ? "registered" : "idle");
}, []);
const attachSession = useCallback((session: RTCSession, incoming: boolean) => {
if (sessionRef.current === session) return;
if (sessionRef.current && !sessionRef.current.isEnded()) {
session.terminate({ status_code: 486, reason_phrase: "Busy Here" });
return;
}
sessionRef.current = session;
setError("");
setPeer(session.remote_identity?.uri?.user || "абонент");
setStatus(incoming ? "incoming" : "calling");
const observedConnections = new WeakSet<RTCPeerConnection>();
const observeConnection = () => {
const connection = session.connection;
if (!connection || observedConnections.has(connection)) return;
observedConnections.add(connection);
connection.addEventListener("track", (event) => {
const stream = event.streams[0] ?? new MediaStream([event.track]);
setRemoteStream(stream);
});
};
observeConnection();
session.on("peerconnection", observeConnection);
session.on("accepted", () => setStatus("connected"));
session.on("confirmed", () => setStatus("connected"));
session.on("ended", clearSession);
session.on("failed", (event) => {
setError(`Вызов завершён: ${event.cause}`);
clearSession();
});
}, [clearSession]);
const disconnect = useCallback(() => {
const session = sessionRef.current;
if (session && !session.isEnded()) session.terminate();
sessionRef.current = null;
registeredRef.current = false;
uaRef.current?.stop();
uaRef.current = null;
setRemoteStream(null);
setPeer("");
setError("");
setStatus("idle");
}, []);
const connect = useCallback((credentials: SipCredentials) => {
const extension = credentials.extension.trim();
const password = credentials.password;
const domain = credentials.domain?.trim() || location.hostname;
if (!/^\d{4}$/.test(extension) || !password) {
setError("Укажите четырёхзначный внутренний номер и пароль SIP.");
return false;
}
disconnect();
domainRef.current = domain;
setStatus("connecting");
try {
const scheme = location.protocol === "https:" ? "wss" : "ws";
const socket = new WebSocketInterface(`${scheme}://${location.host}/sip-ws`);
const ua = new UA({
sockets: socket,
uri: `sip:${extension}@${domain}`,
authorization_user: extension,
password,
register: true,
register_expires: 300,
connection_recovery_min_interval: 2,
connection_recovery_max_interval: 8,
user_agent: "LCT-112-WebRTC/1.0",
});
ua.on("registered", () => {
registeredRef.current = true;
setError("");
setStatus("registered");
});
ua.on("registrationFailed", (event) => {
registeredRef.current = false;
setError(`Регистрация SIP отклонена: ${event.cause || "проверьте номер и пароль"}`);
setStatus("error");
});
ua.on("disconnected", () => {
registeredRef.current = false;
if (!uaRef.current) return;
setStatus("connecting");
});
ua.on("newRTCSession", (event: RTCSessionEvent) => {
attachSession(event.session, event.originator === "remote");
});
uaRef.current = ua;
ua.start();
return true;
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
setStatus("error");
return false;
}
}, [attachSession, disconnect]);
const dial = useCallback((target: string) => {
const normalized = target.trim();
if (!uaRef.current || !registeredRef.current || !/^\d{4}$/.test(normalized)) {
setError("Сначала зарегистрируйте телефон и укажите номер из четырёх цифр.");
return false;
}
try {
const session = uaRef.current.call(`sip:${normalized}@${domainRef.current}`, {
mediaConstraints: media,
});
attachSession(session, false);
return true;
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
setStatus("error");
return false;
}
}, [attachSession]);
const answer = useCallback(() => {
const session = sessionRef.current;
if (!session || status !== "incoming") return false;
try {
session.answer({ mediaConstraints: media });
return true;
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
return false;
}
}, [status]);
const hangup = useCallback(() => {
const session = sessionRef.current;
if (!session || session.isEnded()) return;
session.terminate();
}, []);
useEffect(() => disconnect, [disconnect]);
return { status, error, peer, remoteStream, connect, disconnect, dial, answer, hangup };
}

View file

@ -10,7 +10,8 @@ import { useLogout, useMe } from "@/features/auth/useAuth";
import type { Role } from "@/shared/types/generated";
import {
useAudit, useBackups, useCreateBackup, useCreateUser, usePatchUser, useStatus, useUsers,
useAudit, useBackups, useCreateBackup, useCreateUser, useDiagnostics, usePatchUser, useStatus,
useUsers,
} from "./api";
const ROLE_LABELS: Record<Role, string> = {
@ -31,6 +32,8 @@ export function Admin() {
<button type="button" onClick={() => logout.mutate()}>Выйти</button>
</p>
<Status />
<Diagnostics />
<p><a href="/api/admin/config.xml">Скачать XML-конфигурацию рабочих мест</a></p>
<Users />
<Backups />
<Audit />
@ -38,6 +41,65 @@ export function Admin() {
);
}
function bytes(value: number | null) {
if (value === null) return "нет данных ОС";
if (value >= 1024 ** 3) return `${(value / 1024 ** 3).toFixed(1)} ГБ`;
return `${Math.round(value / 1024 ** 2)} МБ`;
}
function Diagnostics() {
const report = useDiagnostics();
const metrics = report.data?.metrics;
const systemEvents = report.data?.recent_system_events ?? [];
const failedActions = report.data?.failed_actions_24h ?? [];
return (
<>
<h2>Нагрузка и диагностика</h2>
{metrics && (
<table className="grid">
<tbody>
<tr><th>CPU backend</th><td>{metrics.cpu_percent.toFixed(1)}% · {metrics.cpu_cores} ядер на сервере</td></tr>
<tr><th>Нагрузка сервера, 1 мин</th><td>{metrics.load_1m_percent === null ? "нет счётчика ОС" : `${metrics.load_1m_percent.toFixed(1)}%`}</td></tr>
<tr><th>Память backend</th><td>{bytes(metrics.rss_bytes)}</td></tr>
<tr><th>Свободная память сервера</th><td>{bytes(metrics.memory_available_bytes)} из {bytes(metrics.memory_total_bytes)}</td></tr>
<tr><th>Свободно на диске</th><td>{bytes(metrics.disk_free_bytes)} из {bytes(metrics.disk_total_bytes)}</td></tr>
<tr><th>Занятия</th><td>активно {metrics.active_sessions}; завершено за 24 ч {metrics.completed_sessions_24h}; восстановлено после запуска {metrics.restored_sessions}</td></tr>
<tr><th>Процесс</th><td>работает {Math.floor(metrics.uptime_seconds / 60)} мин; потоков {metrics.threads}</td></tr>
</tbody>
</table>
)}
{report.isError && <p className="warn">Диагностика недоступна: {String(report.error)}</p>}
<p><a href="/api/admin/diagnostics.json">Скачать отчёт об ошибках и сбоях JSON</a></p>
<h3>Сбои действий за 24 часа</h3>
{failedActions.length === 0 ? <p>Зафиксированных сбоев действий нет.</p> : (
<table className="grid">
<thead><tr><th>Когда</th><th>Действие</th><th>Кто</th><th>Подробности</th></tr></thead>
<tbody>{failedActions.map((event, index) => (
<tr key={`${event.at}-${index}`}>
<th>{new Date(event.at).toLocaleString("ru-RU")}</th>
<td>{event.action}</td><td>{event.actor}</td><td>{event.detail}</td>
</tr>
))}</tbody>
</table>
)}
<details>
<summary>Предупреждения процесса ({systemEvents.length})</summary>
{systemEvents.length === 0 ? <p>Предупреждений после запуска нет.</p> : (
<table className="grid">
<thead><tr><th>Когда</th><th>Уровень</th><th>Источник</th><th>Сообщение</th></tr></thead>
<tbody>{systemEvents.map((event, index) => (
<tr key={`${event.at}-${index}`}>
<th>{new Date(event.at).toLocaleString("ru-RU")}</th>
<td>{event.level}</td><td>{event.source}</td><td>{event.message}</td>
</tr>
))}</tbody>
</table>
)}
</details>
</>
);
}
function Status() {
const status = useStatus();
return (
@ -104,21 +166,22 @@ function Users() {
<tbody>
<tr>
<th>Логин</th>
<td><input value={form.login} onChange={(e) => setForm({ ...form, login: e.target.value })} /></td>
<td><input value={form.login} maxLength={80} onChange={(e) => setForm({ ...form, login: e.target.value })} /></td>
</tr>
<tr>
<th>ФИО</th>
<td><input value={form.full_name} onChange={(e) => setForm({ ...form, full_name: e.target.value })} /></td>
<td><input value={form.full_name} maxLength={120} onChange={(e) => setForm({ ...form, full_name: e.target.value })} /></td>
</tr>
<tr>
<th>Пароль</th>
<td>
<input
type="password"
maxLength={1024}
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>{" "}
не короче восьми знаков
от восьми до 1024 знаков
</td>
</tr>
<tr>
@ -182,20 +245,24 @@ function Backups() {
function Audit() {
const [action, setAction] = useState("");
const audit = useAudit(action);
const [actor, setActor] = useState("");
const [offset, setOffset] = useState(0);
const audit = useAudit(action, actor, offset);
const pageSize = 200;
const rows = audit.data ?? [];
return (
<>
<h2>Журнал действий</h2>
<p>
Фильтр по действию:{" "}
<input value={action} placeholder="lesson.start" onChange={(e) => setAction(e.target.value)} />
Действие: <input value={action} placeholder="login.failed" onChange={(e) => { setAction(e.target.value); setOffset(0); }} />
{" "}Пользователь: <input value={actor} placeholder="логин" onChange={(e) => { setActor(e.target.value); setOffset(0); }} />
</p>
<table className="grid">
<thead>
<tr><th>Когда</th><th>Кто</th><th>Действие</th><th>Над чем</th><th>Подробности</th></tr>
</thead>
<tbody>
{(audit.data ?? []).map((row, index) => (
{rows.slice(0, pageSize).map((row, index) => (
<tr key={index}>
<th>{new Date(row.at).toLocaleString("ru-RU")}</th>
<td>{row.actor}</td>
@ -206,6 +273,11 @@ function Audit() {
))}
</tbody>
</table>
<p>
<button type="button" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - pageSize))}>Назад</button>{" "}
<span>Записи {rows.length ? offset + 1 : 0}–{offset + Math.min(rows.length, pageSize)}</span>{" "}
<button type="button" disabled={rows.length <= pageSize} onClick={() => setOffset(offset + pageSize)}>Раньше</button>
</p>
</>
);
}

View file

@ -20,6 +20,32 @@ export interface AuditRow {
at: string; actor: string; role: string; action: string; object_id: string | null; detail: string;
}
export interface Backup { name: string; size_bytes: number; at: string }
export interface RuntimeMetrics {
at: string;
uptime_seconds: number;
cpu_percent: number;
load_1m_percent: number | null;
cpu_cores: number;
rss_bytes: number | null;
memory_total_bytes: number | null;
memory_available_bytes: number | null;
disk_total_bytes: number;
disk_free_bytes: number;
threads: number;
active_sessions: number;
restored_sessions: number;
completed_sessions_24h: number;
}
export interface DiagnosticEvent { at: string; level: string; source: string; message: string }
export interface FailureEvent {
at: string; actor: string; action: string; object_id: string | null; detail: string;
}
export interface DiagnosticReport {
generated_at: string;
metrics: RuntimeMetrics;
recent_system_events: DiagnosticEvent[];
failed_actions_24h: FailureEvent[];
}
async function call<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, {
@ -44,10 +70,22 @@ export const useStatus = () =>
refetchInterval: 10_000,
});
export const useAudit = (action: string) =>
export const useDiagnostics = () =>
useQuery({
queryKey: ["admin-audit", action],
queryFn: () => call<AuditRow[]>(`/api/admin/audit${action ? `?action=${encodeURIComponent(action)}` : ""}`),
queryKey: ["admin-diagnostics"],
queryFn: () => call<DiagnosticReport>("/api/admin/diagnostics"),
refetchInterval: 10_000,
});
export const useAudit = (action: string, actor: string, offset: number) =>
useQuery({
queryKey: ["admin-audit", action, actor, offset],
queryFn: () => {
const params = new URLSearchParams({ limit: "201", offset: String(offset) });
if (action) params.set("action", action);
if (actor) params.set("actor", actor);
return call<AuditRow[]>(`/api/admin/audit?${params.toString()}`);
},
});
export const useBackups = () =>

View file

@ -1,36 +1,40 @@
// Учебное АРМ ДДС. Звонок бригаде имитируется внутри приложения, без SIP.
// Учебное АРМ ДДС. Проверяемый текстовый протокол дополняется настоящим
// локальным SIP/WebRTC-телефоном; оба контура явно подписаны в интерфейсе.
import { useEffect, useRef, useState } from "react";
import { GROUPS } from "@/features/kio-card/fields";
import { KioCard } from "@/features/kio-card/KioCard";
import { applyState, empty as emptyCard, readPath, type CardState } from "@/features/kio-card/merge";
import { SipPhone } from "@/features/sip/SipPhone";
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 type { DdsQueueCard, ServiceStatus, SessionReport, StationSnapshot, StationToServer } from "@/shared/types/generated";
import { ALARMING, CARD_STATUS_LABELS, SERVICE_STATUS_LABELS } from "./statuses";
const ACK_LIMIT_S = 30;
type ReportPhase = "dispatched" | "arrived" | "working" | "completed";
interface PhoneReport { service: string; crew: string; phase: ReportPhase; text: string; at: string }
interface PhoneLine { service: string; crew: string; speaker: "dispatcher" | "crew"; text: string; at: string }
interface PhonePending { service: string; crew: string; phase: ReportPhase }
interface CompletedCard { card_id: string; scenario_id: string; score_auto: number }
interface DdsSnapshot extends StationSnapshot {
recipient_services?: string[];
managed_service?: string | null;
crew_options?: string[];
crew_selected?: string | null;
phone_reports?: PhoneReport[];
phone_lines?: PhoneLine[];
phone_pending?: PhonePending | null;
card_id?: string | null;
card_index?: number;
card_total?: number;
reply_text?: string;
completed_cards?: CompletedCard[];
queue_cards?: DdsQueueCard[];
}
const REPORT_LABELS: Record<ReportPhase, string> = {
dispatched: "Доклад о выезде", arrived: "Доклад о прибытии",
working: "Доклад о ходе работ", completed: "Доклад о завершении работ",
};
const FIELDS = GROUPS.flatMap((group) =>
group.fields.filter((item) => !item.readOnly).map((item) => ({ path: item.path, label: item.label })),
);
function field(card: CardState, path: string): string {
const value = readPath(card.server, path);
return value === null || value === undefined || value === "" ? "—" : String(value);
@ -60,19 +64,19 @@ export function Dds() {
const [station, setStation] = useState<DdsSnapshot | null>(null);
const [lastReport, setLastReport] = useState<PhoneReport | null>(null);
const [dialing, setDialing] = useState(false);
const [brief, setBrief] = useState({ address: "", incident: "", request: "" });
const [checkText, setCheckText] = useState("");
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 [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);
const openCardAfterSwitch = useRef(false);
useEffect(() => {
if (!sessionId) return;
@ -104,6 +108,13 @@ export function Dds() {
if (event.type === "station.state") {
const snapshot = event.snapshot as DdsSnapshot;
setStation(snapshot);
if (snapshot.card_id == null && snapshot.pending_cards_count > 0) {
activeCardId.current = null;
setCard(null);
setView("registry");
setPane(null);
setAdvancePending(false);
}
setActiveService((previous) => previous && snapshot.services.includes(previous)
? previous : snapshot.services[0] ?? null);
setReply((previous) => {
@ -121,13 +132,13 @@ export function Dds() {
setReply({ text: "", dirty: false });
setLastReport(null);
setDialing(false);
setMissing([]);
setBounceComment("");
setBounced(false);
setBrief({ address: "", incident: "", request: "" });
setCheckText("");
setZone(null);
setReport(null);
setReportState("idle");
setView("registry");
setView(openCardAfterSwitch.current ? "card" : "registry");
openCardAfterSwitch.current = false;
setPane(null);
setActiveService(null);
}
@ -145,8 +156,11 @@ export function Dds() {
else if (event.type === "phone.report") {
setLastReport(event);
setDialing(false);
setBrief({ address: "", incident: "", request: "" });
setCheckText("");
setError("");
}
else if (event.type === "phone.line") setDialing(false);
},
}).connect();
channel.current = ch;
@ -173,18 +187,21 @@ export function Dds() {
const reports = station?.phone_reports ?? [];
const selectedCrew = station?.crew_selected ?? "";
const latest = lastReport ?? reports.at(-1) ?? null;
const latest = (lastReport?.crew === selectedCrew ? lastReport : null) ??
reports.filter((item) => item.crew === selectedCrew).at(-1) ?? null;
const phoneCall = station?.phone_pending ?? null;
const phoneLines = (station?.phone_lines ?? []).filter((line) => line.crew === selectedCrew);
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 queueCards = station?.queue_cards ?? [];
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")]
const visibleQueueCards = queueCards.filter((item) => !query ||
[item.card_id, item.address ?? "", item.description ?? "", item.incident_type ?? "", item.title]
.some((value) => value.toLocaleLowerCase("ru-RU").includes(query)));
const crewAccepted = Boolean(station?.services.some((service) =>
selectedCrew.startsWith(`${service} —`) &&
@ -271,23 +288,55 @@ export function Dds() {
<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>
{visibleQueueCards.map((item) => {
const received = new Date(item.received_at);
const liveElapsed = item.timer_stopped ? item.elapsed_ms / 1000 : Math.max(
item.elapsed_ms / 1000, (clockNow.getTime() - received.getTime()) / 1000,
);
const openCard = () => {
if (item.active) {
setView("card");
return;
}
openCardAfterSwitch.current = true;
if (!send({ type: "card.open", card_id: item.card_id })) {
openCardAfterSwitch.current = false;
}
};
return <tr key={item.card_id} className={item.active ? "dds-selected" : ""}
aria-current={item.active ? "true" : undefined}>
<td data-label="Связи"><button type="button" className="dds-row-open" onClick={openCard} aria-label="Открыть карточку">▾</button></td>
<td data-label="ЧС">—</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">
<td data-label="Номер"><button type="button" className="dds-row-link" onClick={openCard}>{item.card_id.slice(0, 8).toUpperCase()}</button></td>
<td data-label="Дата">{received.toLocaleDateString("ru-RU")}</td>
<td data-label="Время">{received.toLocaleTimeString("ru-RU")}</td>
<td data-label="Тип происшествия"><span className="dds-cell-value">
<span>{item.title} · {item.incident_type || "тип не указан"}</span>
<small>Описание: {item.description || "—"}</small>
</span></td>
<td data-label="Постр.">{item.victims_count ?? "—"}</td>
<td data-label="Адрес">{item.address || "—"}</td>
<td data-label="Статус службы"><span className="dds-cell-value">
<span>{item.managed_service || "ДДС"}: {SERVICE_STATUS_LABELS[item.service_status]}</span>
<small className={!item.timer_stopped && liveElapsed * 1000 > item.limit_ms ? "state-violated" : ""}>
{item.timer_stopped ? "решение за" : "до первичного решения"} {liveElapsed.toFixed(0)} с
</small>
</span></td>
</tr>})}
{!visibleQueueCards.length && !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>}
{queueCards.length > 1 && <p className="dds-hint">Одновременно в работе {queueCards.length} карточек. Таймер каждой идёт независимо от того, какая строка открыта.</p>}
{station && station.pending_cards_count > 0 && <p className="dds-hint" role="status" aria-live="polite">
{station.next_arrival_in_seconds === null
? `Осталось карточек: ${station.pending_cards_count}. Все карточки уже поступили; необработанные находятся в реестре.`
: station.next_arrival_in_seconds > 0
? `Осталось карточек: ${station.pending_cards_count}. Следующее поступление примерно через ${station.next_arrival_in_seconds} с.`
: queueCards.filter((item) => !item.active).length >= station.max_waiting_cards
? `Осталось карточек: ${station.pending_cards_count}. Очередь ожидания заполнена (${station.max_waiting_cards}); новые поступят, когда освободится место.`
: `Осталось карточек: ${station.pending_cards_count}. Ожидается следующее поступление.`}
</p>}
</section>
</>}
@ -298,7 +347,7 @@ export function Dds() {
<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><span className="dds-call-icon">☎</span><small>телефон на место</small><strong>{field(card, "phone_on_scene")}</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>
@ -314,7 +363,8 @@ export function Dds() {
<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>Получатели карточки: <b>{station?.recipient_services?.join(", ") || field(card, "notify")}</b></div>
<div>Ваша ДДС: <b>{station?.managed_service || "служба не назначена"}</b></div></div>
</div>
<div className="dds-case-stage">
{pane && <div className={`dds-case-drawer dds-case-drawer-${pane}`}>
@ -348,49 +398,75 @@ export function Dds() {
{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 === "учебный сценарий"}
disabled={!isConnected || ended}
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 })} />)}
</> : <p className="dds-empty">Ожидаем состав служб по карточке.</p>}
<p className="dds-hint">Сначала примите карточку или укажите причину отказа. Последующие статусы в занятии ДДС подтверждаются докладами по телефону.</p>
<p className="dds-hint">Сначала примите карточку или укажите причину отказа. Последующие статусы ставятся по фактически полученной информации; телефон — один из возможных каналов.</p>
</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">
{pane === "phone" && <section className="dds-panel dds-phone"><div className="dds-panel-title"><h2>IP-телефон</h2><span>Учебная имитация</span></div>
<p className="dds-hint">Выберите бригаду вручную, свяжитесь со старшим группы и внесите его доклад в карточку.</p>
{pane === "phone" && <section className="dds-panel dds-phone"><div className="dds-panel-title"><h2>IP-телефон</h2><span>SIP/WebRTC + учебный протокол</span></div>
<SipPhone defaultExtension="6101" defaultTarget="6102" title="Реальный локальный канал ДДС" />
<p className="dds-hint">На втором рабочем месте откройте <a href="/phone" target="_blank" rel="noreferrer">телефон старшего группы</a> и зарегистрируйте 6102. Пароли 6101/6102 выдаёт локальный администратор командой <code>make sip-credentials</code>.</p>
<h3 className="dds-protocol-title">Учебный протокол доклада</h3>
<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}
<button type="button" className="dds-dial" disabled={!crewAccepted || !isConnected || ended || dialing || Boolean(phoneCall)}
onClick={() => { if (send({ type: "phone.dial" })) setDialing(true); }}>
☎ {dialing ? "Соединение…" : "Позвонить старшему группы"}</button>
{selectedCrew && !crewAccepted && <p className="dds-hint state-warn">Перед звонком поставьте статус «Принята» для службы этой бригады.</p>}
{phoneCall && <div className="dds-phone-active" aria-live="polite">
<strong>● Соединение установлено · {phoneCall.crew}</strong>
{phoneCall.phase === "dispatched" ? <div className="dds-phone-form">
<p>Передайте сведения из карточки и задачу бригаде. Доклад о выезде появится только после передачи.</p>
<label htmlFor="dds-phone-address">Полный адрес из карточки</label>
<input id="dds-phone-address" value={brief.address} onChange={(event) => setBrief({ ...brief, address: event.target.value })} placeholder="Улица, дом, при наличии квартира и этаж" />
<label htmlFor="dds-phone-incident">Характер происшествия</label>
<textarea id="dds-phone-incident" rows={2} value={brief.incident} onChange={(event) => setBrief({ ...brief, incident: event.target.value })} placeholder="Что случилось и важные обстоятельства" />
<label htmlFor="dds-phone-request">Что требуется от бригады</label>
<input id="dds-phone-request" value={brief.request} onChange={(event) => setBrief({ ...brief, request: event.target.value })} placeholder="Подтвердите выезд и доложите о прибытии" />
<button type="button" disabled={!isConnected || ended || !brief.address.trim() || !brief.incident.trim() || !brief.request.trim()}
onClick={() => send({ type: "phone.brief", ...brief })}>Передать сообщение</button>
</div> : <div className="dds-phone-form">
<p>Уточните обстановку у старшего группы. Ответ станет основанием для следующего статуса.</p>
<label htmlFor="dds-phone-check">Ваш вопрос старшему</label>
<textarea id="dds-phone-check" rows={2} value={checkText} onChange={(event) => setCheckText(event.target.value)} placeholder="Сообщите, пожалуйста, текущее состояние работ по карточке" />
<button type="button" disabled={!isConnected || ended || checkText.trim().length < 8}
onClick={() => send({ type: "phone.check", text: checkText })}>Спросить обстановку</button>
</div>}
<button type="button" className="dds-phone-hangup" onClick={() => send({ type: "phone.hangup" })}>Завершить без доклада</button>
</div>}
{phoneLines.length > 0 && <div className="dds-phone-transcript" aria-live="polite"><strong>Учебный разговор</strong>
{phoneLines.map((line, index) => <p key={`${line.at}-${index}`}><small>{time(line.at)} · {line.speaker === "crew" ? "Старший группы" : "Диспетчер"}</small><br />{line.text}</p>)}
</div>}
{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>}
: <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>}
{pane === "reply" && <section className="dds-panel dds-reply">
<div className="dds-panel-title"><h2>Ответ и комментарий диспетчера</h2>
<div className="dds-panel-title"><h2>Рабочая заметка диспетчера</h2>
<span>{reply.dirty ? "Не сохранено" : "Сохранено"}</span></div>
<label htmlFor="dds-reply-text">Свободный текст по текущей карточке</label>
<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>
<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>
@ -400,20 +476,13 @@ export function Dds() {
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>
<p className="dds-hint">ДДС не исправляет и не возвращает заполненную карточку: её качество контролируют оператор и служба контроля 112.</p>
<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 ? "Завершить карточку и открыть следующую" : "Завершить карточку и занятие"}
{queueCards.length > 1 ? "Закрыть карточку" : "Закрыть карточку и занятие"}
</button>
{cardTotal > 1 && <button type="button" disabled={!isConnected || ended || advancePending}
onClick={() => { if (window.confirm("Завершить занятие досрочно? Остальные карточки не будут пройдены.")) send({ type: "station.finish" }); }}>
@ -434,25 +503,20 @@ export function Dds() {
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>
}}><b>✓ Завершить</b><small>{queueCards.length > 1 ? "Закрыть карточку" : "Занятие"}</small></button>
</nav>
</div>}
</main>;
}
function StatusRow({ service, current, available, disabled, reports, phoneExercise, crewOptions, selectedCrew, onCrew, onSet }: {
function StatusRow({ service, current, available, disabled, crewOptions, selectedCrew, onCrew, onSet }: {
service: string; current: ServiceStatus; available: ServiceStatus[]; disabled: boolean;
reports: PhoneReport[]; phoneExercise: boolean; crewOptions: string[]; selectedCrew: string;
crewOptions: string[]; selectedCrew: string;
onCrew: (crew: string) => void; onSet: (status: ServiceStatus, comment: string) => void;
}) {
const [chosen, setChosen] = useState<ServiceStatus | "">("");
const [comment, setComment] = useState("");
const needsComment = chosen === "declined" || chosen === "refused";
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]);
if (!available.length) return <p className="dds-hint">{serviceTitle(service)}: {SERVICE_STATUS_LABELS[current]}. Дальнейшие статусы недоступны.</p>;
return <div className="dds-status-editor">
<label>Статус
@ -475,10 +539,9 @@ function StatusRow({ service, current, available, disabled, reports, phoneExerci
onChange={(event) => setComment(event.target.value)} />
</label>
<button type="button" className="dds-status-save" aria-label="Сохранить статус"
disabled={disabled || !chosen || (needsComment && !comment.trim()) || !matchingReport}
disabled={disabled || !chosen || (needsComment && !comment.trim())}
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>;
}

View file

@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useAssignTrainee, useCreateGroup, useGroupAnalytics, useGroups, useTrainees } from "@/shared/api/http";
import { useAssignTrainee, useCreateGroup, useGroupAnalytics, useGroupInsight, useGroups, useTrainees } from "@/shared/api/http";
export function Groups() {
const groups = useGroups();
@ -11,6 +11,7 @@ export function Groups() {
const [groupName, setGroupName] = useState("");
const [traineeId, setTraineeId] = useState("");
const report = useGroupAnalytics(groupId);
const insight = useGroupInsight();
useEffect(() => {
if (!groupId && groups.data?.length) setGroupId(groups.data[0].id);
@ -69,9 +70,22 @@ export function Groups() {
</p>
<p className="ref">
Доли ошибок считаются среди курсантов с оценёнными попытками; повторные попытки
одного человека не увеличивают долю. Рекомендации сформированы по правилам,
не нейросетью.
одного человека не увеличивают долю. Табличные рекомендации всегда формируются
воспроизводимыми правилами.
</p>
<section className="group-insight">
<button type="button" disabled={!report.data.scored_attempts || insight.isPending}
onClick={() => groupId && insight.mutate(groupId)}>
{insight.isPending ? "Qwen анализирует агрегаты…" : "Сформировать локальный ИИ-инсайт"}
</button>
<span className="ref">В модель передаются только агрегаты без ФИО и попыток отдельных курсантов.</span>
{insight.data && <div>
<strong>Вывод Qwen</strong>
<p>{insight.data.summary}</p>
<ol>{insight.data.priorities.map((item) => <li key={item}>{item}</li>)}</ol>
</div>}
{insight.isError && <p className="warn">ИИ-инсайт не сформирован; табличная аналитика остаётся доступна.</p>}
</section>
<table className="grid">
<thead><tr><th>Типичная ошибка</th><th>Курсанты</th><th>Рекомендация</th></tr></thead>
<tbody>

View file

@ -17,7 +17,7 @@ import { useObserve } from "@/features/observe/useObserve";
import { InterviewTimer, Timers } from "@/features/timers/Timers";
import { useIncidentGroups, useScenarios, useSessions, useTrainees } from "@/shared/api/http";
import { sessionIdFromUrl } from "@/shared/api/session";
import type { SessionMode } from "@/shared/types/generated";
import type { SessionMode, SessionReport } from "@/shared/types/generated";
const MODES: { value: SessionMode; label: string }[] = [
{ value: "training", label: "тренировочный" },
@ -34,6 +34,14 @@ export function Instructor() {
const [extraScenarioIds, setExtraScenarioIds] = useState<string[]>([]);
const [mode, setMode] = useState<SessionMode>("training");
const [exercise, setExercise] = useState<"dds" | "call" | "card" | "card_dds">("dds");
const [decisionTimeLimit, setDecisionTimeLimit] = useState(30);
const [allowedErrors, setAllowedErrors] = useState(0);
const [requireCorrectGrammar, setRequireCorrectGrammar] = useState(true);
const [scoreWeightsText, setScoreWeightsText] = useState("{}");
const [scoreWeightsError, setScoreWeightsError] = useState("");
const [lessonScoreWeights, setLessonScoreWeights] = useState<Record<string, number>>({});
const [ddsArrivalIntervalSeconds, setDdsArrivalIntervalSeconds] = useState(60);
const [ddsMaxWaiting, setDdsMaxWaiting] = useState(3);
const [trainee, setTrainee] = useState("");
const [traineeId, setTraineeId] = useState("");
const [noteFor, setNoteFor] = useState<string | null>(null);
@ -41,6 +49,11 @@ export function Instructor() {
// ссылка на идущее занятие не должна запускать его заново и сбрасывать.
const toStart = useRef<string | null>(null);
const [noteText, setNoteText] = useState("");
const [overrideReport, setOverrideReport] = useState<SessionReport | null>(null);
const [overrideScore, setOverrideScore] = useState("");
const [overrideComment, setOverrideComment] = useState("");
const [overrideError, setOverrideError] = useState("");
const [overrideBusy, setOverrideBusy] = useState(false);
const scenarios = useScenarios();
const incidentGroups = useIncidentGroups();
@ -49,12 +62,27 @@ export function Instructor() {
const session = useObserve(sessionId);
const control = useControl(sessionId);
useEffect(() => {
if (!session.report) return;
setOverrideReport(null);
setOverrideScore(String(session.report.score_final));
setOverrideComment(session.report.override_comment ?? "");
}, [session.report?.session_id, session.report?.score_final, session.report?.override_comment]);
const visibleScenarios = (scenarios.data ?? []).filter((item) =>
(!ticketFilter || item.ticket === Number(ticketFilter)) &&
(!categoryFilter || item.ekp_group === Number(categoryFilter)) &&
(!scenarioSearch.trim() || `${item.title} ${item.id}`.toLocaleLowerCase("ru-RU")
.includes(scenarioSearch.trim().toLocaleLowerCase("ru-RU")))
).sort((a, b) => (a.ticket ?? 99) - (b.ticket ?? 99) || (a.position ?? 0) - (b.position ?? 0));
const selectedTicketScenarios = ticketFilter
? (scenarios.data ?? []).filter((item) => item.ticket === Number(ticketFilter))
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
: [];
const [wholeTicket, setWholeTicket] = useState(false);
const canStartSelectedExercise = exercise === "dds" && wholeTicket
? selectedTicketScenarios.length === 3
: Boolean(scenarioId);
useEffect(() => {
if (!visibleScenarios.some((item) => item.id === scenarioId)) {
@ -64,6 +92,21 @@ export function Instructor() {
function startLesson() {
if (trainees.data?.length && !traineeId) return;
let parsedWeights: unknown;
try {
parsedWeights = JSON.parse(scoreWeightsText);
} catch {
setScoreWeightsError("Введите корректный JSON, например {\"dds_ack\": 2}.");
return;
}
if (!parsedWeights || typeof parsedWeights !== "object" || Array.isArray(parsedWeights) ||
Object.values(parsedWeights).some((value) => typeof value !== "number" ||
!Number.isFinite(value) || value < 0 || value > 10)) {
setScoreWeightsError("Задайте объект «ключ метрики: вес от 0 до 10».");
return;
}
setScoreWeightsError("");
setLessonScoreWeights(parsedWeights as Record<string, number>);
const id = crypto.randomUUID();
// Номер занятия живёт в адресе: монитор и АРМ курсанта открываются ссылкой.
window.history.replaceState(null, "", `/instructor?session=${id}`);
@ -71,18 +114,64 @@ export function Instructor() {
setSessionId(id);
}
async function saveScoreOverride(report: SessionReport) {
const score = Number(overrideScore);
if (!overrideScore.trim() || !Number.isFinite(score) || score < 0 || score > 100 || !overrideComment.trim()) {
setOverrideError("Укажите оценку от 0 до 100 и обязательное обоснование.");
return;
}
setOverrideBusy(true);
setOverrideError("");
try {
const response = await fetch(`/api/sessions/${report.session_id}/report`, {
method: "PATCH",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ score_final: score, comment: overrideComment.trim() }),
});
const data = 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 : "Не удалось сохранить корректировку.");
}
const updated = data as SessionReport;
setOverrideReport(updated);
setOverrideScore(String(updated.score_final));
setOverrideComment(updated.override_comment ?? "");
} catch (cause) {
setOverrideError(cause instanceof Error ? cause.message : "Не удалось сохранить корректировку.");
} finally {
setOverrideBusy(false);
}
}
// Занятие запускается, когда канал управления открылся.
useEffect(() => {
if (!sessionId || control.status !== "open" || toStart.current !== sessionId) return;
toStart.current = null;
const selected = trainees.data?.find((item) => item.id === traineeId);
const primaryScenarioId = exercise === "dds" && wholeTicket
? selectedTicketScenarios[0]?.id ?? scenarioId : scenarioId;
const scenarioIds = exercise === "dds"
? [scenarioId, ...extraScenarioIds.filter((id) => id !== scenarioId)] : undefined;
control.start(scenarioId, mode, selected?.name || trainee || "Демо-курсант",
? (wholeTicket
? selectedTicketScenarios.map((item) => item.id)
: [scenarioId, ...extraScenarioIds.filter((id) => id !== scenarioId)])
.filter((id, index, all) => id && all.indexOf(id) === index)
: undefined;
control.start(primaryScenarioId, mode, selected?.name || trainee || "Демо-курсант",
exercise === "card_dds" ? "card" : exercise,
undefined, selected?.id, scenarioIds && scenarioIds.length > 1 ? scenarioIds : undefined,
exercise === "card_dds");
}, [sessionId, control, scenarioId, extraScenarioIds, mode, trainee, traineeId, trainees.data, exercise]);
undefined, selected?.id, selected?.service ?? undefined,
scenarioIds && scenarioIds.length > 1 ? scenarioIds : undefined,
ddsArrivalIntervalSeconds, ddsMaxWaiting,
exercise === "card_dds", {
decision_time_limit_seconds: decisionTimeLimit,
allowed_errors: allowedErrors,
require_correct_grammar: requireCorrectGrammar,
score_weights: lessonScoreWeights,
});
}, [sessionId, control, scenarioId, extraScenarioIds, mode, trainee, traineeId, trainees.data,
exercise, decisionTimeLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios,
ddsArrivalIntervalSeconds, ddsMaxWaiting, lessonScoreWeights]);
const links = sessionId
? {
@ -97,6 +186,10 @@ export function Instructor() {
<StandStatus />
<ModeBanner mode={session.mode ?? mode} />
<h1>Рабочее место преподавателя</h1>
<p className="workspace-links">
<a href="#ai-scenario-editor">Перейти к ИИ-формирователю сценария ↓</a>
<a href="/materials">Учебные материалы и назначения →</a>
</p>
<div className="instructor-layout">
<section>
@ -112,14 +205,17 @@ export function Instructor() {
{incidentGroups.data?.groups.map((group) =>
<option key={group.id} value={group.id}>{group.title}</option>)}
</select>
<select aria-label="Билет заказчика" value={ticketFilter} onChange={(event) => setTicketFilter(event.target.value)}>
<select aria-label="Билет заказчика" value={ticketFilter} onChange={(event) => {
setTicketFilter(event.target.value);
setWholeTicket(false);
}}>
<option value="">Все 32 билета</option>
{Array.from({ length: 32 }, (_, index) => <option key={index} value={index + 1}>Билет {index + 1}</option>)}
</select>
<input type="search" aria-label="Поиск сценария" value={scenarioSearch}
placeholder="Название или ID карточки" onChange={(event) => setScenarioSearch(event.target.value)} />
</div>
<select value={scenarioId} onChange={(event) => {
<select value={scenarioId} disabled={exercise === "dds" && wholeTicket} onChange={(event) => {
setScenarioId(event.target.value);
setExtraScenarioIds((previous) => previous.filter((id) => id !== event.target.value));
}}>
@ -131,9 +227,22 @@ export function Instructor() {
))}
</select>
<small className="ref">{visibleScenarios.length} из {scenarios.data?.length ?? 0} карточек (96 — билеты заказчика)</small>
{exercise === "dds" && <details className="scenario-queue">
{exercise === "dds" && ticketFilter && <label className="mode-choice">
<input type="checkbox" checked={wholeTicket && selectedTicketScenarios.length === 3}
disabled={selectedTicketScenarios.length !== 3}
onChange={(event) => {
if (event.target.checked && selectedTicketScenarios[0]) {
setScenarioId(selectedTicketScenarios[0].id);
setExtraScenarioIds([]);
}
setWholeTicket(event.target.checked);
}} />
{` Запустить весь билет ${ticketFilter} целиком (3 вызова)`}
{selectedTicketScenarios.length !== 3 && <small className="ref"> — в библиотеке найдено {selectedTicketScenarios.length} из 3 вызовов</small>}
</label>}
{exercise === "dds" && !wholeTicket && <details className="scenario-queue">
<summary>Дополнительные карточки ({extraScenarioIds.length})</summary>
<p className="ref">Карточки поступят последовательно после завершения текущей.</p>
<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={() =>
@ -179,7 +288,7 @@ export function Instructor() {
<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>
<option key={item.id} value={item.id}>{item.name}{item.group ? ` · ${item.group}` : ""}{item.service ? ` · ${item.service}` : " · служба не назначена"}</option>
))}
</select>
) : (
@ -191,11 +300,57 @@ export function Instructor() {
)}
</td>
</tr>
<tr>
<th>Критерии занятия</th>
<td>
{exercise === "dds" && <>
<label className="lesson-criterion">
Интервал поступления карточек, секунд (0 — без задержки; действует лимит ожидания)
<input aria-label="Интервал поступления карточек ДДС, секунд" type="number" min={0} max={300}
value={ddsArrivalIntervalSeconds} onChange={(event) => setDdsArrivalIntervalSeconds(
Math.max(0, Math.min(300, Number(event.target.value) || 0)))} />
</label>
<label className="lesson-criterion">
Максимум ожидающих карточек
<input aria-label="Максимум ожидающих карточек ДДС" type="number" min={1} max={10}
value={ddsMaxWaiting} onChange={(event) => setDdsMaxWaiting(
Math.max(1, Math.min(10, Number(event.target.value) || 1)))} />
</label>
</>}
<label className="lesson-criterion">
Решение ДДС, секунд
<input aria-label="Лимит решения ДДС, секунд" type="number" min={5} max={300}
value={decisionTimeLimit} onChange={(event) => setDecisionTimeLimit(
Math.max(5, Math.min(300, Number(event.target.value) || 30)))} />
</label>
<label className="lesson-criterion">
Допустимо нарушенных метрик
<input aria-label="Допустимое число ошибок" type="number" min={0} max={50}
value={allowedErrors} onChange={(event) => setAllowedErrors(
Math.max(0, Math.min(50, Number(event.target.value) || 0)))} />
</label>
{exercise !== "dds" && <label className="mode-choice">
<input type="checkbox" checked={requireCorrectGrammar}
onChange={(event) => setRequireCorrectGrammar(event.target.checked)} />
Грамматика карточки оператора 112 входит в оценку
</label>}
<label className="lesson-criterion">
Переопределения весов метрик (JSON; 0 отключает метрику)
<textarea aria-label="Переопределения весов метрик" rows={3}
value={scoreWeightsText}
onChange={(event) => { setScoreWeightsText(event.target.value); setScoreWeightsError(""); }}
placeholder={'{"address": 3, "dds_ack": 2}'} spellCheck={false} />
<small className="ref">Оставьте {"{}"} для стандартной шкалы. Диапазон каждого веса — 0–10; допустимые ключи проверяются сервером.</small>
</label>
{scoreWeightsError && <p className="violated" role="alert">{scoreWeightsError}</p>}
<small className="ref">Нормативы ГОСТ для приёма вызова не изменяются.</small>
</td>
</tr>
</tbody>
</table>
<p>
<button type="button" className="start" onClick={startLesson}
disabled={!scenarioId || trainees.isLoading || Boolean(trainees.data?.length && !traineeId)}>
disabled={!canStartSelectedExercise || trainees.isLoading || Boolean(trainees.data?.length && !traineeId)}>
Начать занятие
</button>
</p>
@ -209,7 +364,10 @@ export function Instructor() {
{sessionId && !session.ended && (
exercise === "call" && <Director onInject={control.inject} disabled={control.status !== "open"} error={session.error} />
)}
{links && (
{links && !session.title && !session.error && (
<p className="ref" role="status">Запускаем занятие и готовим ссылки на рабочие места…</p>
)}
{links && session.title && (
<table className="grid">
<tbody>
<tr><th>АРМ курсанта</th><td><a href={exercise === "dds" ? links.dds : links.trainee}>открыть {exercise === "dds" ? "ДДС" : "112"}</a></td></tr>
@ -284,12 +442,37 @@ export function Instructor() {
</section>
</div>
<ScenarioEditor scenarios={scenarios.data ?? []} onApproved={async (id) => {
setScenarioId(id);
<ScenarioEditor scenarios={scenarios.data ?? []} selectedScenarioId={scenarioId} onApproved={async (id) => {
setTicketFilter("");
setCategoryFilter("");
setScenarioSearch("");
await scenarios.refetch();
}} />
setScenarioId(id);
}} onLibraryChanged={async () => { await scenarios.refetch(); }} />
{session.report && <Debrief report={session.report} />}
{session.report && <>
<section className="debrief">
<h2>Корректировка итоговой оценки</h2>
<p className="ref">Автооценка {session.report.score_auto.toFixed(1)} / 100. Изменение сохраняется в журнале с вашим логином и обоснованием.</p>
<label>Итоговая оценка, 0–100
<input type="number" min={0} max={100} step={0.1}
value={overrideScore}
onChange={(event) => setOverrideScore(event.target.value)} />
</label>
<label>Обязательное обоснование
<textarea rows={3} maxLength={2000} value={overrideComment}
onChange={(event) => setOverrideComment(event.target.value)}
placeholder="Почему итоговая оценка отличается от автоматической" />
</label>
<button type="button" disabled={overrideBusy || !overrideComment.trim()}
onClick={() => void saveScoreOverride(session.report!)}>
{overrideBusy ? "Сохраняем…" : "Сохранить оценку с аудитом"}
</button>
{overrideError && <p className="violated" role="alert">{overrideError}</p>}
</section>
<Debrief report={overrideReport?.session_id === session.report.session_id
? overrideReport : session.report} />
</>}
</main>
);
}

View file

@ -0,0 +1,229 @@
import { useState, type FormEvent } from "react";
import { useMe } from "@/features/auth/useAuth";
import {
useArchiveMaterial,
useAssignMaterial,
useAssignMaterialGroup,
useCompleteMaterial,
useCreateMaterial,
useGroups,
useMaterials,
useScenarios,
useStartMaterialPractice,
useTrainees,
useUpdateMaterial,
type LearningMaterial,
} from "@/shared/api/http";
import type { Level } from "@/shared/types/generated";
async function encodeFile(file: File): Promise<string> {
const bytes = new Uint8Array(await file.arrayBuffer());
let binary = "";
for (let offset = 0; offset < bytes.length; offset += 32_768) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + 32_768));
}
return btoa(binary);
}
function size(value: number): string {
if (value < 1024) return `${value} Б`;
return `${(value / 1024).toFixed(value < 1024 * 100 ? 1 : 0)} КБ`;
}
export function Materials() {
const me = useMe();
const instructor = me.data?.role === "instructor";
const trainee = me.data?.role === "trainee";
const [showArchived, setShowArchived] = useState(false);
const materials = useMaterials(showArchived && !trainee);
const scenarios = useScenarios();
const trainees = useTrainees(instructor);
const groups = useGroups(instructor);
const create = useCreateMaterial();
const update = useUpdateMaterial();
const archive = useArchiveMaterial();
const assign = useAssignMaterial();
const assignGroup = useAssignMaterialGroup();
const complete = useCompleteMaterial();
const startPractice = useStartMaterialPractice();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [level, setLevel] = useState<Level>("L1");
const [kind, setKind] = useState<"text" | "file">("text");
const [body, setBody] = useState("");
const [scenarioId, setScenarioId] = useState("");
const [file, setFile] = useState<File | null>(null);
const [traineeId, setTraineeId] = useState("");
const [groupId, setGroupId] = useState("");
const [notice, setNotice] = useState("");
const [editing, setEditing] = useState<Pick<LearningMaterial,
"id" | "title" | "description" | "level" | "body" | "scenario_id"> | null>(null);
async function submit(event: FormEvent) {
event.preventDefault();
setNotice("");
const content = file ? await encodeFile(file) : undefined;
create.mutate({
title,
description,
level,
kind,
body: kind === "text" ? body : "",
scenario_id: scenarioId || null,
file_name: file?.name,
media_type: file?.type || "application/octet-stream",
content_base64: content,
}, {
onSuccess: () => {
setTitle(""); setDescription(""); setBody(""); setFile(null);
setNotice("Материал сохранён локально.");
},
});
}
return <main className="page materials-page">
<header className="materials-header">
<div>
<h1>Учебные материалы</h1>
<p>Локальная справочная база и назначения курсантам без внешних сервисов.</p>
</div>
<a href={instructor ? "/instructor" : "/profile"}>← Назад к рабочему месту</a>
</header>
{instructor && <section className="material-create">
<h2>Создать материал</h2>
<form onSubmit={(event) => void submit(event)}>
<label>Название<input value={title} required minLength={3}
onChange={(event) => setTitle(event.target.value)} /></label>
<label>Описание<input value={description}
onChange={(event) => setDescription(event.target.value)} /></label>
<div className="material-create-row">
<label>Сложность<select value={level} onChange={(event) => setLevel(event.target.value as Level)}>
<option value="L1">L1 · базовый</option><option value="L2">L2 · рабочий</option>
<option value="L3">L3 · сложный</option>
</select></label>
<label>Связанная практика<select value={scenarioId} onChange={(event) => setScenarioId(event.target.value)}>
<option value="">без сценария</option>
{scenarios.data?.map((scenario) => <option key={scenario.id} value={scenario.id}>
{scenario.title} · {scenario.level}
</option>)}
</select></label>
</div>
<div className="mode-row">
<label className="mode-choice"><input type="radio" checked={kind === "text"}
onChange={() => setKind("text")} /> Текстовая памятка</label>
<label className="mode-choice"><input type="radio" checked={kind === "file"}
onChange={() => setKind("file")} /> Локальный файл</label>
</div>
{kind === "text"
? <label>Содержание<textarea value={body} required rows={7}
onChange={(event) => setBody(event.target.value)} /></label>
: <label>Файл до 5 МБ<input type="file" required
onChange={(event) => setFile(event.target.files?.[0] ?? null)} /></label>}
<button type="submit" disabled={create.isPending || !title || (kind === "text" ? !body : !file)}>
{create.isPending ? "Сохраняем…" : "Сохранить материал"}
</button>
{notice && <span className="state-ok">{notice}</span>}
{create.isError && <span className="violated">Не удалось сохранить материал.</span>}
</form>
</section>}
<section>
<div className="materials-list-head">
<h2>{trainee ? "Назначено мне" : "Библиотека материалов"}</h2>
{instructor && <label className="mode-choice"><input type="checkbox" checked={showArchived}
onChange={(event) => setShowArchived(event.target.checked)} /> Показать архив</label>}
</div>
{instructor && <div className="material-assignment-bar">
<label>Курсант<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}</option>)}
</select></label>
<label>Группа<select value={groupId} onChange={(event) => setGroupId(event.target.value)}>
<option value="">выберите группу</option>
{groups.data?.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select></label>
<span>Выберите получателя, затем нажмите «Назначить» у материала.</span>
</div>}
<div className="material-cards">
{materials.data?.map((item) => <article key={item.id} className={!item.active ? "material-card archived" : "material-card"}>
<header><span className="material-level">{item.level}</span><h3>{item.title}</h3>
{item.completed_at && <strong className="state-ok">✓ изучено</strong>}</header>
{editing?.id === item.id ? <div className="material-edit">
<label>Название<input value={editing.title}
onChange={(event) => setEditing({ ...editing, title: event.target.value })} /></label>
<label>Описание<input value={editing.description}
onChange={(event) => setEditing({ ...editing, description: event.target.value })} /></label>
<div className="material-create-row">
<label>Сложность<select value={editing.level}
onChange={(event) => setEditing({ ...editing, level: event.target.value as Level })}>
<option value="L1">L1</option><option value="L2">L2</option><option value="L3">L3</option>
</select></label>
<label>Практика<select value={editing.scenario_id ?? ""}
onChange={(event) => setEditing({ ...editing, scenario_id: event.target.value || null })}>
<option value="">без сценария</option>
{scenarios.data?.map((scenario) => <option key={scenario.id} value={scenario.id}>{scenario.title}</option>)}
</select></label>
</div>
{item.kind === "text" && <label>Содержание<textarea rows={6} value={editing.body}
onChange={(event) => setEditing({ ...editing, body: event.target.value })} /></label>}
<div className="material-edit-actions">
<button type="button" disabled={!editing.title.trim() || (item.kind === "text" && !editing.body.trim()) || update.isPending}
onClick={() => update.mutate({ id: item.id, patch: {
title: editing.title, description: editing.description, level: editing.level,
body: editing.body, scenario_id: editing.scenario_id,
} }, { onSuccess: () => setEditing(null) })}>
Сохранить изменения
</button>
<button type="button" onClick={() => setEditing(null)}>Отмена</button>
</div>
</div> : <>
{item.description && <p>{item.description}</p>}
{item.kind === "text" && <div className="material-body">{item.body}</div>}
</>}
{item.kind === "file" && <p>
<a href={`/api/materials/${item.id}/download`}>Скачать {item.file_name}</a>
<span className="ref"> · {size(item.file_size)} · SHA-256 {item.file_sha256?.slice(0, 12)}…</span>
</p>}
{item.scenario_id && <p className="ref">Связанная практика: {item.scenario_id}</p>}
{instructor && <footer>
<span>Назначено: {item.assignment_count}</span>
<button type="button" onClick={() => setEditing({
id: item.id, title: item.title, description: item.description,
level: item.level, body: item.body, scenario_id: item.scenario_id,
})}>Редактировать</button>
{item.active && <button type="button" disabled={!traineeId || assign.isPending}
onClick={() => assign.mutate({ materialId: item.id, traineeId })}>Назначить курсанту</button>}
{item.active && <button type="button" disabled={!groupId || assignGroup.isPending}
onClick={() => assignGroup.mutate({ materialId: item.id, groupId })}>Назначить группе</button>}
{item.active
? <button type="button" className="danger" onClick={() => archive.mutate(item.id)}>В архив</button>
: <button type="button" onClick={() => update.mutate({ id: item.id, patch: { active: true } })}>Восстановить</button>}
</footer>}
{trainee && !item.completed_at && <footer>
<button type="button" onClick={() => complete.mutate(item.id)} disabled={complete.isPending}>
Отметить изученным
</button>
</footer>}
{trainee && item.scenario_id && <footer>
<button type="button" className="primary" disabled={startPractice.isPending}
onClick={() => startPractice.mutate(item.id, {
onSuccess: (started) => window.location.assign(started.path),
})}>
Начать самостоятельную практику
</button>
{startPractice.isError && <span className="violated">Практика сейчас недоступна.</span>}
</footer>}
</article>)}
</div>
{!materials.isLoading && materials.data?.length === 0 && <p className="ref">
{trainee ? "Преподаватель пока не назначил материалы." : "Материалов пока нет."}
</p>}
{materials.isError && <p className="violated">Материалы не загрузились.</p>}
</section>
</main>;
}

View file

@ -0,0 +1,9 @@
import { SipPhone } from "@/features/sip/SipPhone";
export function Phone() {
return <main className="page phone-page">
<h1>IP-телефон старшего группы</h1>
<p>Откройте этот экран на втором рабочем месте, зарегистрируйте номер 6102 и примите вызов ДДС 6101.</p>
<SipPhone defaultExtension="6102" defaultTarget="6101" title="Телефон старшего группы" />
</main>;
}

View file

@ -4,9 +4,10 @@
// планирования подготовки. Работа над ошибками превращается в измеримый цикл:
// посмотрел разбор → попробовал снова → увидел дельту (docs/product/MODES.md).
import { useState } from "react";
import { useEffect, useState } from "react";
import { COMPETENCY_LABELS, Radar } from "@/features/debrief/Radar";
import { useMe } from "@/features/auth/useAuth";
import { clock } from "@/features/timers/clock";
import { useProfile, useTrainees } from "@/shared/api/http";
@ -25,14 +26,22 @@ function sign(value: number | null, format: (value: number) => string): string {
export function Profile() {
const url = new URLSearchParams(location.search);
const [traineeId, setTraineeId] = useState<string | null>(url.get("trainee"));
const trainees = useTrainees();
const me = useMe();
const isTrainee = me.data?.role === "trainee";
const trainees = useTrainees(!isTrainee);
const profile = useProfile(traineeId);
useEffect(() => {
if (isTrainee && me.data?.trainee_id) setTraineeId(me.data.trainee_id);
}, [isTrainee, me.data?.trainee_id]);
return (
<main className="page">
<h1>Профиль курсанта</h1>
<p>
<p><a href="/materials">Открыть назначенные учебные материалы →</a></p>
{!isTrainee && <p>
<select value={traineeId ?? ""} onChange={(event) => setTraineeId(event.target.value || null)}>
<option value="">— выберите курсанта —</option>
{trainees.data?.map((trainee) => (
@ -41,7 +50,7 @@ export function Profile() {
</option>
))}
</select>
</p>
</p>}
{profile.data && (
<div className="columns">
@ -59,6 +68,11 @@ export function Profile() {
<p className="ref">
радар — среднее по попыткам; подсказок использовано всего: {profile.data.hints_total}
</p>
{profile.data.attempts.some((attempt) => attempt.score !== null) && (
<p><a href={`/api/trainees/${profile.data.trainee.id}/certificate.pdf`}>
Скачать сертификат PDF
</a></p>
)}
<h3>Дельта попыток</h3>
<table className="grid">
@ -100,6 +114,11 @@ export function Profile() {
` · ${Object.entries(attempt.codes).map(([code, count]) => `${code}×${count}`).join(", ")}`}
</div>
<a href={`/wall?session=${attempt.session_id}`}>разбор</a>
{attempt.recording_available && <>
{" · "}<a href={`/api/sessions/${attempt.session_id}/recording.wav`}>
запись WAV
</a>
</>}
</td>
</tr>
))}

View file

@ -31,6 +31,7 @@ export interface Health {
models_ready: boolean;
embeddings_ready: boolean;
scenarios_loaded: number;
sessions_restored?: number;
offline: boolean;
demo_no_db?: boolean;
}
@ -42,10 +43,13 @@ export interface ScenarioSummary {
level: Level;
topics: string[];
modes: SessionMode[];
signs: string[];
incident_code: string | null;
dds: string | null;
ticket: number | null;
position: number | null;
ekp_group: number | null;
can_manage: boolean;
}
export interface SessionInfo {
@ -78,6 +82,7 @@ export interface TraineeInfo {
id: string;
name: string;
group: string | null;
service: string | null;
}
export interface Attempt {
@ -92,6 +97,7 @@ export interface Attempt {
facts_required: number | null;
hints: number | null;
codes: Record<string, number>;
recording_available: boolean;
}
export interface Profile {
@ -139,8 +145,12 @@ export function useSigns(prefix: string[], group: number | null = null) {
});
}
export const useTrainees = () =>
useQuery({ queryKey: ["trainees"], queryFn: () => request<TraineeInfo[]>("/api/trainees") });
export const useTrainees = (enabled = true) =>
useQuery({
queryKey: ["trainees"],
queryFn: () => request<TraineeInfo[]>("/api/trainees"),
enabled,
});
export interface GroupInfo {
id: string;
@ -163,8 +173,19 @@ export interface GroupAnalytics {
}[];
}
export const useGroups = () =>
useQuery({ queryKey: ["groups"], queryFn: () => request<GroupInfo[]>("/api/groups") });
export interface GroupInsight {
summary: string;
priorities: string[];
source: "local_qwen";
personal_data_sent: false;
}
export const useGroups = (enabled = true) =>
useQuery({
queryKey: ["groups"],
queryFn: () => request<GroupInfo[]>("/api/groups"),
enabled,
});
export function useCreateGroup() {
const cache = useQueryClient();
@ -196,6 +217,14 @@ export const useGroupAnalytics = (groupId: string | null) =>
refetchInterval: 15_000,
});
export function useGroupInsight() {
return useMutation({
mutationFn: (groupId: string) => request<GroupInsight>(
`/api/groups/${groupId}/analytics/insight`, { method: "POST" },
),
});
}
export const useProfile = (traineeId: string | null) =>
useQuery({
queryKey: ["profile", traineeId],
@ -208,3 +237,121 @@ export const useCreateSession = () =>
mutationFn: (body: { scenario_id: string; mode: SessionMode; trainee?: string; group?: string }) =>
request<SessionInfo>("/api/sessions", { method: "POST", body: JSON.stringify(body) }),
});
export interface LearningMaterial {
id: string;
title: string;
description: string;
level: Level;
kind: "text" | "file";
body: string;
scenario_id: string | null;
file_name: string | null;
media_type: string | null;
file_size: number;
file_sha256: string | null;
active: boolean;
created_by: string;
created_at: string;
assigned_at: string | null;
completed_at: string | null;
assignment_count: number;
}
export interface MaterialCreateInput {
title: string;
description: string;
level: Level;
kind: "text" | "file";
body: string;
scenario_id: string | null;
file_name?: string;
media_type?: string;
content_base64?: string;
}
export const useMaterials = (includeArchived = false) => useQuery({
queryKey: ["materials", includeArchived],
queryFn: () => request<LearningMaterial[]>(
`/api/materials${includeArchived ? "?include_archived=true" : ""}`,
),
});
function invalidateMaterials(cache: ReturnType<typeof useQueryClient>) {
return cache.invalidateQueries({ queryKey: ["materials"] });
}
export function useCreateMaterial() {
const cache = useQueryClient();
return useMutation({
mutationFn: (body: MaterialCreateInput) => request<LearningMaterial>("/api/materials", {
method: "POST", body: JSON.stringify(body),
}),
onSuccess: () => void invalidateMaterials(cache),
});
}
export function useUpdateMaterial() {
const cache = useQueryClient();
return useMutation({
mutationFn: ({ id, patch }: { id: string; patch: Partial<LearningMaterial> }) =>
request<LearningMaterial>(`/api/materials/${id}`, {
method: "PATCH", body: JSON.stringify(patch),
}),
onSuccess: () => void invalidateMaterials(cache),
});
}
export function useArchiveMaterial() {
const cache = useQueryClient();
return useMutation({
mutationFn: (id: string) => request<LearningMaterial>(`/api/materials/${id}`, {
method: "DELETE",
}),
onSuccess: () => void invalidateMaterials(cache),
});
}
export function useAssignMaterial() {
const cache = useQueryClient();
return useMutation({
mutationFn: ({ materialId, traineeId }: { materialId: string; traineeId: string }) =>
request<LearningMaterial>(`/api/materials/${materialId}/assign/${traineeId}`, {
method: "PUT",
}),
onSuccess: () => void invalidateMaterials(cache),
});
}
export function useAssignMaterialGroup() {
const cache = useQueryClient();
return useMutation({
mutationFn: ({ materialId, groupId }: { materialId: string; groupId: string }) =>
request<{ material_id: string; assigned: number }>(
`/api/materials/${materialId}/assign-group/${groupId}`, { method: "PUT" },
),
onSuccess: () => void invalidateMaterials(cache),
});
}
export function useCompleteMaterial() {
const cache = useQueryClient();
return useMutation({
mutationFn: (id: string) => request<LearningMaterial>(`/api/materials/${id}/complete`, {
method: "POST",
}),
onSuccess: () => void invalidateMaterials(cache),
});
}
export function useStartMaterialPractice() {
return useMutation({
mutationFn: (id: string) => request<{
session_id: string;
scenario_id: string;
mode: "self";
exercise: "card";
path: string;
}>(`/api/materials/${id}/start`, { method: "POST" }),
});
}

View file

@ -15,25 +15,33 @@ import type {
export type ChannelStatus = "connecting" | "open" | "reconnecting" | "closed";
interface ChannelOptions<In> {
interface ChannelOptions<In, Out> {
onEvent?: (event: In) => void;
/** Бинарный кадр: звук звонящего, без обёртки JSON. */
onBinary?: (frame: ArrayBuffer) => void;
onStatus?: (status: ChannelStatus) => void;
/** Ключ безопасного идемпотентного события, которое можно повторить после
* краткого разрыва. null запрещает буферизацию команды. */
queueKey?: (event: Out) => string | null;
}
const RECONNECT_MIN_MS = 500;
const RECONNECT_MAX_MS = 5_000;
const BUFFER_MAX_AGE_MS = 35_000;
const BUFFER_MAX_ITEMS = 100;
interface Buffered<Out> { key: string; event: Out; at: number }
export class Channel<In extends { type: string }, Out extends { type: string }> {
private socket: WebSocket | null = null;
private attempt = 0;
private timer: ReturnType<typeof setTimeout> | null = null;
private closedByUs = false;
private buffered: Buffered<Out>[] = [];
constructor(
private readonly path: string,
private readonly options: ChannelOptions<In> = {},
private readonly options: ChannelOptions<In, Out> = {},
) {}
connect(): this {
@ -51,6 +59,10 @@ export class Channel<In extends { type: string }, Out extends { type: string }>
socket.onopen = () => {
this.attempt = 0;
this.options.onStatus?.("open");
const now = Date.now();
const pending = this.buffered.filter((item) => now - item.at <= BUFFER_MAX_AGE_MS);
this.buffered = [];
for (const item of pending) socket.send(JSON.stringify(item.event));
};
socket.onmessage = (message) => {
if (typeof message.data !== "string") {
@ -78,11 +90,32 @@ export class Channel<In extends { type: string }, Out extends { type: string }>
return this.socket?.readyState === WebSocket.OPEN;
}
// Пока соединения нет, события не копятся: истина — на сервере, и после
// переподключения интерфейс получит актуальное состояние, а не очередь старых правок.
send(event: Out): boolean {
if (!this.isOpen) return false;
this.socket!.send(JSON.stringify(event));
if (this.isOpen) {
this.socket!.send(JSON.stringify(event));
return true;
}
const key = this.options.queueKey?.(event) ?? null;
if (!key) return false;
const existing = this.buffered.find((item) => item.key === key);
if (existing) {
// Частые kio.patch объединяем: последнее значение каждого поля важнее,
// но правки разных полей не должны затереть друг друга.
if (existing.event.type === "kio.patch" && event.type === "kio.patch") {
const previousPatch = existing.event as unknown as { fields: Record<string, unknown> };
const nextPatch = event as unknown as { fields: Record<string, unknown> };
existing.event = {
...existing.event,
fields: { ...previousPatch.fields, ...nextPatch.fields },
} as Out;
} else {
existing.event = event;
}
existing.at = Date.now();
} else {
this.buffered.push({ key, event, at: Date.now() });
if (this.buffered.length > BUFFER_MAX_ITEMS) this.buffered.shift();
}
return true;
}
@ -96,22 +129,36 @@ export class Channel<In extends { type: string }, Out extends { type: string }>
this.closedByUs = true;
if (this.timer) clearTimeout(this.timer);
this.socket?.close();
this.buffered = [];
}
}
export const callChannel = (sessionId: string, options?: ChannelOptions<ServerToTrainee>) =>
new Channel<ServerToTrainee, TraineeToServer>(`/ws/call/${sessionId}`, options);
export const callChannel = (
sessionId: string,
options?: ChannelOptions<ServerToTrainee, TraineeToServer>,
) => new Channel<ServerToTrainee, TraineeToServer>(`/ws/call/${sessionId}`, {
...options,
queueKey: (event) => event.type === "kio.patch" ? "kio.patch" : null,
});
/** Наблюдатель: отправлять нечего и нельзя — `Out` равен `never`. */
export const observeChannel = (sessionId: string, options?: ChannelOptions<ServerToObserver>) =>
export const observeChannel = (sessionId: string, options?: ChannelOptions<ServerToObserver, never>) =>
new Channel<ServerToObserver, never>(`/ws/observe/${sessionId}`, options);
/** Станция ДДС: только JSON, аудио здесь нет вообще. */
export const stationChannel = (
sessionId: string,
role: string,
options?: ChannelOptions<ServerToStation>,
) => new Channel<ServerToStation, StationToServer>(`/ws/station/${sessionId}?role=${role}`, options);
options?: ChannelOptions<ServerToStation, StationToServer>,
) => new Channel<ServerToStation, StationToServer>(`/ws/station/${sessionId}?role=${role}`, {
...options,
queueKey: (event) => {
if (event.type === "card.reply") return `card.reply:${event.card_id}`;
if (event.type === "crew.select") return "crew.select";
if (event.type === "zone.decision") return "zone.decision";
return null;
},
});
/** Преподаватель: только передача, входящих в этом канале нет. */
export const controlChannel = (sessionId: string, options?: { onStatus?: (status: ChannelStatus) => void }) =>

View file

@ -93,6 +93,12 @@ export interface CardNext {
card_id: string;
}
/** Открыть одну из уже выданных карточек, не меняя её таймер. */
export interface CardOpen {
type: "card.open";
card_id: string;
}
/** Снимок КИО. После передачи не меняется — оператор не дописывает задним числом. */
export interface CardReceived {
type: "card.received";
@ -171,6 +177,25 @@ export interface DdsDispatch {
service?: DDSCode | null;
}
/** Строка общей очереди ДДС; таймер каждой карточки идёт независимо. */
export interface DdsQueueCard {
card_id: string;
scenario_id: string;
title: string;
address?: string | null;
description?: string | null;
incident_type?: string | null;
victims_count?: number | null;
received_at: string;
managed_service?: string | null;
service_status: ServiceStatus;
card_status: CardStatus;
elapsed_ms: number;
limit_ms: number;
timer_stopped: boolean;
active: boolean;
}
/** `immediate` — только для обрыва связи: рвёт TTS на полуслове. */
export type DirectiveMode = "next_turn" | "immediate";
@ -272,6 +297,7 @@ export interface KIO {
caller_number?: string | null;
caller_name?: string | null;
caller_contact?: string | null;
phone_on_scene?: string | null;
language?: string;
okato?: string | null;
address?: string | null;
@ -322,6 +348,14 @@ export interface KioState {
kio: KIO;
}
/** Настраиваемые преподавателем условия именно этого занятия. Нормативы ГОСТ для приёма вызова сюда не входят. Меняется учебный лимит решения ДДС и порог успешности, которые ТЗ прямо отдаёт преподавателю. */
export interface LessonCriteria {
decision_time_limit_seconds?: number;
allowed_errors?: number;
require_correct_grammar?: boolean;
score_weights?: Record<string, number>;
}
/** Уровень сложности сценария. */
export type Level = "L1" | "L2" | "L3";
@ -359,10 +393,51 @@ export type Outcome = "card" | "consultation" | "transfer_region";
/** `auto` — сервер распознал факт из речи, `operator` — эхо правки курсанта. */
export type PatchSource = "auto" | "operator";
/** Что диспетчер передал старшему группы при первом исходящем звонке. */
export interface PhoneBrief {
type: "phone.brief";
address: string;
incident: string;
request?: string;
}
export interface PhoneCallPending {
service: string;
crew: string;
phase: string;
}
/** Запрос диспетчера об обстановке после предыдущего доклада. */
export interface PhoneCheck {
type: "phone.check";
text: string;
}
export interface PhoneDial {
type: "phone.dial";
}
export interface PhoneHangup {
type: "phone.hangup";
}
export interface PhoneLine {
type: "phone.line";
service: string;
crew: string;
speaker: "dispatcher" | "crew";
text: string;
at: string;
}
export interface PhoneLineRecord {
service: string;
crew: string;
speaker: string;
text: string;
at: string;
}
export interface PhoneReport {
type: "phone.report";
service: string;
@ -420,12 +495,16 @@ export interface ScenarioStart {
type: "scenario.start";
scenario_id: string;
scenario_ids?: Array<string> | null;
dds_arrival_interval_seconds?: number;
dds_max_waiting?: number;
trainee: string;
trainee_id?: string | null;
dds_service?: string | null;
group_id?: string | null;
mode: SessionMode;
exercise?: Exercise;
handoff_to_dds?: boolean;
criteria?: LessonCriteria;
}
/** Коррекция оценки. Автооценка сохраняется рядом. */
@ -486,6 +565,9 @@ export interface SessionReport {
scenario_id: string;
mode: SessionMode;
attempt?: number;
criteria: LessonCriteria;
failed_metrics: number;
passed: boolean;
transcript: Array<TranscriptEntry>;
findings: Array<Finding>;
metrics: Array<Metric>;
@ -512,6 +594,7 @@ export interface SessionSnapshot {
level: Level;
mode: SessionMode;
exercise?: Exercise;
criteria?: LessonCriteria;
trainee_name?: string | null;
started_at?: string | null;
kio: KIO;
@ -535,6 +618,8 @@ export interface StationFinish {
/** Что видит диспетчер: службы, их статусы и что доступно дальше. Все поля обязательные, хотя списки и могут быть пустыми: снимок всегда собирается целиком, а необязательное поле уезжает в TypeScript как `| undefined` и заставляет фронт проверять то, чего не бывает. */
export interface StationSnapshot {
services: Array<string>;
recipient_services?: Array<string>;
managed_service?: string | null;
statuses: Record<string, ServiceStatus>;
available: Record<string, Array<ServiceStatus>>;
card: CardStatus;
@ -542,11 +627,17 @@ export interface StationSnapshot {
crew_options?: Array<string>;
crew_selected?: string | null;
phone_reports?: Array<PhoneReportRecord>;
phone_lines?: Array<PhoneLineRecord>;
phone_pending?: PhoneCallPending | null;
card_id?: string | null;
card_index?: number;
card_total?: number;
reply_text?: string;
completed_cards?: Array<DdsCardSummary>;
queue_cards?: Array<DdsQueueCard>;
pending_cards_count: number;
next_arrival_in_seconds: number | null;
max_waiting_cards: number;
}
/** Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше. */
@ -652,9 +743,9 @@ export type ServerToObserver = SessionSnapshot | TranscriptAppend | KioState | T
export type InstructorToServer = ScenarioStart | DirectorInject | ReferencePlay | InstructorNoteAdd | ScoreOverride | ScenarioPublish | SessionStop;
export type ServerToStation = CardReceived | StationState | PhoneReport | TimerTick | SessionEnded | ScoreReady | ErrorEvent;
export type ServerToStation = CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded | ScoreReady | ErrorEvent;
export type StationToServer = CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | StationFinish | CardReply | CardNext | ZoneDecision | CrewDispatched | CrewArrived;
export type StationToServer = CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | PhoneBrief | PhoneCheck | PhoneHangup | StationFinish | CardReply | CardNext | CardOpen | ZoneDecision | CrewDispatched | CrewArrived;
export type EventName =
"bg.start"
@ -671,6 +762,7 @@ export type EventName =
| "card.bounce"
| "card.briefing"
| "card.next"
| "card.open"
| "card.received"
| "card.reply"
| "card.status"
@ -689,7 +781,11 @@ export type EventName =
| "kio.patch"
| "kio.state"
| "mode.set"
| "phone.brief"
| "phone.check"
| "phone.dial"
| "phone.hangup"
| "phone.line"
| "phone.report"
| "reference.play"
| "reference.started"

View file

@ -49,6 +49,10 @@ button:disabled { opacity: 0.5; cursor: default; }
.demo-switcher div { display: grid; min-width: 140px; border-top: 1px solid #b9c8d0; }
.demo-switcher a { padding: 8px 10px; color: #154c71; text-decoration: none; }
.demo-switcher a:hover, .demo-switcher a:focus-visible { background: #d7ebf6; text-decoration: underline; }
@media (max-width: 600px) {
/* The fixed demo menu covered the address cell in the 390px DDS registry. */
.demo-switcher { position: static; width: fit-content; max-width: 100%; margin: 12px 0 0 auto; }
}
/* Две колонки: карточка и разговор рядом, как на настоящем АРМ. */
.columns { display: flex; gap: 24px; align-items: flex-start; flex-wrap: wrap; }
@ -63,6 +67,8 @@ button:disabled { opacity: 0.5; cursor: default; }
.instructor-layout select, .instructor-layout input { box-sizing: border-box; max-width: 100%; min-width: 0; }
.instructor-layout section:first-child select, .instructor-layout section:first-child input[type="text"] { width: 100%; }
.instructor-layout .mode-choice { overflow-wrap: anywhere; }
.lesson-criterion { display: grid; grid-template-columns: minmax(0, 1fr) 72px; gap: 6px; align-items: center; margin-bottom: 5px; }
.lesson-criterion input[type="number"] { width: 72px; padding: 3px 5px; }
.instructor-scenario-filters { display: grid; gap: 5px; margin-bottom: 6px; }
.kio-signs-hint { margin: 4px 0; color: #586572; font-size: 12px; }
@media (max-width: 1350px) {
@ -128,7 +134,9 @@ button:disabled { opacity: 0.5; cursor: default; }
/* Преподаватель редактирует копию шаблона до публикации. */
.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, .scenario-editor-open, .scenario-editor-actions, .scenario-library-actions { display: flex; align-items: end; flex-wrap: wrap; gap: 8px 12px; margin: 12px 0; }
.scenario-library-actions { padding-top: 10px; border-top: 1px solid var(--line); }
.scenario-library-actions span { align-self: center; color: var(--muted); font-size: 13px; }
.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; }
@ -193,6 +201,31 @@ button:disabled { opacity: 0.5; cursor: default; }
.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; }
.sip-phone { margin: 12px; padding: 12px; border: 1px solid #7d9db2; background: #eef5f9; }
.sip-phone > header { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 5px; }
.sip-phone > header span { font-size: 12px; }
.sip-phone-note { margin: 4px 0 10px; color: #52616b; font-size: 12px; }
.sip-phone-settings, .sip-phone-controls { display: flex; align-items: end; flex-wrap: wrap; gap: 8px; }
.sip-phone-settings label, .sip-phone-controls label { display: grid; flex: 1 1 135px; gap: 4px; color: #52616b; font-size: 11px; }
.sip-phone-settings input, .sip-phone-controls input { box-sizing: border-box; width: 100%; min-height: 36px; padding: 6px 8px; border: 1px solid #9aabb5; background: white; font: inherit; }
.sip-phone-settings button, .sip-phone-controls button { min-height: 36px; }
.sip-phone-controls .sip-answer { border-color: #1f6f3d; background: #1f6f3d; color: white; }
.sip-phone-controls .sip-hangup { border-color: #a51d1d; color: #a51d1d; }
.sip-phone audio { display: none; }
.dds-protocol-title { margin: 18px 12px 4px; padding-top: 12px; border-top: 1px solid #ccd5db; font-size: 14px; }
.phone-page { max-width: 760px; margin: 0 auto; }
.phone-page .sip-phone { margin: 20px 0; }
.dds-phone-active { margin: 12px; padding: 12px; border: 1px solid #8eafc8; background: #f5f9fc; }
.dds-phone-active > strong { color: #21506e; }
.dds-phone-form { margin-top: 12px; }
.dds-phone-form p { margin: 6px 0 12px; font-size: 13px; line-height: 1.45; }
.dds-phone-form label { display: block; margin: 10px 0 4px; font-size: 12px; font-weight: 600; }
.dds-phone-form input, .dds-phone-form textarea { display: block; box-sizing: border-box; width: 100%; padding: 8px; font: inherit; border: 1px solid #aab4bf; background: #fff; }
.dds-phone-form button { margin-top: 12px; background: #23577e; color: #fff; border-color: #23577e; }
.dds-phone-hangup { margin-top: 10px; }
.dds-phone-transcript { margin: 12px; padding: 12px; border: 1px solid #d2dbe2; background: #fff; max-height: 260px; overflow-y: auto; }
.dds-phone-transcript p { margin: 8px 0; padding: 7px 9px; background: #f4f6f8; line-height: 1.4; }
.dds-phone-transcript small { color: #5a6670; }
.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; }
@ -231,12 +264,56 @@ button:disabled { opacity: 0.5; cursor: default; }
.dds-register-panel .dds-registry td::before { content: attr(data-label); flex: 0 0 38%; font-weight: 600; color: #56616c; }
.dds-register-panel .dds-registry td.dds-empty { display: block; }
.dds-register-panel .dds-registry td.dds-empty::before { content: none; }
.dds-register-panel .dds-registry .dds-cell-value { flex: 1 1 auto; min-width: 0; }
.dds-register-panel .dds-registry .dds-cell-value > span,
.dds-register-panel .dds-registry .dds-cell-value > small { display: block; overflow-wrap: anywhere; }
.dds-status-action { display: grid; grid-template-columns: 1fr; }
.dds-status-action select, .dds-status-action input { min-width: 0; max-width: none; width: 100%; }
.dds-phone > select, .dds-dial, .dds-reply textarea { font-size: 16px; }
.bounce-fields { grid-template-columns: 1fr; }
}
/* Локальная библиотека учебных материалов. */
.workspace-links { display: flex; flex-wrap: wrap; gap: 8px 24px; }
.materials-page { max-width: 1180px; }
.materials-header, .materials-list-head { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.materials-header h1, .materials-list-head h2 { margin-bottom: 4px; }
.materials-header p { margin-top: 0; color: var(--muted); }
.material-create { margin: 20px 0; padding: 18px; border: 1px solid var(--line); background: var(--panel); }
.material-create form, .material-create label { display: grid; gap: 6px; }
.material-create form { gap: 12px; }
.material-create-row { display: grid; grid-template-columns: 180px minmax(260px, 1fr); gap: 12px; }
.material-create input, .material-create select, .material-create textarea,
.material-assignment-bar select { box-sizing: border-box; width: 100%; }
.material-create textarea { resize: vertical; }
.material-create button { justify-self: start; }
.material-assignment-bar { display: grid; grid-template-columns: minmax(190px, 1fr) minmax(190px, 1fr) 2fr; align-items: end; gap: 12px; margin: 12px 0; padding: 12px; border: 1px solid var(--line); background: #eef4f7; }
.material-assignment-bar label { display: grid; gap: 4px; }
.material-assignment-bar span { align-self: center; color: var(--muted); font-size: 13px; }
.material-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 14px; }
.material-card { padding: 16px; border: 1px solid var(--line); border-left: 5px solid #2578a5; background: var(--panel); box-shadow: 0 2px 8px #24323a12; }
.material-card.archived { border-left-color: #87949b; opacity: .72; }
.material-card > header { display: flex; align-items: center; gap: 10px; }
.material-card h3 { flex: 1; margin: 0; }
.material-level { padding: 3px 7px; border-radius: 3px; background: #176b9d; color: #fff; font-size: 12px; font-weight: 700; }
.material-body { margin: 12px 0; padding: 12px; white-space: pre-wrap; border-left: 3px solid #a9c4d3; background: #f4f7f9; line-height: 1.5; }
.material-edit, .material-edit label { display: grid; gap: 6px; }
.material-edit { gap: 10px; margin: 12px 0; padding: 12px; background: #f4f7f9; }
.material-edit input, .material-edit select, .material-edit textarea { box-sizing: border-box; width: 100%; }
.material-edit-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.material-card > footer { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); }
.material-card > footer span { margin-right: auto; color: var(--muted); }
.group-insight { display: grid; gap: 8px; margin: 14px 0; padding: 14px; border: 1px solid #9ebccc; background: #eef6fa; }
.group-insight button { justify-self: start; }
.group-insight > div { padding: 12px; border-left: 4px solid #176b9d; background: #fff; }
.group-insight p, .group-insight ol { margin-bottom: 0; }
@media (max-width: 700px) {
.materials-header, .materials-list-head { align-items: flex-start; flex-direction: column; }
.material-create-row, .material-assignment-bar, .material-cards { grid-template-columns: 1fr; }
.material-card > footer button { width: 100%; }
}
/* АРМ 112: рабочая геометрия оригинальной карточки — серое поле, белые
плотные панели, синие выбранные признаки и оранжевая полоса действий. */
.arm-page { box-sizing: border-box; min-height: 100vh; padding: 8px; background: #d5dde2; color: #303943; font-family: Arial, "Segoe UI", sans-serif; font-size: 14px; }
@ -385,7 +462,7 @@ button:disabled { opacity: 0.5; cursor: default; }
/* ДДС: повтор композиции предоставленного АРМ (msg480, кадры 2–13, 20).
Тёмный реестр и нижняя полоса служб — ориентиры боевого интерфейса. */
.dds-workspace { box-sizing: border-box; max-width: none; min-height: 100vh; padding: 0; background: #d9e0e5; font-family: Arial, "Segoe UI", sans-serif; font-size: 13px; }
.dds-workspace { box-sizing: border-box; max-width: none; min-height: 100vh; padding: 0; background: #c9ced1; font-family: Arial, "Segoe UI", sans-serif; font-size: 13px; }
.dds-app-top { box-sizing: border-box; display: flex; align-items: center; gap: 12px; min-height: 38px; padding: 4px 14px; background: #edf4fa; border-bottom: 1px solid #c7d3dc; color: #293740; }
.dds-brand { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; background: #bd2630; color: white; font-weight: 800; border-radius: 4px; }
.dds-app-name { flex: 1; font-weight: 600; }
@ -416,12 +493,12 @@ button:disabled { opacity: 0.5; cursor: default; }
.dds-row-open, .dds-row-link { min-height: 0; padding: 0 3px; border: 0; background: transparent; color: #fff; font: inherit; font-weight: 700; text-decoration: underline; }
.dds-row-open:hover, .dds-row-link:hover { color: #aedbf9; }
.dds-register-panel .dds-hint { color: #edf3f6; }
.dds-case-layout { display: flex; flex-direction: column; min-height: calc(100vh - 38px); background: #d7dfe4; }
.dds-case-layout { display: flex; flex-direction: column; min-height: calc(100vh - 38px); background: #c9ced1; }
.dds-case-top { display: flex; align-items: center; gap: 12px; min-height: 30px; padding: 3px 8px; background: #eaf1f6; color: #24323a; }
.dds-case-top button { min-height: 0; padding: 3px 8px; border: 0; background: transparent; color: #164d70; }
.dds-case-top span { font-size: 14px; }
.dds-call-strip { display: grid; grid-template-columns: .9fr 1.1fr 1.1fr 1.1fr 200px 100px; gap: 5px; padding: 7px; background: #d0d8dd; }
.dds-call-strip > div { box-sizing: border-box; min-width: 0; min-height: 64px; padding: 6px 8px; background: #f7f9fa; }
.dds-call-strip { display: grid; grid-template-columns: .9fr 1.1fr 1.1fr 1.1fr 200px 100px; gap: 5px; padding: 7px; background: #c9ced1; }
.dds-call-strip > div { box-sizing: border-box; min-width: 0; min-height: 64px; padding: 6px 8px; background: #efefef; }
.dds-call-strip > div:not(.dds-case-top-buttons) { display: grid; align-content: start; gap: 3px; }
.dds-call-strip small { color: #56636c; font-size: 11px; }
.dds-call-strip strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@ -430,17 +507,17 @@ button:disabled { opacity: 0.5; cursor: default; }
.dds-case-top-buttons { display: grid; gap: 5px; padding: 0 !important; background: transparent !important; }
.dds-case-top-buttons button { min-height: 0; padding: 5px; background: #176b9d; color: #fff; border: 0; font-size: 11px; }
.dds-case-top-buttons button:last-child { background: #303a41; }
.dds-case-meta { display: grid; grid-template-columns: 1.2fr 1fr 65px; gap: 6px; padding: 2px 7px 6px; background: #d0d8dd; }
.dds-case-meta span { min-height: 29px; padding: 7px; background: #f7f9fa; }
.dds-case-meta { display: grid; grid-template-columns: 1.2fr 1fr 65px; gap: 6px; padding: 2px 7px 6px; background: #c9ced1; }
.dds-case-meta span { min-height: 29px; padding: 7px; background: #efefef; }
.dds-case-summary { display: grid; grid-template-columns: .95fr 1.05fr; gap: 7px; padding: 0 7px; }
.dds-case-address, .dds-case-classification { min-width: 0; }
.dds-case-address > strong, .dds-case-address > span, .dds-case-address > div,
.dds-case-classification > strong, .dds-case-classification > div { display: block; box-sizing: border-box; min-height: 30px; margin-bottom: 5px; padding: 7px 9px; background: #f7f9fa; }
.dds-case-classification > strong, .dds-case-classification > div { display: block; box-sizing: border-box; min-height: 30px; margin-bottom: 5px; padding: 7px 9px; background: #efefef; }
.dds-case-address > div { min-height: 330px; }
.dds-case-classification > strong { background: #2c3439; color: #fff; }
.dds-case-classification > div:first-of-type { font-weight: 600; }
.dds-case-stage { position: relative; flex: 1; min-height: 360px; }
.dds-case-drawer { position: absolute; bottom: 12px; left: 4%; z-index: 3; box-sizing: border-box; width: min(920px, 92%); max-height: min(65vh, 650px); overflow-y: auto; border: 1px solid #69899a; background: #f7f9fa; box-shadow: 0 8px 32px #1d2b3280; }
.dds-case-drawer { position: absolute; bottom: 12px; left: 4%; z-index: 3; box-sizing: border-box; width: min(920px, 92%); max-height: min(65vh, 650px); overflow-y: auto; border: 1px solid #69899a; background: #efefef; box-shadow: 0 8px 32px #1d2b3280; }
.dds-drawer-title { position: sticky; top: 0; z-index: 4; display: flex; align-items: center; justify-content: space-between; padding: 4px 9px; background: #1d6e9e; color: #fff; }
.dds-drawer-title button { min-height: 0; padding: 0 7px; border: 0; background: transparent; color: #fff; font-size: 22px; }
.dds-case-drawer .dds-main { display: block; margin: 0; padding: 8px; }
@ -511,3 +588,67 @@ button:disabled { opacity: 0.5; cursor: default; }
.dds-status-editor button { min-height: 38px; }
.dds-status-history > div { grid-template-columns: 1fr; gap: 2px; }
}
/* Сенсорный режим. Узкая раскладка без горизонтального скролла недостаточна:
рабочие поля и действия должны попадаться пальцем, а не только мышью. */
@media (max-width: 700px) {
button,
select,
textarea,
input:not([type="radio"]):not([type="checkbox"]),
summary {
box-sizing: border-box;
min-width: 44px;
min-height: 44px;
}
summary {
display: flex;
align-items: center;
padding-top: 8px;
padding-bottom: 8px;
}
.mode-choice {
display: flex;
align-items: center;
min-height: 40px;
gap: 7px;
}
.mode-choice input[type="radio"],
.mode-choice input[type="checkbox"] {
flex: 0 0 22px;
width: 22px;
height: 22px;
margin: 0;
}
.lesson-criterion input[type="number"] { min-height: 44px; }
.scenario-editor-create select,
.scenario-editor-create input,
.scenario-editor-open input { min-height: 44px; }
.sip-phone { margin: 8px; }
.sip-phone > header { align-items: flex-start; flex-direction: column; }
.sip-phone-settings, .sip-phone-controls { display: grid; grid-template-columns: 1fr; }
.sip-phone-settings label, .sip-phone-controls label { min-width: 0; }
.sip-phone-settings input, .sip-phone-controls input,
.sip-phone-settings button, .sip-phone-controls button { min-height: 44px; }
.arm-call-actions button,
.arm-footer button,
.kio-arm input[type="text"],
.kio-arm input[type="number"],
.kio-arm select,
.kio-arm-choice button,
.kio-arm-type-picker button,
.dds-search-line input,
.dds-search-line button,
.dds-row-open,
.dds-row-link,
.dds-case-top button,
.dds-case-top-buttons button,
.dds-drawer-title button,
.dds-status-history .dds-status-close,
.dds-status-editor select,
.dds-status-editor input,
.dds-status-editor button {
min-width: 44px;
min-height: 44px;
}
}