chore: обновить паузу занятия от main
# Conflicts: # backend/app/scoring/dispatcher.py # backend/app/session/store.py
This commit is contained in:
commit
0d58878cbe
51 changed files with 4545 additions and 87 deletions
|
|
@ -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, {});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
162
frontend/src/features/debrief/FindingReview.tsx
Normal file
162
frontend/src/features/debrief/FindingReview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 ?? ""}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue