chore: обновить паузу занятия от main

# Conflicts:
#	backend/app/scoring/dispatcher.py
#	backend/app/session/store.py
This commit is contained in:
kaifarikman 2026-09-27 23:30:18 +03:00
commit 0d58878cbe
51 changed files with 4545 additions and 87 deletions

View file

@ -74,6 +74,18 @@ socket.receive({ type: "kio.patch", fields: {
}, source: "operator" });
socket = reconnect(socket);
assert.deepEqual(socket.sent, [], "matching checkpoint echo clears the KIO patch");
const refused = { type: "kio.patch", fields: { address: "ул. Новая, 3", notify_extra: ["Нет в каталоге"] } };
call.send(refused);
socket.receive({ type: "kio.patch", fields: { address: patch.fields.address, notify_extra: [] }, source: "auto" });
socket.sent = [];
call.send({ type: "kio.patch", fields: { floor: "2" } });
assert.deepEqual(socket.sent.at(-1).fields, { floor: "2" },
"server values after a refused patch acknowledge it: refused fields are not merged into the next edit");
socket.receive({ type: "kio.patch", fields: { floor: "2" }, source: "operator" });
socket = reconnect(socket);
assert.deepEqual(socket.sent, [], "nothing left to replay after refusal and the next echo");
const refusedCard = applyPatch(edit(empty, "notify_extra", ["Нет в каталоге"]), { notify_extra: [] }, "auto");
assert.deepEqual(refusedCard.pending, {}, "server value after refusal drops the pending edit");
const edited = edit(empty, "coords", { latitude: 55.7, longitude: 37.6 });
assert.deepEqual(applyPatch(edited, { coords: { longitude: 37.6, latitude: 55.7 } }, "operator").pending, {});
assert.deepEqual(applyState(edited, { coords: { longitude: 37.6, latitude: 55.7 } }).pending, {});

View file

@ -39,6 +39,7 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
<article className="debrief-stat">
<span>Автоматическая оценка</span><strong>{report.score_auto.toFixed(0)}<small> из 100</small></strong>
<small>{report.overridden_by ? "до корректировки преподавателя" : "расчёт по критериям занятия"}</small>
{report.score_reviewed != null && <small>после разбора отметок: {report.score_reviewed.toFixed(0)}</small>}
</article>
<article className="debrief-stat">
<span>Упражнение</span><strong>{report.exercise === "dds" ? "ДДС" : report.exercise === "card" ? "КИО" : report.exercise === "call" ? "Вызов 112" : "112 → ДДС"}</strong>
@ -80,6 +81,9 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
{` ${report.criteria.dds_card_work_time_limit_seconds ?? 180} с`}</>}
{report.exercise === "card" && <>Норматив заполнения КИО —
{` ${report.criteria.card_fill_time_limit_seconds ?? 180} с`}</>}
{/* 112 → ДДС приходит как exercise "card": работу ДДС выдают карточки пульта. */}
{(report.exercise === "dds" || Boolean(report.card_results?.length)) && <>; статус по докладу бригады —
{` не позже ${report.criteria.dds_report_reaction_limit_seconds ?? 45} с`}</>}
{report.exercise && report.exercise !== "dds" && <>
; грамматика описания КИО {report.criteria.require_correct_grammar ? "входит" : "не входит"} в оценку
</>}.
@ -92,7 +96,8 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
{Boolean(report.card_results?.length) && <section className="debrief-card-results">
<h3>Карточки занятия ({report.card_results!.length})</h3>
{report.card_results!.map((card, index) => <details key={card.card_id}>
<summary>Карточка {index + 1}: {card.title || card.scenario_id} · {card.score_auto.toFixed(0)} / 100
<summary>Карточка {index + 1}: {card.title || card.scenario_id} · {(card.score_reviewed ?? card.score_auto).toFixed(0)} / 100
{card.score_reviewed != null && ` после разбора (автооценка ${card.score_auto.toFixed(0)})`}
{card.address ? ` · ${card.address}` : ""}</summary>
{card.managed_service && <p><strong>Ответственная служба:</strong> {card.managed_service}</p>}
<p><strong>Ответ диспетчера:</strong> {card.reply_text || "не внесён"}</p>
@ -147,6 +152,10 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
<td>
{finding.summary}
<div className="ref">{finding.fact}{finding.norm && ` · ${finding.norm}`}</div>
{finding.source === "instructor" && <div className="ref">отметка преподавателя {finding.author ?? ""}</div>}
{(finding.reviews ?? []).map((review, reviewIndex) => <div className="ref" key={reviewIndex}>
{review.decision === "dismissed" ? "снята" : "подтверждена"} преподавателем {review.author}: {review.reason}
</div>)}
</td>
</tr>
))}

View file

@ -0,0 +1,162 @@
// Разбор по отметкам: преподаватель подтверждает, снимает и добавляет отметку.
//
// Балл пересчитывает сервер теми же метриками и весами (lct-35): здесь только
// решение и его причина. Причина обязательна — она остаётся в отчёте и в CSV/PDF.
import { useRef, useState } from "react";
import type { ErrorCode, FindingDecision, SessionReport } from "@/shared/types/generated";
const DDS_CODES: ErrorCode[] = ["D1", "D2", "D3", "D4", "D5", "D6"];
const CALL_CODES: ErrorCode[] = ["E1", "E2", "E3", "E4", "E5", "E6"];
async function send(url: string, body: unknown): Promise<SessionReport> {
const response = await fetch(url, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
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 : "Не удалось сохранить решение.");
}
return data as SessionReport;
}
export function FindingReview({ report, onChange }: {
report: SessionReport;
onChange: (report: SessionReport) => void;
}) {
const cards = report.card_results ?? [];
const codes = report.exercise === "dds" ? DDS_CODES
: cards.length ? [...CALL_CODES, ...DDS_CODES] : CALL_CODES;
const [reasons, setReasons] = useState<Record<number, string>>({});
const [code, setCode] = useState<ErrorCode>(codes[0]);
// В ДДС все метрики принадлежат карточкам очереди. В связке 112 → ДДС
// E-коды относятся к занятию, а D-код без карточки — к живой карточке звонка.
const [card, setCard] = useState(report.exercise === "dds" && cards.length ? "1" : "");
const cardChoice = cards.length > 0 && (report.exercise === "dds" || code.startsWith("D"));
// Один ключ на отметку, пока она не сохранена: повтор после сбоя сети
// не добавит её второй раз.
const pendingId = useRef<string | null>(null);
const [fact, setFact] = useState("");
const [norm, setNorm] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const base = `/api/sessions/${report.session_id}/findings`;
async function run(action: () => Promise<SessionReport>) {
setBusy(true);
setError("");
try {
onChange(await action());
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Не удалось сохранить решение.");
} finally {
setBusy(false);
}
}
function decide(index: number, decision: FindingDecision) {
const reason = (reasons[index] ?? "").trim();
if (!reason) {
setError("Укажите причину решения по отметке.");
return;
}
void run(async () => {
const updated = await send(`${base}/${index}/review`, { decision, reason });
setReasons((current) => ({ ...current, [index]: "" }));
return updated;
});
}
function addFinding() {
if (!fact.trim() || !norm.trim()) {
setError("Для отметки нужны факт и норма.");
return;
}
pendingId.current ??= crypto.randomUUID();
const clientId = pendingId.current;
void run(async () => {
const updated = await send(base, {
code, fact: fact.trim(), norm: norm.trim(), card: cardChoice && card ? Number(card) : null,
client_id: clientId,
});
pendingId.current = null;
setFact("");
setNorm("");
return updated;
});
}
return (
<section className="debrief finding-review">
<h2>Разбор по отметкам</h2>
<p className="ref">
Снятая отметка не штрафует свою метрику, добавленная — штрафует. Балл пересчитывается
по тем же критериям и весам; автоматическая оценка ({report.score_auto.toFixed(1)}) сохраняется рядом.
{report.score_reviewed != null && ` После разбора: ${report.score_reviewed.toFixed(1)}.`}
</p>
<table className="grid">
<tbody>
{report.findings.map((finding, index) => (
<tr key={index}>
<th>{finding.code}</th>
<td>
{finding.summary}
<div className="ref">{finding.fact}{finding.norm && ` · ${finding.norm}`}</div>
{(finding.reviews ?? []).map((review, reviewIndex, all) => <div key={reviewIndex}
className={reviewIndex < all.length - 1 || review.decision === "dismissed" ? "ref" : "state-violated"}>
{review.decision === "dismissed" ? "Снята" : "Подтверждена"} ({review.author},{" "}
{new Date(review.at).toLocaleString("ru-RU")}): {review.reason}
</div>)}
<div className="finding-review-actions">
<input aria-label={`Причина решения по отметке ${index + 1}`} maxLength={1000}
placeholder="Причина решения" value={reasons[index] ?? ""}
onChange={(event) => setReasons((current) => ({ ...current, [index]: event.target.value }))} />
<button type="button" disabled={busy} onClick={() => decide(index, "confirmed")}>Подтвердить</button>
<button type="button" disabled={busy} onClick={() => decide(index, "dismissed")}>Снять</button>
</div>
</td>
</tr>
))}
{report.findings.length === 0 && <tr><td colSpan={2}>Автоматических отметок нет.</td></tr>}
</tbody>
</table>
<h3>Добавить отметку</h3>
<div className="finding-review-form">
<label>Код
<select value={code} onChange={(event) => {
pendingId.current = null;
setCode(event.target.value as ErrorCode);
}}>
{codes.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</label>
{cardChoice && <label>Карточка
<select value={card} onChange={(event) => { pendingId.current = null; setCard(event.target.value); }}>
{report.exercise !== "dds" && <option value="">занятие (без карточки)</option>}
{cards.map((item, index) => <option key={item.card_id} value={String(index + 1)}>
{index + 1}: {item.title || item.scenario_id}
</option>)}
</select>
</label>}
<label>Факт
<input maxLength={1000} value={fact} onChange={(event) => { pendingId.current = null; setFact(event.target.value); }}
placeholder="Что сделал курсант" />
</label>
<label>Норма
<input maxLength={1000} value={norm} onChange={(event) => { pendingId.current = null; setNorm(event.target.value); }}
placeholder="Как требовалось" />
</label>
<button type="button" disabled={busy || !fact.trim() || !norm.trim()} onClick={addFinding}>
{busy ? "Сохраняем…" : "Добавить отметку"}
</button>
</div>
{error && <p className="violated" role="alert">{error}</p>}
</section>
);
}

View file

@ -32,6 +32,7 @@ const DDS_METRICS: MetricOption[] = [
{ key: "dds_completion", label: "Завершение работ", hint: "Фиксация завершения или отказа от работ." },
{ key: "dds_reply", label: "Основание и сведения", hint: "Комментарии к статусам и содержание полученных сведений." },
{ key: "dds_work_time", label: "Время отработки карточки", hint: "Соблюдение лимита полного цикла карточки." },
{ key: "dds_report_reaction", label: "Реакция на доклад бригады", hint: "Статус по докладу бригады не позже лимита занятия." },
];
const CARD_METRICS = OPERATOR_METRICS.filter(({ key }) => [
@ -39,6 +40,10 @@ const CARD_METRICS = OPERATOR_METRICS.filter(({ key }) => [
"required_fields", "description_grammar", "card_fill_time",
].includes(key));
const CALL_METRICS = OPERATOR_METRICS.filter(({ key }) => key !== "card_fill_time");
// Метрика появляется, только когда ручной отметке преподавателя нечего провалить.
const REVIEW_METRICS: MetricOption[] = [
{ key: "instructor_finding", label: "Отметка преподавателя", hint: "Ручная отметка на разборе без связанной метрики (например, D4)." },
];
export function ScoreWeightsEditor({
exercise,
@ -57,6 +62,7 @@ export function ScoreWeightsEditor({
: exercise === "card_dds"
? [["Карточка КИО", CARD_METRICS], ["Реагирование ДДС", DDS_METRICS]]
: [["Опрос 112", CALL_METRICS], ["Реагирование ДДС", DDS_METRICS]];
groups.push(["Разбор", REVIEW_METRICS]);
function setWeight(key: string, raw: string) {
const next = { ...values };

View file

@ -1,7 +1,9 @@
// Карточка КИО. Один и тот же компонент рисуется у курсанта, на пульте,
// на внешнем мониторе и на АРМ ДДС — там в режиме `readOnly`.
import { useIncidentGroups, useSigns } from "@/shared/api/http";
import { useState } from "react";
import { useIncidentGroups, useServiceCatalog, useSigns } from "@/shared/api/http";
import { type CardState, display } from "./merge";
import { type Field, type FieldGroup, visibleGroups } from "./fields";
@ -17,6 +19,7 @@ interface Props {
export function KioCard({ state, required = [], readOnly = false, referenceArm = false, onChange }: Props) {
const dds = display(state, "dds") as string | null;
const incidentGroup = display(state, "incident_group") as number | null;
const notify = (display(state, "notify") as string[] | null) ?? [];
const groups = visibleGroups(dds);
const section = (group: FieldGroup) => (
<section className={`kio-arm-section kio-arm-${group.area}`} key={group.title}>
@ -36,6 +39,7 @@ export function KioCard({ state, required = [], readOnly = false, referenceArm =
readOnly={readOnly || field.readOnly}
referenceArm={referenceArm}
incidentGroup={incidentGroup}
notify={notify}
onChange={onChange}
/>
))}
@ -58,7 +62,7 @@ export function KioCard({ state, required = [], readOnly = false, referenceArm =
}
function Row({
field, value, pending, required, readOnly, referenceArm, incidentGroup, onChange,
field, value, pending, required, readOnly, referenceArm, incidentGroup, notify, onChange,
}: {
field: Field;
value: unknown;
@ -67,6 +71,7 @@ function Row({
readOnly?: boolean;
referenceArm: boolean;
incidentGroup: number | null;
notify: string[];
onChange?: (path: string, value: unknown) => void;
}) {
const empty = value === null || value === undefined || value === "" || (Array.isArray(value) && value.length === 0);
@ -85,7 +90,7 @@ function Row({
{readOnly ? (
<span className="kio-arm-readonly">{formatValue(value, field)}</span>
) : (
<Input field={field} value={value} referenceArm={referenceArm} incidentGroup={incidentGroup} onChange={onChange} />
<Input field={field} value={value} referenceArm={referenceArm} incidentGroup={incidentGroup} notify={notify} onChange={onChange} />
)}
</div>
</div>
@ -93,12 +98,13 @@ function Row({
}
function Input({
field, value, referenceArm, incidentGroup, onChange,
field, value, referenceArm, incidentGroup, notify, onChange,
}: {
field: Field;
value: unknown;
referenceArm: boolean;
incidentGroup: number | null;
notify: string[];
onChange?: (path: string, value: unknown) => void;
}) {
const emit = (next: unknown) => onChange?.(field.path, next);
@ -141,6 +147,9 @@ function Input({
</select>
);
}
if (field.kind === "services") {
return <ServicesPicker value={(value as string[]) ?? []} notify={notify} onChange={(next) => emit(next)} />;
}
if (field.kind === "signs") {
return <SignsPicker value={(value as string[]) ?? []} group={incidentGroup} onChange={(next) => emit(next)} />;
}
@ -170,6 +179,46 @@ function Input({
);
}
/** Как `max_length` у `KIO.notify_extra`. */
const SERVICES_LIMIT = 20;
/** Ручное добавление служб из каталога — окно «Добавьте службы» боевого АРМ.
* Список оповещения по признакам остаётся как есть: здесь только добавки. */
function ServicesPicker({ value, notify, onChange }: {
value: string[]; notify: string[]; onChange: (next: string[]) => void;
}) {
const catalog = useServiceCatalog();
const [query, setQuery] = useState("");
const needle = query.trim().toLowerCase();
// Лимит и службы из списка оповещения — те же правила, что на сервере:
// лишняя служба отклонила бы весь пакет правок.
const full = value.length >= SERVICES_LIMIT;
const found = needle && !full
? (catalog.data?.services ?? [])
.filter((item) => !value.includes(item.name) && !notify.includes(item.name)
&& item.name.toLowerCase().includes(needle))
.slice(0, 8)
: [];
return (
<div className="kio-services">
{value.length > 0 && <div className="signs-selected" aria-label="Добавленные службы">
{value.map((name) => <span key={name}>
{name} <button type="button" aria-label={`Убрать ${name}`}
onClick={() => onChange(value.filter((item) => item !== name))}>×</button>
</span>)}
</div>}
<input type="search" aria-label="Поиск службы в каталоге"
placeholder={full ? `Не больше ${SERVICES_LIMIT} служб` : "Поиск службы…"} disabled={full}
value={query} onChange={(event) => setQuery(event.target.value)} />
{found.length > 0 && <ul className="kio-services-found">
{found.map((item) => <li key={item.id}>
<button type="button" onClick={() => { onChange([...value, item.name]); setQuery(""); }}>{item.name}</button>
</li>)}
</ul>}
</div>
);
}
function IncidentGroupPicker({ value, onChange }: { value: number | null; onChange: (next: number | null) => void }) {
const page = useIncidentGroups();
return <select aria-label="Категория происшествия" value={value ?? ""}

View file

@ -6,7 +6,7 @@
import type { DDSCode, IncidentType } from "@/shared/types/generated";
export type FieldKind = "text" | "textarea" | "number" | "bool" | "select" | "signs" | "group" | "list";
export type FieldKind = "text" | "textarea" | "number" | "bool" | "select" | "signs" | "group" | "list" | "services";
export interface Field {
/** Путь в карточке: «floor», «fire.floors» — так же, как в `kio.patch`. */
@ -107,6 +107,8 @@ export const GROUPS: FieldGroup[] = [
fields: [
// Список оповещения оператор не выбирает: он производен от признаков.
{ path: "notify", label: "Список оповещения", kind: "list", readOnly: true },
// Как «Службы: +» в боевом АРМ: добавить можно, убрать автоматическую — нет.
{ path: "notify_extra", label: "Дополнительно оповестить", kind: "services" },
{ path: "dds", label: "Служба (устаревшее)", kind: "select", options: DDS },
{ path: "dispatch_order_at", label: "Приказ на выезд", kind: "text", readOnly: true },
{ path: "arrival_at", label: "Прибытие", kind: "text", readOnly: true },

View file

@ -47,6 +47,11 @@ function field(card: CardState, path: string): string {
const value = readPath(card.server, path);
return value === null || value === undefined || value === "" ? "—" : String(value);
}
/** Список из карточки: `field()` не годится — `String([])` пустая строка, а не «—». */
function names(card: CardState, path: string): string[] {
const value = readPath(card.server, path);
return Array.isArray(value) ? value.map(String) : [];
}
function time(value: string): string {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleTimeString("ru-RU");
@ -386,7 +391,8 @@ export function Dds() {
{Boolean(report.card_results?.length) && <>
<h3>Результаты по карточкам</h3>
{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>
<summary>Карточка {index + 1} · {item.scenario_id} · {(item.score_reviewed ?? item.score_auto).toFixed(0)} / 100
{item.score_reviewed != null && ` после разбора (автооценка ${item.score_auto.toFixed(0)})`}</summary>
<p><b>Ответ диспетчера:</b> {item.reply_text || "не внесён"}</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"}>
@ -556,6 +562,7 @@ export function Dds() {
<div><b>{field(card, "description")}</b></div>
<div>Класс: <b>{field(card, "incident_type")}</b></div>
<div>Получатели карточки: <b>{station?.recipient_services?.join(", ") || field(card, "notify")}</b></div>
{names(card, "notify_extra").length > 0 && <div>Добавлены оператором: <b>{names(card, "notify_extra").join(", ")}</b></div>}
<div>Ваша ДДС: <b>{station?.managed_service || "служба не назначена"}</b></div></div>
</div>
<div className="dds-case-stage">

View file

@ -7,6 +7,7 @@
import { useEffect, useRef, useState } from "react";
import { Debrief } from "@/features/debrief/Debrief";
import { FindingReview } from "@/features/debrief/FindingReview";
import { Director } from "@/features/instructor/Director";
import { ScoreWeightsEditor } from "@/features/instructor/ScoreWeightsEditor";
import { ScenarioEditor } from "@/features/instructor/ScenarioEditor";
@ -22,6 +23,7 @@ import {
useActiveSessions, useIncidentGroups, useReviewScenarioSubmission, useScenarioSubmissions,
useScenarios, useSessions, useTrainees,
} from "@/shared/api/http";
import type { Signal } from "@/shared/api/http";
import { sessionIdFromUrl } from "@/shared/api/session";
import type { Level, SessionMode, SessionReport } from "@/shared/types/generated";
@ -44,6 +46,12 @@ function modeLabel(mode: SessionMode): string {
return MODES.find((item) => item.value === mode)?.label ?? mode;
}
function worstSignalSeverity(signals: Signal[]): "warn" | "violated" | null {
return signals.some((signal) => signal.severity === "violated")
? "violated"
: signals.length ? "warn" : null;
}
function sessionDateLabel(value: string | null): string | null {
if (!value) return null;
const date = new Date(value);
@ -85,6 +93,7 @@ export function Instructor() {
const [decisionTimeLimit, setDecisionTimeLimit] = useState(30);
const [cardFillTimeLimit, setCardFillTimeLimit] = useState(180);
const [ddsCardWorkTimeLimit, setDdsCardWorkTimeLimit] = useState(180);
const [ddsReportReactionLimit, setDdsReportReactionLimit] = useState(45);
const [allowedErrors, setAllowedErrors] = useState(0);
const [requireCorrectGrammar, setRequireCorrectGrammar] = useState(true);
const [lessonScoreWeights, setLessonScoreWeights] = useState<Record<string, number>>({});
@ -299,6 +308,7 @@ export function Instructor() {
decision_time_limit_seconds: decisionTimeLimit,
card_fill_time_limit_seconds: cardFillTimeLimit,
dds_card_work_time_limit_seconds: ddsCardWorkTimeLimit,
dds_report_reaction_limit_seconds: ddsReportReactionLimit,
allowed_errors: allowedErrors,
require_correct_grammar: requireCorrectGrammar,
score_weights: lessonScoreWeights,
@ -306,7 +316,7 @@ export function Instructor() {
});
if (sent) toStart.current = null;
}, [sessionId, control, scenarioId, extraScenarioIds, mode, trainee, traineeId, trainees.data,
exercise, decisionTimeLimit, cardFillTimeLimit, ddsCardWorkTimeLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios,
exercise, decisionTimeLimit, cardFillTimeLimit, ddsCardWorkTimeLimit, ddsReportReactionLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios,
ddsArrivalIntervalSeconds, ddsMaxWaiting, lessonScoreWeights, scenarios.data,
randomScenario, visibleScenarios]);
@ -338,7 +348,7 @@ export function Instructor() {
Список ваших активных сессий обновляется автоматически каждые 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>
<thead><tr><th>Курсант</th><th>Упражнение · карточка</th><th>Ход</th><th>ДДС</th><th>Задержки</th><th>Сигналы</th><th></th></tr></thead>
<tbody>
{(activeSessions.data ?? []).map((item) => (
<tr key={item.session_id}>
@ -383,12 +393,18 @@ export function Instructor() {
? `Просрочено: первичная реакция ${item.dds_overdue_cards}; отработка карточки ${item.dds_work_overdue_cards}`
: "Нормативы не нарушены"}
</td>
<td className={worstSignalSeverity(item.signals) ? `state-${worstSignalSeverity(item.signals)}` : item.presence_known ? "state-ok" : undefined}>
{item.signals.length
? item.signals.map((signal) => <span key={signal.kind}>{signal.text}<br /></span>)
: item.presence_known ? "Сигналов нет" : null}
{!item.presence_known && <span>Связь курсанта не проверена</span>}
</td>
<td><button type="button" onClick={() => openActiveSession(item)}>
{sessionId === item.session_id ? "Открыто" : "Наблюдать"}
</button></td>
</tr>
))}
{!activeSessions.data?.length && <tr><td colSpan={6}>
{!activeSessions.data?.length && <tr><td colSpan={7}>
{activeSessions.isLoading ? "Загружаем активные занятия…" : "Активных занятий пока нет."}
</td></tr>}
</tbody>
@ -648,6 +664,13 @@ export function Instructor() {
onCommit={setDdsCardWorkTimeLimit} />
<small className="ref">По умолчанию 180 секунд; не заменяет 30-секундное первичное решение.</small>
</label>}
{(exercise === "dds" || exercise === "card_dds") && <label className="lesson-criterion">
Реакция на доклад бригады, секунд
<LessonNumberInput label="Лимит реакции на доклад бригады, секунд"
value={ddsReportReactionLimit} min={5} max={600} fallback={45}
onCommit={setDdsReportReactionLimit} />
<small className="ref">По умолчанию 45 секунд: статус по докладу бригады — не позже лимита.</small>
</label>}
<label className="lesson-criterion">
Допустимо нарушенных метрик
<LessonNumberInput label="Допустимое число ошибок" value={allowedErrors}
@ -809,6 +832,8 @@ export function Instructor() {
{session.report && <>
<Debrief report={overrideReport?.session_id === session.report.session_id
? overrideReport : session.report} />
<FindingReview report={overrideReport?.session_id === session.report.session_id
? overrideReport : session.report} onChange={setOverrideReport} />
<section className="debrief debrief-score-override">
<h2>Ручная корректировка оценки</h2>
<p className="ref">Автоматическая оценка: {session.report.score_auto.toFixed(1)} из 100. Меняйте её только при необходимости: причина сохранится в журнале вместе с вашим логином.</p>

View file

@ -3,7 +3,9 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Level, SessionMode, SessionReport, StationSnapshot } from "@/shared/types/generated";
import type {
Level, SessionMode, SessionReport, StationSnapshot, TimerState,
} from "@/shared/types/generated";
import { reportErrorMessage, reportRetryDelay, shouldRetryReport } from "./report.mjs";
@ -94,6 +96,12 @@ export interface SessionInfo {
end_reason: string | null;
}
export interface Signal {
kind: "backlog" | "refusals" | "offline";
severity: TimerState;
text: string;
}
export interface ActiveSessionInfo {
session_id: string;
trainee_name: string | null;
@ -109,6 +117,8 @@ export interface ActiveSessionInfo {
dds_work_overdue_cards: number;
dds_statuses: Record<string, string>;
dds_snapshot: StationSnapshot | null;
signals: Signal[];
presence_known: boolean;
}
export const useActiveSessions = () =>
@ -230,6 +240,17 @@ export const useIncidentGroups = () => useQuery({
queryFn: () => request<IncidentGroupsPage>("/api/ekp/groups"),
});
export interface ServiceCatalogPage {
source: string;
services: { id: string; name: string; organization: string | null; service_level: string | null; source_reference: string }[];
}
/** Каталог окна «Добавьте службы»: 209 служб, включая районные ДДС. */
export const useServiceCatalog = () => useQuery({
queryKey: ["ekp-services"], staleTime: Infinity,
queryFn: () => request<ServiceCatalogPage>("/api/ekp/services"),
});
export function useSigns(prefix: string[], group: number | null = null) {
const params = new URLSearchParams();
if (group !== null) params.set("group", String(group));

View file

@ -164,8 +164,17 @@ export const callChannel = (
...options,
queueKey: (event) => event.type === "kio.patch" ? "kio.patch" : null,
isAcknowledged: (incoming, pending) => {
if (pending.type !== "kio.patch" || incoming.type !== "kio.patch"
|| incoming.source !== "operator") return false;
if (pending.type !== "kio.patch" || incoming.type !== "kio.patch") return false;
// `auto` по всем полям команды с другим значением — сервер её переписал
// (так он отвечает и на отклонённый пакет), а merge.ts уже снял правку из
// pending. Повтор затёр бы серверную истину, а слияние с новыми правками
// отклоняло бы и их. Совпадающий `auto` ничего не доказывает: команда
// могла не дойти, повтор безвреден.
if (incoming.source === "auto") {
const keys = Object.keys(pending.fields);
return keys.every((key) => key in incoming.fields)
&& keys.some((key) => !sameJsonValue(pending.fields[key], incoming.fields[key]));
}
return Object.entries(pending.fields).every(([key, value]) =>
sameJsonValue(value, incoming.fields[key]),
);

View file

@ -177,6 +177,7 @@ export interface DdsCardReport {
card_id: string;
scenario_id: string;
score_auto: number;
score_reviewed?: number | null;
reply_text: string;
metrics: Array<Metric>;
findings: Array<Finding>;
@ -267,6 +268,23 @@ export interface Finding {
competency?: Competency | null;
transcript_ref?: string | null;
at?: string | null;
metric_key?: string | null;
card?: number | null;
service?: string | null;
author?: string | null;
client_id?: string | null;
reviews?: Array<FindingReview>;
}
/** Решение преподавателя по отметке на разборе. */
export type FindingDecision = "confirmed" | "dismissed";
/** Кто, когда и почему подтвердил или снял отметку. Сама отметка остаётся. */
export interface FindingReview {
decision: FindingDecision;
reason: string;
author: string;
at: string;
}
/** Источник объяснимой отметки; `judge` оставлен для старых отчётов. */
@ -346,6 +364,8 @@ export interface KIO {
evacuation_needed?: boolean;
/** Список оповещения */
notify?: Array<string>;
/** Дополнительно оповестить */
notify_extra?: Array<string>;
/** Устаревшее: одна служба. Заменено списком оповещения */
dds?: DDSCode | null;
dispatch_order_at?: string | null;
@ -380,6 +400,7 @@ export interface LessonCriteria {
decision_time_limit_seconds?: number;
card_fill_time_limit_seconds?: number;
dds_card_work_time_limit_seconds?: number;
dds_report_reaction_limit_seconds?: number;
allowed_errors?: number;
require_correct_grammar?: boolean;
score_weights?: Record<string, number>;
@ -407,6 +428,10 @@ export interface Metric {
passed: boolean;
weight?: number;
credit?: number | null;
card?: number | null;
service?: string | null;
passed_auto?: boolean | null;
credit_auto?: number | null;
}
export interface ModeSet {
@ -619,6 +644,7 @@ export interface SessionReport {
self_assessment_diff?: SelfAssessmentDiff | null;
notes?: Array<InstructorNoteShown>;
score_auto: number;
score_reviewed?: number | null;
score_final: number;
overridden_by?: string | null;
override_comment?: string | null;

View file

@ -197,6 +197,9 @@ button:disabled { opacity: 0.5; cursor: default; }
.debrief-stat > strong { font-size: 24px; line-height: 1.15; overflow-wrap: anywhere; }
.debrief-stat > strong small { font-size: 14px; font-weight: 500; }
.debrief-stat > small { color: #5a6068; font-size: 12px; }
.finding-review-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.finding-review-actions input { flex: 1 1 200px; min-width: 0; }
.finding-review-form { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px; align-items: end; }
.debrief-stat.is-passed { border-left-color: var(--ok); }
.debrief-stat.is-failed { border-left-color: var(--violated); }
.debrief-next-step { margin: 12px 0 18px; padding: 12px 15px; border: 1px solid var(--line); border-left: 4px solid #6b7780; border-radius: 5px; background: #fff; }
@ -785,6 +788,11 @@ button:disabled { opacity: 0.5; cursor: default; }
.kio-arm-incident .signs select { flex: 1 1 180px; width: auto; max-width: 100%; padding: 7px 8px; border: 1px solid #acb5bc; background: white; }
.kio-arm-ref .signs-selected { display: flex; flex: 1 1 100%; flex-wrap: wrap; gap: 5px; margin-top: 5px; }
.kio-arm-ref .signs-selected span { padding: 7px 10px; background: #1876b4; color: white; font-size: 12px; font-weight: 700; }
.kio-services { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
.kio-services .signs-selected { display: flex; flex-wrap: wrap; gap: 5px; }
.kio-services .signs-selected button { border: 0; background: transparent; color: inherit; cursor: pointer; padding: 0 0 0 4px; }
.kio-services-found { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); max-height: 220px; overflow-y: auto; }
.kio-services-found button { width: 100%; text-align: left; border: 0; border-bottom: 1px solid var(--line); background: var(--panel); padding: 6px 8px; cursor: pointer; }
.kio-arm-choice { display: flex; flex-wrap: wrap; gap: 6px; padding: 5px 0; }
.kio-arm-choice button { min-width: 49px; padding: 6px 11px; border: 1px solid #aeb6bd; background: white; font-size: 13px; font-weight: 700; }
.kio-arm-choice .kio-arm-selected { border-color: #1979bb; background: #1979bb; color: white; }