Complete DDS training workflow and delivery package

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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