Complete training workflow and acceptance hardening
This commit is contained in:
parent
4c4b91064f
commit
7237265833
243 changed files with 17014 additions and 1500 deletions
|
|
@ -1,7 +1,8 @@
|
|||
// АРМ курсанта: приём вызова и разговор голосом.
|
||||
// Карточка КИО — lct-10, таймеры — lct-11.
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { KioCard } from "@/features/kio-card/KioCard";
|
||||
import { StandStatus } from "@/features/health/StandStatus";
|
||||
|
|
@ -32,10 +33,20 @@ function ddsCode(value: unknown): DDSCode | undefined {
|
|||
}
|
||||
|
||||
export function Call() {
|
||||
const [textTurn, setTextTurn] = useState("");
|
||||
const [resolveComment, setResolveComment] = useState("");
|
||||
const [resolved, setResolved] = useState<"consultation" | "transfer_region" | null>(null);
|
||||
const sessionId = sessionIdFromUrl();
|
||||
const call = useCall(sessionId);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const isCompletionScreen = location.pathname === "/trainee/completed";
|
||||
|
||||
useEffect(() => {
|
||||
if (call.report && sessionId && !isCompletionScreen) {
|
||||
navigate(`/trainee/completed?session=${sessionId}`, { replace: true });
|
||||
}
|
||||
}, [call.report, isCompletionScreen, navigate, sessionId]);
|
||||
|
||||
if (!sessionId) {
|
||||
return (
|
||||
|
|
@ -46,9 +57,50 @@ export function Call() {
|
|||
);
|
||||
}
|
||||
|
||||
if (isCompletionScreen) {
|
||||
if (!call.report) return <main className="page completion-page">
|
||||
<h1>Занятие завершено</h1>
|
||||
{call.reportError
|
||||
? <p role="alert" className="state-violated">{call.reportError}</p>
|
||||
: <p role="status">Получаем итог занятия…</p>}
|
||||
<Link to="/profile">Вернуться к профилю</Link>
|
||||
</main>;
|
||||
const report = call.report;
|
||||
const nextSteps = report.metrics.filter((metric) => !metric.passed).slice(0, 3);
|
||||
const completedCount = Math.max(0, report.metrics.length - report.failed_metrics);
|
||||
return <main className="page completion-page">
|
||||
<header className="completion-heading">
|
||||
<span>Упражнение завершено</span>
|
||||
<h1>{report.passed ? "Зачёт" : "Не зачтено"}</h1>
|
||||
<p>{report.exercise === "card" ? "Заполнение карточки КИО" : report.exercise === "dds" ? "Работа диспетчера ДДС" : "Приём вызова 112"} · {report.scenario_id}</p>
|
||||
</header>
|
||||
<section className={`completion-score ${report.passed ? "is-passed" : "is-failed"}`} aria-label="Итог занятия">
|
||||
<div><span>Ваш результат</span><strong>{report.score_final.toFixed(0)}<small> / 100</small></strong></div>
|
||||
<p>{completedCount} критериев выполнено · {report.failed_metrics} требуют внимания</p>
|
||||
</section>
|
||||
{nextSteps.length > 0 && <section className="completion-focus">
|
||||
<h2>Что потренировать в следующий раз</h2>
|
||||
<ol>{nextSteps.map((metric) => <li key={metric.key}><strong>{metric.title}</strong><span>{metric.fact}</span></li>)}</ol>
|
||||
</section>}
|
||||
<details className="completion-details">
|
||||
<summary>Подробный разбор занятия</summary>
|
||||
<Debrief report={report} />
|
||||
</details>
|
||||
<nav className="completion-actions" aria-label="Действия после занятия">
|
||||
<Link to="/profile">Вернуться к профилю</Link>
|
||||
<a href={`/api/sessions/${report.session_id}/report.pdf`}>Скачать отчёт (PDF)</a>
|
||||
</nav>
|
||||
</main>;
|
||||
}
|
||||
|
||||
if (call.briefing) {
|
||||
const services = display(call.card, "notify");
|
||||
const notifiedServices = Array.isArray(services) ? services.map(String) : [];
|
||||
// Some already-open demo backends still send the old multi-line briefing.
|
||||
// Keep only the caller's opening line; never dump the answer key into view.
|
||||
const openingLine = call.briefing.text.split(/\r?\n/)[1]
|
||||
?? call.briefing.text.split(/\r?\n/)[0]
|
||||
?? "Алло? Вы меня слышите?";
|
||||
return <main className="page arm-page">
|
||||
<ModeBanner mode={call.briefing.mode} />
|
||||
<header className="arm-topbar arm-topbar-text">
|
||||
|
|
@ -56,14 +108,34 @@ export function Call() {
|
|||
<div className="arm-topbar-title"><strong>Карточка информационного обмена</strong><span>Текстовое упражнение · сценарий {call.briefing.scenario_id}</span></div>
|
||||
<div className="arm-topbar-state"><span className={call.status === "open" ? "state-ok" : "state-warn"}>● {call.status === "open" ? "АРМ подключён" : STATUS_LABEL[call.status]}</span><small>{call.cardSubmitted ? "Карточка отправлена" : "Карточка заполняется"}</small></div>
|
||||
</header>
|
||||
<section className="arm-briefing">
|
||||
<div className="arm-briefing-label"><strong>Учебная вводная</strong><span>Текстовый режим · без входящего звонка</span></div>
|
||||
<div className="arm-briefing-text">{call.briefing.text}</div>
|
||||
</section>
|
||||
<div className="arm-card-wrap">
|
||||
<KioCard state={call.card} required={call.briefing.required_fields}
|
||||
readOnly={call.cardSubmitted || call.status !== "open"} referenceArm onChange={call.patchKio} />
|
||||
<div className="arm-text-layout">
|
||||
<section className="arm-text-dialogue" aria-label="Текстовый диалог с заявителем">
|
||||
<header><strong>Диалог с заявителем</strong><span>{call.cardSubmitted ? "Диалог завершён" : "Задайте вопрос"}</span></header>
|
||||
<div className="arm-text-lines" aria-live="polite">
|
||||
{!call.lines.length && <p className="arm-text-line caller"><b>Заявитель</b>{openingLine}</p>}
|
||||
{call.lines.map((line, index) => <p key={`${index}-${line.speaker}`} className={`arm-text-line ${line.speaker}`}>
|
||||
<b>{line.speaker === "caller" ? "Заявитель" : "Вы"}</b>{line.text}
|
||||
{line.speaker === "caller" && <small className="arm-line-source">{line.source === "local_llm" ? "Qwen · ответ модели" : "Сценарный ответ"}</small>}
|
||||
</p>)}
|
||||
</div>
|
||||
<form onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (call.sendText(textTurn)) setTextTurn("");
|
||||
}}>
|
||||
<input aria-label="Ваша реплика заявителю" value={textTurn} maxLength={1000}
|
||||
placeholder="Ваша реплика…" onChange={(event) => setTextTurn(event.target.value)} />
|
||||
<button type="submit" disabled={call.status !== "open" || !textTurn.trim() || call.cardSubmitted}>Отправить</button>
|
||||
</form>
|
||||
{call.error && <p role="alert" className="state-violated">{call.error}</p>}
|
||||
</section>
|
||||
<div className="arm-card-wrap">
|
||||
<KioCard state={call.card} required={call.briefing.required_fields}
|
||||
readOnly={call.cardSubmitted} referenceArm onChange={call.patchKio} />
|
||||
</div>
|
||||
</div>
|
||||
<section className="arm-support" aria-label="Норматив заполнения карточки">
|
||||
<Timers timers={call.timers} />
|
||||
</section>
|
||||
<footer className="arm-footer">
|
||||
<span className="arm-footer-services">Службы:
|
||||
{notifiedServices.length
|
||||
|
|
@ -102,10 +174,20 @@ export function Call() {
|
|||
</header>
|
||||
|
||||
<div className="arm-call-actions">
|
||||
{call.phase === "incoming" && !call.micOn && <button type="button" className="arm-answer" onClick={() => void call.answer()}>☎ Ответить на вызов</button>}
|
||||
{(call.phase === "incoming" || call.phase === "talking") && !call.micOn && <button type="button" className="arm-answer" onClick={() => void call.answer()}>
|
||||
{call.phase === "talking" ? "☎ Продолжить разговор" : "☎ Ответить на вызов"}
|
||||
</button>}
|
||||
{call.micOn && <span className="arm-live">● Разговор идёт</span>}
|
||||
{call.micOn && hintsAllowed(call.incoming?.mode) && <button type="button" onClick={call.hint}>Подсказка</button>}
|
||||
<span className="arm-call-actions-note">{call.phase === "ended" ? "Вызов завершён" : "Заполните адрес и признаки происшествия со слов заявителя"}</span>
|
||||
<span className="arm-call-actions-note">{call.phase === "ended"
|
||||
? "Вызов завершён"
|
||||
: !call.micOn && call.phase === "incoming"
|
||||
? "Поля КИО разблокируются после ответа на входящий вызов"
|
||||
: !call.micOn && call.phase === "talking"
|
||||
? "Разговор продолжается. Нажмите кнопку, чтобы включить звук и микрофон на этом устройстве"
|
||||
: call.micOn
|
||||
? "Заполните адрес и признаки происшествия со слов заявителя"
|
||||
: "Ожидайте входящего вызова"}</span>
|
||||
</div>
|
||||
|
||||
{call.error && <p className="violated arm-message" role="alert">{call.error}</p>}
|
||||
|
|
@ -141,6 +223,7 @@ export function Call() {
|
|||
<p>
|
||||
<input
|
||||
type="text"
|
||||
aria-label="Комментарий при закрытии вызова без карточки"
|
||||
value={resolveComment}
|
||||
placeholder="что сказали заявителю, куда передали"
|
||||
onChange={(event) => setResolveComment(event.target.value)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue