Complete training workflow and acceptance hardening

This commit is contained in:
andreysk0304 2026-09-26 18:12:27 +03:00 • committed by gglamer
commit 7237265833
243 changed files with 17014 additions and 1500 deletions

View file

@ -0,0 +1,64 @@
# The same session UUID must reach its owning SessionHub on every API/WS path.
map $uri $session_route_key {
default $uri;
"~^/ws/(?:control|call|observe|station)/([0-9a-fA-F-]{36})$" $1;
"~^/api/sessions/([0-9a-fA-F-]{36})(?:/|$)" $1;
}
upstream backend_cluster {
zone backend_cluster 64k;
resolver 127.0.0.11 ipv6=off valid=2s;
hash $session_route_key consistent;
server backend:8000 resolve max_fails=1 fail_timeout=5s;
server backend-b:8000 resolve max_fails=1 fail_timeout=5s;
}
server {
listen 5173;
server_name _;
root /usr/share/nginx/html;
gzip on;
gzip_static on;
gzip_vary on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types application/javascript application/json image/svg+xml text/css text/plain;
location /api/ {
proxy_pass http://backend_cluster;
proxy_http_version 1.1;
proxy_next_upstream error timeout http_403 http_500 http_502 http_503 http_504;
proxy_next_upstream_tries 2;
proxy_next_upstream_timeout 5s;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ws/ {
proxy_pass http://backend_cluster;
proxy_http_version 1.1;
proxy_next_upstream error timeout http_403 http_502 http_503 http_504;
proxy_next_upstream_tries 2;
proxy_next_upstream_timeout 5s;
proxy_connect_timeout 1s;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_read_timeout 75s;
}
location ~* \.(?:css|js|woff2?|png|svg)$ {
try_files $uri =404;
expires 1y;
add_header Cache-Control "public, immutable";
}
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
}

View file

@ -0,0 +1,96 @@
# Keep session channels pinned to the node that owns their SessionHub.
map $uri $session_route_key {
default $uri;
"~^/ws/(?:control|call|observe|station)/([0-9a-fA-F-]{36})$" $1;
"~^/api/sessions/([0-9a-fA-F-]{36})(?:/|$)" $1;
}
upstream backend_cluster {
zone backend_cluster 64k;
resolver 127.0.0.11 ipv6=off valid=2s;
hash $session_route_key consistent;
server backend:8000 resolve max_fails=1 fail_timeout=5s;
server backend-b:8000 resolve max_fails=1 fail_timeout=5s;
}
server {
listen 5173;
server_name _;
return 308 https://$host:${PUBLIC_TLS_PORT}$request_uri;
}
server {
listen 5443 ssl;
http2 on;
server_name _;
root /usr/share/nginx/html;
ssl_certificate /etc/nginx/tls/tls.crt;
ssl_certificate_key /etc/nginx/tls/tls.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:LCT_TLS:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "same-origin" always;
gzip on;
gzip_static on;
gzip_vary on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types application/javascript application/json image/svg+xml text/css text/plain;
location /api/ {
proxy_pass http://backend_cluster;
proxy_http_version 1.1;
proxy_next_upstream error timeout http_403 http_500 http_502 http_503 http_504;
proxy_next_upstream_tries 2;
proxy_next_upstream_timeout 5s;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
location /ws/ {
proxy_pass http://backend_cluster;
proxy_http_version 1.1;
proxy_next_upstream error timeout http_403 http_502 http_503 http_504;
proxy_next_upstream_tries 2;
proxy_next_upstream_timeout 5s;
proxy_connect_timeout 1s;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 75s;
}
location = /sip-ws {
resolver 127.0.0.11 ipv6=off valid=10s;
set $sip_backend sip;
proxy_pass http://$sip_backend:8088/ws;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location ~* \.(?:css|js|woff2?|png|svg)$ {
try_files $uri =404;
expires 1y;
add_header Cache-Control "public, immutable";
}
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
}

View file

@ -4,6 +4,8 @@ server {
root /usr/share/nginx/html;
gzip on;
gzip_static on;
gzip_vary on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types application/javascript application/json image/svg+xml text/css text/plain;
@ -12,6 +14,7 @@ server {
proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
@ -22,6 +25,7 @@ server {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_read_timeout 75s;
}

View file

@ -6,6 +6,7 @@ server {
server {
listen 5443 ssl;
http2 on;
server_name _;
root /usr/share/nginx/html;
@ -22,6 +23,8 @@ server {
add_header Referrer-Policy "same-origin" always;
gzip on;
gzip_static on;
gzip_vary on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types application/javascript application/json image/svg+xml text/css text/plain;
@ -30,6 +33,7 @@ server {
proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
@ -40,6 +44,7 @@ server {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 75s;
}

View file

@ -18,6 +18,7 @@
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"esbuild": "^0.21.3",
"typescript": "^5.5.4",
"vite": "^5.4.3"
}

View file

@ -5,10 +5,13 @@
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc --noEmit && vite build",
"build": "tsc --noEmit && vite build && node scripts/precompress-assets.mjs",
"preview": "vite preview --host 0.0.0.0",
"typecheck": "tsc --noEmit && node scripts/check-kio-fields.mjs",
"check:kio": "node scripts/check-kio-fields.mjs"
"typecheck": "tsc --noEmit && node scripts/check-kio-fields.mjs && node scripts/check-accessible-controls.mjs",
"check:kio": "node scripts/check-kio-fields.mjs",
"check:a11y": "node scripts/check-accessible-controls.mjs",
"test:ws-outbox": "node scripts/test-ws-outbox.mjs",
"test:dds-history": "node scripts/test-dds-history.mjs"
},
"dependencies": {
"@tanstack/react-query": "^5.103.1",
@ -21,6 +24,7 @@
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"esbuild": "^0.21.3",
"typescript": "^5.5.4",
"vite": "^5.4.3"
}

View file

@ -0,0 +1,80 @@
import fs from "node:fs";
import path from "node:path";
import ts from "typescript";
const root = path.resolve("src");
const files = [];
function collect(directory) {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const target = path.join(directory, entry.name);
if (entry.isDirectory()) collect(target);
else if (/\.tsx?$/.test(entry.name)) files.push(target);
}
}
collect(root);
const issues = [];
for (const file of files) {
const source = fs.readFileSync(file, "utf8");
const tree = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const labelIds = new Set();
function findLabels(node) {
if (ts.isJsxOpeningElement(node) && node.tagName.getText(tree) === "label") {
const htmlFor = node.attributes.properties.find((item) =>
ts.isJsxAttribute(item) && item.name.getText(tree) === "htmlFor");
if (htmlFor?.initializer && ts.isStringLiteral(htmlFor.initializer)) {
labelIds.add(htmlFor.initializer.text);
}
}
ts.forEachChild(node, findLabels);
}
findLabels(tree);
function inspect(node) {
let opening = null;
if (ts.isJsxSelfClosingElement(node)) opening = node;
else if (ts.isJsxElement(node)) opening = node.openingElement;
if (opening) {
const tag = opening.tagName.getText(tree);
if (["input", "select", "textarea"].includes(tag)) {
const attrs = opening.attributes.properties.filter(ts.isJsxAttribute);
const hasAccessibleName = attrs.some((attr) => {
const name = attr.name.getText(tree);
if (!["aria-label", "aria-labelledby", "title"].includes(name)) return false;
return Boolean(attr.initializer && (
(ts.isStringLiteral(attr.initializer) && attr.initializer.text.trim())
|| ts.isJsxExpression(attr.initializer)
));
});
const idAttr = attrs.find((attr) => attr.name.getText(tree) === "id");
const id = idAttr?.initializer && ts.isStringLiteral(idAttr.initializer)
? idAttr.initializer.text : null;
let parent = node.parent;
let wrappedByLabel = false;
while (parent) {
if (ts.isJsxElement(parent) && parent.openingElement.tagName.getText(tree) === "label") {
wrappedByLabel = true;
break;
}
parent = parent.parent;
}
const typeAttr = attrs.find((attr) => attr.name.getText(tree) === "type");
const hidden = typeAttr?.initializer && ts.isStringLiteral(typeAttr.initializer)
&& typeAttr.initializer.text === "hidden";
if (!hidden && !hasAccessibleName && !wrappedByLabel && !(id && labelIds.has(id))) {
const { line } = tree.getLineAndCharacterOfPosition(opening.getStart(tree));
issues.push(`${path.relative(root, file)}:${line + 1} <${tag}>`);
}
}
}
ts.forEachChild(node, inspect);
}
inspect(tree);
}
if (issues.length) {
console.error(`Элементы формы без доступного имени (${issues.length}):\n${issues.join("\n")}`);
process.exitCode = 1;
} else {
console.log("Доступные имена input/select/textarea: проверены.");
}

View file

@ -8,6 +8,7 @@ import { readFileSync } from "node:fs";
const types = readFileSync(new URL("../src/shared/types/generated.ts", import.meta.url), "utf8");
const spec = readFileSync(new URL("../src/features/kio-card/fields.ts", import.meta.url), "utf8");
const backendKio = readFileSync(new URL("../../backend/app/domain/kio.py", import.meta.url), "utf8");
// Поля, которых на форме нет намеренно.
const SKIP = new Map([
@ -42,4 +43,19 @@ if (missing.length || extra.length) {
if (extra.length) console.error("на форме есть, а в контракте нет:", extra.join(", "));
process.exit(1);
}
const editableBlock = backendKio.match(/EDITABLE_KIO_FIELDS:[\s\S]*?=\s*frozenset\(\{([\s\S]*?)\}\)/)?.[1];
if (!editableBlock) throw new Error("в backend/app/domain/kio.py нет EDITABLE_KIO_FIELDS");
const backendEditable = new Set([...editableBlock.matchAll(/"([^"]+)"/g)].map((match) => match[1]));
const formEditable = new Set([...spec.matchAll(/\{\s*path:\s*"([^"]+)"([^}]*)\}/gs)]
.filter((match) => !/readOnly:\s*true/.test(match[2]))
.map((match) => match[1]));
const unavailableInForm = [...backendEditable].filter((path) => !formEditable.has(path));
const notScorable = [...formEditable].filter((path) => !backendEditable.has(path));
if (unavailableInForm.length || notScorable.length) {
if (unavailableInForm.length) console.error("required_fields, отсутствующие/только для чтения в форме:", unavailableInForm.join(", "));
if (notScorable.length) console.error("редактируемые поля формы без backend scoring allowlist:", notScorable.join(", "));
process.exit(1);
}
console.log(`карточка КИО: все ${expected.length} полей контракта на форме`);
console.log(`required_fields: backend allowlist совпадает с ${formEditable.size} редактируемыми путями формы`);

View file

@ -0,0 +1,39 @@
import { readdir, readFile, stat, utimes, writeFile } from "node:fs/promises";
import { gzip } from "node:zlib";
import { promisify } from "node:util";
const gzipAsync = promisify(gzip);
const assetDirectory = new URL("../dist/assets/", import.meta.url);
const compressedExtensions = /\.(?:js|css|json|svg)$/i;
const minimumBytes = 1024;
let compressedCount = 0;
let sourceBytes = 0;
let gzipBytes = 0;
async function compressAssets(directory) {
for (const entry of await readdir(directory, { withFileTypes: true })) {
const path = new URL(`${entry.name}${entry.isDirectory() ? "/" : ""}`, directory);
if (entry.isDirectory()) {
await compressAssets(path);
continue;
}
if (!compressedExtensions.test(entry.name)) continue;
const info = await stat(path);
if (info.size < minimumBytes) continue;
const source = await readFile(path);
const compressed = await gzipAsync(source, { level: 9 });
const compressedPath = new URL(`${entry.name}.gz`, directory);
await writeFile(compressedPath, compressed);
// Nginx gzip_static can safely pair the sidecar with its source file.
await utimes(compressedPath, info.atime, info.mtime);
compressedCount += 1;
sourceBytes += source.byteLength;
gzipBytes += compressed.byteLength;
}
}
await compressAssets(assetDirectory);
console.log(
`Precompressed ${compressedCount} assets: ${sourceBytes} -> ${gzipBytes} bytes`,
);

View file

@ -0,0 +1,36 @@
import assert from "node:assert/strict";
import { filterSortHistory } from "../src/pages/dds/history.mjs";
const cards = [
{ card_id: "36814001", scenario_id: "t01-1", title: "Медицинская помощь",
received_at: "2026-09-24T14:55:00+03:00", managed_service: "01", recipient_services: ["01"] },
{ card_id: "36814002", scenario_id: "t01-2", title: "Пожар в квартире",
received_at: "2026-09-25T14:50:00+03:00", managed_service: "01", recipient_services: ["01", "02"] },
{ card_id: "36814003", scenario_id: "t01-3", title: "Старая запись без снимка адресатов",
ended_at: "2026-09-23T14:50:00+03:00", managed_service: null },
];
const recipients02 = filterSortHistory(cards, {
query: "", service: "all", notification: "02", descending: true,
});
assert.deepEqual(recipients02.map((item) => item.card_id), ["36814002"]);
const descending = filterSortHistory(cards, {
query: "", service: "all", notification: "all", descending: true,
});
assert.deepEqual(descending.map((item) => item.card_id), ["36814002", "36814001", "36814003"]);
const ascending = filterSortHistory(cards, {
query: "", service: "all", notification: "all", descending: false,
});
assert.deepEqual(ascending.map((item) => item.card_id), ["36814003", "36814001", "36814002"]);
const combined = filterSortHistory(cards, {
query: "ПОЖАР", service: "01", notification: "02", descending: true,
});
assert.deepEqual(combined.map((item) => item.card_id), ["36814002"]);
assert.deepEqual(cards.map((item) => item.card_id), ["36814001", "36814002", "36814003"],
"filter/sort must not mutate the source archive list");
console.log("DDS archive recipient filters, combined search, sorting, and immutability passed.");

View file

@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { transform } from "esbuild";
const source = await readFile(new URL("../src/shared/api/ws.ts", import.meta.url), "utf8");
const { code } = await transform(source, { loader: "ts", format: "esm" });
const moduleUrl = `data:text/javascript;base64,${Buffer.from(code).toString("base64")}`;
const { callChannel, stationChannel } = await import(moduleUrl);
const mergeSource = await readFile(new URL("../src/features/kio-card/merge.ts", import.meta.url), "utf8");
const { code: mergeCode } = await transform(mergeSource, { loader: "ts", format: "esm" });
const { applyPatch, applyState, edit, empty } = await import(
`data:text/javascript;base64,${Buffer.from(mergeCode).toString("base64")}`,
);
class FakeWebSocket {
static OPEN = 1;
static instances = [];
readyState = 0;
sent = [];
constructor(url) {
this.url = url;
FakeWebSocket.instances.push(this);
}
send(value) { this.sent.push(JSON.parse(value)); }
open() { this.readyState = FakeWebSocket.OPEN; this.onopen(); }
drop() { this.readyState = 3; this.onclose(); }
receive(event) { this.onmessage({ data: JSON.stringify(event) }); }
}
globalThis.WebSocket = FakeWebSocket;
globalThis.location = { protocol: "http:", host: "localhost" };
const reconnectTimers = [];
globalThis.setTimeout = (callback) => (reconnectTimers.push(callback), reconnectTimers.length);
globalThis.clearTimeout = () => {};
function reconnect(socket) {
socket.drop();
reconnectTimers.shift()();
const next = FakeWebSocket.instances.at(-1);
next.open();
return next;
}
const call = callChannel("session-1").connect();
let socket = FakeWebSocket.instances.at(-1);
socket.open();
const patch = {
type: "kio.patch",
fields: {
address: "ул. Тестовая, 1",
notify: ["101", "СМП"],
coords: { latitude: 55.7, longitude: 37.6 },
},
};
assert.equal(call.send(patch), true);
const sentPatch = socket.sent[0];
assert.equal(sentPatch.type, patch.type);
assert.deepEqual(sentPatch.fields, patch.fields);
assert.equal(typeof sentPatch._command_id, "string");
socket = reconnect(socket);
assert.deepEqual(socket.sent, [sentPatch], "unacknowledged KIO patch is replayed after reconnect");
socket.receive({ type: "kio.patch", fields: patch.fields, source: "auto" });
socket = reconnect(socket);
assert.deepEqual(socket.sent, [sentPatch], "unrelated automatic patch does not acknowledge operator input");
socket.receive({ type: "kio.patch", fields: { address: "ул. Другая, 2" }, source: "operator" });
socket = reconnect(socket);
assert.deepEqual(socket.sent, [sentPatch], "different operator state does not acknowledge the pending patch");
socket.receive({ type: "kio.patch", fields: {
address: patch.fields.address,
notify: ["101", "СМП"],
coords: { longitude: 37.6, latitude: 55.7 },
}, source: "operator" });
socket = reconnect(socket);
assert.deepEqual(socket.sent, [], "matching checkpoint echo clears the KIO patch");
const edited = edit(empty, "coords", { latitude: 55.7, longitude: 37.6 });
assert.deepEqual(applyPatch(edited, { coords: { longitude: 37.6, latitude: 55.7 } }, "operator").pending, {});
assert.deepEqual(applyState(edited, { coords: { longitude: 37.6, latitude: 55.7 } }).pending, {});
const dds = stationChannel("session-1", "dds").connect();
socket = FakeWebSocket.instances.at(-1);
socket.open();
const crewSelect = { type: "crew.select", crew: "ПСЧ-1" };
assert.equal(dds.send(crewSelect), true);
const sentCrewSelect = socket.sent[0];
assert.equal(sentCrewSelect.type, crewSelect.type);
assert.equal(sentCrewSelect.crew, crewSelect.crew);
assert.equal(typeof sentCrewSelect._command_id, "string");
socket = reconnect(socket);
assert.deepEqual(socket.sent, [sentCrewSelect], "station command is replayed with its stable ID");
socket.receive({ type: "command.ack", command_id: sentCrewSelect._command_id });
socket.sent = [];
const reply = { type: "card.reply", card_id: "card-1", text: "Принято" };
assert.equal(dds.send(reply), true);
const sentReply = socket.sent[0];
assert.equal(sentReply.type, reply.type);
assert.equal(sentReply.card_id, reply.card_id);
assert.equal(sentReply.text, reply.text);
socket = reconnect(socket);
assert.deepEqual(socket.sent, [sentReply], "unacknowledged reply is replayed after reconnect");
socket.receive({ type: "station.state", snapshot: { card_id: "card-2", reply_text: "Принято" } });
socket = reconnect(socket);
assert.deepEqual(socket.sent, [sentReply], "reply for another card does not acknowledge this card");
socket.receive({ type: "station.state", snapshot: { card_id: "card-1", reply_text: "Принято" } });
socket = reconnect(socket);
assert.deepEqual(socket.sent, [], "matching persisted station snapshot clears the reply");
console.log("WebSocket durable outbox replay/ack tests passed");

View file

@ -30,6 +30,7 @@ const guarded = (screen: string, element: JSX.Element) => (
export const router = createBrowserRouter([
{ path: "/", element: <Navigate to={DEMO_MODE ? "/instructor" : "/trainee"} replace /> },
{ path: "/trainee", element: guarded("/trainee", <Call />) },
{ path: "/trainee/completed", element: guarded("/trainee", <Call />) },
{ path: "/instructor", element: guarded("/instructor", <Instructor />) },
{ path: "/wall", element: guarded("/wall", <Wall />) },
{ path: "/profile", element: guarded("/profile", <Profile />) },

View file

@ -1,10 +1,22 @@
// Экран входа. Ставится перед всеми остальными: номер занятия перестал быть
// пропуском, по ссылке пускает роль, а не знание адреса (lct-23).
import { useState, type MouseEvent, type ReactNode } from "react";
import { useEffect, useState, type MouseEvent, type ReactNode } from "react";
import { DEMO_MODE, SCREENS, useDemoLogin, useLogin, useLogout, useMe } from "./useAuth";
const preloadScreenModule: Record<string, () => Promise<unknown>> = {
"/trainee": () => import("@/pages/trainee/Call"),
"/instructor": () => import("@/pages/instructor/Instructor"),
"/wall": () => import("@/pages/wall/Wall"),
"/profile": () => import("@/pages/profile/Profile"),
"/groups": () => import("@/pages/groups/Groups"),
"/dds": () => import("@/pages/dds/Dds"),
"/phone": () => import("@/pages/phone/Phone"),
"/materials": () => import("@/pages/materials/Materials"),
"/admin": () => import("@/pages/admin/Admin"),
};
function DemoLinks() {
const openScreen = (event: MouseEvent<HTMLAnchorElement>, path: string) => {
event.preventDefault();
@ -33,6 +45,12 @@ export function RequireAuth({ screen, children }: { screen: string; children: Re
const me = useMe();
const logout = useLogout();
// Fetch only static route code while the signed cookie is being checked;
// user data and the screen itself remain gated by the role check below.
useEffect(() => {
void preloadScreenModule[screen]?.().catch(() => undefined);
}, [screen]);
if (me.isLoading) {
return <main className="page"><p>Проверяем доступ…</p></main>;
}
@ -76,7 +94,7 @@ function Login() {
<tr>
<th>Логин</th>
<td>
<input value={name} autoFocus onChange={(event) => setName(event.target.value)} />
<input aria-label="Логин" value={name} autoFocus onChange={(event) => setName(event.target.value)} />
</td>
</tr>
<tr>
@ -84,6 +102,7 @@ function Login() {
<td>
<input
type="password"
aria-label="Пароль"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>

View file

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

View file

@ -28,6 +28,7 @@ const PATCH_DEBOUNCE_MS = 300;
export interface Line {
speaker: "caller" | "operator";
text: string;
source?: "local_llm" | "scenario";
partial?: boolean;
}
@ -85,6 +86,10 @@ export function useCall(sessionId: string | null) {
},
onBinary: (frame) => audio.current?.playback.enqueue(frame),
onEvent: (event: ServerToTrainee) => {
if (event.type === "text.turn.accepted") {
append({ speaker: "operator", text: event.text });
return;
}
if (event.type === "card.briefing") {
const briefing = event;
textMode.current = true;
@ -110,7 +115,7 @@ export function useCall(sessionId: string | null) {
append({ speaker: "operator", text: event.text });
break;
case "caller.utterance":
append({ speaker: "caller", text: event.text });
append({ speaker: "caller", text: event.text, source: event.source });
break;
case "tts.begin":
setCallerSpeaking(true);
@ -222,6 +227,12 @@ export function useCall(sessionId: string | null) {
channel.current?.send(service ? { type: "dds.dispatch", service } : { type: "dds.dispatch" });
}, []);
const sendText = useCallback((text: string) => {
const clean = text.trim();
if (!clean || clean.length > 1000) return false;
return Boolean(channel.current?.send({ type: "text.turn", text: clean }));
}, []);
const submitCard = useCallback(() => {
if (patchTimer.current) clearTimeout(patchTimer.current);
patchTimer.current = null;
@ -255,6 +266,6 @@ export function useCall(sessionId: string | null) {
resolve,
status, phase, incoming, briefing, cardSubmitted, lines, timers, callerSpeaking, micOn, error, card,
checklist, selfAssessed, scoreReady, report, reportError,
answer, hangup, hint, patchKio, dispatch, submitCard, submitSelfAssessment,
answer, hangup, hint, patchKio, dispatch, submitCard, submitSelfAssessment, sendText,
};
}

View file

@ -16,39 +16,102 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
const noticed = diff?.noticed ?? [];
const overcautious = diff?.overcautious ?? [];
const notes = report.notes ?? [];
const passedMetrics = Math.max(0, report.metrics.length - report.failed_metrics);
const improvementPoints = report.metrics.filter((metric) => !metric.passed).slice(0, 3);
return (
<section className={big ? "debrief debrief-big" : "debrief"}>
<h2>
Разбор · оценка {report.score_final.toFixed(0)} из 100
{report.overridden_by && ` · скорректировано (${report.overridden_by}), автооценка ${report.score_auto.toFixed(0)}`}
</h2>
<h2>Результаты занятия</h2>
<div className="debrief-summary" aria-label="Краткие итоги занятия">
<article className={report.passed ? "debrief-stat is-passed" : "debrief-stat is-failed"}>
<span>Итог</span><strong>{report.passed ? "Зачёт" : "Не зачтено"}</strong>
<small>Критерии: выполнено {passedMetrics} из {report.metrics.length}</small>
<small>Допустимо не выполнить: {report.criteria.allowed_errors}</small>
</article>
<article className="debrief-stat debrief-score">
<span>Ваша итоговая оценка</span><strong>{report.score_final.toFixed(0)}<small> из 100</small></strong>
{report.overridden_by && <small>Изменено: {report.overridden_by}</small>}
</article>
<article className="debrief-stat">
<span>Автоматическая оценка</span><strong>{report.score_auto.toFixed(0)}<small> из 100</small></strong>
<small>{report.overridden_by ? "до корректировки преподавателя" : "расчёт по критериям занятия"}</small>
</article>
<article className="debrief-stat">
<span>Упражнение</span><strong>{report.exercise === "dds" ? "ДДС" : report.exercise === "card" ? "КИО" : report.exercise === "call" ? "Вызов 112" : "112 → ДДС"}</strong>
<small>{report.attempt ? `Попытка ${report.attempt}` : "Завершённое упражнение"}</small>
</article>
</div>
<details className="debrief-tech-details">
<summary>Служебные сведения</summary>
<p>Идентификатор сценария: {report.scenario_id} · номер занятия: {report.session_id}</p>
</details>
<section className={`debrief-next-step ${report.passed ? "is-passed" : "is-failed"}`} aria-label="Что обсудить на разборе">
<strong>{report.passed ? "Что получилось" : "На чём сосредоточиться"}</strong>
{report.passed ? (
<p>Курсант выполнил критерии зачёта. Разберите сильные решения и закрепите порядок действий.</p>
) : improvementPoints.length ? (
<>
<p>Начните с этих критериев — по ним нужно разобрать решение курсанта:</p>
<ul>{improvementPoints.map((metric) => <li key={metric.key}>{metric.title} — {metric.fact}</li>)}</ul>
</>
) : (
<p>Зачёт не получен. Сверьте отметки и факты ниже с ходом занятия.</p>
)}
</section>
<p className="ref">
Скачать отчёт: <a href={`/api/sessions/${report.session_id}/report.csv`}>CSV</a>
{" · "}<a href={`/api/sessions/${report.session_id}/report.pdf`}>PDF</a>
Скачать отчёт: <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 ? " входит" : " не входит"} в оценку.
{report.passed ? "Зачёт" : "Не зачтено"}: на разборе {report.failed_metrics} пунктов; допустимо пропустить {report.criteria.allowed_errors}. {report.exercise === "dds" && <>На первичное решение ДДС отводится
{` ${report.criteria.decision_time_limit_seconds} с`}</>}
{report.exercise === "dds" && <>; норматив отработки карточки ДДС —
{` ${report.criteria.dds_card_work_time_limit_seconds ?? 180} с`}</>}
{report.exercise === "card" && <>Норматив заполнения КИО —
{` ${report.criteria.card_fill_time_limit_seconds ?? 180} с`}</>}
{report.exercise && report.exercise !== "dds" && <>
; грамматика описания КИО {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>}
{Boolean(report.card_results?.length) && <section className="debrief-card-results">
<h3>Карточки занятия ({report.card_results!.length})</h3>
{report.card_results!.map((card, index) => <details key={card.card_id}>
<summary>Карточка {index + 1}: {card.title || card.scenario_id} · {card.score_auto.toFixed(0)} / 100
{card.address ? ` · ${card.address}` : ""}</summary>
{card.managed_service && <p><strong>Ответственная служба:</strong> {card.managed_service}</p>}
<p><strong>Ответ диспетчера:</strong> {card.reply_text || "не внесён"}</p>
{card.metrics.some((metric) => !metric.passed || (metric.credit != null && metric.credit < 1)) && <ul>
{card.metrics.filter((metric) => !metric.passed || (metric.credit != null && metric.credit < 1)).map((metric) =>
<li key={metric.key} className={metric.passed ? "ref" : "state-violated"}>
<strong>{metric.title}:</strong> {metric.fact} · норматив {metric.norm}
{metric.credit != null && ` · вклад ${Math.round(metric.credit * 100)}%`}
</li>)}
</ul>}
{card.findings.map((finding, findingIndex) => <p className="state-violated" key={`${finding.code}-${findingIndex}`}>
{finding.code}: {finding.summary}{finding.fact ? ` — ${finding.fact}` : ""}
</p>)}
</details>)}
</section>}
<div className="columns">
<section>
<h3>Нормативы и факты</h3>
<h3>Разбор по критериям</h3>
<p className="ref">Сравните, что сделал курсант, с ожидаемым порядком действий.</p>
<table className="grid">
<tbody>
{report.metrics.map((metric) => (
<tr key={metric.key}>
<th>{metric.title}</th>
<td className={metric.passed ? "state-ok" : "state-violated"}>
{metric.fact} · норматив {metric.norm}
{` · вес ${metric.weight ?? 1}`}
<strong className={`debrief-metric-status ${metric.passed ? "is-ok" : "is-needs-work"}`}>
{metric.passed ? "Выполнено" : "Разобрать"}
</strong>
<span>{metric.fact} · ожидалось: {metric.norm}</span>
{metric.ref && <span className="ref"> ({metric.ref})</span>}
</td>
</tr>
@ -56,6 +119,14 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
</tbody>
</table>
{report.metrics.some((metric) => metric.weight != null || metric.credit != null) && <details className="debrief-scoring-details">
<summary>Как критерии повлияли на балл</summary>
<ul>{report.metrics.filter((metric) => metric.weight != null || metric.credit != null).map((metric) => <li key={metric.key}>
<strong>{metric.title}:</strong> вес {metric.weight ?? 1}
{metric.credit != null && ` · вклад в оценку ${Math.round(metric.credit * 100)}%`}
</li>)}</ul>
</details>}
<h3>Отметки</h3>
<table className="grid">
<tbody>
@ -68,7 +139,7 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
</td>
</tr>
))}
{report.findings.length === 0 && <tr><td colSpan={2}>ошибок не найдено</td></tr>}
{report.findings.length === 0 && <tr><td colSpan={2}>Замечаний по ходу занятия нет.</td></tr>}
</tbody>
</table>
</section>
@ -135,7 +206,9 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
</section>
</div>
<h3>Транскрипт</h3>
<details className="debrief-transcript">
<summary>Весь ход разговора ({report.transcript.length} реплик)</summary>
<p className="ref">Отметки и комментарии преподавателя привязаны к соответствующим репликам.</p>
<table className="grid">
<tbody>
{report.transcript.map((line) => {
@ -158,6 +231,7 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
})}
</tbody>
</table>
</details>
</section>
);
}

View file

@ -15,17 +15,21 @@ export const COMPETENCY_LABELS: Record<string, string> = {
export function Radar({ values, size = 220 }: { values: CompetencyScore[]; size?: number }) {
if (values.length < 3) return null;
const center = size / 2;
const radius = center - 28;
// The chart needs horizontal gutters: the left/right competency names are
// outside the polygon and used to be clipped by the square SVG viewport.
const width = size + 160;
const centerX = width / 2;
const centerY = size / 2;
const radius = Math.min(centerY - 28, centerX - 100);
const point = (index: number, value: number) => {
const angle = (Math.PI * 2 * index) / values.length - Math.PI / 2;
return [center + radius * value * Math.cos(angle), center + radius * value * Math.sin(angle)];
return [centerX + radius * value * Math.cos(angle), centerY + radius * value * Math.sin(angle)];
};
const polygon = values.map((item, index) => point(index, item.value).join(",")).join(" ");
return (
<svg width={size} height={size} className="radar" role="img" aria-label="радар компетенций">
<svg width={width} height={size} viewBox={`0 0 ${width} ${size}`} className="radar" role="img" aria-label="радар компетенций">
{[0.25, 0.5, 0.75, 1].map((ring) => (
<polygon
key={ring}
@ -36,9 +40,11 @@ export function Radar({ values, size = 220 }: { values: CompetencyScore[]; size?
))}
<polygon points={polygon} fill="rgba(31,111,61,0.18)" stroke="var(--ok)" strokeWidth={2} />
{values.map((item, index) => {
const [x, y] = point(index, 1.16);
const [x, y] = point(index, 1.08);
const angle = (Math.PI * 2 * index) / values.length - Math.PI / 2;
const anchor = Math.cos(angle) > 0.25 ? "start" : Math.cos(angle) < -0.25 ? "end" : "middle";
return (
<text key={item.competency} x={x} y={y} textAnchor="middle" fontSize={10}>
<text key={item.competency} x={x} y={y} textAnchor={anchor} fontSize={11}>
{COMPETENCY_LABELS[item.competency] ?? item.competency}
</text>
);

View file

@ -61,7 +61,7 @@ export function Director({
рвёт звук немедленно.
</p>
<p>
<input type="text" value={free} placeholder="своя реплика — нужна локальная модель"
<input type="text" aria-label="Своя реплика звонящему" 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

@ -10,6 +10,9 @@ type Draft = {
body: JsonObject;
};
type Validation = { valid: boolean; errors: string[]; ground_truth?: JsonObject };
type GrammarCheck = { valid: boolean; checks: Array<{
field: string; passed: boolean; errors: string[]; source: string;
}> };
async function api<T>(path: string, method = "GET", body?: unknown): Promise<T> {
const response = await fetch(path, {
@ -21,7 +24,7 @@ async function api<T>(path: string, method = "GET", body?: unknown): Promise<T>
const data: unknown = await response.json().catch(() => null);
if (!response.ok) {
const detail = data && typeof data === "object" && "detail" in data ? data.detail : response.statusText;
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
throw new Error(typeof detail === "string" ? detail : "Не удалось выполнить действие. Проверьте поля и повторите попытку.");
}
return data as T;
}
@ -49,10 +52,6 @@ function diff(before: JsonObject, after: JsonObject): JsonObject {
return patch;
}
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] : "";
}
@ -63,6 +62,40 @@ function generationLabel(generation: Draft["generation"]): string {
return "копия шаблона";
}
const SCENARIO_CONTENT_LABELS: Record<string, string> = {
f_address: "Адрес", f_caller: "Данные заявителя", f_conscious: "Состояние сознания",
f_fled: "Сведения о скрывшемся", f_gas: "Запах газа", f_observer: "Сведения очевидца",
f_people: "Люди на месте", f_scale: "Масштаб происшествия", f_smoke: "Дым",
f_suspects: "Сведения о подозреваемых", f_threat: "Угроза", f_vehicle: "Транспортное средство",
f_victims: "Пострадавшие", f_what_burns: "Что горит", f_what_happened: "Что произошло",
f_who: "Кто пострадал или участвует", q_address: "Уточнить адрес",
q_address_check: "Проверить адрес", q_caller: "Уточнить данные заявителя",
q_can_move: "Может ли человек передвигаться", q_conscious: "Проверить сознание",
q_fled: "Уточнить сведения о скрывшемся", q_gas: "Уточнить запах газа",
q_observer: "Уточнить сведения очевидца", q_people: "Спросить о людях на месте",
q_scale: "Уточнить масштаб происшествия", q_smoke: "Уточнить сведения о дыме",
q_suspects: "Уточнить сведения о подозреваемых", q_threat: "Уточнить угрозу",
q_vehicle: "Уточнить сведения о транспорте", q_victims: "Спросить о пострадавших",
q_what_burns: "Уточнить, что горит", q_what_happened: "Уточнить, что произошло",
q_who: "Уточнить, кто пострадал или участвует",
};
function contentLabel(id: unknown, kind: "fact" | "question", index: number): string {
const key = typeof id === "string" ? id : "";
return SCENARIO_CONTENT_LABELS[key]
?? `${kind === "fact" ? "Дополнительный факт" : "Дополнительный вопрос"} ${index + 1}`;
}
function grammarFieldLabel(field: string): string {
if (field === "first_line") return "Первая реплика звонящего";
const fact = /^facts\.([^.]+)\.(value|refined)$/.exec(field);
if (fact) {
const subject = SCENARIO_CONTENT_LABELS[fact[1]] ?? "Факт сценария";
return `${fact[2] === "refined" ? "Уточнённый ответ" : "Ответ звонящего"}: ${subject}`;
}
return "Текст сценария";
}
export function ScenarioEditor({
scenarios,
selectedScenarioId,
@ -80,8 +113,10 @@ export function ScenarioEditor({
const [revisionComment, setRevisionComment] = useState("");
const [draftId, setDraftId] = useState("");
const [draft, setDraft] = useState<Draft | null>(null);
const [bodyText, setBodyText] = useState("");
const [editorBody, setEditorBody] = useState<JsonObject | null>(null);
const [validation, setValidation] = useState<Validation | null>(null);
const [grammarCheck, setGrammarCheck] = useState<GrammarCheck | null>(null);
const [manualEdits, setManualEdits] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
@ -93,8 +128,10 @@ export function ScenarioEditor({
function openDraft(value: Draft) {
setDraft(value);
setDraftId(value.id);
setBodyText(pretty(value.body));
setEditorBody(value.body);
setValidation(null);
setGrammarCheck(null);
setManualEdits(false);
setError("");
setNotice("");
}
@ -155,23 +192,51 @@ export function ScenarioEditor({
async function save(): Promise<Draft> {
if (!draft) throw new Error("Сначала создайте или откройте черновик");
let parsed: unknown;
try {
parsed = JSON.parse(bodyText);
} catch (cause) {
throw new Error(`Некорректный JSON: ${cause instanceof Error ? cause.message : String(cause)}`);
}
if (!isObject(parsed)) throw new Error("Тело сценария должно быть JSON-объектом");
if (parsed.id !== draft.id) throw new Error("id черновика менять нельзя");
const patch = diff(draft.body, parsed);
if (!editorBody) throw new Error("Откройте черновик заново.");
const patch = diff(draft.body, editorBody);
if (!Object.keys(patch).length) return draft;
const updated = await api<Draft>(`/api/scenarios/drafts/${encodeURIComponent(draft.id)}`, "PATCH", patch);
setDraft(updated);
setBodyText(pretty(updated.body));
setEditorBody(updated.body);
setValidation(null);
return updated;
}
function editBodyField(key: string, value: unknown) {
setEditorBody((previous) => previous ? { ...previous, [key]: value } : previous);
setValidation(null);
setGrammarCheck(null);
setManualEdits(true);
}
function editArrayField(arrayKey: string, index: number, key: string, value: unknown) {
setEditorBody((previous) => {
if (!previous || !Array.isArray(previous[arrayKey])) return previous;
const items = [...previous[arrayKey] as unknown[]];
if (!isObject(items[index])) return previous;
items[index] = { ...items[index], [key]: value };
return { ...previous, [arrayKey]: items };
});
setValidation(null);
setGrammarCheck(null);
setManualEdits(true);
}
function editDdsDecision(key: "expected" | "reason", value: string) {
setEditorBody((previous) => {
if (!previous) return previous;
const current = isObject(previous.dds_decision)
? previous.dds_decision : { expected: "accept" };
const decision = { ...current, [key]: value };
if (key === "expected" && value === "accept") delete decision.reason;
if (key === "expected" && value === "decline" && typeof decision.reason !== "string") {
decision.reason = "";
}
return { ...previous, dds_decision: decision };
});
setValidation(null);
}
async function validate() {
await perform(async () => {
const current = await save();
@ -181,6 +246,20 @@ export function ScenarioEditor({
});
}
async function checkGrammar() {
await perform(async () => {
const current = await save();
const result = await api<GrammarCheck>(
`/api/scenarios/drafts/${encodeURIComponent(current.id)}/grammar-check`, "POST",
);
setGrammarCheck(result);
setManualEdits(!result.valid);
setNotice(result.valid
? "Языковая проверка пройдена для первой реплики и текстовых фактов."
: "Языковая проверка нашла замечания; исправьте их и запустите проверку повторно.");
});
}
async function revise() {
await perform(async () => {
setNotice("Qwen перерабатывает черновик по комментарию…");
@ -232,6 +311,16 @@ export function ScenarioEditor({
});
}
const workingBody = editorBody ?? draft?.body ?? {};
const ddsDecision = isObject(workingBody.dds_decision) ? workingBody.dds_decision : {};
const expectedDdsDecision = ddsDecision.expected === "decline" ? "decline" : "accept";
const editableFacts = Array.isArray(workingBody.facts)
? workingBody.facts.map((item, index) => ({ item, index })).filter(({ item }) => isObject(item)) as Array<{ item: JsonObject; index: number }>
: [];
const editableChecklist = Array.isArray(workingBody.checklist)
? workingBody.checklist.map((item, index) => ({ item, index })).filter(({ item }) => isObject(item)) as Array<{ item: JsonObject; index: number }>
: [];
return (
<section className="scenario-editor" id="ai-scenario-editor" aria-busy={busy}>
<h2>ИИ-формирователь и редактор сценариев</h2>
@ -290,15 +379,72 @@ export function ScenarioEditor({
</div>
{draft && (
<>
<p className="ref">Черновик {draft.id} ({generationLabel(draft.generation)}). Факты и эталон доступны только преподавателю до утверждения. Поле score_weights позволяет задать веса метрик (0–10), например {"{\"address\": 3, \"dds_ack\": 2}"}.</p>
<p className="ref">Черновик {draft.id} ({generationLabel(draft.generation)}). Факты и эталон доступны только преподавателю до утверждения. Веса критериев занятия настраиваются отдельно при запуске.</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
<strong>Предпросмотр: {stringField(workingBody, "title")}</strong>
<p>Первая реплика: «{stringField(workingBody, "first_line")}»</p>
<p>Эталон первичного решения ДДС: {expectedDdsDecision === "decline"
? `отказать — ${stringField(ddsDecision, "reason") || "нужно указать причину"}`
: "принять карточку"}</p>
<p className="ref">Проверьте, что сюжетные факты и ответы согласуются друг с другом.</p>
<ul>{editableFacts.map(({ item, index }) =>
<li key={`${index}-${String(item.id)}`}><strong>{contentLabel(item.id, "fact", index)}:</strong> {String(item.value ?? "")}</li>
)}</ul>
</div>
<div className="scenario-human-editor">
<h3>Содержание сценария</h3>
<label>Название занятия
<input value={stringField(workingBody, "title")} maxLength={200}
onChange={(event) => editBodyField("title", event.target.value)} />
</label>
<label>Первая реплика звонящего
<textarea rows={3} value={stringField(workingBody, "first_line")}
onChange={(event) => editBodyField("first_line", event.target.value)} />
</label>
<fieldset className="scenario-dds-decision">
<legend>Эталон первичного решения ДДС</legend>
<p className="ref">Оставьте «Принять», если карточка относится к компетенции службы. Выберите отказ только для подтверждённого исключения: например, дубль или неподведомственная территория.</p>
<label>Какое решение считается правильным
<select value={expectedDdsDecision}
onChange={(event) => editDdsDecision("expected", event.target.value)}>
<option value="accept">Принять карточку</option>
<option value="decline">Не принять карточку</option>
</select>
</label>
{expectedDdsDecision === "decline" && <label>Почему отказ здесь правильный
<input aria-label="Причина эталонного отказа ДДС" value={stringField(ddsDecision, "reason")}
maxLength={500} placeholder="Например: дубликат уже передан по карточке…"
onChange={(event) => editDdsDecision("reason", event.target.value)} />
<small className="ref">Причина обязательна; она войдёт в норму оценки и отчёт курсанта.</small>
</label>}
</fieldset>
<h4>Факты и ответы</h4>
{editableFacts.map(({ item, index }) => <fieldset key={`${index}-${String(item.id)}`}>
<legend>{contentLabel(item.id, "fact", index)}</legend>
<label>Что сообщает звонящий
<textarea rows={2} value={stringField(item, "value")}
onChange={(event) => editArrayField("facts", index, "value", event.target.value)} />
</label>
{typeof item.refined === "string" && <label>Уточнённый ответ после переспроса
<input value={item.refined} onChange={(event) => editArrayField("facts", index, "refined", event.target.value)} />
</label>}
</fieldset>)}
{editableChecklist.length > 0 && <>
<h4>Контрольные вопросы и эталонные ответы</h4>
{editableChecklist.map(({ item, index }) => <fieldset key={`${index}-${String(item.id)}`}>
<legend>{contentLabel(item.id, "question", index)}</legend>
<label>Ожидаемый вопрос оператора
<input value={stringField(item, "question")}
onChange={(event) => editArrayField("checklist", index, "question", event.target.value)} />
</label>
<label>Факт, который нужно выяснить
<textarea rows={2} value={stringField(item, "fact")}
onChange={(event) => editArrayField("checklist", index, "fact", event.target.value)} />
</label>
</fieldset>)}
</>}
<p className="ref">После правок сохраните черновик, проверьте сценарий и повторите языковую проверку перед утверждением.</p>
</div>
<div className="scenario-editor-create">
<label>Комментарий для переработки
<input value={revisionComment} onChange={(event) => setRevisionComment(event.target.value)}
@ -308,20 +454,34 @@ export function ScenarioEditor({
<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>
<div className="scenario-editor-actions">
<button type="button" onClick={() => void perform(async () => { await save(); setNotice("Черновик сохранён."); })} disabled={busy}>Сохранить</button>
<button type="button" onClick={validate} disabled={busy}>Проверить</button>
<button type="button" className="start" onClick={approve} disabled={busy || !validation?.valid}>Утвердить</button>
{manualEdits && <button type="button" onClick={checkGrammar} disabled={busy}>Проверить грамматику после правок</button>}
<button type="button" className="start" onClick={approve}
disabled={busy || !validation?.valid || manualEdits}>Утвердить</button>
</div>
</>
)}
{validation && <div className={validation.valid ? "scenario-editor-result" : "scenario-editor-result violated"}>
{validation.valid ? <><strong>Проверка пройдена</strong><pre>{pretty(validation.ground_truth ?? {})}</pre></> :
{validation.valid ? <><strong>Проверка пройдена</strong><dl className="scenario-ground-truth">
{typeof validation.ground_truth?.incident_type === "string" && <div><dt>Тип происшествия</dt><dd>{validation.ground_truth.incident_type}</dd></div>}
{typeof validation.ground_truth?.dds === "string" && <div><dt>Ответственная служба</dt><dd>{validation.ground_truth.dds}</dd></div>}
{typeof validation.ground_truth?.incident_code === "string" && <div><dt>Код ЕКП</dt><dd>{validation.ground_truth.incident_code}</dd></div>}
{typeof validation.ground_truth?.address === "string" && <div><dt>Адрес</dt><dd>{validation.ground_truth.address}</dd></div>}
{typeof validation.ground_truth?.victims === "number" && <div><dt>Пострадавших</dt><dd>{validation.ground_truth.victims}</dd></div>}
{Array.isArray(validation.ground_truth?.notify) && <div><dt>Оповещаемые службы</dt><dd>{validation.ground_truth.notify.map(String).join(", ") || "не назначены"}</dd></div>}
{Array.isArray(validation.ground_truth?.required_facts) && <div><dt>Обязательные факты</dt><dd>{validation.ground_truth.required_facts.map(String).join(", ") || "не заданы"}</dd></div>}
</dl></> :
<><strong>Ошибки проверки</strong><ul>{validation.errors.map((item, index) => <li key={`${index}-${item}`}>{item}</li>)}</ul></>}
</div>}
{grammarCheck && <div className={grammarCheck.valid ? "scenario-editor-result" : "scenario-editor-result violated"}>
<strong>{grammarCheck.valid ? "Грамматика: замечаний нет" : "Замечания к грамматике"}</strong>
<ul>{grammarCheck.checks.flatMap((item) => item.errors.map((message, index) =>
<li key={`${item.field}-${index}`}><strong>{grammarFieldLabel(item.field)}:</strong> {message}</li>
))}</ul>
<small className="ref">Проверены первая реплика и текстовые значения фактов; код не переписывает текст. Источник проверки для каждого поля: правила или Vikhr.</small>
</div>}
{error && <p className="violated" role="alert">{error}</p>}
{notice && <p className="state-ok" role="status">{notice}</p>}
</section>

View file

@ -0,0 +1,105 @@
import { useState } from "react";
import type { Exercise } from "@/shared/types/generated";
type LessonExercise = Exercise | "card_dds";
type MetricOption = { key: string; label: string; hint: string };
const OPERATOR_METRICS: MetricOption[] = [
{ key: "answer_time", label: "Ответ на вызов", hint: "Скорость принятия звонка." },
{ key: "interview_time", label: "Длительность опроса", hint: "Соблюдение норматива опроса." },
{ key: "checklist_completeness", label: "Полнота опроса", hint: "Получение обязательных сведений из сценария." },
{ key: "incident_signs", label: "Признаки происшествия", hint: "Верность признаков и кода ЕКП." },
{ key: "incident_type", label: "Тип происшествия", hint: "Верность типа, если код ЕКП не задан." },
{ key: "dds_choice", label: "Выбор службы", hint: "Маршрутизация для сценариев без кода ЕКП." },
{ key: "outcome", label: "Исход вызова", hint: "Карточка, справка или иной предусмотренный итог." },
{ key: "address_refined", label: "Уточнение адреса", hint: "Подтверждение уточняемых адресных ориентиров." },
{ key: "address", label: "Адрес в карточке", hint: "Совпадение адреса с эталоном по компонентам." },
{ key: "victims_count", label: "Пострадавшие", hint: "Точность числа, если оно известно из сценария." },
{ key: "required_fields", label: "Обязательные поля КИО", hint: "Полнота заполнения полей карточки." },
{ key: "dds_chain", label: "Передача карточки в ДДС", hint: "Успешная передача, если она предусмотрена." },
{ key: "callback", label: "Обратный дозвон", hint: "Норматив повторного вызова при обрыве." },
{ key: "description_grammar", label: "Грамматика описания", hint: "Учитывается, если включён критерий занятия." },
{ key: "card_fill_time", label: "Время заполнения КИО", hint: "Соблюдение заданного преподавателем норматива." },
];
const DDS_METRICS: MetricOption[] = [
{ key: "dds_primary", label: "Первичное решение", hint: "Зафиксирован ли статус «Принята» или «Не принята»." },
{ key: "dds_ack", label: "Срок первичного решения", hint: "Соблюдение установленного лимита реакции." },
{ key: "dds_decision", label: "Решение по эталону", hint: "Соответствие принятию или обоснованному отказу." },
{ key: "dds_crew", label: "Назначение бригады", hint: "Выбор необходимой бригады ДДС." },
{ key: "dds_progress", label: "Ход реагирования", hint: "Статусы по фактическому ходу работ или отказу." },
{ key: "dds_completion", label: "Завершение работ", hint: "Фиксация завершения или отказа от работ." },
{ key: "dds_reply", label: "Основание и сведения", hint: "Комментарии к статусам и содержание полученных сведений." },
{ key: "dds_work_time", label: "Время отработки карточки", hint: "Соблюдение лимита полного цикла карточки." },
];
const CARD_METRICS = OPERATOR_METRICS.filter(({ key }) => [
"incident_signs", "incident_type", "dds_choice", "address", "victims_count",
"required_fields", "description_grammar", "card_fill_time",
].includes(key));
const CALL_METRICS = OPERATOR_METRICS.filter(({ key }) => key !== "card_fill_time");
export function ScoreWeightsEditor({
exercise,
values,
onChange,
}: {
exercise: LessonExercise;
values: Record<string, number>;
onChange: (weights: Record<string, number>) => void;
}) {
const [drafts, setDrafts] = useState<Record<string, string>>({});
const groups: [string, MetricOption[]][] = exercise === "dds"
? [["Реагирование ДДС", DDS_METRICS]]
: exercise === "card"
? [["Карточка КИО", CARD_METRICS]]
: exercise === "card_dds"
? [["Карточка КИО", CARD_METRICS], ["Реагирование ДДС", DDS_METRICS]]
: [["Опрос 112", CALL_METRICS], ["Реагирование ДДС", DDS_METRICS]];
function setWeight(key: string, raw: string) {
const next = { ...values };
if (raw === "") delete next[key];
else {
const value = Number(raw);
if (!Number.isFinite(value)) return;
next[key] = Math.max(0, Math.min(10, value));
}
onChange(next);
}
return <details className="lesson-score-weight-editor">
<summary>Настроить веса оценки (необязательно)</summary>
<p className="ref">
Пустое поле сохраняет вес сценария или стандартный вес. Значение 0 исключает
метрику из расчёта; допустимый диапазон — от 0 до 10.
</p>
{groups.map(([group, metrics]) => <fieldset key={group}>
<legend>{group}</legend>
{metrics.map((metric) => <label className="lesson-criterion" key={metric.key}>
<span title={metric.hint}>{metric.label}
<small className="weight-metric-hint" id={`weight-hint-${metric.key}`}>{metric.hint}</small>
</span>
<input type="number" min={0} max={10} step={0.5}
aria-label={`Вес метрики «${metric.label}»`}
aria-describedby={`weight-hint-${metric.key}`}
value={drafts[metric.key] ?? values[metric.key] ?? ""} placeholder="—"
onChange={(event) => setDrafts((previous) => ({ ...previous, [metric.key]: event.target.value }))}
onBlur={(event) => {
const raw = event.target.value.trim();
if (!raw) {
setDrafts((previous) => ({ ...previous, [metric.key]: "" }));
setWeight(metric.key, "");
return;
}
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return;
const normalized = Math.max(0, Math.min(10, parsed));
setDrafts((previous) => ({ ...previous, [metric.key]: String(normalized) }));
setWeight(metric.key, String(normalized));
}} />
</label>)}
</fieldset>)}
</details>;
}

View file

@ -12,20 +12,33 @@ import type { LessonCriteria, SessionMode } from "@/shared/types/generated";
export function useControl(sessionId: string | null) {
const [status, setStatus] = useState<ChannelStatus>("closed");
const channel = useRef<ReturnType<typeof controlChannel> | null>(null);
const [openSessionId, setOpenSessionId] = useState<string | null>(null);
const channel = useRef<{ sessionId: string; socket: ReturnType<typeof controlChannel> } | null>(null);
useEffect(() => {
if (!sessionId) return;
const ch = controlChannel(sessionId, { onStatus: setStatus }).connect();
channel.current = ch;
channel.current = null;
setOpenSessionId(null);
if (!sessionId) {
setStatus("closed");
return;
}
let active = true;
setStatus("connecting");
const ch = controlChannel(sessionId, { onStatus: (next) => {
if (!active) return;
setStatus(next);
setOpenSessionId(next === "open" ? sessionId : null);
} }).connect();
channel.current = { sessionId, socket: ch };
return () => {
active = false;
ch.close();
channel.current = null;
if (channel.current?.socket === ch) channel.current = null;
};
}, [sessionId]);
const start = useCallback(
(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) => {
(targetSessionId: string, 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, randomScenarioIds?: string[]) => {
const message = {
type: "scenario.start",
scenario_id: scenarioId,
@ -37,24 +50,27 @@ export function useControl(sessionId: string | null) {
exercise,
handoff_to_dds: handoffToDds,
scenario_ids: scenarioIds?.length ? scenarioIds : undefined,
random_scenario_ids: randomScenarioIds?.length ? randomScenarioIds : undefined,
dds_arrival_interval_seconds: ddsArrivalIntervalSeconds,
dds_max_waiting: ddsMaxWaiting,
criteria,
} as const;
channel.current?.send(message);
const current = channel.current;
if (!current || current.sessionId !== targetSessionId) return false;
return current.socket.send(message);
},
[],
);
const note = useCallback(
(transcriptRef: string, text: string) =>
channel.current?.send({ type: "instructor_note.add", transcript_ref: transcriptRef, text }),
channel.current?.socket.send({ type: "instructor_note.add", transcript_ref: transcriptRef, text }),
[],
);
const inject = useCallback(
(directive: string, immediate: boolean) =>
channel.current?.send({
channel.current?.socket.send({
type: "director.inject",
directive,
// `immediate` рвёт звук на полуслове и годится только для обрыва связи:
@ -64,8 +80,8 @@ export function useControl(sessionId: string | null) {
[],
);
const stop = useCallback(() => channel.current?.send({ type: "session.stop" }), []);
const playReference = useCallback(() => channel.current?.send({ type: "reference.play" }), []);
const stop = useCallback(() => channel.current?.socket.send({ type: "session.stop" }), []);
const playReference = useCallback(() => channel.current?.socket.send({ type: "reference.play" }), []);
return { status, start, note, inject, stop, playReference };
return { status, openSessionId, start, note, inject, stop, playReference };
}

View file

@ -20,7 +20,9 @@ export function KioCard({ state, required = [], readOnly = false, referenceArm =
const groups = visibleGroups(dds);
const section = (group: FieldGroup) => (
<section className={`kio-arm-section kio-arm-${group.area}`} key={group.title}>
{(!referenceArm || group.area !== "header") && <div className="kio-arm-section-title">{group.title}</div>}
{(!referenceArm || group.area !== "header") && <div className="kio-arm-section-title">
{referenceArm && group.area === "incident" ? "Введите тип происшествия" : group.title}
</div>}
<div className="kio-arm-fields">
{(referenceArm && group.area === "incident"
? [...group.fields].sort((a, b) => Number(b.path === "incident_type") - Number(a.path === "incident_type"))
@ -198,6 +200,7 @@ function SignsPicker({
{levels.map((level) => (
<SignSelect
key={level}
level={level}
prefix={value.slice(0, level)}
group={group}
chosen={value[level] ?? ""}
@ -218,8 +221,9 @@ function SignsPicker({
}
function SignSelect({
prefix, group, chosen, onPick,
level, prefix, group, chosen, onPick,
}: {
level: number;
prefix: string[];
group: number | null;
chosen: string;
@ -230,7 +234,7 @@ function SignSelect({
// Уровня может не быть: часть происшествий классифицируется двумя признаками.
if (!page.isLoading && values.length === 0) return null;
return (
<select aria-label={`Признак происшествия ${prefix.length + 1}`} value={chosen} onChange={(e) => onPick(e.target.value)} disabled={page.isLoading}>
<select aria-label={`Признак происшествия ${level + 1}`} value={chosen} onChange={(e) => onPick(e.target.value)} disabled={page.isLoading}>
<option value="">—</option>
{values.map((sign) => (
<option key={sign} value={sign}>{sign}</option>

View file

@ -15,6 +15,24 @@ export interface CardState {
export const empty: CardState = { server: {}, pending: {} };
function sameValue(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) return true;
if (Array.isArray(left) || Array.isArray(right)) {
return Array.isArray(left) && Array.isArray(right)
&& left.length === right.length
&& left.every((item, index) => sameValue(item, right[index]));
}
if (left && right && typeof left === "object" && typeof right === "object") {
const leftRecord = left as Record<string, unknown>;
const rightRecord = right as Record<string, unknown>;
const keys = Object.keys(leftRecord);
return keys.length === Object.keys(rightRecord).length
&& keys.every((key) => key in rightRecord
&& sameValue(leftRecord[key], rightRecord[key]));
}
return false;
}
export function setNested(card: Record<string, unknown>, path: string, value: unknown): Record<string, unknown> {
const [head, ...rest] = path.split(".");
if (rest.length === 0) return { ...card, [head]: value };
@ -42,7 +60,7 @@ export function applyPatch(
const pending = { ...state.pending };
for (const [path, value] of Object.entries(fields)) {
server = setNested(server, path, value);
if (source === "auto" || pending[path] === value) delete pending[path];
if (source === "auto" || sameValue(pending[path], value)) delete pending[path];
}
return { server, pending };
}
@ -51,7 +69,7 @@ export function applyPatch(
export function applyState(state: CardState, card: Record<string, unknown>): CardState {
const pending = { ...state.pending };
for (const path of Object.keys(pending)) {
if (readPath(card, path) === pending[path]) delete pending[path];
if (sameValue(readPath(card, path), pending[path])) delete pending[path];
}
return { server: card, pending };
}

View file

@ -47,16 +47,46 @@ export function useObserve(sessionId: string | null): ObservedSession {
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setStatus(sessionId ? "connecting" : "closed");
setTitle(null);
setMode(undefined);
setTrainee(null);
setCard({ server: { ...emptyCard.server }, pending: { ...emptyCard.pending } });
setRequiredFields([]);
setTranscript([]);
setTimers([]);
setHintsUsed(0);
setNotes(new Map());
setEnded(false);
setReport(null);
setError(null);
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);
let active = true;
// Active monitors used to query this on every open. Almost all such requests
// could only return score_not_ready, competing with the snapshot under load.
// Ask for the report only after completion or when no live session exists
// (e.g. history opened after a backend restart).
let reportLoading = false;
let reportLoaded = false;
const loadReport = () => {
if (reportLoading || reportLoaded) return;
reportLoading = true;
void fetch(`/api/sessions/${sessionId}/report`, { credentials: "same-origin" })
.then((response) => (response.ok ? response.json() : null))
.then((saved) => {
if (!active) return;
if (saved) {
reportLoaded = true;
setReport(saved as SessionReport);
}
})
.catch(() => undefined)
.finally(() => { reportLoading = false; });
};
const channel = observeChannel(sessionId, {
onStatus: setStatus,
onStatus: (next) => { if (active) setStatus(next); },
onEvent: (event) => {
if (!active) return;
switch (event.type) {
case "session.snapshot":
setTitle(event.scenario_title);
@ -68,6 +98,7 @@ export function useObserve(sessionId: string | null): ObservedSession {
setTimers(event.timers);
setHintsUsed(event.hints_used ?? 0);
setEnded(event.ended ?? false);
if (event.ended) loadReport();
break;
case "kio.state":
setCard((prev) => applyState(prev, event.kio as unknown as Record<string, unknown>));
@ -89,20 +120,19 @@ export function useObserve(sessionId: string | null): ObservedSession {
break;
case "session.ended":
setEnded(true);
loadReport();
break;
case "error":
setError(event.message);
if (event.code === "session_not_found") loadReport();
break;
case "score.ready":
void fetch(`/api/sessions/${sessionId}/report`)
.then((response) => (response.ok ? response.json() : null))
.then(setReport)
.catch(() => setReport(null));
loadReport();
break;
}
},
}).connect();
return () => channel.close();
return () => { active = false; channel.close(); };
}, [sessionId]);
return { status, title, mode, trainee, card, requiredFields, transcript, timers, hintsUsed, notes, ended, report, error };

View file

@ -47,6 +47,7 @@ export function SelfAssessment({
<td>
<input
type="text"
aria-label="Комментарий к самооценке"
value={comment}
placeholder="например: запнулся на адресе, растерялся"
onChange={(event) => setComment(event.target.value)}

View file

@ -13,6 +13,8 @@ const TITLES: Record<string, string> = {
interview: "Опрос",
dds_notify: "Оповещение ДДС",
dds_ack: "Подтверждение ДДС",
dds_work: "Отработка карточки ДДС",
card_fill: "Заполнение карточки КИО",
zone_check: "Зона ответственности",
callback: "Обратный дозвон",
close: "Снятие с контроля",

View file

@ -69,7 +69,6 @@ function Diagnostics() {
</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">
@ -130,7 +129,7 @@ function Users() {
<h2>Учётные записи</h2>
<table className="grid">
<thead>
<tr><th>Логин</th><th>ФИО</th><th>Роль</th><th>Служба</th><th>Состояние</th></tr>
<tr><th>Логин</th><th>ФИО</th><th>Роль</th><th>Служба</th><th>Источник</th><th>Состояние</th></tr>
</thead>
<tbody>
{(users.data ?? []).map((user) => (
@ -139,6 +138,7 @@ function Users() {
<td>{user.full_name}</td>
<td>{ROLE_LABELS[user.role]}</td>
<td>{user.service ?? "—"}</td>
<td>{user.auth_provider === "ldap" ? "AD / LDAP" : "Локальная"}</td>
<td>
<button
type="button"
@ -166,17 +166,18 @@ function Users() {
<tbody>
<tr>
<th>Логин</th>
<td><input value={form.login} maxLength={80} onChange={(e) => setForm({ ...form, login: e.target.value })} /></td>
<td><input aria-label="Логин пользователя" value={form.login} maxLength={80} onChange={(e) => setForm({ ...form, login: e.target.value })} /></td>
</tr>
<tr>
<th>ФИО</th>
<td><input value={form.full_name} maxLength={120} onChange={(e) => setForm({ ...form, full_name: e.target.value })} /></td>
<td><input aria-label="ФИО пользователя" value={form.full_name} maxLength={120} onChange={(e) => setForm({ ...form, full_name: e.target.value })} /></td>
</tr>
<tr>
<th>Пароль</th>
<td>
<input
type="password"
aria-label="Пароль пользователя"
maxLength={1024}
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
@ -187,7 +188,7 @@ function Users() {
<tr>
<th>Роль</th>
<td>
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value as Role })}>
<select aria-label="Роль пользователя" value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value as Role })}>
{Object.entries(ROLE_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
@ -199,7 +200,7 @@ function Users() {
{form.role === "trainee" && (
<tr>
<th>Служба ДДС</th>
<td><input value={form.service} onChange={(e) => setForm({ ...form, service: e.target.value })} /></td>
<td><input aria-label="Служба ДДС пользователя" value={form.service} onChange={(e) => setForm({ ...form, service: e.target.value })} /></td>
</tr>
)}
</tbody>
@ -254,8 +255,9 @@ function Audit() {
<>
<h2>Журнал действий</h2>
<p>
Действие: <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); }} />
Действие: <input aria-label="Фильтр аудита по действию" value={action} placeholder="login.failed" onChange={(e) => { setAction(e.target.value); setOffset(0); }} />
{" "}Пользователь: <input aria-label="Фильтр аудита по пользователю" value={actor} placeholder="логин" onChange={(e) => { setActor(e.target.value); setOffset(0); }} />
{" "}<a href={`/api/admin/audit.csv?${new URLSearchParams({ ...(action ? { action } : {}), ...(actor ? { actor } : {}) }).toString()}`}>Скачать весь фильтрованный журнал CSV</a>
</p>
<table className="grid">
<thead>

View file

@ -10,6 +10,7 @@ export interface AdminUser {
login: string;
full_name: string;
role: Role;
auth_provider: "local" | "ldap";
service: string | null;
blocked: boolean;
created_at: string;
@ -94,7 +95,7 @@ export const useBackups = () =>
export function useCreateUser() {
const cache = useQueryClient();
return useMutation({
mutationFn: (body: Omit<AdminUser, "id" | "blocked" | "created_at"> & { password: string }) =>
mutationFn: (body: Omit<AdminUser, "id" | "blocked" | "created_at" | "auth_provider"> & { password: string }) =>
call<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(body) }),
onSuccess: () => cache.invalidateQueries({ queryKey: ["admin-users"] }),
});

View file

@ -1,21 +1,28 @@
// Учебное АРМ ДДС. Проверяемый текстовый протокол дополняется настоящим
// локальным SIP/WebRTC-телефоном; оба контура явно подписаны в интерфейсе.
import { useEffect, useRef, useState } from "react";
import { lazy, Suspense, useEffect, useRef, useState } from "react";
import { KioCard } from "@/features/kio-card/KioCard";
import { Timers } from "@/features/timers/Timers";
import { applyState, empty as emptyCard, readPath, type CardState } from "@/features/kio-card/merge";
import { SipPhone } from "@/features/sip/SipPhone";
import { filterSortHistory } from "./history.mjs";
import { sessionIdFromUrl } from "@/shared/api/session";
import { stationChannel, type ChannelStatus } from "@/shared/api/ws";
import type { DdsQueueCard, ServiceStatus, SessionReport, StationSnapshot, StationToServer } from "@/shared/types/generated";
import type { DdsQueueCard, ServiceStatus, SessionReport, StationSnapshot, StationToServer, TimerSnapshot } from "@/shared/types/generated";
import { ALARMING, CARD_STATUS_LABELS, SERVICE_STATUS_LABELS } from "./statuses";
const ACK_LIMIT_S = 30;
const SipPhone = lazy(() => import("@/features/sip/SipPhone").then(({ SipPhone }) => ({ default: SipPhone })));
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 CompletedCard {
card_id: string; scenario_id: string; score_auto: number; score_final?: number;
session_id?: string; ended_at?: string; title?: string | null; address?: string | null;
description?: string | null; incident_type?: string | null; victims_count?: number | null;
received_at?: string | null; managed_service?: string | null; recipient_services?: string[];
}
interface DdsSnapshot extends StationSnapshot {
recipient_services?: string[];
managed_service?: string | null;
@ -45,23 +52,181 @@ function time(value: string): string {
}
function serviceTitle(service: string): string {
return service.startsWith("Служба ") ? service : `Служба ${service}`;
return /^\d{2,3}$/.test(service) ? `Служба ${service}` : service;
}
function useDdsHistory() {
const [cards, setCards] = useState<CompletedCard[]>([]);
const [error, setError] = useState("");
useEffect(() => {
const controller = new AbortController();
void fetch("/api/sessions/dds-history?limit=200", {
credentials: "same-origin", signal: controller.signal,
}).then(async (response) => {
if (!response.ok) throw new Error(`Не удалось загрузить историю ДДС (HTTP ${response.status}).`);
setCards(await response.json() as CompletedCard[]);
setError("");
}).catch((cause: unknown) => {
if (controller.signal.aborted) return;
setError(cause instanceof Error ? cause.message : "Не удалось загрузить историю ДДС.");
});
return () => controller.abort();
}, []);
return { cards, error };
}
function DdsHistoryOnly({ cards, error }: { cards: CompletedCard[]; error: string }) {
const [search, setSearch] = useState("");
const [searchOpen, setSearchOpen] = useState(false);
const searchInput = useRef<HTMLInputElement>(null);
const [advanced, setAdvanced] = useState(false);
const [service, setService] = useState("all");
const [notification, setNotification] = useState("all");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [descending, setDescending] = useState(true);
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(timer);
}, []);
useEffect(() => {
if (searchOpen) searchInput.current?.focus();
}, [searchOpen]);
const query = search.trim().toLocaleLowerCase("ru-RU");
const filtered = filterSortHistory(cards, { query, service, notification, descending });
const pages = Math.max(1, Math.ceil(filtered.length / pageSize));
const currentPage = Math.min(page, pages);
const offset = (currentPage - 1) * pageSize;
const visible = filtered.slice(offset, offset + pageSize);
const services = [...new Set(cards.map((item) => item.managed_service).filter((value): value is string => Boolean(value)))];
const notifications = [...new Set(cards.flatMap((item) => item.recipient_services ?? []))];
return <main className="page dds-workspace">
<section className="dds-search">
<div className="dds-search-main">
<h1>Поиск происшествий</h1>
<button type="button" className="dds-search-icon" aria-label={searchOpen ? "Свернуть поиск" : "Открыть поиск"}
aria-expanded={searchOpen} aria-controls="dds-history-search-line"
title={searchOpen ? "Свернуть поиск" : "Открыть поиск"}
onClick={() => {
if (search.trim()) searchInput.current?.focus();
else setSearchOpen((open) => !open);
}}>⌕</button>
<div id="dds-history-search-line" className={`dds-search-line${searchOpen ? " is-open" : ""}`}>
{searchOpen && <>
<label htmlFor="dds-history-search">Поиск по номеру, адресу или описанию</label>
<input ref={searchInput} id="dds-history-search" type="search" value={search}
onChange={(event) => { setSearch(event.target.value); setPage(1); }}
placeholder="Введите номер или адрес" />
</>}
<button type="button" onClick={() => {
setSearch(""); setService("all"); setNotification("all"); setPage(1);
}}>сбросить</button>
<button type="button" className="dds-advanced-toggle" aria-expanded={advanced}
onClick={() => setAdvanced((open) => !open)}>
расширенный поиск {advanced ? "⌃" : "⌄"}
</button>
</div>
{advanced && <div className="dds-advanced-search">
<label>Служба<select aria-label="Фильтр службы" value={service}
onChange={(event) => { setService(event.target.value); setPage(1); }}>
<option value="all">Все службы</option>
{services.map((value) => <option key={value} value={value}>{serviceTitle(value)}</option>)}
</select></label>
</div>}
</div>
<div className="dds-clock">
<small>{now.toLocaleDateString("ru-RU", { weekday: "long", day: "numeric", month: "long", year: "numeric" })}</small>
<strong>{now.toLocaleTimeString("ru-RU")}</strong>
<span>Учебный контур · архивные карточки</span>
</div>
</section>
<section className="dds-panel dds-register-panel">
<div className="dds-panel-title dds-history-title"><h2>Список происшествий⌃</h2>
<label className="dds-history-notifications">ⓘ уведомления<select aria-label="Фильтр уведомлений" value={notification}
onChange={(event) => { setNotification(event.target.value); setPage(1); }}>
<option value="all">выберите что показать</option>
{notifications.map((value) => <option key={value} value={value}>{serviceTitle(value)}</option>)}
</select></label>
</div>
{error && <p className="dds-alert" role="alert">{error}</p>}
<div className="dds-table-wrap"><table className="dds-registry dds-history-registry"><colgroup>
<col style={{ width: "8.5%" }} /><col style={{ width: "3%" }} /><col style={{ width: "3%" }} />
<col style={{ width: "3%" }} /><col style={{ width: "4%" }} /><col style={{ width: "4%" }} />
<col style={{ width: "4%" }} /><col style={{ width: "21%" }} /><col style={{ width: "4%" }} />
<col style={{ width: "34.5%" }} /><col style={{ width: "11%" }} />
</colgroup><thead><tr>
<th>Связи</th><th>ЧС</th><th>Опер.</th><th>АРМ</th><th>Номер</th>
<th><button type="button" className="dds-row-link" onClick={() => setDescending((value) => !value)}>
Дата {descending ? "↓" : "↑"}</button></th><th>Время</th><th>Тип происшествия</th>
<th>Постр.</th><th>Адрес</th><th>Статус службы</th>
</tr></thead><tbody>
{visible.map((item) => {
const occurred = item.received_at ? new Date(item.received_at) :
item.ended_at ? new Date(item.ended_at) : null;
return <>
<tr key={`${item.session_id ?? ""}-${item.card_id}`}>
<td data-label="Связи"><button type="button" className="dds-row-open" aria-label="Сведения о карточке">▾</button></td>
<td data-label="ЧС">—</td><td data-label="Опер.">0</td><td data-label="АРМ">ДДС</td>
<td data-label="Номер">{item.card_id.slice(0, 8).toUpperCase()}</td>
<td data-label="Дата">{occurred ? occurred.toLocaleDateString("ru-RU") : "—"}</td>
<td data-label="Время">{occurred ? occurred.toLocaleTimeString("ru-RU") : "—"}</td>
<td data-label="Тип происшествия">{item.title || item.incident_type || `Сценарий ${item.scenario_id}`}</td>
<td data-label="Постр.">{item.victims_count ?? "—"}</td><td data-label="Адрес">{item.address || "—"}</td>
<td data-label="Статус службы">Отработана · {(item.score_final ?? item.score_auto).toFixed(0)} / 100</td>
</tr>
{item.description && <tr key={`${item.card_id}-description`} className="dds-history-description">
<td colSpan={8} data-label="Описание">Описание: {occurred ? `${occurred.toLocaleDateString("ru-RU")} ${occurred.toLocaleTimeString("ru-RU")} · ` : ""}{item.description}</td><td colSpan={3}></td>
</tr>}
</>;
})}
{!visible.length && <tr><td colSpan={11} className="dds-empty">
{error ? "История временно недоступна." : query ? "По запросу ничего не найдено." : "Сохранённых карточек пока нет."}
</td></tr>}
</tbody></table></div>
<div className="dds-pagination dds-history-pagination" aria-label="Страницы исторического реестра">
<label>Страница<select aria-label="Страница реестра" value={currentPage}
onChange={(event) => setPage(Number(event.target.value))}>
{Array.from({ length: pages }, (_, index) => index + 1).map((value) =>
<option key={value} value={value}>{value}</option>)}
</select></label>
<label>Записей на странице<select aria-label="Количество записей на странице" value={pageSize}
onChange={(event) => { setPageSize(Number(event.target.value)); setPage(1); }}>
{[10, 25, 50].map((value) => <option key={value} value={value}>{value}</option>)}
</select></label>
<span>{filtered.length ? `${offset + 1}–${Math.min(offset + pageSize, filtered.length)} из ${filtered.length}` : "0 из 0"}</span>
<button type="button" aria-label="Предыдущая страница" disabled={currentPage <= 1}
onClick={() => setPage(currentPage - 1)}>‹</button>
<button type="button" aria-label="Следующая страница" disabled={currentPage >= pages}
onClick={() => setPage(currentPage + 1)}>›</button>
</div>
</section>
</main>;
}
export function Dds() {
const sessionId = sessionIdFromUrl();
const role = new URLSearchParams(location.search).get("role") ?? "dds_01";
const ddsHistory = useDdsHistory();
const [status, setStatus] = useState<ChannelStatus>("connecting");
const [view, setView] = useState<"registry" | "card">("registry");
const [pane, setPane] = useState<"status" | "phone" | "reply" | "full" | "journal" | "more" | null>(null);
const [activeService, setActiveService] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [advancedSearchOpen, setAdvancedSearchOpen] = useState(false);
const [serviceFilter, setServiceFilter] = useState("all");
const [statusFilter, setStatusFilter] = useState<"all" | "pending" | "completed">("all");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [clockNow, setClockNow] = useState(() => new Date());
const [card, setCard] = useState<CardState | null>(null);
const [from, setFrom] = useState("");
const [receivedAt, setReceivedAt] = useState<number | null>(null);
const [elapsed, setElapsed] = useState(0);
const [station, setStation] = useState<DdsSnapshot | null>(null);
const [timers, setTimers] = useState<TimerSnapshot[]>([]);
const [lastReport, setLastReport] = useState<PhoneReport | null>(null);
const [dialing, setDialing] = useState(false);
const [brief, setBrief] = useState({ address: "", incident: "", request: "" });
@ -108,7 +273,8 @@ 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) {
setZone(snapshot.zone_decision ?? null);
if (snapshot.card_id == null && (snapshot.pending_cards_count ?? 0) > 0) {
activeCardId.current = null;
setCard(null);
setView("registry");
@ -150,7 +316,8 @@ export function Dds() {
setReceivedAt(Number.isNaN(transmittedAt) ? Date.now() : transmittedAt);
setEnded(false);
setAdvancePending(false);
} else if (event.type === "error") { setError(event.message); setDialing(false); setAdvancePending(false); }
} else if (event.type === "timer.tick") setTimers(event.timers);
else if (event.type === "error") { setError(event.message); setDialing(false); setAdvancePending(false); }
else if (event.type === "session.ended") { setEnded(true); setDialing(false); setAdvancePending(false); setPane(null); setView("registry"); }
else if (event.type === "score.ready") { void loadReport(); }
else if (event.type === "phone.report") {
@ -182,8 +349,7 @@ export function Dds() {
}, []);
const send = (command: StationToServer): boolean => channel.current?.send(command) ?? false;
if (!sessionId) return <main className="page"><h1>АРМ диспетчера ДДС</h1>
<p className="warn">Откройте ссылку занятия вида /dds?session=…&role=dds_01</p></main>;
if (!sessionId) return <DdsHistoryOnly cards={ddsHistory.cards} error={ddsHistory.error} />;
const reports = station?.phone_reports ?? [];
const selectedCrew = station?.crew_selected ?? "";
@ -195,26 +361,41 @@ export function Dds() {
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 completedCards = [...(station?.completed_cards ?? [])];
for (const archived of ddsHistory.cards) {
if (!completedCards.some((item) => item.card_id === archived.card_id)) completedCards.push(archived);
}
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 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 filteredCompletedCards = statusFilter === "pending" ? [] : completedCards.filter((item) => {
const serviceMatches = serviceFilter === "all" || item.managed_service === serviceFilter;
const textMatches = !query || [item.card_id, item.scenario_id, item.title ?? "", item.address ?? "",
item.description ?? "", item.incident_type ?? ""].some((value) => value.toLocaleLowerCase("ru-RU").includes(query));
return serviceMatches && textMatches;
});
const filteredQueueCards = statusFilter === "completed" ? [] : queueCards.filter((item) => {
const textMatches = !query ||
[item.card_id, item.address ?? "", item.description ?? "", item.incident_type ?? "", item.title]
.some((value) => value.toLocaleLowerCase("ru-RU").includes(query));
const serviceMatches = serviceFilter === "all" || item.managed_service === serviceFilter;
return textMatches && serviceMatches;
});
const filteredRecordCount = filteredCompletedCards.length + filteredQueueCards.length;
const pageCount = Math.max(1, Math.ceil(filteredRecordCount / pageSize));
const currentPage = Math.min(page, pageCount);
const pageOffset = (currentPage - 1) * pageSize;
const visibleCompletedCards = filteredCompletedCards.slice(pageOffset, pageOffset + pageSize);
const queuePageStart = Math.max(0, pageOffset - filteredCompletedCards.length);
const queuePageEnd = Math.max(0, pageOffset + pageSize - filteredCompletedCards.length);
const visibleQueueCards = filteredQueueCards.slice(queuePageStart, queuePageEnd);
const crewAccepted = Boolean(station?.services.some((service) =>
selectedCrew.startsWith(`${service} —`) &&
["accepted", "responding", "arrived", "working"].includes(station.statuses[service])));
return <main className="page dds-workspace">
<header className="dds-app-top">
<span className="dds-brand">112</span>
<span className="dds-app-name">ГБУ Система 112 · учебное АРМ ДДС</span>
<span className="dds-top-role">{role}</span>
<span className={`dds-connection ${isConnected ? "state-ok" : "state-warn"}`}>
{isConnected ? "● На связи" : `● ${status}`}</span>
</header>
<span className="dds-connection-live" role="status" aria-live="polite">
АРМ {role}: {isConnected ? "на связи" : status}
</span>
{error && <div className="dds-alert" role="alert">Действие не принято: {error}</div>}
{ended && <div className="dds-alert dds-done">Занятие завершено. Оценка появится после расчёта.</div>}
{(ended || reportState !== "idle") && <section className="dds-panel" aria-live="polite">
@ -229,17 +410,21 @@ export function Dds() {
{report.card_results?.map((item, index) => <details className="dds-details" key={item.card_id}>
<summary>Карточка {index + 1} · {item.scenario_id} · {item.score_auto.toFixed(0)} / 100</summary>
<p><b>Ответ диспетчера:</b> {item.reply_text || "не внесён"}</p>
{item.metrics.filter((metric) => !metric.passed).map((metric) =>
<p key={metric.key} className="state-violated">{metric.title}: {metric.fact} · норматив {metric.norm}</p>)}
{item.metrics.filter((metric) => !metric.passed || (metric.credit != null && metric.credit < 1)).map((metric) =>
<p key={metric.key} className={metric.passed ? "ref" : "state-violated"}>
{metric.title}: {metric.fact} · норматив {metric.norm}
{metric.credit != null && ` · оценочный вклад ${Math.round(metric.credit * 100)}%`}</p>)}
{item.findings.map((finding, findingIndex) =>
<p key={`${finding.code}-${findingIndex}`}>{finding.code}: {finding.summary}</p>)}
</details>)}
</>}
<h3>Не выполнены нормативы</h3>
<h3>Нормативы и оценочный вклад</h3>
<table className="dds-registry"><tbody>
{report.metrics.filter((metric) => !metric.passed).map((metric) => <tr key={metric.key}>
<th>{metric.title}</th><td>{metric.fact} · норматив: {metric.norm}</td></tr>)}
{report.metrics.every((metric) => metric.passed) && <tr><td>Все проверенные нормативы выполнены.</td></tr>}
{report.metrics.filter((metric) => !metric.passed || (metric.credit != null && metric.credit < 1)).map((metric) => <tr key={metric.key}>
<th>{metric.title}</th><td>{metric.fact} · норматив: {metric.norm}
{metric.credit != null && ` · оценочный вклад ${Math.round(metric.credit * 100)}%`}</td></tr>)}
{report.metrics.every((metric) => metric.passed && (metric.credit == null || metric.credit === 1)) &&
<tr><td>Все проверенные нормативы выполнены, временные метрики дали полный вклад.</td></tr>}
</tbody></table>
<h3>Замечания</h3>
<table className="dds-registry"><tbody>
@ -270,12 +455,29 @@ export function Dds() {
<div className="dds-search-main">
<h1>Поиск происшествий</h1>
<div className="dds-search-line"><label htmlFor="dds-search-input">Поиск по номеру, адресу или описанию</label>
<input id="dds-search-input" type="search" value={search} onChange={(event) => setSearch(event.target.value)}
<input id="dds-search-input" type="search" value={search} onChange={(event) => { setSearch(event.target.value); setPage(1); }}
placeholder="Введите номер или адрес" />
<button type="button" onClick={() => setSearch("")}>сбросить</button></div>
<button type="button" onClick={() => { setSearch(""); setServiceFilter("all"); setStatusFilter("all"); setPage(1); }}>сбросить</button>
<button type="button" className="dds-advanced-toggle" aria-expanded={advancedSearchOpen}
onClick={() => setAdvancedSearchOpen((open) => !open)}>
расширенный поиск {advancedSearchOpen ? "⌃" : "⌄"}
</button></div>
{advancedSearchOpen && <div className="dds-advanced-search">
<label>Статус<select aria-label="Фильтр статуса происшествий" value={statusFilter}
onChange={(event) => { setStatusFilter(event.target.value as typeof statusFilter); setPage(1); }}>
<option value="all">Все происшествия</option><option value="pending">В очереди</option>
<option value="completed">Отработанные</option></select></label>
<label>Служба<select aria-label="Фильтр службы" value={serviceFilter}
onChange={(event) => { setServiceFilter(event.target.value); setPage(1); }}>
<option value="all">Все службы</option>
{[...new Set(queueCards.map((item) => item.managed_service).filter((value): value is string => Boolean(value)))].map((service) =>
<option key={service} value={service}>{serviceTitle(service)}</option>)}
</select></label>
</div>}
</div>
<div className="dds-clock"><small>{clockNow.toLocaleDateString("ru-RU", { weekday: "long", day: "numeric", month: "long", year: "numeric" })}</small>
<strong>{clockNow.toLocaleTimeString("ru-RU")}</strong><span>Учебный контур</span></div>
<strong>{clockNow.toLocaleTimeString("ru-RU")}</strong>
<span>Учебный контур · {isConnected ? "● На связи" : `● ${status}`}</span></div>
</section>
<section className="dds-panel dds-register-panel"><div className="dds-panel-title"><h2>Список происшествий⌃</h2>
<span>Карточка {cardIndex} из {cardTotal} · обработано {completedCards.length}</span></div>
@ -284,9 +486,14 @@ export function Dds() {
</tr></thead><tbody>
{visibleCompletedCards.map((item) => <tr key={item.card_id}>
<td data-label="Связи">▾</td><td data-label="ЧС">—</td><td data-label="Опер.">0</td><td data-label="АРМ">ДДС</td>
<td data-label="Номер">{item.card_id.slice(0, 8).toUpperCase()}</td><td data-label="Дата">—</td><td data-label="Время">—</td>
<td data-label="Тип происшествия">Сценарий {item.scenario_id}</td><td data-label="Постр.">—</td><td data-label="Адрес">—</td>
<td data-label="Статус службы">Отработана · {item.score_auto.toFixed(0)} / 100</td>
<td data-label="Номер">{item.card_id.slice(0, 8).toUpperCase()}</td>
<td data-label="Дата">{item.received_at ? new Date(item.received_at).toLocaleDateString("ru-RU") :
item.ended_at ? new Date(item.ended_at).toLocaleDateString("ru-RU") : "—"}</td>
<td data-label="Время">{item.received_at ? new Date(item.received_at).toLocaleTimeString("ru-RU") :
item.ended_at ? new Date(item.ended_at).toLocaleTimeString("ru-RU") : "—"}</td>
<td data-label="Тип происшествия">{item.title || item.incident_type || `Сценарий ${item.scenario_id}`}</td>
<td data-label="Постр.">{item.victims_count ?? "—"}</td><td data-label="Адрес">{item.address || "—"}</td>
<td data-label="Статус службы">Отработана · {(item.score_final ?? item.score_auto).toFixed(0)} / 100</td>
</tr>)}
{visibleQueueCards.map((item) => {
const received = new Date(item.received_at);
@ -295,6 +502,7 @@ export function Dds() {
);
const openCard = () => {
if (item.active) {
send({ type: "card.open", card_id: item.card_id });
setView("card");
return;
}
@ -327,30 +535,50 @@ export function Dds() {
{!visibleQueueCards.length && !visibleCompletedCards.length && <tr><td colSpan={11} className="dds-empty">
{query ? "По запросу ничего не найдено." : "Ожидание готовой карточки от преподавателя или оператора 112…"}</td></tr>}
</tbody></table></div>
<div className="dds-pagination" aria-label="Страницы реестра происшествий">
<label>Страница<select aria-label="Страница реестра" value={currentPage}
onChange={(event) => setPage(Number(event.target.value))}>
{Array.from({ length: pageCount }, (_, index) => index + 1).map((value) =>
<option key={value} value={value}>{value}</option>)}
</select></label>
<label>Записей на странице<select aria-label="Количество записей на странице" value={pageSize}
onChange={(event) => { setPageSize(Number(event.target.value)); setPage(1); }}>
{[10, 25, 50].map((value) => <option key={value} value={value}>{value}</option>)}
</select></label>
<span>{filteredRecordCount === 0 ? "0 из 0" : `${pageOffset + 1}–${Math.min(pageOffset + pageSize, filteredRecordCount)} из ${filteredRecordCount}`}</span>
<button type="button" aria-label="Предыдущая страница" disabled={currentPage <= 1}
onClick={() => setPage(currentPage - 1)}>‹</button>
<button type="button" aria-label="Следующая страница" disabled={currentPage >= pageCount}
onClick={() => setPage(currentPage + 1)}>›</button>
</div>
{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 && (station.pending_cards_count ?? 0) > 0 && <p className="dds-hint" role="status" aria-live="polite">
{station.next_arrival_in_seconds === null || station.next_arrival_in_seconds === undefined
? `Осталось карточек: ${station.pending_cards_count ?? 0}. Все карточки уже поступили; необработанные находятся в реестре.`
: 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}. Ожидается следующее поступление.`}
? `Осталось карточек: ${station.pending_cards_count ?? 0}. Следующее поступление примерно через ${station.next_arrival_in_seconds} с.`
: queueCards.filter((item) => !item.active).length >= (station.max_waiting_cards ?? 3)
? `Осталось карточек: ${station.pending_cards_count ?? 0}. Очередь ожидания заполнена (${station.max_waiting_cards ?? 3}); новые поступят, когда освободится место.`
: `Осталось карточек: ${station.pending_cards_count ?? 0}. Ожидается следующее поступление.`}
</p>}
</section>
</>}
{card && view === "card" && <div className="dds-case-layout">
<div className="dds-case-top"><button type="button" onClick={() => { setView("registry"); setPane(null); }}>← Список происшествий</button>
<span>Происшествие {cardId.slice(0, 8).toUpperCase()} · карточка {cardIndex} из {cardTotal}</span></div>
<div className="dds-call-strip">
<div className="dds-call-off"><span className="dds-call-icon">☎</span><strong>Отключение</strong><small>записи звонков · список SMS</small></div>
<div><span className="dds-call-icon">☎</span><small>АОН</small><strong>{field(card, "caller_number")}</strong></div>
<div><span className="dds-call-icon">☎</span><small>предоставленный</small><strong>{field(card, "caller_contact")}</strong></div>
<div><span className="dds-call-icon">☎</span><small>телефон на место</small><strong>{field(card, "phone_on_scene")}</strong></div>
<div className="dds-call-incident"><strong>Происшествие {cardId.slice(0, 8).toUpperCase()}</strong>
<div className="dds-call-incident"><div className="dds-call-incident-title">
<button type="button" className="dds-card-back" aria-label="К списку происшествий"
onClick={() => { setView("registry"); setPane(null); }}>←</button>
<strong>Происшествие {cardId.slice(0, 8).toUpperCase()}</strong>
</div>
<small>Сохр. {receivedAt ? new Date(receivedAt).toLocaleString("ru-RU") : "—"}</small>
<small>Опер., АРМ ДДС</small></div>
<small className={`dds-connection ${isConnected ? "state-ok" : "state-warn"}`}>
Опер., АРМ ДДС · {isConnected ? "● На связи" : `● ${status}`}
</small></div>
<div className="dds-case-top-buttons"><button type="button" onClick={() => setPane("full")}>просмотр</button>
<button type="button" onClick={() => setPane("more")}>дополнение</button></div>
</div>
@ -386,6 +614,7 @@ export function Dds() {
{station ? CARD_STATUS_LABELS[station.card] : "—"}</span></div>
{!primaryRecorded && receivedAt !== null && <div className={`dds-deadline ${elapsed > ACK_LIMIT_S ? "state-violated" : "state-ok"}`}>
Первичное решение «Принята» / «Не принята»: {elapsed.toFixed(0)} из {ACK_LIMIT_S} секунд</div>}
<Timers timers={timers.filter((timer) => timer.code === "dds_ack" || timer.code === "dds_work")} />
{station && station.services.length > 0 ? <>
<div className="dds-status-history"><strong>Служба {activeService ?? station.services[0]}</strong>
<button type="button" className="dds-status-close" onClick={() => setPane(null)} aria-label="Закрыть статусы">×</button>
@ -412,7 +641,9 @@ export function Dds() {
<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>SIP/WebRTC + учебный протокол</span></div>
<SipPhone defaultExtension="6101" defaultTarget="6102" title="Реальный локальный канал ДДС" />
<Suspense fallback={<p className="dds-hint">Загружаем IP-телефон…</p>}>
<SipPhone defaultExtension="6101" defaultTarget="6102" title="Реальный локальный канал ДДС" />
</Suspense>
<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>
@ -473,9 +704,9 @@ export function Dds() {
</section>}
{pane === "more" && <section className="dds-panel dds-secondary"><div className="dds-panel-title"><h2>Дополнительные действия</h2></div>
<div className="dds-zone">Зона ответственности: <button type="button" disabled={zone !== null || !isConnected || ended}
onClick={() => { channel.current?.send({ type: "zone.decision", in_zone: true }); setZone(true); }}>Наша</button>
onClick={() => { channel.current?.send({ type: "zone.decision", in_zone: true }); }}>Наша</button>
<button type="button" disabled={zone !== null || !isConnected || ended}
onClick={() => { channel.current?.send({ type: "zone.decision", in_zone: false }); setZone(false); }}>Не наша</button></div>
onClick={() => { channel.current?.send({ type: "zone.decision", in_zone: false }); }}>Не наша</button></div>
<p className="dds-hint">ДДС не исправляет и не возвращает заполненную карточку: её качество контролируют оператор и служба контроля 112.</p>
<button type="button" className="dds-finish" disabled={!cardId || !isConnected || ended || advancePending}
onClick={() => {
@ -492,18 +723,28 @@ export function Dds() {
</div>
<nav className="dds-service-dock" aria-label="Действия с карточкой">
<span className="dds-dock-label">Службы:</span>
{station?.services.map((service) => <button type="button" className={pane === "status" && activeService === service ? "active" : ""}
key={service} onClick={() => { setActiveService(service); setPane("status"); }}>
<b>{serviceTitle(service)}</b><small>{SERVICE_STATUS_LABELS[station.statuses[service]]}</small>
</button>)}
<button type="button" onClick={() => setPane("phone")}><b>☎ Связь</b><small>Бригада</small></button>
<button type="button" onClick={() => setPane("reply")}><b>✎ Ответ</b><small>{reply.dirty ? "Не сохранён" : "Комментарий"}</small></button>
<button type="button" onClick={() => setPane("journal")}><b>Журнал</b><small>Статусы</small></button>
<button type="button" disabled={!cardId || !isConnected || ended || advancePending}
onClick={() => {
if (reply.dirty && !send({ type: "card.reply", card_id: cardId, text: reply.text })) return;
if (send({ type: "card.next", card_id: cardId })) setAdvancePending(true);
}}><b>✓ Завершить</b><small>{queueCards.length > 1 ? "Закрыть карточку" : "Занятие"}</small></button>
<div className="dds-service-recipient-list" role="group" aria-label="Службы-получатели">
{(station?.recipient_services?.length ? station.recipient_services : station?.services ?? []).map((service) => {
const canManage = station?.services.includes(service) ?? false;
const serviceStatus = station?.statuses[service] ?? "added";
return <button type="button" className={`dds-service-recipient ${pane === "status" && activeService === service ? "active" : ""}`}
key={service} disabled={!canManage}
title={canManage ? `Обновить статус: ${service}` : `Уведомление направлено ${service}; статус меняет назначенная ДДС`}
onClick={() => { if (canManage) { setActiveService(service); setPane("status"); } }}>
<b>{serviceTitle(service)}</b><small>{SERVICE_STATUS_LABELS[serviceStatus]}</small>
</button>;
})}
</div>
<div className="dds-service-tools" role="group" aria-label="Действия диспетчера">
<button type="button" onClick={() => setPane("phone")}><b>☎ Связь</b><small>Бригада</small></button>
<button type="button" onClick={() => setPane("reply")}><b>✎ Ответ</b><small>{reply.dirty ? "Не сохранён" : "Комментарий"}</small></button>
<button type="button" onClick={() => setPane("journal")}><b>Журнал</b><small>Статусы</small></button>
<button type="button" disabled={!cardId || !isConnected || ended || advancePending}
onClick={() => {
if (reply.dirty && !send({ type: "card.reply", card_id: cardId, text: reply.text })) return;
if (send({ type: "card.next", card_id: cardId })) setAdvancePending(true);
}}><b>✓ Завершить</b><small>{queueCards.length > 1 ? "Закрыть карточку" : "Занятие"}</small></button>
</div>
</nav>
</div>}
</main>;
@ -515,8 +756,9 @@ function StatusRow({ service, current, available, disabled, crewOptions, selecte
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 [basis, setBasis] = useState("");
const [information, setInformation] = useState("");
const [recipient, setRecipient] = useState("");
if (!available.length) return <p className="dds-hint">{serviceTitle(service)}: {SERVICE_STATUS_LABELS[current]}. Дальнейшие статусы недоступны.</p>;
return <div className="dds-status-editor">
<label>Статус
@ -533,15 +775,29 @@ function StatusRow({ service, current, available, disabled, crewOptions, selecte
{crewOptions.map((crew) => <option value={crew} key={crew}>{crew}</option>)}
</select>
</label>
<label>Комментарий
<input type="text" value={comment} disabled={disabled} aria-label={`Комментарий к статусу ${service}`}
placeholder={needsComment ? "Причина отказа и кому передана информация" : "Комментарий"}
onChange={(event) => setComment(event.target.value)} />
<label>Основание статуса
<input type="text" value={basis} disabled={disabled} aria-label={`Основание статуса ${service}`}
placeholder={chosen === "declined" || chosen === "refused" ? "Причина отказа" : "Например: доклад о выезде"}
onChange={(event) => setBasis(event.target.value)} />
</label>
<label>Полученные сведения
<input type="text" value={information} disabled={disabled} aria-label={`Полученные сведения ${service}`}
placeholder="Что сообщено о ходе работ"
onChange={(event) => setInformation(event.target.value)} />
</label>
<label>Кому переданы сведения · необязательно
<input type="text" value={recipient} disabled={disabled} aria-label={`Получатель сведений ${service}`}
placeholder="Например: бригаде № 12"
onChange={(event) => setRecipient(event.target.value)} />
</label>
<button type="button" className="dds-status-save" aria-label="Сохранить статус"
disabled={disabled || !chosen || (needsComment && !comment.trim())}
onClick={() => { if (chosen) { onSet(chosen, comment.trim()); setChosen(""); setComment(""); } }}>✓</button>
disabled={disabled || !chosen || !basis.trim() || !information.trim()}
onClick={() => { if (chosen) {
const lines = [`Основание: ${basis.trim()}`, `Сведения: ${information.trim()}`];
if (recipient.trim()) lines.push(`Передано: ${recipient.trim()}`);
onSet(chosen, lines.join("\n")); setChosen(""); setBasis(""); setInformation(""); setRecipient("");
} }}>✓</button>
<button type="button" className="dds-status-cancel" aria-label="Отменить изменение статуса"
onClick={() => { setChosen(""); setComment(""); }}>×</button>
onClick={() => { setChosen(""); setBasis(""); setInformation(""); setRecipient(""); }}>×</button>
</div>;
}

View file

@ -0,0 +1,19 @@
export interface DdsHistoryCard {
card_id: string;
scenario_id: string;
score_auto?: number;
score_final?: number;
title?: string | null;
address?: string | null;
description?: string | null;
incident_type?: string | null;
received_at?: string | null;
ended_at?: string | null;
managed_service?: string | null;
recipient_services?: string[];
}
export function filterSortHistory<T extends DdsHistoryCard>(
cards: T[],
filters: { query: string; service: string; notification: string; descending: boolean },
): T[];

View file

@ -0,0 +1,37 @@
/**
* @typedef {Object} DdsHistoryCard
* @property {string} card_id
* @property {string} scenario_id
* @property {number} [score_auto]
* @property {number} [score_final]
* @property {string|null} [title]
* @property {string|null} [address]
* @property {string|null} [description]
* @property {string|null} [incident_type]
* @property {string|null} [received_at]
* @property {string|null} [ended_at]
* @property {string|null} [managed_service]
* @property {string[]} [recipient_services]
*/
/**
* Filter and sort the archived DDS register without mutating the server result.
* @param {DdsHistoryCard[]} cards
* @param {{query: string, service: string, notification: string, descending: boolean}} filters
* @returns {DdsHistoryCard[]}
*/
export function filterSortHistory(cards, { query, service, notification, descending }) {
const normalizedQuery = query.trim().toLocaleLowerCase("ru-RU");
return cards.filter((item) => {
const textMatches = !normalizedQuery || [item.card_id, item.scenario_id, item.title ?? "", item.address ?? "",
item.description ?? "", item.incident_type ?? ""].some((value) =>
value.toLocaleLowerCase("ru-RU").includes(normalizedQuery));
const serviceMatches = service === "all" || item.managed_service === service;
const notificationMatches = notification === "all" || item.recipient_services?.includes(notification);
return textMatches && serviceMatches && notificationMatches;
}).sort((left, right) => {
const leftAt = Date.parse(left.received_at ?? left.ended_at ?? "") || 0;
const rightAt = Date.parse(right.received_at ?? right.ended_at ?? "") || 0;
return (descending ? -1 : 1) * (leftAt - rightAt);
});
}

View file

@ -8,6 +8,7 @@ import { useEffect, useRef, useState } from "react";
import { Debrief } from "@/features/debrief/Debrief";
import { Director } from "@/features/instructor/Director";
import { ScoreWeightsEditor } from "@/features/instructor/ScoreWeightsEditor";
import { ScenarioEditor } from "@/features/instructor/ScenarioEditor";
import { useControl } from "@/features/instructor/useControl";
import { KioCard } from "@/features/kio-card/KioCard";
@ -15,30 +16,77 @@ import { StandStatus } from "@/features/health/StandStatus";
import { ModeBanner } from "@/features/mode-banner/ModeBanner";
import { useObserve } from "@/features/observe/useObserve";
import { InterviewTimer, Timers } from "@/features/timers/Timers";
import { useIncidentGroups, useScenarios, useSessions, useTrainees } from "@/shared/api/http";
import { CARD_STATUS_LABELS, SERVICE_STATUS_LABELS } from "@/pages/dds/statuses";
import { DEMO_MODE } from "@/features/auth/useAuth";
import {
useActiveSessions, useIncidentGroups, useReviewScenarioSubmission, useScenarioSubmissions,
useScenarios, useSessions, useTrainees,
} from "@/shared/api/http";
import { sessionIdFromUrl } from "@/shared/api/session";
import type { SessionMode, SessionReport } from "@/shared/types/generated";
import type { Level, SessionMode, SessionReport } from "@/shared/types/generated";
const MODES: { value: SessionMode; label: string }[] = [
{ value: "training", label: "тренировочный" },
{ value: "exam", label: "контрольный" },
{ value: "self", label: "самостоятельный" },
const MODES: { value: SessionMode; label: string; hint: string }[] = [
{ value: "training", label: "Тренировочный", hint: "Подсказки доступны" },
{ value: "exam", label: "Контрольный", hint: "Подсказки отключены" },
{ value: "self", label: "Самостоятельная работа", hint: "Курсант работает самостоятельно; подсказки доступны" },
];
function levelLabel(level: Level): string {
const labels: Record<Level, string> = {
L1: "L1 · простой вызов",
L2: "L2 · заявитель в стрессе",
L3: "L3 · обрыв связи / обратный вызов",
};
return labels[level];
}
function modeLabel(mode: SessionMode): string {
return MODES.find((item) => item.value === mode)?.label ?? mode;
}
function sessionDateLabel(value: string | null): string | null {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return new Intl.DateTimeFormat("ru-RU", { dateStyle: "medium", timeStyle: "short" }).format(date);
}
function LessonNumberInput({
label, value, min, max, fallback, onCommit,
}: {
label: string; value: number; min: number; max: number; fallback: number;
onCommit: (value: number) => void;
}) {
const [draft, setDraft] = useState(String(value));
useEffect(() => setDraft(String(value)), [value]);
function commit() {
const parsed = draft.trim() ? Number(draft) : fallback;
const normalized = Number.isFinite(parsed) ? Math.max(min, Math.min(max, parsed)) : fallback;
setDraft(String(normalized));
onCommit(normalized);
}
return <input type="number" min={min} max={max} aria-label={label} value={draft}
onChange={(event) => setDraft(event.target.value)} onBlur={commit} />;
}
export function Instructor() {
const [sessionId, setSessionId] = useState<string | null>(sessionIdFromUrl());
const [scenarioId, setScenarioId] = useState("");
const [ticketFilter, setTicketFilter] = useState("");
const [categoryFilter, setCategoryFilter] = useState("");
const [levelFilter, setLevelFilter] = useState<Level | "">("");
const [scenarioSearch, setScenarioSearch] = useState("");
const [cardSource, setCardSource] = useState<"system" | "trainee" | "mixed">("system");
const [submissionReviewComments, setSubmissionReviewComments] = useState<Record<string, string>>({});
const [extraScenarioIds, setExtraScenarioIds] = useState<string[]>([]);
const [randomScenario, setRandomScenario] = useState(false);
const [mode, setMode] = useState<SessionMode>("training");
const [exercise, setExercise] = useState<"dds" | "call" | "card" | "card_dds">("dds");
const [decisionTimeLimit, setDecisionTimeLimit] = useState(30);
const [cardFillTimeLimit, setCardFillTimeLimit] = useState(180);
const [ddsCardWorkTimeLimit, setDdsCardWorkTimeLimit] = useState(180);
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);
@ -48,6 +96,10 @@ export function Instructor() {
// Запускается только занятие, созданное этой кнопкой. Открытая из истории
// ссылка на идущее занятие не должна запускать его заново и сбрасывать.
const toStart = useRef<string | null>(null);
const [startPendingId, setStartPendingId] = useState<string | null>(null);
const startPendingRef = useRef<string | null>(null);
const [startError, setStartError] = useState("");
const startTimeout = useRef<number | null>(null);
const [noteText, setNoteText] = useState("");
const [overrideReport, setOverrideReport] = useState<SessionReport | null>(null);
const [overrideScore, setOverrideScore] = useState("");
@ -56,11 +108,16 @@ export function Instructor() {
const [overrideBusy, setOverrideBusy] = useState(false);
const scenarios = useScenarios();
const scenarioSubmissions = useScenarioSubmissions();
const reviewScenarioSubmission = useReviewScenarioSubmission();
const incidentGroups = useIncidentGroups();
const history = useSessions();
const activeSessions = useActiveSessions();
const trainees = useTrainees();
const session = useObserve(sessionId);
const control = useControl(sessionId);
const pendingSubmissions = (scenarioSubmissions.data ?? []).filter((item) => item.status === "pending");
const ddsExcludedScenarios = (scenarios.data ?? []).filter((item) => item.outcome !== "card");
useEffect(() => {
if (!session.report) return;
@ -70,48 +127,98 @@ export function Instructor() {
}, [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)) &&
(exercise !== "dds" || item.outcome === "card") &&
(cardSource === "mixed" || item.source === cardSource) &&
(!ticketFilter || cardSource === "trainee" || item.source === "trainee" || item.ticket === Number(ticketFilter)) &&
(!categoryFilter || item.source === "trainee" || item.ekp_group === Number(categoryFilter)) &&
(!levelFilter || item.level === levelFilter) &&
(!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 selectedScenario = visibleScenarios.find((item) => item.id === scenarioId);
const hasScenarioFilters = Boolean(ticketFilter || categoryFilter || levelFilter || scenarioSearch.trim() || cardSource !== "system");
function clearScenarioFilters() {
setTicketFilter("");
setCategoryFilter("");
setLevelFilter("");
setScenarioSearch("");
setCardSource("system");
setWholeTicket(false);
}
const selectedTicketScenarios = ticketFilter
? (scenarios.data ?? []).filter((item) => item.ticket === Number(ticketFilter))
.filter((item) => item.source === "system")
.filter((item) => exercise !== "dds" || item.outcome === "card")
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
: [];
const [wholeTicket, setWholeTicket] = useState(false);
const canStartSelectedExercise = exercise === "dds" && wholeTicket
? selectedTicketScenarios.length === 3
? selectedTicketScenarios.length > 0
: Boolean(scenarioId);
const startDisabledReason = scenarios.isLoading || trainees.isLoading
? "Загружаются сценарии и список курсантов…"
: !canStartSelectedExercise
? "Выберите подходящий сценарий или билет перед запуском."
: Boolean(trainees.data?.length && (!traineeId || (traineeId === "__demo__" && !DEMO_MODE)))
? DEMO_MODE ? "Выберите курсанта из списка или пункт «Демо-курсант»." : "Сначала выберите курсанта из списка."
: "";
const selectedTraineeLabel = trainees.data?.find((item) => item.id === traineeId)?.name
?? (traineeId === "__demo__" ? trainee.trim() || "Демо-курсант" : trainee.trim());
const exerciseLabel = exercise === "dds" ? "Диспетчер ДДС"
: exercise === "call" ? "Оператор 112 · звонок"
: exercise === "card" ? "Оператор 112 · текстовая вводная" : "Полный цикл 112 → ДДС";
useEffect(() => () => {
if (startTimeout.current !== null) window.clearTimeout(startTimeout.current);
}, []);
useEffect(() => {
if (!sessionId || sessionId !== startPendingId) return;
if (session.title) {
if (startTimeout.current !== null) window.clearTimeout(startTimeout.current);
startTimeout.current = null;
startPendingRef.current = null;
setStartPendingId(null);
setStartError("");
return;
}
// observe may connect just before scenario.start and report a transient
// session_not_found. Do not present that expected startup race as failure.
if (session.error && session.error !== "Занятие не запущено") {
if (startTimeout.current !== null) window.clearTimeout(startTimeout.current);
startTimeout.current = null;
toStart.current = null;
startPendingRef.current = null;
setStartPendingId(null);
setStartError(session.error);
}
}, [sessionId, startPendingId, session.title, session.error]);
useEffect(() => {
if (!visibleScenarios.some((item) => item.id === scenarioId)) {
setScenarioId(visibleScenarios[0]?.id ?? "");
}
}, [scenarios.data, ticketFilter, categoryFilter, scenarioSearch, scenarioId]);
}, [scenarios.data, ticketFilter, categoryFilter, levelFilter, scenarioSearch, scenarioId, exercise, cardSource]);
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>);
if (startPendingId || toStart.current || startDisabledReason) return;
if (trainees.data?.length && (!traineeId || (traineeId === "__demo__" && !DEMO_MODE))) return;
const id = crypto.randomUUID();
// Номер занятия живёт в адресе: монитор и АРМ курсанта открываются ссылкой.
window.history.replaceState(null, "", `/instructor?session=${id}`);
toStart.current = id;
startPendingRef.current = id;
setStartPendingId(id);
setStartError("");
setSessionId(id);
startTimeout.current = window.setTimeout(() => {
if (startPendingRef.current !== id) return;
startPendingRef.current = null;
if (toStart.current === id) toStart.current = null;
startTimeout.current = null;
setStartPendingId(null);
setStartError("Не удалось запустить занятие. Проверьте подключение и нажмите «Повторить запуск».");
}, 15_000);
}
async function saveScoreOverride(report: SessionReport) {
@ -145,33 +252,55 @@ export function Instructor() {
}
}
function openActiveSession(item: NonNullable<typeof activeSessions.data>[number]) {
toStart.current = null;
startPendingRef.current = null;
if (startTimeout.current !== null) window.clearTimeout(startTimeout.current);
startTimeout.current = null;
setStartPendingId(null);
window.history.replaceState(null, "", `/instructor?session=${item.session_id}`);
setSessionId(item.session_id);
setScenarioId(item.scenario_id);
setExercise(item.exercise);
setMode(item.mode);
}
// Занятие запускается, когда канал управления открылся.
useEffect(() => {
if (!sessionId || control.status !== "open" || toStart.current !== sessionId) return;
toStart.current = null;
if (!sessionId || control.openSessionId !== sessionId || toStart.current !== sessionId) return;
const selected = trainees.data?.find((item) => item.id === traineeId);
const primaryScenarioId = exercise === "dds" && wholeTicket
? selectedTicketScenarios[0]?.id ?? scenarioId : scenarioId;
const scenarioIds = exercise === "dds"
? (wholeTicket
const scenarioIds = exercise === "dds" || exercise === "card_dds"
? (exercise === "dds" && wholeTicket
? selectedTicketScenarios.map((item) => item.id)
: [scenarioId, ...extraScenarioIds.filter((id) => id !== scenarioId)])
: [scenarioId, ...extraScenarioIds.filter((id) => id !== scenarioId &&
visibleScenarios.some((item) => item.id === id))])
.filter((id) => scenarios.data?.find((item) => item.id === id)?.outcome === "card")
.filter((id, index, all) => id && all.indexOf(id) === index)
: undefined;
control.start(primaryScenarioId, mode, selected?.name || trainee || "Демо-курсант",
const randomScenarioIds = randomScenario && exercise !== "call"
&& !(exercise === "dds" && wholeTicket)
? visibleScenarios.filter((item) => item.outcome === "card").map((item) => item.id)
: undefined;
const sent = control.start(sessionId, primaryScenarioId, mode, selected?.name || trainee || "Демо-курсант",
exercise === "card_dds" ? "card" : exercise,
undefined, selected?.id, selected?.service ?? undefined,
scenarioIds && scenarioIds.length > 1 ? scenarioIds : undefined,
ddsArrivalIntervalSeconds, ddsMaxWaiting,
exercise === "card_dds", {
decision_time_limit_seconds: decisionTimeLimit,
card_fill_time_limit_seconds: cardFillTimeLimit,
dds_card_work_time_limit_seconds: ddsCardWorkTimeLimit,
allowed_errors: allowedErrors,
require_correct_grammar: requireCorrectGrammar,
score_weights: lessonScoreWeights,
});
}, randomScenarioIds);
if (sent) toStart.current = null;
}, [sessionId, control, scenarioId, extraScenarioIds, mode, trainee, traineeId, trainees.data,
exercise, decisionTimeLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios,
ddsArrivalIntervalSeconds, ddsMaxWaiting, lessonScoreWeights]);
exercise, decisionTimeLimit, cardFillTimeLimit, ddsCardWorkTimeLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios,
ddsArrivalIntervalSeconds, ddsMaxWaiting, lessonScoreWeights, scenarios.data,
randomScenario, visibleScenarios]);
const links = sessionId
? {
@ -182,7 +311,7 @@ export function Instructor() {
: null;
return (
<main className="page instructor-page">
<main className={`page instructor-page${session.report ? " has-report" : ""}`}>
<StandStatus />
<ModeBanner mode={session.mode ?? mode} />
<h1>Рабочее место преподавателя</h1>
@ -191,45 +320,197 @@ export function Instructor() {
<a href="/materials">Учебные материалы и назначения →</a>
</p>
<div className="instructor-layout">
<details className="instructor-live-group instructor-secondary-panel" open={Boolean(activeSessions.data?.length)}>
<summary>
<span>Занятия группы в реальном времени</span>
<small>{activeSessions.isLoading ? "Обновляем список…" : activeSessions.data?.length
? `${activeSessions.data.length} активных` : "Сейчас нет активных занятий"}</small>
</summary>
<p className="ref" role="status" aria-live="polite">
Список ваших активных сессий обновляется автоматически каждые 2 секунды.
</p>
<div className="dds-table-wrap"><table className="grid">
<thead><tr><th>Курсант</th><th>Упражнение · карточка</th><th>Ход</th><th>ДДС</th><th>Задержки</th><th></th></tr></thead>
<tbody>
{(activeSessions.data ?? []).map((item) => (
<tr key={item.session_id}>
<td>{item.trainee_name || "Демо-курсант"}<small className="ref">{modeLabel(item.mode)} · {Math.floor(item.elapsed_seconds / 60)} мин {item.elapsed_seconds % 60} с</small></td>
<td>{item.scenario_title}<small className="ref">{item.scenario_id}{item.exercise === "dds" && ` · ${item.dds_open_cards}/${item.dds_card_total} карточек открыто`}</small></td>
<td>{Object.entries(item.dds_statuses).map(([service, status]) =>
<span key={service}>{service}: {status}<br /></span>)}
{!Object.keys(item.dds_statuses).length && (item.exercise === "dds" ? "Ожидает первичного решения" : "Упражнение выполняется")}
{item.dds_snapshot && <details className="active-session-details">
<summary>Карточки и доклады ({item.dds_snapshot.queue_cards?.length ?? 0})</summary>
{item.dds_snapshot.crew_selected && <p><strong>Бригада:</strong> {item.dds_snapshot.crew_selected}</p>}
{item.dds_snapshot.phone_pending && <p className="ref" role="status">
Идёт связь с бригадой {item.dds_snapshot.phone_pending.crew}.
</p>}
{item.dds_snapshot.queue_cards?.map((card) => <article className="active-session-card" key={card.card_id}>
<strong>{card.active ? "Сейчас в работе: " : "Ожидает: "}{card.title}</strong>
{card.address && <span>{card.address}</span>}
<small>{card.managed_service ? `${card.managed_service} · ` : ""}{SERVICE_STATUS_LABELS[card.service_status]} · {CARD_STATUS_LABELS[card.card_status]}</small>
<small>{card.timer_stopped ? `Первичная реакция за ${Math.ceil(card.elapsed_ms / 1000)} с` :
card.elapsed_ms > card.limit_ms ? `Просрочена первичная реакция · ${Math.ceil((card.elapsed_ms - card.limit_ms) / 1000)} с` :
`На первичное решение осталось ${Math.ceil((card.limit_ms - card.elapsed_ms) / 1000)} с`}</small>
</article>)}
{item.dds_snapshot.pending_cards_count ? <p className="ref">Ещё поступят по сценарию: {item.dds_snapshot.pending_cards_count}</p> : null}
{item.dds_snapshot.log.length > 0 && <div className="active-session-log">
<strong>Последние отметки служб</strong>
{item.dds_snapshot.log.slice(-4).reverse().map((entry, index) => <p key={`${entry.at}-${index}`}>
<span>{entry.service} · {SERVICE_STATUS_LABELS[entry.status]}</span>
{entry.comment && <small>{entry.comment}</small>}
</p>)}
</div>}
{item.dds_snapshot.phone_reports?.length ? <div className="active-session-log">
<strong>Доклады бригады</strong>
{item.dds_snapshot.phone_reports.map((report, index) => <p key={`${report.at}-${index}`}>
<span>{report.crew} · {report.phase === "dispatched" ? "выезд" : report.phase === "arrived" ? "прибытие" : report.phase === "working" ? "ход работ" : report.phase === "completed" ? "завершение" : report.phase}</span>
<small>{report.text}</small>
</p>)}
</div> : null}
</details>}</td>
<td>{item.exercise === "dds" ? `Открытых карточек: ${item.dds_open_cards}` : "—"}</td>
<td className={item.dds_overdue_cards ? "state-violated" : "state-ok"}>
{item.dds_overdue_cards || item.dds_work_overdue_cards
? `Просрочено: первичная реакция ${item.dds_overdue_cards}; отработка карточки ${item.dds_work_overdue_cards}`
: "Нормативы не нарушены"}
</td>
<td><button type="button" onClick={() => openActiveSession(item)}>
{sessionId === item.session_id ? "Открыто" : "Наблюдать"}
</button></td>
</tr>
))}
{!activeSessions.data?.length && <tr><td colSpan={6}>
{activeSessions.isLoading ? "Загружаем активные занятия…" : "Активных занятий пока нет."}
</td></tr>}
</tbody>
</table></div>
</details>
<details className="student-scenario-review instructor-secondary-panel" open={pendingSubmissions.length > 0}>
<summary>
<span>Карточки курсантов на проверку</span>
<small>{scenarioSubmissions.isLoading ? "Проверяем очередь…" : pendingSubmissions.length
? `${pendingSubmissions.length} ожидают решения` : "Новых карточек нет"}</small>
</summary>
<p className="ref">Только ваши учебные группы. Утверждение создаёт карточку для очереди ДДС; преподаватель проверяет её факты до публикации.</p>
{pendingSubmissions.map((item) => (
<article className="student-scenario-proposal" key={item.id}>
<header><strong>{item.title}</strong><span>{item.author_name} · {item.level} · {item.incident_type}</span></header>
{item.kio ? <details><summary>Проверить КИО целиком</summary>
<KioCard state={{ server: item.kio, pending: {} }} readOnly />
</details> : <><p>{item.description}</p>
{item.address && <p><strong>Адрес:</strong> {item.address}</p>}
{item.victims !== null && <p><strong>Пострадавших:</strong> {item.victims}</p>}
</>}
<label>Комментарий преподавателя<textarea maxLength={1000} value={submissionReviewComments[item.id] ?? ""}
onChange={(event) => setSubmissionReviewComments((previous) => ({ ...previous, [item.id]: event.target.value }))} /></label>
<footer>
<button type="button" disabled={reviewScenarioSubmission.isPending}
onClick={() => reviewScenarioSubmission.mutate({ id: item.id, decision: "approve", comment: submissionReviewComments[item.id] })}>
Проверил(а), утвердить в банк ДДС
</button>
<button type="button" disabled={reviewScenarioSubmission.isPending || !(submissionReviewComments[item.id] ?? "").trim()}
onClick={() => reviewScenarioSubmission.mutate({ id: item.id, decision: "reject", comment: submissionReviewComments[item.id] })}>
Вернуть на доработку
</button>
{reviewScenarioSubmission.isError && <span role="alert" className="state-violated">{reviewScenarioSubmission.error.message}</span>}
</footer>
</article>
))}
{!scenarioSubmissions.isLoading && !pendingSubmissions.length &&
<p className="ref">Предложения курсантов из ваших групп появятся здесь.</p>}
</details>
<div className={`instructor-layout${sessionId ? " has-session" : " is-idle"}`}>
<section>
<h2>Занятие</h2>
<h2>Настроить занятие</h2>
<p className="lesson-setup-intro"><strong>Четыре шага до запуска</strong><span>Выберите сценарий, упражнение, режим и курсанта. Остальные настройки уже подготовлены — при необходимости их можно раскрыть ниже.</span></p>
<table className="grid">
<tbody>
<tr>
<th>Сценарий</th>
<th><span className="lesson-step-title"><i>1</i> Сценарий</span></th>
<td>
<div className="instructor-scenario-filters">
<select aria-label="Категория происшествия" value={categoryFilter} onChange={(event) => setCategoryFilter(event.target.value)}>
<details className="scenario-filters-details">
<summary>Фильтры сценариев <span>{hasScenarioFilters ? "изменены · нажмите, чтобы настроить" : "необязательно · нажмите, чтобы открыть"}</span></summary>
<div className="instructor-scenario-filters">
<label><span>Источник карточек</span><select aria-label="Источник карточек" value={cardSource} onChange={(event) => {
setCardSource(event.target.value as "system" | "trainee" | "mixed");
setWholeTicket(false);
}}>
<option value="system">Системные карточки</option>
<option value="trainee">Сформированные курсантами</option>
<option value="mixed">Системные + курсанты</option>
</select></label>
<label><span>Категория ЕКП</span><select aria-label="Категория происшествия" value={categoryFilter} onChange={(event) => setCategoryFilter(event.target.value)}>
<option value="">Все 23 категории ЕКП</option>
{incidentGroups.data?.groups.map((group) =>
<option key={group.id} value={group.id}>{group.title}</option>)}
</select>
<select aria-label="Билет заказчика" value={ticketFilter} onChange={(event) => {
</select></label>
<label><span>Билет</span><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)} />
</select></label>
<label><span>Сложность</span><select aria-label="Уровень сложности" value={levelFilter}
onChange={(event) => setLevelFilter(event.target.value as Level | "")}>
<option value="">Все уровни сложности</option>
<option value="L1">L1 · простой вызов</option>
<option value="L2">L2 · заявитель в стрессе</option>
<option value="L3">L3 · обрыв связи / обратный вызов</option>
</select></label>
</div>
{hasScenarioFilters && <button className="scenario-filters-reset" type="button" onClick={clearScenarioFilters}>Сбросить фильтры</button>}
</details>
<div className="instructor-scenario-filters scenario-search-only">
<label className="instructor-filter-search"><span>Найти карточку</span><input type="search" aria-label="Поиск сценария" value={scenarioSearch}
placeholder="Название или номер" onChange={(event) => setScenarioSearch(event.target.value)} /></label>
</div>
<select value={scenarioId} disabled={exercise === "dds" && wholeTicket} onChange={(event) => {
setScenarioId(event.target.value);
setExtraScenarioIds((previous) => previous.filter((id) => id !== event.target.value));
}}>
{!visibleScenarios.length && <option value="">Карточки не найдены</option>}
{visibleScenarios.map((scenario) => (
<option key={scenario.id} value={scenario.id}>
{scenario.ticket ? `Билет ${scenario.ticket}, вызов ${scenario.position} · ` : ""}{scenario.title} · {scenario.level}
</option>
))}
</select>
<small className="ref">{visibleScenarios.length} из {scenarios.data?.length ?? 0} карточек (96 — билеты заказчика)</small>
{exercise === "dds" && ticketFilter && <label className="mode-choice">
<input type="checkbox" checked={wholeTicket && selectedTicketScenarios.length === 3}
disabled={selectedTicketScenarios.length !== 3}
<label className="instructor-primary-scenario"><span>Сценарий занятия</span>
<select aria-label="Сценарий занятия" value={scenarioId} disabled={exercise === "dds" && wholeTicket} onChange={(event) => {
setScenarioId(event.target.value);
setExtraScenarioIds((previous) => previous.filter((id) => id !== event.target.value));
}}>
{!visibleScenarios.length && <option value="">Карточки не найдены</option>}
{visibleScenarios.map((scenario) => (
<option key={scenario.id} value={scenario.id}>
{scenario.source === "trainee" ? "От курсанта · " : scenario.ticket ? `Билет ${scenario.ticket}, вызов ${scenario.position} · ` : ""}{scenario.title} · {levelLabel(scenario.level)}
</option>
))}
</select>
</label>
{!scenarios.isLoading && visibleScenarios.length === 0 && <div className="scenario-empty-state" role="status">
<strong>Подходящих сценариев не найдено</strong>
<span>Измените поиск или фильтры. Можно быстро вернуться к системным карточкам.</span>
{hasScenarioFilters && <button type="button" onClick={clearScenarioFilters}>Сбросить фильтры</button>}
</div>}
{selectedScenario && <div className="selected-scenario-summary" aria-live="polite">
<span>Для занятия выбрано</span>
<strong>{selectedScenario.title}</strong>
<small>{selectedScenario.source === "trainee" ? "Предложение курсанта" : selectedScenario.ticket ? `Билет ${selectedScenario.ticket}` : "Учебный сценарий"} · {levelLabel(selectedScenario.level)}</small>
{(selectedScenario.dds || selectedScenario.topics?.length || selectedScenario.outcome) && <div className="selected-scenario-details">
{selectedScenario.dds && <span><b>Для ДДС:</b> {selectedScenario.dds}</span>}
{Boolean(selectedScenario.topics?.length) && <span><b>Темы:</b> {selectedScenario.topics!.join(" · ")}</span>}
{selectedScenario.outcome && <span><b>Тип:</b> {selectedScenario.outcome === "card" ? "карточка происшествия" : selectedScenario.outcome === "consultation" ? "консультация" : "передача в другой регион"}</span>}
</div>}
</div>}
<small className="ref scenario-count">
Найдено: {visibleScenarios.length} из {scenarios.data?.length ?? 0} сценариев
{exercise === "dds" && ddsExcludedScenarios.length > 0 &&
` · ${ddsExcludedScenarios.length} не являются карточками ДДС`}
</small>
{exercise !== "call" && !(exercise === "dds" && wholeTicket) && <label className="mode-choice">
<input type="checkbox" checked={randomScenario}
disabled={!visibleScenarios.some((item) => item.outcome === "card")}
onChange={(event) => setRandomScenario(event.target.checked)} />
{" Случайный порядок карточек из отфильтрованного набора"}
<small className="ref"> — порядок формирует сервер; новые карточки поступают по заданному темпу, занятие можно завершить в любой момент</small>
</label>}
{exercise === "dds" && ticketFilter && cardSource === "system" && <label className="mode-choice">
<input type="checkbox" checked={wholeTicket && selectedTicketScenarios.length > 0}
disabled={selectedTicketScenarios.length === 0}
onChange={(event) => {
if (event.target.checked && selectedTicketScenarios[0]) {
setScenarioId(selectedTicketScenarios[0].id);
@ -237,13 +518,15 @@ export function Instructor() {
}
setWholeTicket(event.target.checked);
}} />
{` Запустить весь билет ${ticketFilter} целиком (3 вызова)`}
{selectedTicketScenarios.length !== 3 && <small className="ref"> — в библиотеке найдено {selectedTicketScenarios.length} из 3 вызовов</small>}
{` Запустить из билета ${ticketFilter} карточки для ДДС (${selectedTicketScenarios.length})`}
{exercise === "dds" && selectedTicketScenarios.length < 3 &&
<small className="ref"> — вызовы без карточки в ДДС не включаются в очередь</small>}
{selectedTicketScenarios.length === 0 && <small className="ref"> — нет карточек для упражнения ДДС</small>}
</label>}
{exercise === "dds" && !wholeTicket && <details className="scenario-queue">
<summary>Дополнительные карточки ({extraScenarioIds.length})</summary>
<p className="ref">Интервал и максимум ожидающих задаются в критериях занятия. Таймер каждой карточки начинается при поступлении и идёт, пока курсант занят другой.</p>
{scenarios.data?.filter((scenario) => scenario.id !== scenarioId).map((scenario) =>
{(exercise === "dds" || exercise === "card_dds") && !wholeTicket && <details className="scenario-queue">
<summary>{exercise === "card_dds" ? "Готовые карточки после КИО курсанта" : "Дополнительные карточки"} ({extraScenarioIds.length})</summary>
<p className="ref">{exercise === "card_dds" ? "Сначала курсант заполнит и передаст КИО, затем карточка попадёт в ДДС первой; отмеченные сценарии поступят следом." : "Интервал и максимум ожидающих задаются в критериях занятия. Таймер каждой карточки начинается при поступлении и идёт, пока курсант занят другой."}</p>
{visibleScenarios.filter((scenario) => scenario.id !== scenarioId && scenario.outcome === "card").map((scenario) =>
<label key={scenario.id} className="mode-choice">
<input type="checkbox" checked={extraScenarioIds.includes(scenario.id)} onChange={() =>
setExtraScenarioIds((previous) => previous.includes(scenario.id)
@ -254,46 +537,58 @@ export function Instructor() {
</td>
</tr>
<tr>
<th>Упражнение</th>
<th><span className="lesson-step-title"><i>2</i> Упражнение</span></th>
<td>
<label className="mode-choice"><input type="radio" checked={exercise === "dds"}
onChange={() => setExercise("dds")} /> Диспетчер ДДС · готовая карточка</label>
<label className="mode-choice"><input type="radio" checked={exercise === "call"}
onChange={() => setExercise("call")} /> Оператор 112 · звонок</label>
<label className="mode-choice"><input type="radio" checked={exercise === "card"}
onChange={() => setExercise("card")} /> Оператор 112 · текстовая вводная</label>
<label className="mode-choice"><input type="radio" checked={exercise === "card_dds"}
onChange={() => setExercise("card_dds")} /> Связка 112 → ДДС · одна карточка</label>
<div className="exercise-options">
<label className="exercise-option"><input type="radio" checked={exercise === "dds"}
onChange={() => setExercise("dds")} /><span><strong>Диспетчер ДДС</strong><small>Разобрать готовую карточку и передать её службам.</small></span></label>
<label className="exercise-option"><input type="radio" checked={exercise === "call"}
onChange={() => setExercise("call")} /><span><strong>Оператор 112 · звонок</strong><small>Принять учебный вызов и заполнить карточку.</small></span></label>
<label className="exercise-option"><input type="radio" checked={exercise === "card"}
onChange={() => setExercise("card")} /><span><strong>Оператор 112 · текстовая вводная</strong><small>Отработать заполнение КИО без аудиозвонка.</small></span></label>
<label className="exercise-option"><input type="radio" checked={exercise === "card_dds"}
onChange={() => setExercise("card_dds")} /><span><strong>Полный цикл 112 → ДДС</strong><small>Курсант заполнит КИО, затем отработает готовые карточки в ДДС.</small></span></label>
</div>
{exercise === "call" && <p className="ref">В режиме звонка поля КИО откроются после ответа на вызов; браузеру понадобятся разрешения на микрофон и звук. Для демонстрации без аудио выберите «Оператор 112 · текстовая вводная» — её поля доступны сразу.</p>}
</td>
</tr>
<tr>
<th>Режим</th>
<th><span className="lesson-step-title"><i>3</i> Режим</span></th>
<td>
{MODES.map((item) => (
<label key={item.value} className="mode-choice">
<input
type="radio"
checked={mode === item.value}
onChange={() => setMode(item.value)}
/>{" "}
{item.label}
<label key={item.value} className="mode-choice lesson-mode-choice">
<input
type="radio"
checked={mode === item.value}
onChange={() => setMode(item.value)}
/>{" "}
<span><strong>{item.label}</strong><small>{item.hint}</small></span>
</label>
))}
</td>
</tr>
<tr>
<th>Курсант</th>
<th><span className="lesson-step-title"><i>4</i> Курсант</span></th>
<td>
{trainees.data?.length ? (
<select value={traineeId} onChange={(event) => setTraineeId(event.target.value)}>
<option value="">Выберите учётную запись</option>
{trainees.data.map((item) => (
<option key={item.id} value={item.id}>{item.name}{item.group ? ` · ${item.group}` : ""}{item.service ? ` · ${item.service}` : " · служба не назначена"}</option>
))}
</select>
<>
<select aria-label="Учётная запись курсанта" 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}` : ""}{item.service ? ` · ${item.service}` : " · служба не назначена"}</option>
))}
{DEMO_MODE && <option value="__demo__">Демо-курсант без учётной записи</option>}
</select>
{DEMO_MODE && traineeId === "__demo__" && <label className="demo-trainee-name">
Имя для этого занятия
<input type="text" aria-label="Имя демо-курсанта" value={trainee}
placeholder="Например, Иван Петров" onChange={(event) => setTrainee(event.target.value)} />
<small className="ref">Учётная запись и персональный отчёт не создаются; это безопасный режим для демонстрации.</small>
</label>}
</>
) : (
<>
<input type="text" value={trainee} placeholder="имя для демо"
<input type="text" aria-label="Имя демо-курсанта" value={trainee} placeholder="имя для демо"
onChange={(event) => setTrainee(event.target.value)} />
<small className="ref">Без учётной записи: личный отчёт курсанту недоступен.</small>
</>
@ -301,71 +596,106 @@ export function Instructor() {
</td>
</tr>
<tr>
<th>Критерии занятия</th>
<th>Оценивание</th>
<td>
<details className="lesson-criteria-details">
<summary>Настроить нормативы и критерии оценки</summary>
<p className="ref">
{exercise === "dds"
? `По умолчанию: решение за ${decisionTimeLimit} с, отработка карточки за ${ddsCardWorkTimeLimit} с; новые карточки поступают каждые ${ddsArrivalIntervalSeconds} с.`
: exercise === "card_dds"
? `По умолчанию: заполнение КИО за ${cardFillTimeLimit} с, отработка карточки ДДС за ${ddsCardWorkTimeLimit} с.`
: exercise === "card"
? `По умолчанию: заполнение КИО за ${cardFillTimeLimit} с.`
: "Для звонка действуют нормативы приёма вызова по ГОСТ."}
</p>
{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)))} />
<LessonNumberInput label="Интервал поступления карточек ДДС, секунд"
value={ddsArrivalIntervalSeconds} min={0} max={300} fallback={0}
onCommit={setDdsArrivalIntervalSeconds} />
</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)))} />
<LessonNumberInput label="Максимум ожидающих карточек ДДС"
value={ddsMaxWaiting} min={1} max={10} fallback={1} onCommit={setDdsMaxWaiting} />
</label>
</>}
<label className="lesson-criterion">
{(exercise === "dds" || exercise === "card_dds") && <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>
<LessonNumberInput label="Лимит решения ДДС, секунд" value={decisionTimeLimit}
min={5} max={300} fallback={30} onCommit={setDecisionTimeLimit} />
</label>}
{(exercise === "card" || exercise === "card_dds") && <label className="lesson-criterion">
Норматив заполнения карточки КИО, секунд
<LessonNumberInput label="Норматив заполнения карточки КИО, секунд"
value={cardFillTimeLimit} min={30} max={1800} fallback={180} onCommit={setCardFillTimeLimit} />
<small className="ref">По умолчанию 180 секунд; в отчёте будут факт и отклонение от норматива.</small>
</label>}
{(exercise === "dds" || exercise === "card_dds") && <label className="lesson-criterion">
Норматив отработки карточки ДДС, секунд
<LessonNumberInput label="Норматив отработки карточки ДДС, секунд"
value={ddsCardWorkTimeLimit} min={30} max={1800} fallback={180}
onCommit={setDdsCardWorkTimeLimit} />
<small className="ref">По умолчанию 180 секунд; не заменяет 30-секундное первичное решение.</small>
</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)))} />
<LessonNumberInput label="Допустимое число ошибок" value={allowedErrors}
min={0} max={50} fallback={0} onCommit={setAllowedErrors} />
</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>}
<ScoreWeightsEditor exercise={exercise === "card_dds" ? "card_dds" : exercise}
values={lessonScoreWeights} onChange={setLessonScoreWeights} />
<small className="ref">Нормативы ГОСТ для приёма вызова не изменяются.</small>
</details>
</td>
</tr>
</tbody>
</table>
<p>
<div className="lesson-launch-summary" aria-label="Проверка настроек занятия">
<strong>Перед запуском</strong>
<ul>
<li className={canStartSelectedExercise ? "is-ready" : "is-missing"}>
<span aria-hidden="true">{canStartSelectedExercise ? "✓" : "1"}</span>
<span><b>Сценарий</b><small>{selectedScenario?.title ?? (wholeTicket ? `Билет ${ticketFilter}` : "Выберите сценарий выше")}</small></span>
</li>
<li className="is-ready">
<span aria-hidden="true">✓</span><span><b>Упражнение и режим</b><small>{exerciseLabel} · {MODES.find((item) => item.value === mode)?.label}</small></span>
</li>
<li className={selectedTraineeLabel ? "is-ready" : "is-missing"}>
<span aria-hidden="true">{selectedTraineeLabel ? "✓" : "3"}</span>
<span><b>Курсант</b><small>{selectedTraineeLabel || "Выберите участника выше"}</small></span>
</li>
</ul>
</div>
<div className="lesson-start-bar">
<button type="button" className="start" onClick={startLesson}
disabled={!canStartSelectedExercise || trainees.isLoading || Boolean(trainees.data?.length && !traineeId)}>
Начать занятие
disabled={Boolean(startDisabledReason) || Boolean(startPendingId)}>
{startPendingId ? "Создаём занятие…" : session.title ? "Начать новое занятие" : "Начать занятие"}
</button>
</p>
{sessionId && (
{startDisabledReason && <small className="ref" role="status">{startDisabledReason}</small>}
{startPendingId && <small className="ref" role="status">Подключаемся к занятию. Не нажимайте повторно — кнопка станет доступна после запуска.</small>}
{startError && <small className="ref" role="alert">{startError} <button type="button" onClick={startLesson}>Повторить запуск</button></small>}
{links && session.title && <small className="ref">После запуска появятся отдельные ссылки для курсанта, ДДС и монитора.</small>}
</div>
{sessionId && session.title && (
<p>
{session.ended ? "занятие завершено" : (
<button type="button" onClick={control.stop}>Завершить занятие</button>
)}
</p>
)}
{sessionId && !session.ended && (
{sessionId && session.title && !session.ended && (
exercise === "call" && <Director onInject={control.inject} disabled={control.status !== "open"} error={session.error} />
)}
{links && !session.title && !session.error && (
<p className="ref" role="status">Запускаем занятие и готовим ссылки на рабочие места…</p>
{links && startPendingId && (
<p className="ref" role="status">Создаём занятие. Ссылки появятся автоматически после подключения.</p>
)}
{links && session.title && (
<table className="grid">
@ -380,7 +710,10 @@ export function Instructor() {
<section>
<h2>Ход занятия</h2>
{!sessionId && <p>Занятие не запущено.</p>}
{!sessionId && <div className="lesson-empty-state">
<strong>Здесь появится ход занятия</strong>
<span>После запуска здесь будут диалог, таймеры и карточка курсанта.</span>
</div>}
{sessionId && (
<>
<InterviewTimer timers={session.timers} />
@ -400,7 +733,7 @@ export function Instructor() {
)}
{noteFor === line.ref && (
<div>
<input type="text" value={noteText} autoFocus
<input type="text" aria-label="Пометка преподавателя к реплике" value={noteText} autoFocus
placeholder="здесь надо было предупредить о прибытии"
onChange={(event) => setNoteText(event.target.value)} />{" "}
<button type="button" onClick={() => {
@ -419,6 +752,7 @@ export function Instructor() {
<h3>Нормативы</h3>
<Timers timers={session.timers} />
<h3>Карточка курсанта</h3>
<p className="ref instructor-readonly-note">Это просмотр преподавателя: редактировать карточку здесь нельзя. После запуска откройте отдельную ссылку «АРМ курсанта» — ввод выполняется там.</p>
{/* Только чтение: преподаватель управляет ситуацией, а не работой обучаемого. */}
<KioCard state={session.card} required={session.requiredFields} readOnly />
</>
@ -428,17 +762,23 @@ export function Instructor() {
<section>
<h2>История группы</h2>
<p><a href="/profile">профили курсантов</a> · <a href="/groups">аналитика группы</a></p>
<table className="grid">
<tbody>
{history.data?.slice(0, 15).map((item) => (
<tr key={item.session_id}>
<th>{item.mode} · попытка {item.attempt}</th>
<td><a href={`/instructor?session=${item.session_id}`}>{item.scenario_id}</a></td>
</tr>
))}
{!history.data?.length && <tr><td colSpan={2}>занятий ещё не было</td></tr>}
</tbody>
</table>
{history.data?.length ? <div className="lesson-history-list">
{history.data.slice(0, 15).map((item) => {
const scenario = scenarios.data?.find((candidate) => candidate.id === item.scenario_id);
const completed = Boolean(item.ended_at);
const dateLabel = sessionDateLabel(item.started_at ?? item.ended_at);
return <a key={item.session_id} href={`/instructor?session=${item.session_id}`}>
<strong>{scenario?.title ?? "Учебное занятие"}</strong>
<span>{modeLabel(item.mode)} · попытка {item.attempt}{dateLabel && ` · ${dateLabel}`}</span>
<small className={completed ? "lesson-history-complete" : "lesson-history-active"}>
{completed ? "Завершено · открыть результаты →" : "Занятие ещё идёт · открыть ход занятия →"}
</small>
</a>;
})}
</div> : <div className="lesson-history-empty">
<strong>История занятий пока пуста</strong>
<span>Запущенные занятия и их результаты появятся здесь автоматически.</span>
</div>}
</section>
</div>
@ -451,9 +791,11 @@ export function Instructor() {
}} onLibraryChanged={async () => { await scenarios.refetch(); }} />
{session.report && <>
<section className="debrief">
<h2>Корректировка итоговой оценки</h2>
<p className="ref">Автооценка {session.report.score_auto.toFixed(1)} / 100. Изменение сохраняется в журнале с вашим логином и обоснованием.</p>
<Debrief report={overrideReport?.session_id === session.report.session_id
? overrideReport : session.report} />
<section className="debrief debrief-score-override">
<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}
@ -466,12 +808,10 @@ export function Instructor() {
</label>
<button type="button" disabled={overrideBusy || !overrideComment.trim()}
onClick={() => void saveScoreOverride(session.report!)}>
{overrideBusy ? "Сохраняем…" : "Сохранить оценку с аудитом"}
{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

@ -37,7 +37,7 @@ export function Materials() {
const trainee = me.data?.role === "trainee";
const [showArchived, setShowArchived] = useState(false);
const materials = useMaterials(showArchived && !trainee);
const scenarios = useScenarios();
const scenarios = useScenarios(instructor, me.data?.role ?? "anonymous");
const trainees = useTrainees(instructor);
const groups = useGroups(instructor);
const create = useCreateMaterial();

View file

@ -7,9 +7,14 @@
import { useEffect, useState } from "react";
import { COMPETENCY_LABELS, Radar } from "@/features/debrief/Radar";
import { KioCard } from "@/features/kio-card/KioCard";
import { setNested } from "@/features/kio-card/merge";
import { useMe } from "@/features/auth/useAuth";
import { clock } from "@/features/timers/clock";
import { useProfile, useTrainees } from "@/shared/api/http";
import {
ApiError, useCreateScenarioSubmission, useProfile, useScenarioSubmissions, useTrainees,
} from "@/shared/api/http";
import type { Level } from "@/shared/types/generated";
const MODE_LABELS: Record<string, string> = {
training: "тренировочный",
@ -23,13 +28,35 @@ function sign(value: number | null, format: (value: number) => string): string {
return `${value > 0 ? "+" : "−"}${format(Math.abs(value))}`;
}
const EMPTY_STUDENT_KIO: Record<string, unknown> = { language: "ru", signs: [], notify: [] };
export function Profile() {
const url = new URLSearchParams(location.search);
const [traineeId, setTraineeId] = useState<string | null>(url.get("trainee"));
const [submissionTitle, setSubmissionTitle] = useState("");
const [submissionLevel, setSubmissionLevel] = useState<Level>("L1");
const [submissionKio, setSubmissionKio] = useState<Record<string, unknown>>(EMPTY_STUDENT_KIO);
const me = useMe();
const isTrainee = me.data?.role === "trainee";
const trainees = useTrainees(!isTrainee);
const profile = useProfile(traineeId);
const submissions = useScenarioSubmissions(Boolean(isTrainee));
const createSubmission = useCreateScenarioSubmission();
const submissionErrorCode = createSubmission.error instanceof ApiError
? createSubmission.error.code
: null;
const submissionError = submissionErrorCode === "trainee_group_required_for_review"
? "Попросите преподавателя включить вас в свою учебную группу, затем отправьте КИО ещё раз."
: submissionErrorCode === "instructor_group_required_for_review"
? "Для вашей группы пока не назначен преподаватель, который сможет проверить КИО."
: createSubmission.error?.message;
const submissionReady = typeof submissionKio.incident_type === "string"
&& typeof submissionKio.description === "string" && submissionKio.description.trim().length >= 20
&& Number.isInteger(submissionKio.incident_group)
&& Array.isArray(submissionKio.signs) && submissionKio.signs.length > 0
&& (typeof submissionKio.address === "string" && submissionKio.address.trim().length > 0
|| typeof submissionKio.street === "string" && submissionKio.street.trim().length > 0
&& typeof submissionKio.building === "string" && submissionKio.building.trim().length > 0);
useEffect(() => {
if (isTrainee && me.data?.trainee_id) setTraineeId(me.data.trainee_id);
@ -41,8 +68,44 @@ export function Profile() {
<p><a href="/materials">Открыть назначенные учебные материалы →</a></p>
{isTrainee && <section className="student-scenario-submit">
<h2>Отправить КИО на проверку</h2>
<p className="ref">Черновик увидит преподаватель вашей группы. Только после утверждения карточка появится в банке ДДС. Используйте вымышленные или обезличенные данные; не указывайте реальные имена и телефоны.</p>
<form onSubmit={(event) => {
event.preventDefault();
createSubmission.mutate({
title: submissionTitle,
level: submissionLevel,
kio: submissionKio,
}, { onSuccess: () => {
setSubmissionTitle("");
setSubmissionKio(EMPTY_STUDENT_KIO);
} });
}}>
<label>Название<input required minLength={3} maxLength={200} value={submissionTitle}
onChange={(event) => setSubmissionTitle(event.target.value)} /></label>
<label>Сложность<select value={submissionLevel} onChange={(event) => setSubmissionLevel(event.target.value as Level)}>
<option value="L1">L1</option><option value="L2">L2</option><option value="L3">L3</option>
</select></label>
<p className="ref">Заполните карточку КИО. Для отправки нужны описание от 20 символов, адрес, категория и хотя бы один признак происшествия. Код ЕКП и список служб рассчитываются автоматически; системные поля заполнит сервер.</p>
<KioCard state={{ server: submissionKio, pending: {} }} referenceArm onChange={(path, value) =>
setSubmissionKio((previous) => setNested(previous, path, value))} />
<button type="submit" disabled={createSubmission.isPending || !submissionReady}>Сохранить черновик преподавателю</button>
{createSubmission.isError && <p role="alert" className="state-violated">Не удалось отправить: {submissionError}</p>}
{createSubmission.isSuccess && <p role="status" className="state-ok">Черновик КИО отправлен и ожидает проверки.</p>}
</form>
<h3>Мои предложения</h3>
<ul>
{submissions.data?.map((item) => <li key={item.id}>
{item.title} · {item.level} — {item.status === "pending" ? "на проверке" : item.status === "approved" ? "утверждено, доступно в банке ДДС" : "нужно доработать"}
{item.review_comment && <span className="ref"> · комментарий: {item.review_comment}</span>}
</li>)}
{!submissions.data?.length && <li className="ref">Предложений пока нет.</li>}
</ul>
</section>}
{!isTrainee && <p>
<select value={traineeId ?? ""} onChange={(event) => setTraineeId(event.target.value || null)}>
<select aria-label="Курсант для просмотра профиля" value={traineeId ?? ""} onChange={(event) => setTraineeId(event.target.value || null)}>
<option value="">— выберите курсанта —</option>
{trainees.data?.map((trainee) => (
<option key={trainee.id} value={trainee.id}>
@ -68,6 +131,19 @@ export function Profile() {
<p className="ref">
радар — среднее по попыткам; подсказок использовано всего: {profile.data.hints_total}
</p>
<h3>Рекомендации к следующему занятию</h3>
{profile.data.recommendations.length > 0 ? (
<ul>
{profile.data.recommendations.map((item) => (
<li key={item.code}>
<strong>{item.code} · {item.title}:</strong> {item.recommendation}
<span className="ref"> · в последней оценённой попытке: {item.occurrences}</span>
</li>
))}
</ul>
) : (
<p className="ref">Для последней оценённой попытки замечаний нет.</p>
)}
{profile.data.attempts.some((attempt) => attempt.score !== null) && (
<p><a href={`/api/trainees/${profile.data.trainee.id}/certificate.pdf`}>
Скачать сертификат PDF

View file

@ -1,7 +1,8 @@
// АРМ курсанта: приём вызова и разговор голосом.
// Карточка КИО — lct-10, таймеры — lct-11.
import { useState } from "react";
import { useEffect, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { KioCard } from "@/features/kio-card/KioCard";
import { StandStatus } from "@/features/health/StandStatus";
@ -32,10 +33,20 @@ function ddsCode(value: unknown): DDSCode | undefined {
}
export function Call() {
const [textTurn, setTextTurn] = useState("");
const [resolveComment, setResolveComment] = useState("");
const [resolved, setResolved] = useState<"consultation" | "transfer_region" | null>(null);
const sessionId = sessionIdFromUrl();
const call = useCall(sessionId);
const location = useLocation();
const navigate = useNavigate();
const isCompletionScreen = location.pathname === "/trainee/completed";
useEffect(() => {
if (call.report && sessionId && !isCompletionScreen) {
navigate(`/trainee/completed?session=${sessionId}`, { replace: true });
}
}, [call.report, isCompletionScreen, navigate, sessionId]);
if (!sessionId) {
return (
@ -46,9 +57,50 @@ export function Call() {
);
}
if (isCompletionScreen) {
if (!call.report) return <main className="page completion-page">
<h1>Занятие завершено</h1>
{call.reportError
? <p role="alert" className="state-violated">{call.reportError}</p>
: <p role="status">Получаем итог занятия…</p>}
<Link to="/profile">Вернуться к профилю</Link>
</main>;
const report = call.report;
const nextSteps = report.metrics.filter((metric) => !metric.passed).slice(0, 3);
const completedCount = Math.max(0, report.metrics.length - report.failed_metrics);
return <main className="page completion-page">
<header className="completion-heading">
<span>Упражнение завершено</span>
<h1>{report.passed ? "Зачёт" : "Не зачтено"}</h1>
<p>{report.exercise === "card" ? "Заполнение карточки КИО" : report.exercise === "dds" ? "Работа диспетчера ДДС" : "Приём вызова 112"} · {report.scenario_id}</p>
</header>
<section className={`completion-score ${report.passed ? "is-passed" : "is-failed"}`} aria-label="Итог занятия">
<div><span>Ваш результат</span><strong>{report.score_final.toFixed(0)}<small> / 100</small></strong></div>
<p>{completedCount} критериев выполнено · {report.failed_metrics} требуют внимания</p>
</section>
{nextSteps.length > 0 && <section className="completion-focus">
<h2>Что потренировать в следующий раз</h2>
<ol>{nextSteps.map((metric) => <li key={metric.key}><strong>{metric.title}</strong><span>{metric.fact}</span></li>)}</ol>
</section>}
<details className="completion-details">
<summary>Подробный разбор занятия</summary>
<Debrief report={report} />
</details>
<nav className="completion-actions" aria-label="Действия после занятия">
<Link to="/profile">Вернуться к профилю</Link>
<a href={`/api/sessions/${report.session_id}/report.pdf`}>Скачать отчёт (PDF)</a>
</nav>
</main>;
}
if (call.briefing) {
const services = display(call.card, "notify");
const notifiedServices = Array.isArray(services) ? services.map(String) : [];
// Some already-open demo backends still send the old multi-line briefing.
// Keep only the caller's opening line; never dump the answer key into view.
const openingLine = call.briefing.text.split(/\r?\n/)[1]
?? call.briefing.text.split(/\r?\n/)[0]
?? "Алло? Вы меня слышите?";
return <main className="page arm-page">
<ModeBanner mode={call.briefing.mode} />
<header className="arm-topbar arm-topbar-text">
@ -56,14 +108,34 @@ export function Call() {
<div className="arm-topbar-title"><strong>Карточка информационного обмена</strong><span>Текстовое упражнение · сценарий {call.briefing.scenario_id}</span></div>
<div className="arm-topbar-state"><span className={call.status === "open" ? "state-ok" : "state-warn"}>● {call.status === "open" ? "АРМ подключён" : STATUS_LABEL[call.status]}</span><small>{call.cardSubmitted ? "Карточка отправлена" : "Карточка заполняется"}</small></div>
</header>
<section className="arm-briefing">
<div className="arm-briefing-label"><strong>Учебная вводная</strong><span>Текстовый режим · без входящего звонка</span></div>
<div className="arm-briefing-text">{call.briefing.text}</div>
</section>
<div className="arm-card-wrap">
<KioCard state={call.card} required={call.briefing.required_fields}
readOnly={call.cardSubmitted || call.status !== "open"} referenceArm onChange={call.patchKio} />
<div className="arm-text-layout">
<section className="arm-text-dialogue" aria-label="Текстовый диалог с заявителем">
<header><strong>Диалог с заявителем</strong><span>{call.cardSubmitted ? "Диалог завершён" : "Задайте вопрос"}</span></header>
<div className="arm-text-lines" aria-live="polite">
{!call.lines.length && <p className="arm-text-line caller"><b>Заявитель</b>{openingLine}</p>}
{call.lines.map((line, index) => <p key={`${index}-${line.speaker}`} className={`arm-text-line ${line.speaker}`}>
<b>{line.speaker === "caller" ? "Заявитель" : "Вы"}</b>{line.text}
{line.speaker === "caller" && <small className="arm-line-source">{line.source === "local_llm" ? "Qwen · ответ модели" : "Сценарный ответ"}</small>}
</p>)}
</div>
<form onSubmit={(event) => {
event.preventDefault();
if (call.sendText(textTurn)) setTextTurn("");
}}>
<input aria-label="Ваша реплика заявителю" value={textTurn} maxLength={1000}
placeholder="Ваша реплика…" onChange={(event) => setTextTurn(event.target.value)} />
<button type="submit" disabled={call.status !== "open" || !textTurn.trim() || call.cardSubmitted}>Отправить</button>
</form>
{call.error && <p role="alert" className="state-violated">{call.error}</p>}
</section>
<div className="arm-card-wrap">
<KioCard state={call.card} required={call.briefing.required_fields}
readOnly={call.cardSubmitted} referenceArm onChange={call.patchKio} />
</div>
</div>
<section className="arm-support" aria-label="Норматив заполнения карточки">
<Timers timers={call.timers} />
</section>
<footer className="arm-footer">
<span className="arm-footer-services">Службы:
{notifiedServices.length
@ -102,10 +174,20 @@ export function Call() {
</header>
<div className="arm-call-actions">
{call.phase === "incoming" && !call.micOn && <button type="button" className="arm-answer" onClick={() => void call.answer()}>☎ Ответить на вызов</button>}
{(call.phase === "incoming" || call.phase === "talking") && !call.micOn && <button type="button" className="arm-answer" onClick={() => void call.answer()}>
{call.phase === "talking" ? "☎ Продолжить разговор" : "☎ Ответить на вызов"}
</button>}
{call.micOn && <span className="arm-live">● Разговор идёт</span>}
{call.micOn && hintsAllowed(call.incoming?.mode) && <button type="button" onClick={call.hint}>Подсказка</button>}
<span className="arm-call-actions-note">{call.phase === "ended" ? "Вызов завершён" : "Заполните адрес и признаки происшествия со слов заявителя"}</span>
<span className="arm-call-actions-note">{call.phase === "ended"
? "Вызов завершён"
: !call.micOn && call.phase === "incoming"
? "Поля КИО разблокируются после ответа на входящий вызов"
: !call.micOn && call.phase === "talking"
? "Разговор продолжается. Нажмите кнопку, чтобы включить звук и микрофон на этом устройстве"
: call.micOn
? "Заполните адрес и признаки происшествия со слов заявителя"
: "Ожидайте входящего вызова"}</span>
</div>
{call.error && <p className="violated arm-message" role="alert">{call.error}</p>}
@ -141,6 +223,7 @@ export function Call() {
<p>
<input
type="text"
aria-label="Комментарий при закрытии вызова без карточки"
value={resolveComment}
placeholder="что сказали заявителю, куда передали"
onChange={(event) => setResolveComment(event.target.value)}

View file

@ -4,13 +4,20 @@
// шрифт, высокий контраст, минимум деталей. Никакого взаимодействия — экран висит
// и показывает (docs/arch/FRONTEND.md).
import { Debrief } from "@/features/debrief/Debrief";
import { lazy, Suspense } from "react";
import { KioCard } from "@/features/kio-card/KioCard";
import { ModeBanner } from "@/features/mode-banner/ModeBanner";
import { InterviewTimer } from "@/features/timers/Timers";
import { useObserve } from "@/features/observe/useObserve";
import { sessionIdFromUrl } from "@/shared/api/session";
// Большинство внешних мониторов открывают во время занятия. Не загружаем
// отчёт, радар и его тяжёлые зависимости, пока оценка ещё не готова.
const Debrief = lazy(() => import("@/features/debrief/Debrief").then((module) => ({
default: module.Debrief,
})));
export function Wall() {
const sessionId = sessionIdFromUrl();
const session = useObserve(sessionId);
@ -29,7 +36,9 @@ export function Wall() {
<main className="page wall">
<ModeBanner mode={session.mode} />
<h1>{session.title} · разбор</h1>
<Debrief report={session.report} big />
<Suspense fallback={<p role="status">Готовим разбор занятия…</p>}>
<Debrief report={session.report} big />
</Suspense>
</main>
);
}

View file

@ -3,7 +3,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Level, SessionMode } from "@/shared/types/generated";
import type { Level, SessionMode, StationSnapshot } from "@/shared/types/generated";
export class ApiError extends Error {
constructor(
@ -21,7 +21,16 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new ApiError(response.status, body.detail ?? response.statusText);
const detail = body.detail;
const message = typeof detail === "string"
? detail
: Array.isArray(detail)
? detail.map((item: { loc?: unknown[]; msg?: string }) => {
const path = item.loc?.slice(1).join(".");
return `${path ? `${path}: ` : ""}${item.msg ?? "некорректное значение"}`;
}).join("; ")
: response.statusText;
throw new ApiError(response.status, message);
}
return response.json() as Promise<T>;
}
@ -39,17 +48,36 @@ export interface Health {
export interface ScenarioSummary {
id: string;
title: string;
type: string;
outcome?: "card" | "consultation" | "transfer_region";
type?: string;
level: Level;
topics: string[];
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;
signs?: string[];
incident_code?: string | null;
dds?: string | null;
ticket?: number | null;
position?: number | null;
ekp_group?: number | null;
can_manage?: boolean;
source?: "system" | "trainee";
}
export interface ScenarioSubmission {
id: string;
author_name: string;
title: string;
incident_type: string;
level: Level;
description: string;
address: string;
victims: number | null;
kio: Record<string, unknown> | null;
status: "pending" | "approved" | "rejected";
review_comment: string;
scenario_id: string | null;
created_at: string;
reviewed_at: string | null;
}
export interface SessionInfo {
@ -59,13 +87,75 @@ export interface SessionInfo {
attempt: number;
trainee_id: string | null;
group_id: string | null;
started_at: string | null;
ended_at: string | null;
end_reason: string | null;
}
export interface ActiveSessionInfo {
session_id: string;
trainee_name: string | null;
scenario_id: string;
scenario_title: string;
mode: SessionMode;
exercise: "call" | "card" | "dds";
started_at: string | null;
elapsed_seconds: number;
dds_card_total: number;
dds_open_cards: number;
dds_overdue_cards: number;
dds_work_overdue_cards: number;
dds_statuses: Record<string, string>;
dds_snapshot: StationSnapshot | null;
}
export const useActiveSessions = () =>
useQuery({
queryKey: ["sessions", "active"],
queryFn: () => request<ActiveSessionInfo[]>("/api/sessions/active"),
refetchInterval: 2_000,
});
export const useHealth = () =>
useQuery({ queryKey: ["health"], queryFn: () => request<Health>("/api/health"), refetchInterval: 5_000 });
export const useScenarios = () =>
useQuery({ queryKey: ["scenarios"], queryFn: () => request<ScenarioSummary[]>("/api/scenarios") });
export const useScenarios = (enabled = true, roleScope = "instructor") =>
useQuery({
queryKey: ["scenarios", roleScope],
queryFn: () => request<ScenarioSummary[]>("/api/scenarios"),
enabled,
});
export const useScenarioSubmissions = (enabled = true) =>
useQuery({
queryKey: ["scenario-submissions"],
queryFn: () => request<ScenarioSubmission[]>("/api/scenario-submissions"),
enabled,
});
export const useCreateScenarioSubmission = () => {
const client = useQueryClient();
return useMutation({
mutationFn: (payload: { title: string; level: Level; kio: Record<string, unknown> }) => request<ScenarioSubmission>("/api/scenario-submissions", {
method: "POST", body: JSON.stringify(payload),
}),
onSuccess: () => client.invalidateQueries({ queryKey: ["scenario-submissions"] }),
});
};
export const useReviewScenarioSubmission = () => {
const client = useQueryClient();
return useMutation({
mutationFn: (payload: { id: string; decision: "approve" | "reject"; comment?: string }) =>
request<ScenarioSubmission>(`/api/scenario-submissions/${payload.id}/review`, {
method: "POST", body: JSON.stringify({ decision: payload.decision, comment: payload.comment ?? "" }),
}),
onSuccess: () => {
client.invalidateQueries({ queryKey: ["scenario-submissions"] });
client.invalidateQueries({ queryKey: ["scenarios"] });
},
});
};
export const useSessions = (params: { trainee?: string; group?: string; mode?: SessionMode } = {}) => {
const query = new URLSearchParams(
@ -112,6 +202,12 @@ export interface Profile {
interview_ms: number | null;
facts_got: number | null;
}[];
recommendations: {
code: string;
title: string;
recommendation: string;
occurrences: number;
}[];
hints_total: number;
}

View file

@ -23,6 +23,8 @@ interface ChannelOptions<In, Out> {
/** Ключ безопасного идемпотентного события, которое можно повторить после
* краткого разрыва. null запрещает буферизацию команды. */
queueKey?: (event: Out) => string | null;
/** Подтверждение, что серверный durable snapshot уже содержит команду. */
isAcknowledged?: (incoming: In, pending: Out) => boolean;
}
const RECONNECT_MIN_MS = 500;
@ -30,7 +32,7 @@ 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 }
interface Buffered<Out> { key: string; commandId: string; event: Out; at: number }
export class Channel<In extends { type: string }, Out extends { type: string }> {
private socket: WebSocket | null = null;
@ -61,15 +63,25 @@ export class Channel<In extends { type: string }, Out extends { type: string }>
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));
this.buffered = pending;
for (const item of pending) socket.send(this.encode(item));
};
socket.onmessage = (message) => {
if (typeof message.data !== "string") {
this.options.onBinary?.(message.data as ArrayBuffer);
return;
}
this.options.onEvent?.(JSON.parse(message.data) as In);
const event = JSON.parse(message.data) as In;
if (event.type === "command.ack" && typeof (event as { command_id?: unknown }).command_id === "string") {
const commandId = (event as unknown as { command_id: string }).command_id;
this.buffered = this.buffered.filter((item) => item.commandId !== commandId);
}
if (this.options.isAcknowledged) {
this.buffered = this.buffered.filter(
(item) => !this.options.isAcknowledged!(event, item.event),
);
}
this.options.onEvent?.(event);
};
socket.onclose = () => {
this.socket = null;
@ -91,13 +103,14 @@ export class Channel<In extends { type: string }, Out extends { type: string }>
}
send(event: Out): boolean {
if (this.isOpen) {
const key = this.options.queueKey?.(event) ?? null;
if (!key) {
if (!this.isOpen) return false;
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);
let pending: Buffered<Out>;
if (existing) {
// Частые kio.patch объединяем: последнее значение каждого поля важнее,
// но правки разных полей не должны затереть друг друга.
@ -112,13 +125,24 @@ export class Channel<In extends { type: string }, Out extends { type: string }>
existing.event = event;
}
existing.at = Date.now();
pending = existing;
} else {
this.buffered.push({ key, event, at: Date.now() });
pending = { key, commandId: crypto.randomUUID(), event, at: Date.now() };
this.buffered.push(pending);
if (this.buffered.length > BUFFER_MAX_ITEMS) this.buffered.shift();
}
// Keep a replay-safe command (for at most BUFFER_MAX_AGE_MS) until an event
// emitted after the server's durable checkpoint proves it was applied. A
// send() on an open socket is not itself an acknowledgement: the connection
// may die before the server receives it or the response reaches this browser.
if (this.isOpen) this.socket!.send(this.encode(pending));
return true;
}
private encode(item: Buffered<Out>): string {
return JSON.stringify({ ...item.event, _command_id: item.commandId });
}
sendBinary(frame: ArrayBuffer): boolean {
if (!this.isOpen) return false;
this.socket!.send(frame);
@ -139,6 +163,13 @@ export const callChannel = (
) => new Channel<ServerToTrainee, TraineeToServer>(`/ws/call/${sessionId}`, {
...options,
queueKey: (event) => event.type === "kio.patch" ? "kio.patch" : null,
isAcknowledged: (incoming, pending) => {
if (pending.type !== "kio.patch" || incoming.type !== "kio.patch"
|| incoming.source !== "operator") return false;
return Object.entries(pending.fields).every(([key, value]) =>
sameJsonValue(value, incoming.fields[key]),
);
},
});
/** Наблюдатель: отправлять нечего и нельзя — `Out` равен `never`. */
@ -154,12 +185,34 @@ export const stationChannel = (
...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;
// Non-replacement station actions are each their own replayable command.
// The server deduplicates by this ID inside its persisted session snapshot.
return `command:${crypto.randomUUID()}`;
},
isAcknowledged: (incoming, pending) => incoming.type === "station.state"
&& pending.type === "card.reply"
&& incoming.snapshot.card_id === pending.card_id
&& incoming.snapshot.reply_text === pending.text,
});
/** Преподаватель: только передача, входящих в этом канале нет. */
export const controlChannel = (sessionId: string, options?: { onStatus?: (status: ChannelStatus) => void }) =>
new Channel<never, InstructorToServer>(`/ws/control/${sessionId}`, options);
function sameJsonValue(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) return true;
if (Array.isArray(left) || Array.isArray(right)) {
return Array.isArray(left) && Array.isArray(right)
&& left.length === right.length
&& left.every((item, index) => sameJsonValue(item, right[index]));
}
if (left && right && typeof left === "object" && typeof right === "object") {
const leftRecord = left as Record<string, unknown>;
const rightRecord = right as Record<string, unknown>;
const keys = Object.keys(leftRecord);
return keys.length === Object.keys(rightRecord).length
&& keys.every((key) => key in rightRecord
&& sameJsonValue(leftRecord[key], rightRecord[key]));
}
return false;
}

View file

@ -3,6 +3,17 @@
// Контракт: docs/arch/CONTRACT.md
export interface AICoaching {
status: "ready" | "unavailable" | "disabled" | "not_needed";
model?: string | null;
recommendations?: Array<AIRecommendation>;
}
export interface AIRecommendation {
metric_key: string;
text: string;
}
/** Аудио-фон происшествия. Файл лежит в сборке фронта, трафика фон не создаёт. */
export interface BgStart {
type: "bg.start";
@ -63,11 +74,13 @@ export interface CallerUtterance {
text: string;
at: string;
mood: Mood;
source?: "local_llm" | "scenario";
}
/** Останавливает норматив `dds_ack` (≤ 30 с). */
export interface CardAck {
type: "card.ack";
comment: string;
}
/** Возврат на уточнение: неполнота КИО становится сорванным выездом. */
@ -122,6 +135,12 @@ export interface CardSubmit {
type: "card.submit";
}
/** Durable confirmation for a station command, safe to replay by ID. */
export interface CommandAck {
type: "command.ack";
command_id: string;
}
/** Шесть осей радара. Проекция уже посчитанных метрик, без пересчёта весов. */
export type Competency = "intake" | "interview" | "card" | "routing" | "norms" | "communication";
@ -163,6 +182,14 @@ export interface DdsCardReport {
findings: Array<Finding>;
actions?: Array<Record<string, unknown>>;
duration_ms?: number;
title?: string | null;
address?: string | null;
description?: string | null;
incident_type?: string | null;
victims_count?: number | null;
received_at?: string | null;
managed_service?: string | null;
recipient_services?: Array<string>;
}
export interface DdsCardSummary {
@ -242,8 +269,8 @@ export interface Finding {
at?: string | null;
}
/** Кто выставил отметку. `judge` — единственный недетерминированный источник. */
export type FindingSource = "slots" | "dispatcher" | "ground_truth" | "timers" | "kio" | "chain" | "judge" | "instructor";
/** Источник объяснимой отметки; `judge` оставлен для старых отчётов. */
export type FindingSource = "slots" | "dispatcher" | "ground_truth" | "timers" | "kio" | "chain" | "grammar" | "judge" | "instructor";
/** Уточняющие поля 01 — МЧС. */
export interface FireDetails {
@ -348,9 +375,11 @@ export interface KioState {
kio: KIO;
}
/** Настраиваемые преподавателем условия именно этого занятия. Нормативы ГОСТ для приёма вызова сюда не входят. Меняется учебный лимит решения ДДС и порог успешности, которые ТЗ прямо отдаёт преподавателю. */
/** Настраиваемые преподавателем условия именно этого занятия. Нормативы ГОСТ для приёма вызова сюда не входят. Для занятия меняются учебные лимиты первичной реакции и полного цикла карточки ДДС, заполнения КИО, порог успешности и веса метрик; веса сценария остаются базовыми, настройки занятия могут их переопределить. */
export interface LessonCriteria {
decision_time_limit_seconds?: number;
card_fill_time_limit_seconds?: number;
dds_card_work_time_limit_seconds?: number;
allowed_errors?: number;
require_correct_grammar?: boolean;
score_weights?: Record<string, number>;
@ -368,7 +397,7 @@ export interface MedicalDetails {
age?: number | null;
}
/** Метрика оценки: факт против норматива со ссылкой. Не балл, а обоснование. */
/** Факт против норматива со ссылкой; `credit` задаёт частичный вклад времени. */
export interface Metric {
key: string;
title: string;
@ -377,6 +406,7 @@ export interface Metric {
ref?: string | null;
passed: boolean;
weight?: number;
credit?: number | null;
}
export interface ModeSet {
@ -486,15 +516,11 @@ export type ResponseStatus = "registered" | "transferred" | "in_progress" | "clo
export type Role = "admin" | "instructor" | "trainee";
export interface ScenarioPublish {
type: "scenario.publish";
scenario_id: string;
}
export interface ScenarioStart {
type: "scenario.start";
scenario_id: string;
scenario_ids?: Array<string> | null;
random_scenario_ids?: Array<string> | null;
dds_arrival_interval_seconds?: number;
dds_max_waiting?: number;
trainee: string;
@ -564,6 +590,7 @@ export interface SessionReport {
session_id: string;
scenario_id: string;
mode: SessionMode;
exercise?: Exercise;
attempt?: number;
criteria: LessonCriteria;
failed_metrics: number;
@ -571,6 +598,7 @@ export interface SessionReport {
transcript: Array<TranscriptEntry>;
findings: Array<Finding>;
metrics: Array<Metric>;
ai_coaching?: AICoaching | null;
card_results?: Array<DdsCardReport>;
competencies: Array<CompetencyScore>;
reference_questions: Array<HintShown>;
@ -626,6 +654,7 @@ export interface StationSnapshot {
log: Array<StatusEntry>;
crew_options?: Array<string>;
crew_selected?: string | null;
zone_decision?: boolean | null;
phone_reports?: Array<PhoneReportRecord>;
phone_lines?: Array<PhoneLineRecord>;
phone_pending?: PhoneCallPending | null;
@ -635,9 +664,9 @@ export interface StationSnapshot {
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;
pending_cards_count?: number;
next_arrival_in_seconds?: number | null;
max_waiting_cards?: number;
}
/** Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше. */
@ -666,8 +695,19 @@ export interface SttPartial {
text: string;
}
export interface TextTurn {
type: "text.turn";
text: string;
}
export interface TextTurnAccepted {
type: "text.turn.accepted";
text: string;
at: string;
}
/** Коды одинаковы на бэке, фронте и в отчёте. */
export type TimerCode = "answer" | "interview" | "dds_notify" | "dds_ack" | "zone_check" | "callback" | "close";
export type TimerCode = "answer" | "interview" | "dds_notify" | "dds_ack" | "dds_work" | "card_fill" | "zone_check" | "callback" | "close";
/** Один таймер в событии `timer.tick`. */
export interface TimerSnapshot {
@ -735,15 +775,15 @@ export interface ZoneDecision {
// Союзы по каналам — смотри docs/arch/CONTRACT.md#каналы
export type ServerToTrainee = CallIncoming | CardBriefing | CallStarted | SttPartial | SttFinal | CallerUtterance | TtsBegin | TtsEnd | TtsCancel | BgStart | BgStop | EraData | KioPatchOut | HintShown | TimerTick | CallEnded | ScoreReady | ErrorEvent;
export type ServerToTrainee = CallIncoming | CardBriefing | TextTurnAccepted | CallStarted | SttPartial | SttFinal | CallerUtterance | TtsBegin | TtsEnd | TtsCancel | BgStart | BgStop | EraData | KioPatchOut | HintShown | TimerTick | CallEnded | ScoreReady | ErrorEvent;
export type TraineeToServer = CallAnswer | CardSubmit | KioPatchIn | HintRequest | SelfAssessmentSubmit | DdsDispatch | CallResolve | CallHangup | CallbackDial;
export type TraineeToServer = CallAnswer | CardSubmit | TextTurn | KioPatchIn | HintRequest | SelfAssessmentSubmit | DdsDispatch | CallResolve | CallHangup | CallbackDial;
export type ServerToObserver = SessionSnapshot | TranscriptAppend | KioState | TimerTick | ModeSet | HintShown | BgStart | BgStop | CallerUtterance | SessionEnded | ScoreReady | InstructorNoteShown | ReferenceStarted | ErrorEvent;
export type InstructorToServer = ScenarioStart | DirectorInject | ReferencePlay | InstructorNoteAdd | ScoreOverride | ScenarioPublish | SessionStop;
export type InstructorToServer = ScenarioStart | DirectorInject | ReferencePlay | InstructorNoteAdd | ScoreOverride | SessionStop;
export type ServerToStation = CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded | ScoreReady | ErrorEvent;
export type ServerToStation = CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded | ScoreReady | CommandAck | ErrorEvent;
export type StationToServer = CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | PhoneBrief | PhoneCheck | PhoneHangup | StationFinish | CardReply | CardNext | CardOpen | ZoneDecision | CrewDispatched | CrewArrived;
@ -767,6 +807,7 @@ export type EventName =
| "card.reply"
| "card.status"
| "card.submit"
| "command.ack"
| "crew.arrived"
| "crew.dispatched"
| "crew.select"
@ -789,7 +830,6 @@ export type EventName =
| "phone.report"
| "reference.play"
| "reference.started"
| "scenario.publish"
| "scenario.start"
| "score.override"
| "score.ready"
@ -801,6 +841,8 @@ export type EventName =
| "station.state"
| "stt.final"
| "stt.partial"
| "text.turn"
| "text.turn.accepted"
| "timer.tick"
| "transcript.append"
| "tts.begin"

View file

@ -59,25 +59,100 @@ button:disabled { opacity: 0.5; cursor: default; }
.columns > section { flex: 1 1 420px; min-width: 0; }
/* Пульт содержит длинные названия сценариев. Узкая первая колонка вместе с
min-width таблицы раньше перекрывала «Ход занятия» при zoom 200%. */
.instructor-layout { display: grid; grid-template-columns: minmax(300px, 360px) minmax(0, 1fr) minmax(230px, 280px); gap: 20px; align-items: start; }
.instructor-layout > section { min-width: 0; }
.instructor-layout { display: grid; grid-template-columns: minmax(500px, 1.15fr) minmax(380px, 1.7fr) minmax(270px, 0.7fr); gap: 16px; align-items: start; }
.instructor-layout.is-idle { grid-template-columns: minmax(0, 1.5fr) minmax(290px, 0.75fr); }
.instructor-layout.is-idle > section:nth-child(2) { display: none; }
.instructor-layout.is-idle > section:last-child { grid-column: 2; }
.instructor-page { display: flex; flex-direction: column; }
.instructor-page > .instructor-layout { order: 1; }
.instructor-page > .instructor-live-group { order: 2; }
.instructor-page > .student-scenario-review { order: 3; }
.instructor-page > .scenario-editor { order: 4; }
.instructor-page > .debrief { order: 5; }
.instructor-page.has-report > .scenario-editor { order: 6; }
.instructor-page > .workspace-links { display: flex; flex-wrap: wrap; gap: 8px; }
.instructor-page > .workspace-links a { display: inline-flex; align-items: center; min-height: 38px; padding: 0 12px; border: 1px solid #aab8c2; border-radius: 5px; background: #fff; color: #23577e; text-decoration: none; }
.instructor-page > .workspace-links a:hover { background: #e8f0fb; }
.instructor-page > h1 { margin: 10px 0 4px; font-size: 28px; }
.instructor-layout > section { min-width: 0; padding: 14px; border: 1px solid #d4d9de; border-radius: 7px; background: #fff; box-shadow: 0 1px 3px #15212b0c; }
.instructor-layout > section h2 { margin: 0 0 12px; }
.lesson-setup-intro { padding: 9px 12px; border-left: 4px solid #316798; background: #e8f0fb; color: #273c4d; }
.lesson-setup-intro { display: grid; gap: 3px; margin: 10px 0 14px; border-radius: 0 6px 6px 0; line-height: 1.45; }
.lesson-setup-intro strong { font-size: 15px; }
.lesson-setup-intro span { font-size: 13px; }
.lesson-step-title { display: inline-flex; align-items: center; gap: 7px; color: #344954; }
.lesson-step-title i { display: inline-grid; flex: 0 0 24px; width: 24px; height: 24px; place-items: center; border-radius: 50%; background: #e6eff4; color: #285f82; font-size: 12px; font-style: normal; font-weight: 700; }
.scenario-filters-details { margin: 0 0 8px; border: 1px solid #dce3e7; border-radius: 6px; background: #f8fafb; }
.scenario-filters-details > summary { padding: 9px 10px; color: #334b5b; cursor: pointer; font-weight: 600; }
.scenario-filters-details > summary span { margin-left: 5px; color: #64737c; font-size: 12px; font-weight: 400; }
.scenario-filters-details[open] { padding: 0 9px 8px; }
.scenario-filters-details[open] > summary { margin: 0 -9px 8px; border-bottom: 1px solid #e3e8eb; }
.scenario-search-only { margin: 0 0 8px; }
.scenario-search-only .instructor-filter-search { grid-column: 1 / -1; }
.scenario-count { display: block; margin-top: 7px; }
.instructor-layout .grid { width: 100%; min-width: 0; table-layout: fixed; }
.instructor-layout .grid th { width: 105px; }
.instructor-layout .grid td { min-width: 0; overflow-wrap: anywhere; }
.instructor-layout > section:first-child .grid { border-collapse: separate; border-spacing: 0 7px; background: transparent; }
.instructor-layout > section:first-child .grid th { border: 0; background: transparent; color: #45535d; vertical-align: top; padding: 10px 8px 0 2px; }
.instructor-layout > section:first-child .grid td { border: 1px solid #dfe3e7; border-radius: 5px; background: #fff; padding: 9px; }
.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; }
.instructor-layout .lesson-mode-choice { display: flex; align-items: flex-start; gap: 8px; margin: 5px 0; padding: 9px 10px; border: 1px solid #e0e6e9; border-radius: 6px; background: #fbfcfd; }
.instructor-layout .lesson-mode-choice:has(input:checked) { border-color: #6a9b81; background: #f1f8f3; }
.instructor-layout .lesson-mode-choice input { flex: 0 0 auto; margin-top: 3px; }
.lesson-mode-choice > span { display: grid; gap: 2px; }
.lesson-mode-choice small { color: #627078; font-size: 12px; font-weight: 400; }
.instructor-layout .start { width: 100%; min-height: 46px; border: 0; border-radius: 5px; background: #235b42; color: #fff; box-shadow: 0 2px 4px #182a201c; }
.instructor-layout .start:not(:disabled):hover { background: #194831; }
.lesson-start-bar { position: static; display: grid; gap: 5px; margin: 12px 0 0; padding: 12px; border: 1px solid #cbd3d8; border-radius: 8px; background: #f7faf8; }
.lesson-start-bar { position: sticky; bottom: 8px; z-index: 3; box-shadow: 0 5px 18px #172a201c; }
.lesson-empty-state { display: grid; gap: 5px; min-height: 100px; align-content: center; padding: 14px; border: 1px dashed #bdc6cc; border-radius: 5px; background: #f7f9fa; color: #43515a; }
.lesson-empty-state span { color: #66737b; font-size: 13px; }
.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; }
.lesson-criteria-details { padding: 9px 11px; border: 1px solid #dfe5e8; border-radius: 6px; background: #fbfcfd; }
.lesson-criteria-details > summary { color: #23577e; font-weight: 600; cursor: pointer; }
.lesson-criteria-details[open] > summary { margin-bottom: 8px; }
.lesson-criteria-details > p { margin: 5px 0 10px; }
.lesson-score-weight-editor { margin-top: 8px; }
.lesson-score-weight-editor summary { cursor: pointer; color: #23577e; }
.lesson-score-weight-editor fieldset { min-width: 0; margin: 8px 0; padding: 8px; }
.weight-metric-hint { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.instructor-scenario-filters { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-bottom: 9px; }
.instructor-scenario-filters label, .instructor-primary-scenario { display: grid; gap: 4px; min-width: 0; color: #53616a; font-size: 12px; }
.instructor-scenario-filters .instructor-filter-search { grid-column: 1 / -1; }
.instructor-scenario-filters select, .instructor-scenario-filters input, .instructor-primary-scenario select { min-height: 42px; padding: 5px 8px; border: 1px solid #b9c2c8; border-radius: 4px; background: #fff; color: #20282e; font: inherit; }
.instructor-primary-scenario { margin: 8px 0 5px; color: #303b42; font-weight: 600; }
.selected-scenario-summary { display: grid; gap: 3px; margin: 8px 0; padding: 9px 11px; border-left: 3px solid #4b8062; border-radius: 4px; background: #f0f7f2; }
.selected-scenario-summary > span, .selected-scenario-summary > small { color: #52645a; font-size: 12px; }
.exercise-options { display: grid; gap: 7px; }
.exercise-option { display: flex; align-items: flex-start; gap: 9px; padding: 10px 11px; border: 1px solid #dfe5e8; border-radius: 6px; background: #fbfcfd; cursor: pointer; }
.exercise-option:has(input:checked) { border-color: #6a9b81; background: #f1f8f3; box-shadow: inset 3px 0 #4b8062; }
.exercise-option input { flex: 0 0 auto; margin: 3px 0 0; }
.exercise-option span { display: grid; gap: 3px; }
.exercise-option small { color: #627078; font-size: 12px; font-weight: 400; }
.instructor-scenario-filters input:focus, .instructor-scenario-filters select:focus, .instructor-primary-scenario select:focus { outline: 2px solid #6da5cc; outline-offset: 1px; }
.lesson-history-list { display: grid; gap: 8px; }
.lesson-history-list > a { display: grid; gap: 5px; padding: 10px; border: 1px solid #d9dfe3; border-radius: 5px; background: #f9fafb; color: #26343c; text-decoration: none; }
.lesson-history-list > a:hover { border-color: #7e9caf; background: #eef5f9; }
.lesson-history-list span, .lesson-history-list small { color: #64727b; font-size: 12px; }
.lesson-history-list small { color: #23577e; font-weight: 600; }
.lesson-history-empty { display: grid; gap: 5px; padding: 12px; border: 1px dashed #cbd3d8; border-radius: 5px; background: #f7f9fa; }
.lesson-history-empty span { color: #66737b; font-size: 13px; }
.instructor-readonly-note { margin: 0 0 8px; padding: 9px 11px; border-left: 3px solid #7895a5; border-radius: 4px; background: #f2f6f8; line-height: 1.45; }
.kio-signs-hint { margin: 4px 0; color: #586572; font-size: 12px; }
@media (max-width: 1350px) {
.instructor-layout { grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); }
.instructor-layout { grid-template-columns: minmax(400px, 0.9fr) minmax(0, 1.35fr); }
.instructor-layout.is-idle { grid-template-columns: minmax(0, 1.3fr) minmax(260px, 0.7fr); }
.instructor-layout > section:last-child { grid-column: 1 / -1; }
}
@media (max-width: 900px) {
.instructor-layout { display: block; }
.instructor-layout > section { margin-bottom: 24px; }
.instructor-scenario-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.lesson-start-bar { position: static; backdrop-filter: none; }
}
.kio { display: flex; flex-direction: column; gap: 12px; }
.kio .grid { width: 100%; }
@ -110,7 +185,80 @@ button:disabled { opacity: 0.5; cursor: default; }
обоснования не появляется ни в одном интерфейсе. */
.debrief h3 { font-size: 15px; margin: 16px 0 6px; }
.ref { color: #5a6068; font-size: 12px; }
.radar { display: block; margin: 4px 0 8px; }
.radar { display: block; max-width: 100%; height: auto; margin: 4px 0 8px; overflow: visible; }
.debrief-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin: 12px 0 16px; }
.debrief { padding: 18px; border: 1px solid #d5dde1; border-radius: 9px; background: #fbfcfd; }
.debrief > h2 { margin-top: 0; font-size: 22px; }
.debrief-tech-details { margin: -5px 0 12px; color: #596972; font-size: 12px; }
.debrief-tech-details summary { display: flex; align-items: center; min-height: 40px; width: fit-content; cursor: pointer; }
.debrief-tech-details p { overflow-wrap: anywhere; }
.debrief-stat { display: flex; flex-direction: column; gap: 4px; min-width: 0; padding: 12px; border: 1px solid var(--line); border-left: 4px solid #6b7780; background: var(--panel); }
.debrief-stat > span { color: #5a6068; font-size: 12px; }
.debrief-stat > strong { font-size: 24px; line-height: 1.15; overflow-wrap: anywhere; }
.debrief-stat > strong small { font-size: 14px; font-weight: 500; }
.debrief-stat > small { color: #5a6068; font-size: 12px; }
.debrief-stat.is-passed { border-left-color: var(--ok); }
.debrief-stat.is-failed { border-left-color: var(--violated); }
.debrief-next-step { margin: 12px 0 18px; padding: 12px 15px; border: 1px solid var(--line); border-left: 4px solid #6b7780; border-radius: 5px; background: #fff; }
.debrief-next-step.is-passed { border-left-color: var(--ok); background: #f3f8f3; }
.debrief-next-step.is-failed { border-left-color: var(--violated); background: #fff8f7; }
.debrief-next-step > strong { font-size: 15px; }
.debrief-next-step p { margin: 5px 0; }
.debrief-next-step ul { margin: 6px 0 0; padding-left: 22px; }
.debrief-next-step li { margin: 3px 0; }
.debrief-transcript { margin-top: 18px; border: 1px solid var(--line); border-radius: 6px; background: #fff; }
.debrief-transcript > summary { display: flex; align-items: center; min-height: 40px; padding: 8px 14px; cursor: pointer; color: #23577e; font-weight: 600; }
.debrief-transcript[open] { padding: 0 12px 12px; }
.debrief-transcript[open] > summary { margin: 0 -12px 8px; border-bottom: 1px solid var(--line); }
.debrief-transcript .grid { width: 100%; min-width: 0; }
.debrief-metric-status { display: inline-flex; margin: 0 8px 5px 0; padding: 3px 7px; border: 1px solid currentColor; border-radius: 999px; font-size: 12px; line-height: 1.2; white-space: nowrap; }
.debrief-metric-status.is-ok { color: #245e3c; background: #edf7ef; }
.debrief-metric-status.is-needs-work { color: #8a2f2f; background: #fff0ef; }
.debrief .columns > section:first-child .grid td { line-height: 1.55; }
.debrief-card-results { margin: 14px 0; padding: 12px; border: 1px solid var(--line); background: var(--panel); }
.debrief-card-results h3 { margin-top: 0; }
.debrief-card-results details { border-top: 1px solid var(--line); padding: 8px 0; }
.debrief-card-results summary { display: flex; align-items: center; min-height: 40px; cursor: pointer; font-weight: 600; }
.debrief-card-results ul { margin: 8px 0; padding-left: 24px; }
.debrief-card-results li { margin: 5px 0; }
.debrief-scoring-details { margin-top: 10px; border: 1px solid #e0e6e9; border-radius: 7px; background: #fbfcfd; }
.debrief-scoring-details > summary { min-height: 42px; display: flex; align-items: center; padding: 0 12px; cursor: pointer; color: #285f82; font-weight: 600; }
.debrief-scoring-details[open] { padding-bottom: 8px; }
.debrief-scoring-details ul { margin: 4px 12px; padding-left: 20px; line-height: 1.5; }
.completion-page { max-width: 920px; margin: 0 auto; padding-top: 32px; }
.completion-heading { padding: 20px 24px; border-left: 6px solid #64727b; background: #fff; }
.completion-heading > span, .completion-score > div > span { color: #5a6068; font-size: 14px; }
.completion-heading h1 { margin: 4px 0; font-size: clamp(30px, 5vw, 46px); }
.completion-heading p { margin: 4px 0 0; color: #596972; }
.completion-score { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 14px 0; padding: 20px 24px; border: 1px solid var(--line); background: #fff; }
.completion-score.is-passed { border-left: 6px solid var(--ok); }
.completion-score.is-failed { border-left: 6px solid var(--violated); }
.completion-score > div { display: grid; gap: 3px; }
.completion-score > div > strong { font-size: 42px; line-height: 1; }
.completion-score > div > strong small { color: #69727a; font-size: 20px; font-weight: 500; }
.completion-score > p { margin: 0; color: #596972; text-align: right; }
.completion-focus { margin: 14px 0; padding: 16px 20px; border: 1px solid var(--line); background: #fff; }
.completion-focus h2 { margin: 0 0 8px; font-size: 18px; }
.completion-focus ol { display: grid; gap: 8px; margin: 0; padding-left: 24px; }
.completion-focus li { padding-left: 4px; }
.completion-focus li strong, .completion-focus li span { display: block; }
.completion-focus li span { margin-top: 2px; color: #596972; }
.completion-details { margin: 14px 0; border: 1px solid var(--line); background: #fff; }
.completion-details > summary { min-height: 48px; padding: 14px 18px; cursor: pointer; color: #23577e; font-weight: 700; }
.completion-details[open] { padding: 0 12px 12px; }
.completion-details[open] > summary { margin: 0 -12px 10px; border-bottom: 1px solid var(--line); }
.completion-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 18px; }
.completion-actions a { display: inline-flex; align-items: center; min-height: 44px; padding: 0 16px; border: 1px solid #52636e; background: #26343b; color: white; font-weight: 700; text-decoration: none; }
.completion-actions a + a { background: #fff; color: #26343b; }
@media (max-width: 700px) {
.debrief-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.debrief { padding: 12px; }
.completion-page { padding: 18px 12px; }
.completion-heading, .completion-score, .completion-focus { padding: 16px; }
.completion-score { align-items: flex-start; flex-direction: column; }
.completion-score > p { text-align: left; }
.scenario-filters-details > summary span { display: block; margin: 3px 0 0; }
}
/* Внешний монитор: читается с последнего ряда. */
.wall { font-size: 20px; }
@ -141,12 +289,22 @@ button:disabled { opacity: 0.5; cursor: default; }
.scenario-editor-create select, .scenario-editor-create input, .scenario-editor-open input { box-sizing: border-box; width: 100%; min-height: 34px; padding: 4px 6px; border: 1px solid var(--line); font: inherit; background: var(--panel); }
.scenario-editor-create label:first-child { flex: 1 1 350px; }
.scenario-editor-open label { flex: 1 1 350px; }
.scenario-editor-json { display: grid; gap: 6px; font-weight: 600; }
.scenario-editor-json textarea { box-sizing: border-box; width: 100%; min-height: 320px; padding: 10px; border: 1px solid var(--line); font: 13px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; resize: vertical; }
.scenario-editor-result { padding: 10px 12px; border: 1px solid var(--ok); background: #e7f4e9; }
.scenario-editor-result.violated { border-color: var(--violated); background: #fde8e8; }
.scenario-editor-result pre { margin: 8px 0 0; overflow-x: auto; white-space: pre-wrap; font-size: 12px; }
.scenario-editor-result ul { margin: 8px 0 0; }
.scenario-human-editor { display: grid; gap: 10px; margin: 14px 0; }
.scenario-human-editor h3, .scenario-human-editor h4 { margin: 4px 0; }
.scenario-human-editor > label, .scenario-human-editor fieldset label { display: grid; gap: 5px; font-weight: 600; }
.scenario-human-editor input, .scenario-human-editor textarea, .scenario-human-editor select { box-sizing: border-box; width: 100%; min-height: 42px; padding: 7px 9px; border: 1px solid var(--line); border-radius: 5px; background: var(--panel); color: var(--text); font: inherit; }
.scenario-human-editor textarea { resize: vertical; }
.scenario-human-editor fieldset { display: grid; gap: 10px; min-width: 0; margin: 0; padding: 12px; border: 1px solid var(--line); }
.scenario-human-editor .scenario-dds-decision { border-left: 4px solid #6887a0; border-radius: 6px; background: #f7fafc; }
.scenario-dds-decision > p { margin: 0; line-height: 1.45; }
.scenario-human-editor legend { padding: 0 6px; font-weight: 600; }
.scenario-ground-truth { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px 14px; margin: 10px 0 0; }
.scenario-ground-truth div { min-width: 0; padding: 7px; background: #f1f3f5; }
.scenario-ground-truth dt { color: #5a6068; font-size: 12px; }
.scenario-ground-truth dd { margin: 2px 0 0; font-weight: 600; overflow-wrap: anywhere; }
/* Возврат карточки: список полей в две колонки, чтобы диспетчер не скроллил. */
.bounce-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 16px; margin: 8px 0; }
@ -307,16 +465,207 @@ button:disabled { opacity: 0.5; cursor: default; }
.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; }
.student-scenario-submit { margin: 18px 0; padding: 16px; border: 1px solid var(--line); border-left: 5px solid #176b9d; background: var(--panel); }
.student-scenario-submit form, .student-scenario-submit label { display: grid; gap: 6px; }
.student-scenario-submit form { max-width: 900px; gap: 12px; }
.student-scenario-submit input, .student-scenario-submit select, .student-scenario-submit textarea { box-sizing: border-box; width: 100%; }
.student-scenario-submit textarea { min-height: 110px; resize: vertical; }
.student-scenario-submit button { justify-self: start; }
.student-scenario-fields { display: grid; grid-template-columns: minmax(200px, 1fr) 160px; gap: 12px; }
.student-scenario-submit ul { margin-bottom: 0; }
.student-scenario-review { margin: 18px 0; padding: 16px; border: 1px solid var(--line); background: var(--panel); }
.student-scenario-proposal { margin: 12px 0; padding: 14px; border-left: 4px solid #db7b32; background: #fffaf4; }
.student-scenario-proposal > header, .student-scenario-proposal > footer { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; }
.student-scenario-proposal > header span { color: var(--muted); font-size: 13px; }
.student-scenario-proposal label { display: grid; gap: 5px; }
.student-scenario-proposal textarea { box-sizing: border-box; width: 100%; min-height: 52px; }
.student-scenario-proposal > footer { margin-top: 10px; }
/* Преподавательский пульт: спокойная иерархия, читаемые формы и явный ход
занятия. Остальные АРМ сохраняют свой отдельный, более плотный стиль. */
.instructor-page {
--instructor-accent: #285f82;
--instructor-border: #d9e1e5;
max-width: 1680px;
width: 100%;
box-sizing: border-box;
margin-inline: auto;
padding: 20px clamp(16px, 2.4vw, 36px) 48px;
gap: 14px;
}
.instructor-page > h1 { letter-spacing: -.025em; }
.instructor-page > .workspace-links { margin: 0 0 4px; gap: 8px; }
.instructor-page > .workspace-links a {
min-height: 40px;
border-color: #d5e0e6;
border-radius: 8px;
box-shadow: 0 1px 2px #1c34400a;
transition: background-color .15s ease, border-color .15s ease;
}
.instructor-page > .workspace-links a:hover { border-color: #9ab4c4; }
.instructor-live-group, .student-scenario-review, .instructor-layout > section,
.instructor-page > .scenario-editor, .instructor-page > .debrief {
border: 1px solid var(--instructor-border);
border-radius: 10px;
box-shadow: 0 2px 8px #2037430a;
}
.instructor-live-group, .student-scenario-review { padding: 16px; background: #fff; }
.instructor-secondary-panel { min-width: 0; }
.instructor-secondary-panel > summary {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 5px 12px;
min-height: 34px;
cursor: pointer;
color: #263943;
font-weight: 650;
list-style-position: inside;
}
.instructor-secondary-panel > summary::marker { color: #59727f; }
.instructor-secondary-panel > summary small {
color: #61727b;
font-size: 13px;
font-weight: 450;
}
.instructor-secondary-panel[open] > summary { margin-bottom: 10px; }
.instructor-secondary-panel > p:last-child { margin-bottom: 0; }
.instructor-live-group h2, .student-scenario-review h2 { margin-top: 0; }
.instructor-live-group .dds-table-wrap { overflow-x: auto; border: 1px solid #e3e9ec; border-radius: 7px; }
.instructor-live-group .grid { width: 100%; min-width: 720px; }
.instructor-live-group .grid th { white-space: nowrap; }
.active-session-details { margin-top: 7px; }
.active-session-details > summary { min-height: 40px; display: flex; align-items: center; cursor: pointer; color: #285f82; font-weight: 650; }
.active-session-card { display: grid; gap: 3px; margin: 7px 0; padding: 8px; border-left: 3px solid #8aa4b1; background: #f7fafb; }
.active-session-card small, .active-session-card span { color: #52636c; font-size: 12px; }
.active-session-log { margin-top: 9px; padding-top: 8px; border-top: 1px solid #e0e7ea; }
.active-session-log > strong { display: block; font-size: 13px; }
.active-session-log p { display: grid; gap: 2px; margin: 6px 0; font-size: 12px; }
.active-session-log p small { color: #596972; line-height: 1.4; }
.instructor-layout { gap: 18px; }
.instructor-layout > section { padding: clamp(16px, 1.6vw, 24px); }
.instructor-layout > section h2 { font-size: 19px; letter-spacing: -.015em; }
.lesson-setup-intro { display: grid; gap: 4px; margin: 0 0 12px; padding: 12px 14px; border: 1px solid #d7e5ed; border-left: 4px solid var(--instructor-accent); border-radius: 8px; background: #f1f7fa; line-height: 1.45; }
.lesson-setup-intro strong { color: #294c60; }
.lesson-setup-intro span { color: #52646e; }
.instructor-layout > section:first-child .grid { border-spacing: 0 9px; }
.instructor-layout > section:first-child .grid th { width: 112px; padding-top: 13px; color: #43535d; }
.instructor-layout > section:first-child .grid td { padding: 12px; border-color: #e2e8eb; border-radius: 8px; }
.instructor-layout select, .instructor-layout input:not([type="radio"]):not([type="checkbox"]),
.instructor-layout textarea {
min-height: 42px;
padding: 8px 10px;
border: 1px solid #bfcbd1;
border-radius: 6px;
background: #fff;
color: #20282e;
font: inherit;
}
.instructor-layout textarea { min-height: 88px; }
.instructor-layout select:focus-visible, .instructor-layout input:focus-visible,
.instructor-layout textarea:focus-visible, .instructor-layout button:focus-visible,
.instructor-page a:focus-visible { outline: 3px solid #82b5d4; outline-offset: 2px; }
.scenario-filters-details, .lesson-criteria-details { border-color: #dce5e9; border-radius: 8px; }
.scenario-filters-details > summary, .lesson-criteria-details > summary { min-height: 42px; display: flex; align-items: center; }
.instructor-scenario-filters select, .instructor-scenario-filters input,
.instructor-primary-scenario select { min-height: 44px; }
.selected-scenario-summary { border: 1px solid #d9e9de; border-left: 4px solid #4b8062; border-radius: 7px; padding: 11px 13px; }
.selected-scenario-details { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 5px; }
.selected-scenario-details > span { padding: 4px 7px; border-radius: 5px; background: #f3f6f4; color: #40534a; font-size: 12px; line-height: 1.4; }
.selected-scenario-details > span b { color: #33443b; }
.scenario-filters-reset { min-height: 36px; padding: 6px 10px; border: 1px solid #c9d8df; border-radius: 6px; background: #fff; color: #285f82; cursor: pointer; }
.scenario-filters-reset:hover { background: #f0f6f8; }
.scenario-empty-state { display: grid; gap: 6px; margin: 8px 0; padding: 12px; border: 1px dashed #c8d4da; border-radius: 8px; background: #f8fafb; color: #3d505a; }
.scenario-empty-state span { color: #5e6e76; font-size: 13px; line-height: 1.4; }
.scenario-empty-state button { justify-self: start; min-height: 38px; padding: 6px 10px; border: 1px solid #c9d8df; border-radius: 6px; background: #fff; color: #285f82; cursor: pointer; }
.exercise-options { gap: 9px; }
.exercise-option { min-height: 54px; align-items: center; padding: 12px 14px; border-color: #dce5e9; border-radius: 8px; }
.exercise-option:has(input:checked) { border-color: #79a48a; background: #f2f8f4; }
.lesson-mode-choice { border-radius: 8px !important; }
.lesson-start-bar { position: sticky; bottom: 10px; z-index: 3; margin-top: 16px; padding: 12px; border: 1px solid #d4e2d8; border-radius: 10px; background: rgb(245 250 246 / 96%); box-shadow: 0 4px 18px #152b2020; backdrop-filter: blur(8px); }
.instructor-layout .start { min-height: 50px; border-radius: 8px; font-size: 16px; }
.lesson-start-bar .ref { line-height: 1.45; }
.lesson-launch-summary { margin-top: 14px; padding: 12px 14px; border: 1px solid #e0e7ea; border-radius: 9px; background: #f8fafb; }
.lesson-launch-summary > strong { display: block; margin-bottom: 9px; color: #354750; font-size: 14px; }
.lesson-launch-summary ul { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin: 0; padding: 0; list-style: none; }
.lesson-launch-summary li { display: flex; gap: 9px; min-width: 0; align-items: flex-start; padding: 9px; border: 1px solid #e0e7ea; border-radius: 7px; background: #fff; }
.lesson-launch-summary li > span:first-child { display: grid; flex: 0 0 22px; width: 22px; height: 22px; place-items: center; border-radius: 50%; background: #e9eef1; color: #42545d; font-size: 12px; font-weight: 700; }
.lesson-launch-summary li.is-ready > span:first-child { background: #e5f2e9; color: #28613e; }
.lesson-launch-summary li.is-missing > span:first-child { background: #fff0df; color: #854e17; }
.lesson-launch-summary li b, .lesson-launch-summary li small { display: block; }
.lesson-launch-summary li b { font-size: 13px; }
.lesson-launch-summary li small { margin-top: 3px; color: #5e6e76; line-height: 1.35; overflow-wrap: anywhere; }
.lesson-start-bar { display: grid; gap: 7px; }
.lesson-start-bar > .ref { display: block; margin: 0; }
.lesson-empty-state { border-color: #d2dce1; border-radius: 9px; background: linear-gradient(135deg, #f8fafb, #f2f6f8); }
.instructor-layout > section:nth-child(2) h3 { margin-top: 20px; }
.instructor-layout > section:nth-child(2) .grid { width: 100%; min-width: 0; }
.instructor-layout > section:nth-child(2) .grid th { width: 110px; }
.lesson-history-list > a { border-color: #e0e7ea; border-radius: 8px; padding: 12px; transition: border-color .15s ease, background .15s ease; }
.lesson-history-list > a:hover { border-color: #91adbc; }
.lesson-history-list > a > span { color: #52636c; }
.lesson-history-list > a > .lesson-history-complete { color: #28613e; }
.lesson-history-list > a > .lesson-history-active { color: #285f82; }
.debrief { padding: clamp(16px, 2vw, 28px); background: #fff; }
.debrief > h2 { margin-top: 0; }
.debrief-summary { gap: 12px; }
.debrief-stat { border-color: #e0e6e9; border-radius: 8px; padding: 14px; }
.debrief-stat > span { font-size: 13px; }
.debrief-stat > strong { font-size: clamp(22px, 2vw, 28px); }
.debrief-next-step { border-radius: 8px; padding: 15px 18px; line-height: 1.55; }
.debrief .columns { gap: 18px; }
.debrief .columns > section { min-width: min(100%, 360px); }
.debrief .columns .grid { width: 100%; min-width: 0; }
.debrief .columns .grid th { width: 30%; }
.debrief-card-results, .debrief-transcript { border-color: #dfe6e9; border-radius: 8px; }
@media (max-width: 900px) {
.instructor-page { padding: 14px 14px 36px; gap: 10px; }
.instructor-layout > section { padding: 16px; }
.instructor-layout > section:first-child .grid th { width: 88px; }
.instructor-layout > section:first-child .grid td { padding: 10px; }
}
@media (max-width: 600px) {
.instructor-page { font-size: 15px; }
.instructor-page > h1 { font-size: 24px; }
.instructor-page > .workspace-links a { min-height: 44px; font-size: 14px; }
.instructor-live-group, .student-scenario-review { padding: 12px; }
.instructor-secondary-panel > summary { min-height: 42px; font-size: 15px; }
.instructor-secondary-panel > summary small { flex-basis: 100%; padding-left: 18px; }
.instructor-page .ref, .instructor-page .lesson-setup-intro span,
.instructor-page .exercise-option small, .instructor-page .lesson-mode-choice small,
.instructor-page .lesson-history-empty span { font-size: 14px; line-height: 1.45; }
.instructor-layout select, .instructor-layout input:not([type="radio"]):not([type="checkbox"]),
.instructor-layout textarea { min-height: 46px; font-size: 16px; }
.instructor-layout textarea { min-height: 96px; }
.exercise-option { padding: 13px; }
.lesson-setup-intro { padding: 12px; }
.instructor-layout > section:first-child .grid,
.instructor-layout > section:first-child .grid tbody,
.instructor-layout > section:first-child .grid tr,
.instructor-layout > section:first-child .grid th,
.instructor-layout > section:first-child .grid td { display: block; width: 100%; box-sizing: border-box; }
.instructor-layout > section:first-child .grid th { padding: 5px 2px 0; }
.instructor-layout > section:first-child .grid td { padding: 9px; }
.instructor-layout > section:first-child .grid tr { margin: 0 0 8px; }
.instructor-layout > section:first-child .grid td { border-top: 0; }
.instructor-scenario-filters { grid-template-columns: 1fr; }
.debrief-summary { grid-template-columns: 1fr 1fr; }
.debrief .columns > section { flex-basis: 100%; }
.debrief .columns .grid th { width: 38%; }
.lesson-launch-summary ul { grid-template-columns: 1fr; }
.lesson-launch-summary li { align-items: center; }
.lesson-start-bar .start { width: 100%; }
}
@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-create-row, .material-assignment-bar, .material-cards, .student-scenario-fields { 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; }
.arm-page { box-sizing: border-box; min-height: 100vh; padding: 8px; background: #c9ced1; color: #303943; font-family: Arial, "Segoe UI", sans-serif; font-size: 14px; }
.arm-page .mode-banner, .arm-page .stand { margin: -8px -8px 8px; padding: 5px 12px; font-size: 12px; }
.arm-topbar { display: flex; align-items: stretch; gap: 5px; min-height: 76px; margin-bottom: 8px; }
.arm-topbar > div { box-sizing: border-box; background: #f8f9fa; }
@ -346,6 +695,20 @@ button:disabled { opacity: 0.5; cursor: default; }
.arm-briefing-label span, .arm-briefing p { color: #69727a; font-size: 12px; }
.arm-briefing-text { min-height: 0; padding: 8px 12px; overflow: auto; white-space: pre-wrap; line-height: 1.4; }
.arm-briefing p { margin: 0; padding: 0 15px 10px; }
.arm-text-layout { display: grid; grid-template-columns: minmax(360px, .72fr) minmax(0, 1.28fr); align-items: start; gap: 8px; min-height: calc(100vh - 155px); }
.arm-text-dialogue { position: sticky; top: 8px; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; gap: 10px; height: calc(100vh - 185px); min-height: 460px; max-height: 860px; margin: 0; padding: 14px; border: 1px solid #aab3b9; background: #f8f9fa; box-sizing: border-box; }
.arm-text-dialogue > header { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 16px; }
.arm-text-dialogue > header strong { font-size: 20px; }
.arm-text-dialogue > header span { color: #69727a; font-size: 14px; }
.arm-text-lines { min-height: 0; overflow: auto; padding: 12px; border: 1px solid #d1d6da; background: white; }
.arm-text-line { display: flex; gap: 8px; margin: 4px 0; line-height: 1.45; }
.arm-text-line { font-size: 17px; }
.arm-text-line.caller { color: #285c3c; }
.arm-line-source { align-self: center; flex: 0 0 auto; margin-left: auto; padding: 2px 7px; border-radius: 999px; background: #edf2f3; color: #5f6c72; font-size: 11px; font-weight: 600; white-space: nowrap; }
.arm-text-dialogue form { display: flex; gap: 8px; }
.arm-text-dialogue input { flex: 1; min-width: 0; min-height: 58px; padding: 12px; font-size: 17px; }
.arm-text-dialogue button { min-height: 58px; padding: 10px 20px; border: 1px solid #aab3b9; background: #26343b; color: white; font-size: 16px; font-weight: 700; }
.arm-text-dialogue button:disabled { opacity: .55; }
.arm-card-wrap { min-width: 0; }
.arm-footer { position: sticky; z-index: 4; bottom: 0; display: flex; align-items: center; justify-content: space-between; gap: 14px; min-height: 70px; margin: 8px -8px 0; padding: 8px 16px; box-sizing: border-box; background: #f26137; color: white; }
.arm-footer > span { font-size: 13px; font-weight: 700; }
@ -358,7 +721,7 @@ button:disabled { opacity: 0.5; cursor: default; }
.arm-footer button:disabled { opacity: .55; }
.arm-message { margin: 8px 0; padding: 8px 12px; background: white; }
.arm-secondary { display: grid; grid-template-columns: minmax(280px, 1fr) minmax(420px, 2fr); gap: 8px; margin-top: 8px; }
.arm-resolve, .arm-support { min-width: 0; padding: 12px; background: #f8f9fa; }
.arm-resolve, .arm-support { min-width: 0; padding: 12px; background: #efefef; }
.arm-resolve summary { color: #235b85; cursor: pointer; font-weight: 700; }
.arm-resolve input { width: min(100%, 450px); padding: 7px; border: 1px solid #bac1c6; }
.arm-resolve button { margin: 2px 4px 2px 0; }
@ -371,7 +734,7 @@ button:disabled { opacity: 0.5; cursor: default; }
.kio-arm-main { display: grid; grid-template-columns: minmax(340px, 45%) minmax(420px, 55%); gap: 8px; align-items: stretch; }
.arm-page .kio-arm-main { min-height: calc(100vh - 315px); }
.kio-arm-left, .kio-arm-right { display: flex; min-width: 0; flex-direction: column; gap: 8px; }
.kio-arm-section { min-width: 0; box-sizing: border-box; background: #f8f9fa; }
.kio-arm-section { min-width: 0; box-sizing: border-box; background: #efefef; }
.kio-arm-section-title { padding: 8px 10px; border-bottom: 1px solid #d5d9dc; color: #67717a; font-size: 12px; }
.kio-arm-fields { padding: 4px 10px 12px; }
.kio-arm-row { min-width: 0; box-sizing: border-box; }
@ -411,6 +774,7 @@ button:disabled { opacity: 0.5; cursor: default; }
.kio-arm-incident .kio-arm-row-signs { display: block; padding: 7px 0 12px; border-bottom: 1px solid #d6dade; }
.kio-arm-incident .kio-arm-row-signs .kio-arm-label { margin: 0 0 7px; font-size: 16px; }
.kio-arm-ref .kio-arm-incident .kio-arm-row-incident_type { display: block; min-height: 0; padding: 6px 0 10px; border-bottom: 1px solid #d6dade; }
.kio-arm-ref .kio-arm-incident .kio-arm-section-title { background: #efefef; color: #67717a; font-weight: 400; }
.kio-arm-ref .kio-arm-incident .kio-arm-row-incident_type .kio-arm-label { margin: 2px 0 12px; font-size: 20px; font-weight: 400; }
.kio-arm-type-picker { display: flex; flex-wrap: wrap; gap: 7px; }
.kio-arm-type-picker button { padding: 8px 12px; border: 1px solid #b3bdc5; background: white; color: #303943; font-size: 13px; font-weight: 700; }
@ -433,6 +797,8 @@ button:disabled { opacity: 0.5; cursor: default; }
.kio-arm-address, .kio-arm-incident { min-height: 0; }
.arm-page .kio-arm-main { min-height: 0; }
.arm-page .kio-arm-address { flex-basis: auto; }
.arm-text-layout { grid-template-columns: 1fr; min-height: 0; }
.arm-text-dialogue { position: static; height: 48vh; min-height: 340px; max-height: 520px; }
}
@container kio-card (max-width: 800px) {
.kio-arm-top, .kio-arm-main { grid-template-columns: 1fr; }
@ -453,6 +819,8 @@ button:disabled { opacity: 0.5; cursor: default; }
.arm-footer { margin-right: -6px; margin-left: -6px; }
.arm-briefing { grid-template-columns: 1fr; max-height: 160px; }
.arm-briefing-label { border-right: 0; border-bottom: 1px solid #d1d6da; }
.arm-text-dialogue { height: 42vh; min-height: 300px; }
.arm-text-dialogue form { align-items: stretch; }
.arm-secondary, .kio-arm-top { grid-template-columns: 1fr; }
.kio-arm-top .kio-arm-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.kio-arm-address .kio-arm-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@ -463,23 +831,27 @@ 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: #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; }
.dds-top-role { color: #52616d; }
.dds-workspace .dds-connection { color: #52616d; font-size: 12px; }
.dds-connection-live { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.dds-workspace .dds-connection { color: #52616d; font-size: 11px; }
.dds-search { display: grid; grid-template-columns: minmax(0, 1fr) 340px; min-height: 122px; background: #f7f9fa; }
.dds-search-main { padding: 14px 18px 10px; }
.dds-search-main { position: relative; padding: 14px 18px 10px; }
.dds-search-main h1 { margin: 0 0 8px; padding-bottom: 4px; border-bottom: 1px solid #252f35; font-size: 30px; line-height: 1.2; font-weight: 400; }
.dds-search-icon { position: absolute; top: 16px; right: 16px; min-height: 30px; padding: 0 6px; border: 0; background: transparent; color: #202a2f; font-size: 28px; line-height: 1; }
.dds-search-line { display: flex; align-items: center; gap: 8px; }
.dds-search-line:not(.is-open) { justify-content: space-between; }
.dds-search-line:not(.is-open) .dds-advanced-toggle { order: -1; margin-right: auto; margin-left: 0; }
.dds-search-line label { font-size: 12px; color: #53616c; }
.dds-search-line input { flex: 1; min-width: 100px; max-width: 520px; padding: 5px 8px; border: 0; border-bottom: 1px solid #8b989f; background: transparent; font: inherit; }
.dds-search-line button { padding: 4px 10px; font-size: 12px; }
.dds-search-line .dds-advanced-toggle { margin-left: 12px; border: 0; background: transparent; color: #394850; }
.dds-advanced-search { display: flex; align-items: center; flex-wrap: wrap; gap: 12px 22px; padding: 7px 0 0; color: #44525b; font-size: 12px; }
.dds-advanced-search label { display: flex; align-items: center; gap: 7px; }
.dds-advanced-search select { min-width: 150px; padding: 4px 6px; font: inherit; }
.dds-clock { display: grid; align-content: center; grid-template-columns: 1fr auto; gap: 3px 8px; padding: 10px 16px; background: #2d363b; color: white; }
.dds-clock small { font-size: 14px; font-weight: 600; }
.dds-clock strong { grid-row: span 2; font-size: 44px; line-height: 1; letter-spacing: -.04em; font-variant-numeric: tabular-nums; }
.dds-clock span { font-size: 11px; color: #b9c5ca; }
.dds-register-panel { box-sizing: border-box; min-height: calc(100vh - 160px); margin: 0; padding: 10px 17px 24px; border: 0; background: #7e8d96; color: #f5f8fa; }
.dds-register-panel { box-sizing: border-box; min-height: calc(100vh - 122px); margin: 0; padding: 10px 17px 24px; border: 0; background: #7e8d96; color: #f5f8fa; }
.dds-register-panel .dds-panel-title { padding: 8px 8px 12px; border: 0; background: transparent; color: #fff; }
.dds-register-panel .dds-panel-title h2 { font-size: 18px; }
.dds-register-panel .dds-panel-title span { color: #dce5e8; }
@ -490,18 +862,32 @@ button:disabled { opacity: 0.5; cursor: default; }
.dds-register-panel .dds-registry tr.dds-selected { box-shadow: none; }
.dds-register-panel .dds-registry td small { display: block; margin-top: 5px; padding-top: 5px; border-top: 1px solid #697881; color: #ccd6dc; }
.dds-register-panel .dds-registry td.dds-empty { background: transparent; color: #fff; }
.dds-history-title { min-height: 38px; }
.dds-history-notifications { display: flex; align-items: center; gap: 10px; font-size: 12px; color: #e5ecf0; }
.dds-history-notifications select { min-width: 190px; padding: 5px 8px; border: 1px solid #aab7be; background: transparent; color: inherit; font: inherit; }
.dds-history-notifications option { color: #27343a; background: #fff; }
.dds-history-registry { table-layout: fixed; width: 100%; }
.dds-history-registry th, .dds-history-registry td { overflow: hidden; text-overflow: ellipsis; }
.dds-history-description td { padding: 5px 8px; border: 0; background: #47555e; color: #d8e0e4; font-size: 11px; }
.dds-history-pagination { justify-content: flex-end; margin-top: 18px; }
.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: #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-pagination { display: flex; align-items: center; justify-content: flex-end; gap: 8px; min-height: 34px; padding-top: 6px; color: #e5edf0; font-size: 12px; }
.dds-pagination label { display: inline-flex; align-items: center; gap: 5px; }
.dds-pagination select { padding: 2px 4px; border: 0; background: transparent; color: inherit; font: inherit; }
.dds-pagination option { color: #26343d; }
.dds-pagination button { min-width: 24px; min-height: 24px; padding: 0 5px; border: 0; background: transparent; color: #fff; font-size: 20px; }
.dds-pagination button:disabled { color: #aeb8be; }
.dds-case-layout { display: flex; flex-direction: column; min-height: 100vh; background: #c9ced1; }
.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; }
.dds-call-incident-title { display: flex; align-items: center; gap: 4px; min-width: 0; }
.dds-card-back { display: grid; flex: 0 0 44px; place-items: center; min-width: 44px; min-height: 44px; padding: 0; border: 0; background: transparent; color: #176b9d; font-size: 18px; }
.dds-call-incident-title strong { min-width: 0; }
.dds-call-icon { float: left; color: #65737b; font-size: 18px; }
.dds-call-off { border-left: 4px solid #697984; }
.dds-case-top-buttons { display: grid; gap: 5px; padding: 0 !important; background: transparent !important; }
@ -516,7 +902,7 @@ button:disabled { opacity: 0.5; cursor: default; }
.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-stage { position: relative; flex: 1; min-height: clamp(96px, calc(100vh - 760px), 320px); }
.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; }
@ -539,12 +925,15 @@ button:disabled { opacity: 0.5; cursor: default; }
.dds-status-editor select, .dds-status-editor input { box-sizing: border-box; width: 100%; min-width: 0; min-height: 25px; padding: 2px 4px; border: 0; border-bottom: 1px solid #8999a2; background: #fff; color: #26343d; font-size: 12px; }
.dds-status-editor button { min-width: 0; min-height: 25px; padding: 0; border: 1px solid #9bacb6; color: #25333b; font-size: 18px; }
.dds-status-warning { grid-column: 1 / -1; font-size: 11px; }
.dds-service-dock { display: flex; align-items: stretch; min-height: 64px; overflow-x: auto; background: #43525b; color: #fff; }
.dds-service-dock { display: flex; align-items: stretch; min-height: 64px; overflow: hidden; background: #43525b; color: #fff; }
.dds-dock-label { display: flex; align-items: center; padding: 8px; border-right: 1px solid #85929b; font-size: 12px; }
.dds-service-dock button { display: grid; align-content: center; gap: 3px; flex: 0 0 auto; min-width: 115px; padding: 8px 11px; border: 0; border-right: 1px solid #87949b; background: #43525b; color: #fff; text-align: left; }
.dds-service-dock button:hover, .dds-service-dock button.active { background: #176b9d; }
.dds-service-dock button:disabled { color: #d0d7db; }
.dds-service-dock button small { color: #d8e5ea; font-size: 10px; }
.dds-service-recipient-list { display: flex; align-items: stretch; flex: 1 1 auto; min-width: 0; overflow-x: auto; scrollbar-width: thin; }
.dds-service-tools { display: flex; align-items: stretch; flex: 0 0 auto; border-left: 2px solid #aab4ba; }
.dds-service-tools button { min-width: 92px; }
@media (max-width: 900px) {
.dds-search { grid-template-columns: 1fr 220px; }
@ -555,12 +944,14 @@ button:disabled { opacity: 0.5; cursor: default; }
}
@media (max-width: 700px) {
.dds-workspace { overflow-x: hidden; }
.dds-app-top { flex-wrap: wrap; }
.dds-app-name { flex-basis: calc(100% - 45px); }
.dds-search { display: block; }
.dds-search-main h1 { font-size: 24px; }
.dds-search-line { flex-wrap: wrap; }
.dds-search-line:not(.is-open) { flex-wrap: nowrap; }
.dds-search-line label { flex-basis: 100%; }
.dds-search-line .dds-advanced-toggle { margin-left: 0; }
.dds-advanced-search { align-items: stretch; flex-direction: column; }
.dds-advanced-search label { justify-content: space-between; }
.dds-clock { display: flex; align-items: center; justify-content: space-between; min-height: 42px; }
.dds-clock small { font-size: 11px; }
.dds-clock strong { font-size: 22px; }
@ -569,7 +960,6 @@ button:disabled { opacity: 0.5; cursor: default; }
.dds-register-panel .dds-registry tr { border-bottom: 2px solid #b1c2cb; }
.dds-register-panel .dds-registry td { background: #303b42; color: #fff; border-bottom: 1px solid #596b75; }
.dds-register-panel .dds-registry td::before { color: #d2dce1; }
.dds-case-top { flex-wrap: wrap; }
.dds-call-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dds-call-strip .dds-call-incident, .dds-case-top-buttons { grid-column: span 2; }
.dds-case-meta { grid-template-columns: 1fr 1fr; }
@ -578,9 +968,12 @@ button:disabled { opacity: 0.5; cursor: default; }
.dds-case-address > div { min-height: 150px; }
.dds-case-stage { min-height: 0; }
.dds-case-drawer { position: static; width: auto; max-height: none; margin: 10px; overflow: visible; }
.dds-service-dock { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); overflow: visible; }
.dds-dock-label { grid-column: span 2; }
.dds-service-dock button { min-width: 0; }
.dds-service-dock { display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
.dds-pagination { justify-content: flex-start; flex-wrap: wrap; }
.dds-dock-label { min-height: 28px; border-right: 0; border-bottom: 1px solid #85929b; }
.dds-service-recipient-list { flex: 0 0 64px; width: 100%; overflow-x: auto; overflow-y: hidden; }
.dds-service-tools { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); border-top: 1px solid #87949b; border-left: 0; }
.dds-service-tools button { min-width: 0; }
.dds-case-drawer .dds-status-table, .dds-case-drawer .dds-registry { min-width: 500px; }
.dds-case-drawer-status { width: auto; }
.dds-status-editor { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@ -652,3 +1045,18 @@ button:disabled { opacity: 0.5; cursor: default; }
min-height: 44px;
}
}
/* Keep the dense 11-column history legible on phones; scroll inside the table wrapper. */
@media (min-width: 701px) and (max-width: 1440px) {
.dds-history-registry { min-width: 1600px; }
.dds-register-panel .dds-table-wrap { max-width: 100%; overflow-x: auto; }
}
@media (max-width: 700px) {
.dds-history-registry { min-width: 1600px; }
.dds-register-panel .dds-table-wrap { max-width: 100%; overflow-x: auto; }
.dds-register-panel .dds-history-registry thead { display: table-header-group; }
.dds-register-panel .dds-history-registry tr { display: table-row; }
.dds-register-panel .dds-history-registry th,
.dds-register-panel .dds-history-registry td { display: table-cell; }
.dds-register-panel .dds-history-registry td::before { content: none; }
}