Merge remote-tracking branch 'origin/main' into feat/dds-timers
# Conflicts: # backend/app/scoring/dispatcher.py # backend/app/scoring/export.py # backend/app/session/finish.py # docs/arch/CONTRACT.md # docs/spec/GAP.md # frontend/src/features/instructor/ScoreWeightsEditor.tsx
This commit is contained in:
commit
4abdf0a901
61 changed files with 4084 additions and 167 deletions
|
|
@ -50,6 +50,7 @@ export function useCall(sessionId: string | null) {
|
|||
const [scoreReady, setScoreReady] = useState(false);
|
||||
const { report, error: reportError } = useSessionReport(sessionId, scoreReady);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [paused, setPaused] = useState(false);
|
||||
|
||||
const channel = useRef<ReturnType<typeof callChannel> | null>(null);
|
||||
const capture = useRef<Capture | null>(null);
|
||||
|
|
@ -58,6 +59,8 @@ export function useCall(sessionId: string | null) {
|
|||
const outbox = useRef<Record<string, unknown>>({});
|
||||
const patchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const textMode = useRef(false);
|
||||
const pausedRef = useRef(false);
|
||||
const endedRef = useRef(false);
|
||||
|
||||
const append = useCallback((line: Line) => {
|
||||
setLines((prev) => {
|
||||
|
|
@ -68,22 +71,14 @@ export function useCall(sessionId: string | null) {
|
|||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
endedRef.current = false;
|
||||
const ch = callChannel(sessionId, {
|
||||
onStatus: (next) => {
|
||||
setStatus(next);
|
||||
if (next === "open") {
|
||||
// Браузер не подтверждает доставку WebSocket-кадра. Всё, что ещё
|
||||
// отмечено pending и не пришло эхом от сервера, повторяем после
|
||||
// reconnect: kio.patch идемпотентен и не создаёт двойных действий.
|
||||
queueMicrotask(() => setCard((current) => {
|
||||
if (Object.keys(current.pending).length) {
|
||||
channel.current?.send({ type: "kio.patch", fields: current.pending });
|
||||
}
|
||||
return current;
|
||||
}));
|
||||
}
|
||||
},
|
||||
onBinary: (frame) => audio.current?.playback.enqueue(frame),
|
||||
onBinary: (frame) => {
|
||||
if (!pausedRef.current) audio.current?.playback.enqueue(frame);
|
||||
},
|
||||
onEvent: (event: ServerToTrainee) => {
|
||||
if (event.type === "text.turn.accepted") {
|
||||
append({ speaker: "operator", text: event.text });
|
||||
|
|
@ -140,6 +135,7 @@ export function useCall(sessionId: string | null) {
|
|||
setTimers(event.timers);
|
||||
break;
|
||||
case "call.ended":
|
||||
endedRef.current = true;
|
||||
setPhase("ended");
|
||||
audio.current?.ambience.stop();
|
||||
if (textMode.current) { setCardSubmitted(true); break; }
|
||||
|
|
@ -153,6 +149,33 @@ export function useCall(sessionId: string | null) {
|
|||
case "score.ready":
|
||||
setScoreReady(true);
|
||||
break;
|
||||
case "session.paused":
|
||||
pausedRef.current = event.paused;
|
||||
setPaused(event.paused);
|
||||
if (event.paused) {
|
||||
audio.current?.playback.flush();
|
||||
setCallerSpeaking(false);
|
||||
if (patchTimer.current) clearTimeout(patchTimer.current);
|
||||
patchTimer.current = null;
|
||||
} else if (endedRef.current) {
|
||||
// Занятие завершили прямо на паузе: сервер снимает её после
|
||||
// `call.ended`, и отложенные правки уже не войдут в оценку.
|
||||
outbox.current = {};
|
||||
} else {
|
||||
// При переподключении сервер первым присылает состояние паузы.
|
||||
// Только после этого повторяем правки, ожидающие подтверждения.
|
||||
queueMicrotask(() => setCard((current) => {
|
||||
if (Object.keys(current.pending).length) {
|
||||
channel.current?.send({ type: "kio.patch", fields: current.pending });
|
||||
}
|
||||
if (Object.keys(outbox.current).length) {
|
||||
channel.current?.send({ type: "kio.patch", fields: outbox.current });
|
||||
outbox.current = {};
|
||||
}
|
||||
return current;
|
||||
}));
|
||||
}
|
||||
break;
|
||||
case "error":
|
||||
setError(event.message);
|
||||
break;
|
||||
|
|
@ -170,13 +193,15 @@ export function useCall(sessionId: string | null) {
|
|||
}, [sessionId, append]);
|
||||
|
||||
const answer = useCallback(async () => {
|
||||
if (pausedRef.current) return;
|
||||
try {
|
||||
setError(null);
|
||||
// Контекст создаётся по нажатию: браузер не даёт играть звук без действия человека.
|
||||
const context = new AudioContext({ sampleRate: CALLER_RATE });
|
||||
audio.current = { context, playback: new Playback(context), ambience: new Ambience(context) };
|
||||
|
||||
capture.current = await startCapture((frame) => {
|
||||
const startedCapture = await startCapture((frame) => {
|
||||
if (pausedRef.current) return;
|
||||
const playback = audio.current?.playback;
|
||||
// Перебивание: фронт гасит звук мгновенно, сервер подтвердит `tts.cancel`.
|
||||
if (gate.current.push(frame) && playback?.speaking) {
|
||||
|
|
@ -185,6 +210,13 @@ export function useCall(sessionId: string | null) {
|
|||
}
|
||||
channel.current?.sendBinary(frame);
|
||||
});
|
||||
if (pausedRef.current) {
|
||||
await startedCapture.stop();
|
||||
await context.close();
|
||||
audio.current = null;
|
||||
return;
|
||||
}
|
||||
capture.current = startedCapture;
|
||||
setMicOn(true);
|
||||
channel.current?.send({ type: "call.answer" });
|
||||
} catch (err) {
|
||||
|
|
@ -193,36 +225,45 @@ export function useCall(sessionId: string | null) {
|
|||
}, []);
|
||||
|
||||
const hangup = useCallback(() => {
|
||||
if (pausedRef.current) return;
|
||||
channel.current?.send({ type: "call.hangup" });
|
||||
void capture.current?.stop();
|
||||
capture.current = null;
|
||||
setMicOn(false);
|
||||
}, []);
|
||||
|
||||
const hint = useCallback(() => channel.current?.send({ type: "hint.request" }), []);
|
||||
const hint = useCallback(() => {
|
||||
if (!pausedRef.current) channel.current?.send({ type: "hint.request" });
|
||||
}, []);
|
||||
|
||||
const patchKio = useCallback((path: string, value: unknown) => {
|
||||
if (pausedRef.current) return;
|
||||
setCard((prev) => edit(prev, path, value));
|
||||
outbox.current[path] = value;
|
||||
if (patchTimer.current) clearTimeout(patchTimer.current);
|
||||
patchTimer.current = setTimeout(() => {
|
||||
const fields = outbox.current;
|
||||
outbox.current = {};
|
||||
if (Object.keys(fields).length) channel.current?.send({ type: "kio.patch", fields });
|
||||
if (pausedRef.current) {
|
||||
outbox.current = { ...fields, ...outbox.current };
|
||||
} else if (Object.keys(fields).length) channel.current?.send({ type: "kio.patch", fields });
|
||||
}, PATCH_DEBOUNCE_MS);
|
||||
}, []);
|
||||
|
||||
const dispatch = useCallback((service?: DDSCode) => {
|
||||
if (pausedRef.current) return;
|
||||
channel.current?.send(service ? { type: "dds.dispatch", service } : { type: "dds.dispatch" });
|
||||
}, []);
|
||||
|
||||
const sendText = useCallback((text: string) => {
|
||||
if (pausedRef.current) return false;
|
||||
const clean = text.trim();
|
||||
if (!clean || clean.length > 1000) return false;
|
||||
return Boolean(channel.current?.send({ type: "text.turn", text: clean }));
|
||||
}, []);
|
||||
|
||||
const submitCard = useCallback(() => {
|
||||
if (pausedRef.current) return;
|
||||
if (patchTimer.current) clearTimeout(patchTimer.current);
|
||||
patchTimer.current = null;
|
||||
const fields = outbox.current;
|
||||
|
|
@ -243,17 +284,19 @@ export function useCall(sessionId: string | null) {
|
|||
* Отдельное действие, а не «положил трубку»: система должна отличить
|
||||
* осознанное решение от брошенного вызова (docs/spec/TICKETS.md). */
|
||||
const resolve = useCallback((outcome: Outcome, comment: string) => {
|
||||
if (pausedRef.current) return;
|
||||
channel.current?.send({ type: "call.resolve", outcome, comment });
|
||||
}, []);
|
||||
|
||||
const submitSelfAssessment = useCallback((missed: string[], comment: string) => {
|
||||
if (pausedRef.current) return;
|
||||
channel.current?.send({ type: "self_assessment.submit", missed, comment });
|
||||
setSelfAssessed(true);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
resolve,
|
||||
status, phase, incoming, briefing, cardSubmitted, lines, timers, callerSpeaking, micOn, error, card,
|
||||
status, phase, incoming, briefing, cardSubmitted, lines, timers, callerSpeaking, micOn, error, paused, card,
|
||||
checklist, selfAssessed, scoreReady, report, reportError,
|
||||
answer, hangup, hint, patchKio, dispatch, submitCard, submitSelfAssessment, sendText,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
|
|||
const improvementPoints = report.metrics.filter((metric) => !metric.passed).slice(0, 3);
|
||||
const metricTitles = new Map(report.metrics.map((metric) => [metric.key, metric.title]));
|
||||
const coaching = report.ai_coaching?.status === "ready" ? report.ai_coaching.recommendations ?? [] : [];
|
||||
// Вверх: короткая пауза не должна выглядеть как «0 с», а без пауз строки нет.
|
||||
const pausedSeconds = Math.ceil((report.total_paused_ms ?? 0) / 1000);
|
||||
|
||||
return (
|
||||
<section className={big ? "debrief debrief-big" : "debrief"}>
|
||||
|
|
@ -37,12 +39,14 @@ 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>
|
||||
<small>{report.attempt ? `Попытка ${report.attempt}` : "Завершённое упражнение"}</small>
|
||||
</article>
|
||||
</div>
|
||||
{pausedSeconds > 0 && <p className="ref">Паузы занятия: {Math.floor(pausedSeconds / 60)} мин {pausedSeconds % 60} с. Время пауз исключено из нормативов.</p>}
|
||||
<details className="debrief-tech-details">
|
||||
<summary>Служебные сведения</summary>
|
||||
<p>Идентификатор сценария: {report.scenario_id} · номер занятия: {report.session_id}</p>
|
||||
|
|
@ -77,6 +81,11 @@ 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_enabled
|
||||
? ` не позже ${report.criteria.dds_report_reaction_limit_seconds ?? 45} с`
|
||||
: " не применяется"}</>}
|
||||
{report.exercise && report.exercise !== "dds" && <>
|
||||
; грамматика описания КИО {report.criteria.require_correct_grammar ? "входит" : "не входит"} в оценку
|
||||
</>}.
|
||||
|
|
@ -89,7 +98,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>
|
||||
|
|
@ -144,6 +154,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,13 +40,19 @@ 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,
|
||||
reportReactionEnabled,
|
||||
values,
|
||||
onChange,
|
||||
}: {
|
||||
exercise: LessonExercise;
|
||||
reportReactionEnabled: boolean;
|
||||
values: Record<string, number>;
|
||||
onChange: (weights: Record<string, number>) => void;
|
||||
}) {
|
||||
|
|
@ -57,6 +64,12 @@ export function ScoreWeightsEditor({
|
|||
: exercise === "card_dds"
|
||||
? [["Карточка КИО", CARD_METRICS], ["Реагирование ДДС", DDS_METRICS]]
|
||||
: [["Опрос 112", CALL_METRICS], ["Реагирование ДДС", DDS_METRICS]];
|
||||
groups.push(["Разбор", REVIEW_METRICS]);
|
||||
if (!reportReactionEnabled) {
|
||||
for (const group of groups) {
|
||||
group[1] = group[1].filter(({ key }) => key !== "dds_report_reaction");
|
||||
}
|
||||
}
|
||||
|
||||
function setWeight(key: string, raw: string) {
|
||||
const next = { ...values };
|
||||
|
|
|
|||
|
|
@ -63,7 +63,9 @@ export function useControl(sessionId: string | null) {
|
|||
);
|
||||
|
||||
const stop = useCallback(() => channel.current?.socket.send({ type: "session.stop" }), []);
|
||||
const pause = useCallback(() => channel.current?.socket.send({ type: "session.pause" }), []);
|
||||
const resume = useCallback(() => channel.current?.socket.send({ type: "session.resume" }), []);
|
||||
const playReference = useCallback(() => channel.current?.socket.send({ type: "reference.play" }), []);
|
||||
|
||||
return { status, openSessionId, start, note, inject, stop, playReference };
|
||||
return { status, openSessionId, start, note, inject, stop, pause, resume, playReference };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ export const GROUPS: FieldGroup[] = [
|
|||
{ path: "incident_type", label: "Тип происшествия", kind: "select", options: INCIDENT },
|
||||
{ path: "victims_count", label: "Пострадавших", kind: "number" },
|
||||
{ path: "is_emergency", label: "Признак ЧС", kind: "bool" },
|
||||
{ path: "offence_signs", label: "Признаки правонарушения", kind: "bool" },
|
||||
{ path: "life_threat", label: "Угроза жизни", kind: "bool" },
|
||||
{ path: "evacuation_needed", label: "Нужна эвакуация", kind: "bool" },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ export interface ObservedSession {
|
|||
hintsUsed: number;
|
||||
notes: Map<string, string>;
|
||||
ended: boolean;
|
||||
paused: boolean;
|
||||
report: SessionReport | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
|
@ -45,6 +46,7 @@ export function useObserve(sessionId: string | null): ObservedSession {
|
|||
const [hintsUsed, setHintsUsed] = useState(0);
|
||||
const [notes, setNotes] = useState<Map<string, string>>(new Map());
|
||||
const [ended, setEnded] = useState(false);
|
||||
const [paused, setPaused] = useState(false);
|
||||
// Сессия, для которой отчёт уже стоит просить. Привязка к id, а не флаг:
|
||||
// при переключении истории старый флаг не должен дёрнуть запрос новой сессии.
|
||||
const [reportFor, setReportFor] = useState<string | null>(null);
|
||||
|
|
@ -65,6 +67,7 @@ export function useObserve(sessionId: string | null): ObservedSession {
|
|||
setHintsUsed(0);
|
||||
setNotes(new Map());
|
||||
setEnded(false);
|
||||
setPaused(false);
|
||||
setError(null);
|
||||
if (!sessionId) return;
|
||||
let active = true;
|
||||
|
|
@ -88,6 +91,7 @@ export function useObserve(sessionId: string | null): ObservedSession {
|
|||
setTimers(event.timers);
|
||||
setHintsUsed(event.hints_used ?? 0);
|
||||
setEnded(event.ended ?? false);
|
||||
setPaused(event.paused ?? false);
|
||||
if (event.ended) loadReport();
|
||||
break;
|
||||
case "kio.state":
|
||||
|
|
@ -112,6 +116,9 @@ export function useObserve(sessionId: string | null): ObservedSession {
|
|||
setEnded(true);
|
||||
loadReport();
|
||||
break;
|
||||
case "session.paused":
|
||||
setPaused(event.paused);
|
||||
break;
|
||||
case "error":
|
||||
setError(event.message);
|
||||
if (event.code === "session_not_found") loadReport();
|
||||
|
|
@ -127,5 +134,5 @@ export function useObserve(sessionId: string | null): ObservedSession {
|
|||
return () => { active = false; channel.close(); };
|
||||
}, [sessionId, queries]);
|
||||
|
||||
return { status, title, mode, trainee, card, requiredFields, transcript, timers, hintsUsed, notes, ended, report, error };
|
||||
return { status, title, mode, trainee, card, requiredFields, transcript, timers, hintsUsed, notes, ended, paused, report, error };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,12 +12,19 @@ const LABEL: Record<SipStatus, string> = {
|
|||
error: "ошибка",
|
||||
};
|
||||
|
||||
/** Абонент быстрого набора: пульт ДДС звонит бригаде и начальнику одной кнопкой. */
|
||||
export interface SipContact {
|
||||
label: string;
|
||||
number: string;
|
||||
}
|
||||
|
||||
export function SipPhone({
|
||||
defaultExtension = "6101", defaultTarget = "6102", title = "Локальный SIP/WebRTC",
|
||||
defaultExtension = "6101", defaultTarget = "6102", title = "Локальный SIP/WebRTC", contacts = [],
|
||||
}: {
|
||||
defaultExtension?: string;
|
||||
defaultTarget?: string;
|
||||
title?: string;
|
||||
contacts?: SipContact[];
|
||||
}) {
|
||||
const phone = useSipPhone();
|
||||
const [extension, setExtension] = useState(defaultExtension);
|
||||
|
|
@ -46,6 +53,10 @@ export function SipPhone({
|
|||
<label>Номер собеседника<input aria-label="Номер SIP собеседника" inputMode="numeric" value={target}
|
||||
disabled={inCall} onChange={(event) => setTarget(event.target.value)} /></label>
|
||||
{phone.status === "registered" && <button type="button" onClick={() => phone.dial(target)}>Позвонить</button>}
|
||||
{phone.status === "registered" && contacts.map((contact) => <button type="button" key={contact.number}
|
||||
onClick={() => { setTarget(contact.number); phone.dial(contact.number); }}>
|
||||
Позвонить: {contact.label} · {contact.number}
|
||||
</button>)}
|
||||
{phone.status === "incoming" && <button type="button" className="sip-answer" onClick={phone.answer}>Ответить · {phone.peer}</button>}
|
||||
{inCall && phone.status !== "incoming" && <span>{phone.peer || target}</span>}
|
||||
{inCall && <button type="button" className="sip-hangup" onClick={phone.hangup}>Завершить</button>}
|
||||
|
|
|
|||
|
|
@ -238,6 +238,7 @@ export function Dds() {
|
|||
const [checkText, setCheckText] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [ended, setEnded] = useState(false);
|
||||
const [paused, setPaused] = useState(false);
|
||||
// Разбор просим только после `score.ready`; новая карточка снимает флаг.
|
||||
const [scoreReady, setScoreReady] = useState(false);
|
||||
const { report, loading: reportLoading, error: reportError, refetch: refetchReport } =
|
||||
|
|
@ -257,6 +258,7 @@ export function Dds() {
|
|||
if (event.type === "station.state") {
|
||||
const snapshot = event.snapshot as DdsSnapshot;
|
||||
setStation(snapshot);
|
||||
setPaused(snapshot.paused ?? false);
|
||||
setZone(snapshot.zone_decision ?? null);
|
||||
if (snapshot.card_id == null && (snapshot.pending_cards_count ?? 0) > 0) {
|
||||
activeCardId.current = null;
|
||||
|
|
@ -303,6 +305,7 @@ export function Dds() {
|
|||
} else if (event.type === "timer.tick") setTimers(event.timers);
|
||||
else if (event.type === "error") { setError(event.message); setDialing(false); setAdvancePending(false); }
|
||||
else if (event.type === "session.ended") { setEnded(true); setDialing(false); setAdvancePending(false); setPane(null); setView("registry"); }
|
||||
else if (event.type === "session.paused") setPaused(event.paused);
|
||||
else if (event.type === "score.ready") setScoreReady(true);
|
||||
else if (event.type === "phone.report") {
|
||||
setLastReport(event);
|
||||
|
|
@ -323,7 +326,7 @@ export function Dds() {
|
|||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const send = (command: StationToServer): boolean => channel.current?.send(command) ?? false;
|
||||
const send = (command: StationToServer): boolean => !paused && (channel.current?.send(command) ?? false);
|
||||
if (!sessionId) return <DdsHistoryOnly cards={ddsHistory.cards} error={ddsHistory.error} />;
|
||||
|
||||
const reports = station?.phone_reports ?? [];
|
||||
|
|
@ -333,6 +336,8 @@ export function Dds() {
|
|||
const phoneCall = station?.phone_pending ?? null;
|
||||
const phoneLines = (station?.phone_lines ?? []).filter((line) => line.crew === selectedCrew);
|
||||
const isConnected = status === "open";
|
||||
// Вверх: короткая пауза не должна выглядеть как «0 с», а без пауз строки нет.
|
||||
const pausedSeconds = Math.ceil((report?.total_paused_ms ?? 0) / 1000);
|
||||
const cardId = card ? String(readPath(card.server, "card_id") ?? "") : "";
|
||||
const cardIndex = station?.card_index ?? 1;
|
||||
const cardTotal = station?.card_total ?? 1;
|
||||
|
|
@ -373,18 +378,21 @@ export function Dds() {
|
|||
АРМ {role}: {isConnected ? "на связи" : status}
|
||||
</span>
|
||||
{error && <div className="dds-alert" role="alert">Действие не принято: {error}</div>}
|
||||
{paused && !ended && <div className="dds-alert dds-paused">Пауза, ждите преподавателя</div>}
|
||||
{ended && <div className="dds-alert dds-done">Занятие завершено. Оценка появится после расчёта.</div>}
|
||||
{(ended || scoreReady) && <section className="dds-panel" aria-live="polite">
|
||||
<div className="dds-panel-title"><h2>Итог упражнения</h2></div>
|
||||
{report ? <div style={{ padding: 12 }}>
|
||||
<p><strong>Итоговая оценка: {report.score_final.toFixed(0)} из 100</strong>
|
||||
{report.score_final !== report.score_auto && ` · автооценка ${report.score_auto.toFixed(0)}`}</p>
|
||||
{pausedSeconds > 0 && <p className="ref">Паузы занятия: {Math.floor(pausedSeconds / 60)} мин {pausedSeconds % 60} с. Время пауз исключено из нормативов.</p>}
|
||||
<p className="ref">Скачать отчёт: <a href={`/api/sessions/${sessionId}/report.csv`}>CSV</a>
|
||||
{" · "}<a href={`/api/sessions/${sessionId}/report.pdf`}>PDF</a></p>
|
||||
{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"}>
|
||||
|
|
@ -414,6 +422,7 @@ export function Dds() {
|
|||
</div>}
|
||||
</section>}
|
||||
|
||||
<fieldset className="dds-pause-controls" disabled={paused && !ended}>
|
||||
{view === "registry" && <>
|
||||
<section className="dds-search">
|
||||
<div className="dds-search-main">
|
||||
|
|
@ -604,9 +613,11 @@ export function Dds() {
|
|||
</div><aside className="dds-right">
|
||||
{pane === "phone" && <section className="dds-panel dds-phone"><div className="dds-panel-title"><h2>IP-телефон</h2><span>SIP/WebRTC + учебный протокол</span></div>
|
||||
<Suspense fallback={<p className="dds-hint">Загружаем IP-телефон…</p>}>
|
||||
<SipPhone defaultExtension="6101" defaultTarget="6102" title="Реальный локальный канал ДДС" />
|
||||
<SipPhone defaultExtension="6101" defaultTarget="6102" title="Реальный локальный канал ДДС"
|
||||
contacts={[{ label: "старший группы", number: "6102" },
|
||||
{ label: "вышестоящий начальник", number: "6103" }]} />
|
||||
</Suspense>
|
||||
<p className="dds-hint">На втором рабочем месте откройте <a href="/phone" target="_blank" rel="noreferrer">телефон старшего группы</a> и зарегистрируйте 6102. Пароли 6101/6102 выдаёт локальный администратор командой <code>make sip-credentials</code>.</p>
|
||||
<p className="dds-hint">На втором рабочем месте откройте <a href="/phone" target="_blank" rel="noreferrer">телефон старшего группы</a> (6102); преподаватель в роли вышестоящего начальника — <a href="/phone?role=superior" target="_blank" rel="noreferrer">телефон 6103</a>. Пароли 6101–6103 выдаёт локальный администратор командой <code>make sip-credentials</code>.</p>
|
||||
<h3 className="dds-protocol-title">Учебный протокол доклада</h3>
|
||||
<p className="dds-hint">Связь используется при необходимости. Разговор и учебный доклад сохраняются в журнале, но не блокируют изменение статуса.</p>
|
||||
<label htmlFor="dds-crew">Дежурная бригада</label>
|
||||
|
|
@ -669,7 +680,7 @@ export function Dds() {
|
|||
onClick={() => { channel.current?.send({ type: "zone.decision", in_zone: true }); }}>Наша</button>
|
||||
<button type="button" disabled={zone !== null || !isConnected || ended}
|
||||
onClick={() => { channel.current?.send({ type: "zone.decision", in_zone: false }); }}>Не наша</button></div>
|
||||
<p className="dds-hint">ДДС не исправляет и не возвращает заполненную карточку: её качество контролируют оператор и служба контроля 112.</p>
|
||||
<p className="dds-hint">Карточку не правят на АРМ ДДС и не возвращают в 112 при принятии. Если бригада с места доложит о расхождении с карточкой — сообщите об ошибке в 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;
|
||||
|
|
@ -709,6 +720,7 @@ export function Dds() {
|
|||
</div>
|
||||
</nav>
|
||||
</div>}
|
||||
</fieldset>
|
||||
</main>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -92,6 +93,8 @@ export function Instructor() {
|
|||
const [decisionTimeLimit, setDecisionTimeLimit] = useState(30);
|
||||
const [cardFillTimeLimit, setCardFillTimeLimit] = useState(180);
|
||||
const [ddsCardWorkTimeLimit, setDdsCardWorkTimeLimit] = useState(180);
|
||||
const [ddsReportReactionEnabled, setDdsReportReactionEnabled] = useState(false);
|
||||
const [ddsReportReactionLimit, setDdsReportReactionLimit] = useState(45);
|
||||
const [allowedErrors, setAllowedErrors] = useState(0);
|
||||
const [requireCorrectGrammar, setRequireCorrectGrammar] = useState(true);
|
||||
const [lessonScoreWeights, setLessonScoreWeights] = useState<Record<string, number>>({});
|
||||
|
|
@ -290,6 +293,7 @@ export function Instructor() {
|
|||
&& !(exercise === "dds" && wholeTicket)
|
||||
? visibleScenarios.filter((item) => item.outcome === "card").map((item) => item.id)
|
||||
: undefined;
|
||||
const reportReactionEnabled = (exercise === "dds" || exercise === "card_dds") && ddsReportReactionEnabled;
|
||||
const sent = control.start(sessionId, {
|
||||
scenarioId: primaryScenarioId,
|
||||
mode,
|
||||
|
|
@ -306,14 +310,18 @@ export function Instructor() {
|
|||
decision_time_limit_seconds: decisionTimeLimit,
|
||||
card_fill_time_limit_seconds: cardFillTimeLimit,
|
||||
dds_card_work_time_limit_seconds: ddsCardWorkTimeLimit,
|
||||
dds_report_reaction_enabled: reportReactionEnabled,
|
||||
dds_report_reaction_limit_seconds: ddsReportReactionLimit,
|
||||
allowed_errors: allowedErrors,
|
||||
require_correct_grammar: requireCorrectGrammar,
|
||||
score_weights: lessonScoreWeights,
|
||||
score_weights: reportReactionEnabled
|
||||
? lessonScoreWeights
|
||||
: Object.fromEntries(Object.entries(lessonScoreWeights).filter(([key]) => key !== "dds_report_reaction")),
|
||||
},
|
||||
});
|
||||
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, ddsReportReactionEnabled, ddsReportReactionLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios,
|
||||
ddsArrivalIntervalSeconds, ddsMaxWaiting, lessonScoreWeights, scenarios.data,
|
||||
randomScenario, visibleScenarios]);
|
||||
|
||||
|
|
@ -661,6 +669,20 @@ export function Instructor() {
|
|||
onCommit={setDdsCardWorkTimeLimit} />
|
||||
<small className="ref">По умолчанию 180 секунд от открытия до первого статуса с текстом; дальше работы по времени не нормируются.</small>
|
||||
</label>}
|
||||
{(exercise === "dds" || exercise === "card_dds") && <>
|
||||
<label className="mode-choice">
|
||||
<input type="checkbox" checked={ddsReportReactionEnabled}
|
||||
onChange={(event) => setDdsReportReactionEnabled(event.target.checked)} />
|
||||
Оценивать срок отметки статуса после доклада бригады
|
||||
</label>
|
||||
{ddsReportReactionEnabled && <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}
|
||||
|
|
@ -672,6 +694,7 @@ export function Instructor() {
|
|||
Грамматика карточки оператора 112 входит в оценку
|
||||
</label>}
|
||||
<ScoreWeightsEditor exercise={exercise === "card_dds" ? "card_dds" : exercise}
|
||||
reportReactionEnabled={(exercise === "dds" || exercise === "card_dds") && ddsReportReactionEnabled}
|
||||
values={lessonScoreWeights} onChange={setLessonScoreWeights} />
|
||||
<small className="ref">Нормативы ГОСТ для приёма вызова не изменяются.</small>
|
||||
</details>
|
||||
|
|
@ -708,12 +731,20 @@ export function Instructor() {
|
|||
{sessionId && session.title && (
|
||||
<p>
|
||||
{session.ended ? "занятие завершено" : (
|
||||
<button type="button" onClick={control.stop}>Завершить занятие</button>
|
||||
<>
|
||||
<button type="button" onClick={control.stop}>Завершить занятие</button>{" "}
|
||||
{session.paused ? (
|
||||
<button type="button" onClick={control.resume}>Возобновить</button>
|
||||
) : (
|
||||
<button type="button" onClick={control.pause}>Пауза</button>
|
||||
)}
|
||||
{session.paused && <small className="ref" role="status">Занятие на паузе — время не идёт</small>}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{sessionId && session.title && !session.ended && (
|
||||
exercise === "call" && <Director onInject={control.inject} disabled={control.status !== "open"} error={session.error} />
|
||||
exercise === "call" && <Director onInject={control.inject} disabled={control.status !== "open" || session.paused} error={session.error} />
|
||||
)}
|
||||
{links && startPendingId && (
|
||||
<p className="ref" role="status">Создаём занятие. Ссылки появятся автоматически после подключения.</p>
|
||||
|
|
@ -814,6 +845,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>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,22 @@
|
|||
import { SipPhone } from "@/features/sip/SipPhone";
|
||||
|
||||
// Второе рабочее место голосовой связи ДДС. Старший группы — по умолчанию;
|
||||
// вышестоящего начальника (?role=superior) играет преподаватель: это
|
||||
// обязательный маршрут по ответу заказчика (П.7).
|
||||
const ROLES = {
|
||||
crew: { title: "Телефон старшего группы", extension: "6102", heading: "IP-телефон старшего группы" },
|
||||
superior: { title: "Телефон вышестоящего начальника", extension: "6103", heading: "IP-телефон вышестоящего начальника" },
|
||||
} as const;
|
||||
|
||||
export function Phone() {
|
||||
const role = new URLSearchParams(window.location.search).get("role") === "superior" ? "superior" : "crew";
|
||||
const { title, extension, heading } = ROLES[role];
|
||||
return <main className="page phone-page">
|
||||
<h1>IP-телефон старшего группы</h1>
|
||||
<p>Откройте этот экран на втором рабочем месте, зарегистрируйте номер 6102 и примите вызов ДДС 6101.</p>
|
||||
<SipPhone defaultExtension="6102" defaultTarget="6101" title="Телефон старшего группы" />
|
||||
<h1>{heading}</h1>
|
||||
<p>Откройте этот экран на втором рабочем месте, зарегистрируйте номер {extension} и примите вызов ДДС 6101.</p>
|
||||
<SipPhone key={role} defaultExtension={extension} defaultTarget="6101" title={title} />
|
||||
<p className="ref">{role === "crew"
|
||||
? <>Роль вышестоящего начальника — <a href="/phone?role=superior">телефон 6103</a>.</>
|
||||
: <>Роль старшего группы — <a href="/phone">телефон 6102</a>.</>}</p>
|
||||
</main>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,15 +122,16 @@ export function Call() {
|
|||
event.preventDefault();
|
||||
if (call.sendText(textTurn)) setTextTurn("");
|
||||
}}>
|
||||
<input aria-label="Ваша реплика заявителю" value={textTurn} maxLength={1000}
|
||||
<input aria-label="Ваша реплика заявителю" value={textTurn} maxLength={1000} disabled={call.paused}
|
||||
placeholder="Ваша реплика…" onChange={(event) => setTextTurn(event.target.value)} />
|
||||
<button type="submit" disabled={call.status !== "open" || !textTurn.trim() || call.cardSubmitted}>Отправить</button>
|
||||
<button type="submit" disabled={call.paused || call.status !== "open" || !textTurn.trim() || call.cardSubmitted}>Отправить</button>
|
||||
</form>
|
||||
{call.paused && <p role="status" className="state-warn">Пауза, ждите преподавателя</p>}
|
||||
{call.error && <p role="alert" className="state-violated">{call.error}</p>}
|
||||
</section>
|
||||
<div className="arm-card-wrap">
|
||||
<KioCard state={call.card} required={call.briefing.required_fields}
|
||||
readOnly={call.cardSubmitted} referenceArm onChange={call.patchKio} />
|
||||
readOnly={call.paused || call.cardSubmitted} referenceArm onChange={call.patchKio} />
|
||||
</div>
|
||||
</div>
|
||||
<section className="arm-support" aria-label="Норматив заполнения карточки">
|
||||
|
|
@ -142,7 +143,7 @@ export function Call() {
|
|||
? notifiedServices.map((service) => <b key={service}>{service}</b>)
|
||||
: <em>появятся после выбора признаков</em>}
|
||||
</span>
|
||||
{!call.cardSubmitted && <button type="button" className="arm-primary" disabled={call.status !== "open"}
|
||||
{!call.cardSubmitted && <button type="button" className="arm-primary" disabled={call.paused || call.status !== "open"}
|
||||
onClick={call.submitCard}>Сохранить и проверить</button>}
|
||||
{call.cardSubmitted && <strong>{call.briefing.handoff_to_dds ? "Карточка передана ДДС" : "Карточка отправлена"}</strong>}
|
||||
</footer>
|
||||
|
|
@ -174,11 +175,11 @@ export function Call() {
|
|||
</header>
|
||||
|
||||
<div className="arm-call-actions">
|
||||
{(call.phase === "incoming" || call.phase === "talking") && !call.micOn && <button type="button" className="arm-answer" onClick={() => void call.answer()}>
|
||||
{(call.phase === "incoming" || call.phase === "talking") && !call.micOn && <button type="button" className="arm-answer" disabled={call.paused} onClick={() => void call.answer()}>
|
||||
{call.phase === "talking" ? "☎ Продолжить разговор" : "☎ Ответить на вызов"}
|
||||
</button>}
|
||||
{call.micOn && <span className="arm-live">● Разговор идёт</span>}
|
||||
{call.micOn && hintsAllowed(call.incoming?.mode) && <button type="button" onClick={call.hint}>Подсказка</button>}
|
||||
{call.micOn && hintsAllowed(call.incoming?.mode) && <button type="button" disabled={call.paused} onClick={call.hint}>Подсказка</button>}
|
||||
<span className="arm-call-actions-note">{call.phase === "ended"
|
||||
? "Вызов завершён"
|
||||
: !call.micOn && call.phase === "incoming"
|
||||
|
|
@ -190,13 +191,14 @@ export function Call() {
|
|||
: "Ожидайте входящего вызова"}</span>
|
||||
</div>
|
||||
|
||||
{call.paused && <p className="state-warn arm-message" role="status">Пауза, ждите преподавателя</p>}
|
||||
{call.error && <p className="violated arm-message" role="alert">{call.error}</p>}
|
||||
|
||||
<div className="arm-card-wrap">
|
||||
<KioCard
|
||||
state={call.card}
|
||||
required={call.incoming?.required_fields ?? []}
|
||||
readOnly={!call.micOn}
|
||||
readOnly={call.paused || !call.micOn}
|
||||
referenceArm
|
||||
onChange={call.patchKio}
|
||||
/>
|
||||
|
|
@ -207,12 +209,12 @@ export function Call() {
|
|||
<button
|
||||
type="button"
|
||||
className="arm-primary"
|
||||
disabled={!call.micOn || (!service && !autoRoute)}
|
||||
disabled={call.paused || !call.micOn || (!service && !autoRoute)}
|
||||
onClick={() => call.dispatch(service)}
|
||||
>
|
||||
Передать в ДДС
|
||||
</button>
|
||||
{call.micOn && <button type="button" onClick={call.hangup}>Завершить вызов</button>}
|
||||
{call.micOn && <button type="button" disabled={call.paused} onClick={call.hangup}>Завершить вызов</button>}
|
||||
</div>
|
||||
</footer>
|
||||
<div className="arm-secondary">
|
||||
|
|
@ -225,6 +227,7 @@ export function Call() {
|
|||
type="text"
|
||||
aria-label="Комментарий при закрытии вызова без карточки"
|
||||
value={resolveComment}
|
||||
disabled={call.paused}
|
||||
placeholder="что сказали заявителю, куда передали"
|
||||
onChange={(event) => setResolveComment(event.target.value)}
|
||||
/>
|
||||
|
|
@ -232,7 +235,7 @@ export function Call() {
|
|||
<p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!call.micOn || resolved !== null}
|
||||
disabled={call.paused || !call.micOn || resolved !== null}
|
||||
onClick={() => {
|
||||
call.resolve("consultation", resolveComment);
|
||||
setResolved("consultation");
|
||||
|
|
@ -242,7 +245,7 @@ export function Call() {
|
|||
</button>{" "}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!call.micOn || resolved !== null}
|
||||
disabled={call.paused || !call.micOn || resolved !== null}
|
||||
onClick={() => {
|
||||
call.resolve("transfer_region", resolveComment);
|
||||
setResolved("transfer_region");
|
||||
|
|
|
|||
|
|
@ -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` оставлен для старых отчётов. */
|
||||
|
|
@ -342,6 +360,7 @@ export interface KIO {
|
|||
description?: string | null;
|
||||
victims_count?: number | null;
|
||||
is_emergency?: boolean;
|
||||
offence_signs?: boolean | null;
|
||||
life_threat?: boolean;
|
||||
evacuation_needed?: boolean;
|
||||
/** Список оповещения */
|
||||
|
|
@ -382,6 +401,8 @@ export interface LessonCriteria {
|
|||
decision_time_limit_seconds?: number;
|
||||
card_fill_time_limit_seconds?: number;
|
||||
dds_card_work_time_limit_seconds?: number;
|
||||
dds_report_reaction_enabled?: boolean;
|
||||
dds_report_reaction_limit_seconds?: number;
|
||||
allowed_errors?: number;
|
||||
require_correct_grammar?: boolean;
|
||||
score_weights?: Record<string, number>;
|
||||
|
|
@ -409,6 +430,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 {
|
||||
|
|
@ -587,6 +612,17 @@ export interface SessionEnded {
|
|||
/** Режим сессии. Меняет доступность подсказок и протоколирование, но не поведение звонящего (docs/product/MODES.md). */
|
||||
export type SessionMode = "training" | "exam" | "self";
|
||||
|
||||
/** Остановить время занятия; карточку курсанта команда не меняет (lct-39). */
|
||||
export interface SessionPause {
|
||||
type: "session.pause";
|
||||
}
|
||||
|
||||
/** Пульт остановил или возобновил время занятия (lct-39). */
|
||||
export interface SessionPaused {
|
||||
type: "session.paused";
|
||||
paused: boolean;
|
||||
}
|
||||
|
||||
/** Единица истории: из отчётов складываются профиль, дельта попыток, аналитика. */
|
||||
export interface SessionReport {
|
||||
session_id: string;
|
||||
|
|
@ -610,9 +646,15 @@ 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;
|
||||
total_paused_ms?: number;
|
||||
}
|
||||
|
||||
export interface SessionResume {
|
||||
type: "session.resume";
|
||||
}
|
||||
|
||||
/** Обязательно при подключении: монитор в классе включают посреди занятия. */
|
||||
|
|
@ -633,6 +675,7 @@ export interface SessionSnapshot {
|
|||
timers: Array<TimerSnapshot>;
|
||||
hints_used?: number;
|
||||
ended?: boolean;
|
||||
paused?: boolean;
|
||||
}
|
||||
|
||||
export interface SessionStop {
|
||||
|
|
@ -669,6 +712,7 @@ export interface StationSnapshot {
|
|||
pending_cards_count?: number;
|
||||
next_arrival_in_seconds?: number | null;
|
||||
max_waiting_cards?: number;
|
||||
paused?: boolean;
|
||||
}
|
||||
|
||||
/** Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше. */
|
||||
|
|
@ -777,15 +821,15 @@ export interface ZoneDecision {
|
|||
|
||||
// Союзы по каналам — смотри docs/arch/CONTRACT.md#каналы
|
||||
|
||||
export type ServerToTrainee = CallIncoming | CardBriefing | TextTurnAccepted | CallStarted | SttPartial | SttFinal | CallerUtterance | TtsBegin | TtsEnd | TtsCancel | BgStart | BgStop | EraData | KioPatchOut | HintShown | TimerTick | CallEnded | ScoreReady | ErrorEvent;
|
||||
export type ServerToTrainee = CallIncoming | CardBriefing | TextTurnAccepted | CallStarted | SttPartial | SttFinal | CallerUtterance | TtsBegin | TtsEnd | TtsCancel | BgStart | BgStop | EraData | KioPatchOut | HintShown | TimerTick | CallEnded | ScoreReady | SessionPaused | ErrorEvent;
|
||||
|
||||
export type TraineeToServer = CallAnswer | CardSubmit | TextTurn | KioPatchIn | HintRequest | SelfAssessmentSubmit | DdsDispatch | CallResolve | CallHangup | CallbackDial;
|
||||
|
||||
export type ServerToObserver = SessionSnapshot | TranscriptAppend | KioState | TimerTick | ModeSet | HintShown | BgStart | BgStop | CallerUtterance | SessionEnded | ScoreReady | InstructorNoteShown | ReferenceStarted | ErrorEvent;
|
||||
export type ServerToObserver = SessionSnapshot | TranscriptAppend | KioState | TimerTick | ModeSet | HintShown | BgStart | BgStop | CallerUtterance | SessionEnded | ScoreReady | InstructorNoteShown | ReferenceStarted | SessionPaused | ErrorEvent;
|
||||
|
||||
export type InstructorToServer = ScenarioStart | DirectorInject | ReferencePlay | InstructorNoteAdd | ScoreOverride | SessionStop;
|
||||
export type InstructorToServer = ScenarioStart | DirectorInject | ReferencePlay | InstructorNoteAdd | ScoreOverride | SessionStop | SessionPause | SessionResume;
|
||||
|
||||
export type ServerToStation = CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded | ScoreReady | CommandAck | ErrorEvent;
|
||||
export type ServerToStation = CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded | ScoreReady | CommandAck | SessionPaused | ErrorEvent;
|
||||
|
||||
export type StationToServer = CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | PhoneBrief | PhoneCheck | PhoneHangup | StationFinish | CardReply | CardNext | CardOpen | ZoneDecision | CrewDispatched | CrewArrived;
|
||||
|
||||
|
|
@ -837,6 +881,9 @@ export type EventName =
|
|||
| "score.ready"
|
||||
| "self_assessment.submit"
|
||||
| "session.ended"
|
||||
| "session.pause"
|
||||
| "session.paused"
|
||||
| "session.resume"
|
||||
| "session.snapshot"
|
||||
| "session.stop"
|
||||
| "station.finish"
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
@ -316,6 +319,7 @@ button:disabled { opacity: 0.5; cursor: default; }
|
|||
|
||||
/* АРМ ДДС: реестр остаётся плотной таблицей, карточка и связь — рядом. */
|
||||
.dds-workspace { max-width: 1800px; margin: 0 auto; }
|
||||
.dds-pause-controls { display: contents; border: 0; margin: 0; padding: 0; min-width: 0; }
|
||||
.dds-header { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 8px 0 16px; }
|
||||
.dds-header h1 { margin: 2px 0 0; }
|
||||
.dds-header h1 span { color: #576273; font-size: 15px; font-weight: 400; }
|
||||
|
|
@ -349,6 +353,7 @@ button:disabled { opacity: 0.5; cursor: default; }
|
|||
.dds-status-action button { padding: 4px 8px; }
|
||||
.dds-alert { padding: 10px 12px; margin: 0 0 12px; color: var(--violated); background: #fde8e8; border: 1px solid var(--violated); }
|
||||
.dds-done { color: var(--ok); background: #e7f4e9; border-color: var(--ok); }
|
||||
.dds-paused { color: var(--warn); background: #fdf3d8; border-color: var(--warn); }
|
||||
.dds-phone > label { display: block; margin: 14px 12px 5px; font-size: 13px; font-weight: 600; }
|
||||
.dds-phone > select { display: block; width: calc(100% - 24px); margin: 0 12px; padding: 7px; box-sizing: border-box; }
|
||||
.dds-dial { display: block; margin: 12px; width: calc(100% - 24px); text-align: center; background: #23577e; border-color: #23577e; color: white; font-weight: 600; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue