diff --git a/frontend/src/features/mode-banner/ModeBanner.tsx b/frontend/src/features/mode-banner/ModeBanner.tsx new file mode 100644 index 0000000..6de0439 --- /dev/null +++ b/frontend/src/features/mode-banner/ModeBanner.tsx @@ -0,0 +1,17 @@ +// Баннер режима. Виден на всех четырёх экранах и определяет доступность подсказки: +// в контрольном режиме опоры нет, и это часть нормы контроля (docs/product/MODES.md). + +import type { SessionMode } from "@/shared/types/generated"; + +const LABELS: Record = { + training: "Тренировочный режим — подсказки доступны", + exam: "Контрольный режим — подсказки недоступны", + self: "Самостоятельная работа — подсказки доступны", +}; + +export const hintsAllowed = (mode: SessionMode | undefined): boolean => mode !== "exam"; + +export function ModeBanner({ mode }: { mode: SessionMode | undefined }) { + if (!mode) return null; + return
{LABELS[mode]}
; +} diff --git a/frontend/src/features/timers/Timers.tsx b/frontend/src/features/timers/Timers.tsx new file mode 100644 index 0000000..e62bc72 --- /dev/null +++ b/frontend/src/features/timers/Timers.tsx @@ -0,0 +1,54 @@ +// Таймеры ГОСТ Р 22.7.03-2021. +// +// Три состояния — единственное место, где цвет несёт смысл (docs/arch/FRONTEND.md). +// `interview` (75 с) — центральная метрика продукта: она крупная и видна курсанту, +// преподавателю и на внешнем мониторе одновременно (docs/arch/CONTRACT.md). + +import type { TimerSnapshot } from "@/shared/types/generated"; + +import { clock } from "./clock"; + +const TITLES: Record = { + answer: "Ответ на звонок", + interview: "Опрос", + dds_notify: "Оповещение ДДС", + dds_ack: "Подтверждение ДДС", + zone_check: "Зона ответственности", + callback: "Обратный дозвон", + close: "Снятие с контроля", +}; + +/** Крупный индикатор опроса. `big` — для внешнего монитора: читается с последнего ряда. */ +export function InterviewTimer({ timers, big = false }: { timers: TimerSnapshot[]; big?: boolean }) { + const interview = timers.find((timer) => timer.code === "interview"); + if (!interview) return null; + return ( +
+ Опрос + {clock(interview.elapsed_ms)} + норматив {clock(interview.limit_ms)} +
+ ); +} + +export function Timers({ timers }: { timers: TimerSnapshot[] }) { + if (timers.length === 0) return null; + return ( + + + {timers.map((timer) => ( + + + + + ))} + +
+ {TITLES[timer.code] ?? timer.code} + {(timer.attempt ?? 1) > 1 && ` · попытка ${timer.attempt}`} + + {clock(timer.elapsed_ms)} из {clock(timer.limit_ms)} + {timer.stopped && " · остановлен"} +
+ ); +} diff --git a/frontend/src/features/timers/clock.ts b/frontend/src/features/timers/clock.ts new file mode 100644 index 0000000..5b1c575 --- /dev/null +++ b/frontend/src/features/timers/clock.ts @@ -0,0 +1,5 @@ +/** Миллисекунды → мм:сс. Отдельным модулем без импортов: так проверяется в Node. */ +export function clock(ms: number): string { + const total = Math.max(0, Math.round(ms / 1000)); + return `${String(Math.floor(total / 60)).padStart(2, "0")}:${String(total % 60).padStart(2, "0")}`; +} diff --git a/frontend/src/pages/trainee/Call.tsx b/frontend/src/pages/trainee/Call.tsx index b030cc2..93b33b1 100644 --- a/frontend/src/pages/trainee/Call.tsx +++ b/frontend/src/pages/trainee/Call.tsx @@ -2,6 +2,8 @@ // Карточка КИО — lct-10, таймеры — lct-11. import { KioCard } from "@/features/kio-card/KioCard"; +import { ModeBanner, hintsAllowed } from "@/features/mode-banner/ModeBanner"; +import { InterviewTimer, Timers } from "@/features/timers/Timers"; import { display } from "@/features/kio-card/merge"; import { useCall } from "@/features/call/useCall"; import { sessionIdFromUrl } from "@/shared/api/session"; @@ -28,7 +30,9 @@ export function Call() { return (
+

АРМ оператора 112

+ @@ -43,7 +47,7 @@ export function Call() { )} {call.micOn && }{" "} - {call.micOn && call.incoming?.mode !== "exam" && ( + {call.micOn && hintsAllowed(call.incoming?.mode) && ( )}

@@ -70,6 +74,8 @@ export function Call() {

+

Нормативы

+

Разговор

Канал{STATUS_LABEL[call.status]}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css index f06e78d..7beb82d 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -51,3 +51,21 @@ button:disabled { opacity: 0.5; cursor: default; } .required { color: var(--warn); font-weight: 700; } /* Правка, которую сервер ещё не подтвердил. */ .pending { background: #fffbe6; } + +/* Три состояния норматива — единственное место, где цвет несёт смысл. + На внешнем мониторе они должны различаться с последнего ряда. */ +.state-ok { color: var(--ok); } +.state-warn { color: var(--warn); font-weight: 600; } +.state-violated { color: var(--violated); font-weight: 700; } + +.interview { display: flex; align-items: baseline; gap: 12px; margin: 8px 0 16px; } +.interview-label { text-transform: uppercase; letter-spacing: 0.08em; font-size: 12px; } +.interview-value { font-size: 40px; font-variant-numeric: tabular-nums; font-weight: 700; line-height: 1; } +.interview-limit { font-size: 12px; color: #5a6068; } +.interview-big .interview-value { font-size: 96px; } +.interview-big .interview-label, .interview-big .interview-limit { font-size: 20px; } + +.mode-banner { padding: 4px 24px; margin: -16px -24px 12px; border-bottom: 1px solid var(--line); font-weight: 600; } +.mode-training { background: #e8f0fb; } +.mode-exam { background: #fde8e8; } +.mode-self { background: #eef3ea; }