feat: реагирование смежных служб в карточке ДДС и вводная «бригада просит смежников»
This commit is contained in:
parent
40660a6b08
commit
6d3ebb72c8
19 changed files with 1139 additions and 21 deletions
|
|
@ -33,6 +33,8 @@ const DDS_METRICS: MetricOption[] = [
|
|||
{ key: "dds_reply", label: "Основание и сведения", hint: "Комментарии к статусам и содержание полученных сведений." },
|
||||
{ key: "dds_work_time", label: "Первая запись", hint: "Первый статус с текстом в пределах лимита после открытия карточки." },
|
||||
{ key: "dds_report_reaction", label: "Реакция на доклад бригады", hint: "Статус по докладу бригады не позже лимита занятия." },
|
||||
{ key: "dds_adjacent_check", label: "Сверка перед ответом бригаде", hint: "Ответ на запрос бригады о смежной службе сверен с журналом карточки." },
|
||||
{ key: "dds_adjacent_call", label: "Звонок молчащей смежной службе", hint: "Связь со смежной службой и ответ бригаде не позже лимита вводной." },
|
||||
];
|
||||
|
||||
const CARD_METRICS = OPERATOR_METRICS.filter(({ key }) => [
|
||||
|
|
|
|||
|
|
@ -17,7 +17,10 @@ const SipPhone = lazy(() => import("@/features/sip/SipPhone").then(({ SipPhone }
|
|||
type ReportPhase = "dispatched" | "arrived" | "working" | "completed";
|
||||
interface PhoneReport { service: string; crew: string; phase: ReportPhase; text: string; at: string }
|
||||
interface PhoneLine { service: string; crew: string; speaker: "dispatcher" | "crew"; text: string; at: string }
|
||||
interface PhonePending { service: string; crew: string; phase: ReportPhase }
|
||||
interface PhonePending {
|
||||
service: string; crew: string; phase: string;
|
||||
incoming?: boolean; adjacent_service?: string | null;
|
||||
}
|
||||
interface CompletedCard {
|
||||
card_id: string; scenario_id: string; score_auto: number; score_final?: number;
|
||||
session_id?: string; ended_at?: string; title?: string | null; address?: string | null;
|
||||
|
|
@ -236,6 +239,9 @@ export function Dds() {
|
|||
const [dialing, setDialing] = useState(false);
|
||||
const [brief, setBrief] = useState({ address: "", incident: "", request: "" });
|
||||
const [checkText, setCheckText] = useState("");
|
||||
const [crewReplyText, setCrewReplyText] = useState("");
|
||||
const [adjacentTarget, setAdjacentTarget] = useState("");
|
||||
const [adjacentText, setAdjacentText] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [ended, setEnded] = useState(false);
|
||||
const [paused, setPaused] = useState(false);
|
||||
|
|
@ -286,6 +292,9 @@ export function Dds() {
|
|||
setDialing(false);
|
||||
setBrief({ address: "", incident: "", request: "" });
|
||||
setCheckText("");
|
||||
setCrewReplyText("");
|
||||
setAdjacentTarget("");
|
||||
setAdjacentText("");
|
||||
setZone(null);
|
||||
setScoreReady(false);
|
||||
setView(openCardAfterSwitch.current ? "card" : "registry");
|
||||
|
|
@ -334,7 +343,15 @@ export function Dds() {
|
|||
const latest = (lastReport?.crew === selectedCrew ? lastReport : null) ??
|
||||
reports.filter((item) => item.crew === selectedCrew).at(-1) ?? null;
|
||||
const phoneCall = station?.phone_pending ?? null;
|
||||
const phoneLines = (station?.phone_lines ?? []).filter((line) => line.crew === selectedCrew);
|
||||
// Лента разговора следует за активным вызовом: входящий от бригады может
|
||||
// прийти и без выбранной в селекторе бригады.
|
||||
const activeCallCrew = phoneCall?.crew ?? selectedCrew;
|
||||
const phoneLines = (station?.phone_lines ?? []).filter((line) => line.crew === activeCallCrew);
|
||||
// Смежные службы: получатели карточки, которыми курсант не управляет.
|
||||
const adjacentServices = (station?.recipient_services ?? [])
|
||||
.filter((service) => !(station?.services ?? []).includes(service));
|
||||
const adjacentService = adjacentTarget || phoneCall?.adjacent_service || adjacentServices[0] || "";
|
||||
const adjacentLines = (station?.phone_lines ?? []).filter((line) => line.crew.endsWith("— диспетчер"));
|
||||
const isConnected = status === "open";
|
||||
// Вверх: короткая пауза не должна выглядеть как «0 с», а без пауз строки нет.
|
||||
const pausedSeconds = Math.ceil((report?.total_paused_ms ?? 0) / 1000);
|
||||
|
|
@ -378,6 +395,10 @@ export function Dds() {
|
|||
АРМ {role}: {isConnected ? "на связи" : status}
|
||||
</span>
|
||||
{error && <div className="dds-alert" role="alert">Действие не принято: {error}</div>}
|
||||
{phoneCall?.incoming && pane !== "phone" && !ended && <div className="dds-alert" role="alert">
|
||||
Входящий вызов бригады {phoneCall.crew}.{" "}
|
||||
<button type="button" onClick={() => { setView("card"); setPane("phone"); }}>Ответить</button>
|
||||
</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">
|
||||
|
|
@ -630,7 +651,22 @@ export function Dds() {
|
|||
onClick={() => { if (send({ type: "phone.dial" })) setDialing(true); }}>
|
||||
☎ {dialing ? "Соединение…" : "Позвонить старшему группы"}</button>
|
||||
{selectedCrew && !crewAccepted && <p className="dds-hint state-warn">Перед звонком поставьте статус «Принята» для службы этой бригады.</p>}
|
||||
{phoneCall && <div className="dds-phone-active" aria-live="polite">
|
||||
{phoneCall?.incoming && <div className="dds-phone-active" aria-live="polite">
|
||||
<strong>● Входящий вызов · {phoneCall.crew}</strong>
|
||||
<div className="dds-phone-form">
|
||||
<p>Бригада спрашивает о смежной службе{phoneCall.adjacent_service ? ` «${phoneCall.adjacent_service}»` : ""}.
|
||||
Сверьтесь с журналом карточки: если служба реагирует — передайте это бригаде,
|
||||
если молчит — сообщите, что уточните, и позвоните её диспетчеру.</p>
|
||||
<label htmlFor="dds-phone-reply">Ваш ответ бригаде</label>
|
||||
<textarea id="dds-phone-reply" rows={2} value={crewReplyText}
|
||||
onChange={(event) => setCrewReplyText(event.target.value)}
|
||||
placeholder="Служба реагирует, бригада направлена — или: уточню по карточке и свяжусь со службой" />
|
||||
<button type="button" disabled={!isConnected || ended || crewReplyText.trim().length < 8}
|
||||
onClick={() => { if (send({ type: "phone.reply", text: crewReplyText })) setCrewReplyText(""); }}>
|
||||
Ответить бригаде</button>
|
||||
</div>
|
||||
</div>}
|
||||
{phoneCall && !phoneCall.incoming && <div className="dds-phone-active" aria-live="polite">
|
||||
<strong>● Соединение установлено · {phoneCall.crew}</strong>
|
||||
{phoneCall.phase === "dispatched" ? <div className="dds-phone-form">
|
||||
<p>Передайте сведения из карточки и задачу бригаде. Доклад о выезде появится только после передачи.</p>
|
||||
|
|
@ -661,6 +697,28 @@ export function Dds() {
|
|||
{reports.length > 0 && <details className="dds-details"><summary>История докладов ({reports.length})</summary>
|
||||
<ol className="dds-reports">{reports.map((report, index) => <li key={`${report.at}-${index}`}><b>{REPORT_LABELS[report.phase]}</b> · {time(report.at)}<br />{report.text}</li>)}</ol>
|
||||
</details>}
|
||||
{adjacentServices.length > 0 && <>
|
||||
<h3 className="dds-protocol-title">Смежные службы</h3>
|
||||
<p className="dds-hint">Реагирование смежных служб видно в журнале и на кнопках служб.
|
||||
Если нужная служба в карточке молчит — позвоните её диспетчеру.</p>
|
||||
<label htmlFor="dds-adjacent-service">Смежная служба</label>
|
||||
<select id="dds-adjacent-service" value={adjacentService}
|
||||
disabled={!isConnected || ended}
|
||||
onChange={(event) => setAdjacentTarget(event.target.value)}>
|
||||
{adjacentServices.map((service) => <option key={service} value={service}>{serviceTitle(service)}</option>)}
|
||||
</select>
|
||||
<label htmlFor="dds-adjacent-text">Запрос диспетчеру службы</label>
|
||||
<textarea id="dds-adjacent-text" rows={2} value={adjacentText}
|
||||
onChange={(event) => setAdjacentText(event.target.value)}
|
||||
placeholder="Прошу перекрыть газовый ввод по карточке, работает бригада" />
|
||||
<button type="button" className="dds-dial"
|
||||
disabled={!isConnected || ended || Boolean(phoneCall) || !adjacentService || adjacentText.trim().length < 8}
|
||||
onClick={() => { if (send({ type: "adjacent.dial", service: adjacentService, text: adjacentText })) setAdjacentText(""); }}>
|
||||
☎ Позвонить диспетчеру службы</button>
|
||||
{adjacentLines.length > 0 && <div className="dds-phone-transcript" aria-live="polite"><strong>Разговоры со смежными службами</strong>
|
||||
{adjacentLines.map((line, index) => <p key={`${line.at}-${index}`}><small>{time(line.at)} · {line.speaker === "crew" ? line.crew : "Диспетчер ДДС"}</small><br />{line.text}</p>)}
|
||||
</div>}
|
||||
</>}
|
||||
</section>}
|
||||
{pane === "reply" && <section className="dds-panel dds-reply">
|
||||
<div className="dds-panel-title"><h2>Рабочая заметка диспетчера</h2>
|
||||
|
|
@ -709,7 +767,8 @@ export function Dds() {
|
|||
})}
|
||||
</div>
|
||||
<div className="dds-service-tools" role="group" aria-label="Действия диспетчера">
|
||||
<button type="button" onClick={() => setPane("phone")}><b>☎ Связь</b><small>Бригада</small></button>
|
||||
<button type="button" onClick={() => setPane("phone")}>
|
||||
<b>☎ Связь</b><small>{phoneCall?.incoming ? "Входящий!" : "Бригада"}</small></button>
|
||||
<button type="button" onClick={() => setPane("reply")}><b>✎ Ответ</b><small>{reply.dirty ? "Не сохранён" : "Комментарий"}</small></button>
|
||||
<button type="button" onClick={() => setPane("journal")}><b>Журнал</b><small>Статусы</small></button>
|
||||
<button type="button" disabled={!cardId || !isConnected || ended || advancePending}
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ export function Instructor() {
|
|||
const [ddsCardWorkTimeLimit, setDdsCardWorkTimeLimit] = useState(180);
|
||||
const [ddsReportReactionEnabled, setDdsReportReactionEnabled] = useState(false);
|
||||
const [ddsReportReactionLimit, setDdsReportReactionLimit] = useState(45);
|
||||
const [ddsAdjacentLimit, setDdsAdjacentLimit] = useState(90);
|
||||
const [allowedErrors, setAllowedErrors] = useState(0);
|
||||
const [requireCorrectGrammar, setRequireCorrectGrammar] = useState(true);
|
||||
const [lessonScoreWeights, setLessonScoreWeights] = useState<Record<string, number>>({});
|
||||
|
|
@ -312,6 +313,7 @@ export function Instructor() {
|
|||
dds_card_work_time_limit_seconds: ddsCardWorkTimeLimit,
|
||||
dds_report_reaction_enabled: reportReactionEnabled,
|
||||
dds_report_reaction_limit_seconds: ddsReportReactionLimit,
|
||||
dds_adjacent_limit_seconds: ddsAdjacentLimit,
|
||||
allowed_errors: allowedErrors,
|
||||
require_correct_grammar: requireCorrectGrammar,
|
||||
score_weights: reportReactionEnabled
|
||||
|
|
@ -321,7 +323,7 @@ export function Instructor() {
|
|||
});
|
||||
if (sent) toStart.current = null;
|
||||
}, [sessionId, control, scenarioId, extraScenarioIds, mode, trainee, traineeId, trainees.data,
|
||||
exercise, decisionTimeLimit, cardFillTimeLimit, ddsCardWorkTimeLimit, ddsReportReactionEnabled, ddsReportReactionLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios,
|
||||
exercise, decisionTimeLimit, cardFillTimeLimit, ddsCardWorkTimeLimit, ddsReportReactionEnabled, ddsReportReactionLimit, ddsAdjacentLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios,
|
||||
ddsArrivalIntervalSeconds, ddsMaxWaiting, lessonScoreWeights, scenarios.data,
|
||||
randomScenario, visibleScenarios]);
|
||||
|
||||
|
|
@ -682,6 +684,13 @@ export function Instructor() {
|
|||
onCommit={setDdsReportReactionLimit} />
|
||||
<small className="ref">Учебный лимит при включённом критерии; исходное значение — 45 секунд.</small>
|
||||
</label>}
|
||||
<label className="lesson-criterion">
|
||||
Лимит вводной «бригада просит смежников», секунд
|
||||
<LessonNumberInput label="Лимит вводной о смежной службе, секунд"
|
||||
value={ddsAdjacentLimit} min={5} max={600} fallback={90}
|
||||
onCommit={setDdsAdjacentLimit} />
|
||||
<small className="ref">Действует в сценариях с вводной: за это время нужно ответить бригаде, а при молчании смежной службы — позвонить ей.</small>
|
||||
</label>
|
||||
</>}
|
||||
<label className="lesson-criterion">
|
||||
Допустимо нарушенных метрик
|
||||
|
|
|
|||
|
|
@ -14,6 +14,13 @@ export interface AIRecommendation {
|
|||
text: string;
|
||||
}
|
||||
|
||||
/** Звонок диспетчеру смежной службы из списка оповещения карточки. Один обмен: запрос курсанта и скриптовый ответ смежной службы; её статус после звонка появляется в журнале карточки. */
|
||||
export interface AdjacentDial {
|
||||
type: "adjacent.dial";
|
||||
service: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Аудио-фон происшествия. Файл лежит в сборке фронта, трафика фон не создаёт. */
|
||||
export interface BgStart {
|
||||
type: "bg.start";
|
||||
|
|
@ -403,6 +410,7 @@ export interface LessonCriteria {
|
|||
dds_card_work_time_limit_seconds?: number;
|
||||
dds_report_reaction_enabled?: boolean;
|
||||
dds_report_reaction_limit_seconds?: number;
|
||||
dds_adjacent_limit_seconds?: number;
|
||||
allowed_errors?: number;
|
||||
require_correct_grammar?: boolean;
|
||||
score_weights?: Record<string, number>;
|
||||
|
|
@ -462,6 +470,8 @@ export interface PhoneCallPending {
|
|||
service: string;
|
||||
crew: string;
|
||||
phase: string;
|
||||
incoming?: boolean;
|
||||
adjacent_service?: string | null;
|
||||
}
|
||||
|
||||
/** Запрос диспетчера об обстановке после предыдущего доклада. */
|
||||
|
|
@ -495,6 +505,12 @@ export interface PhoneLineRecord {
|
|||
at: string;
|
||||
}
|
||||
|
||||
/** Ответ диспетчера на входящий вызов бригады (вводная «бригада просит смежников»). Свободный текст проверяется кодом по основам слов. */
|
||||
export interface PhoneReply {
|
||||
type: "phone.reply";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface PhoneReport {
|
||||
type: "phone.report";
|
||||
service: string;
|
||||
|
|
@ -831,10 +847,11 @@ export type InstructorToServer = ScenarioStart | DirectorInject | ReferencePlay
|
|||
|
||||
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;
|
||||
export type StationToServer = CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | PhoneBrief | PhoneCheck | PhoneHangup | PhoneReply | AdjacentDial | StationFinish | CardReply | CardNext | CardOpen | ZoneDecision | CrewDispatched | CrewArrived;
|
||||
|
||||
export type EventName =
|
||||
"bg.start"
|
||||
"adjacent.dial"
|
||||
| "bg.start"
|
||||
| "bg.stop"
|
||||
| "call.answer"
|
||||
| "call.ended"
|
||||
|
|
@ -873,6 +890,7 @@ export type EventName =
|
|||
| "phone.dial"
|
||||
| "phone.hangup"
|
||||
| "phone.line"
|
||||
| "phone.reply"
|
||||
| "phone.report"
|
||||
| "reference.play"
|
||||
| "reference.started"
|
||||
|
|
|
|||
Loading…
Reference in a new issue