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

@ -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>
);
}